Skip to content

Add Vensim macro (:MACRO:) support#564

Merged
bpowers merged 49 commits into
mainfrom
macros
May 16, 2026
Merged

Add Vensim macro (:MACRO:) support#564
bpowers merged 49 commits into
mainfrom
macros

Conversation

@bpowers
Copy link
Copy Markdown
Owner

@bpowers bpowers commented May 15, 2026

Summary

  • Adds full Vensim :MACRO: / XMILE <macro> support. Macro definitions import as macro-marked datamodel::Models, persisted via a MacroSpec marker on Model through protobuf, Rust/TS/Python JSON, and the JSON schema. Single-output macros inline through the compiler's BuiltinVisitor; multi-output (:-list) invocations materialize at import. Round-trips through MDL and XMILE (the latter via simlin: vendor extensions for multi-output, plus a <uses_macros> header option).
  • Generalizes the existing stdlib-as-modules mechanism into a unified ModuleFunctionDescriptor / MacroRegistry resolver (module_functions.rs plus a db_macro_registry.rs salsa query) shared by stdlib functions and user macros, with a single shared renamed-builtin-collision predicate so the pre-scan and walk-time paths cannot drift. Diagram UI excludes macro-marked models from the model-reference dropdown and standalone navigation.
  • Validated against hero corpora (C-LEARN, 17 metasd macro models) and 9 focused fixtures; all 37 acceptance criteria (macros.AC1.1-macros.AC6.6) are covered by non-vacuous automated tests, with classifiers non-vacuity-pinned in both directions. Includes two user-approved in-scope prerequisite bug fixes: GH engine: macro whose body wraps a same-canonical-name intrinsic (INIT = INITIAL(x)) causes false recursion cycle, blocking C-LEARN macro expansion #554 (false init -> init macro-recursion blocking C-LEARN) and a engine: macro whose body wraps a same-canonical-name intrinsic (INIT = INITIAL(x)) causes false recursion cycle, blocking C-LEARN macro expansion #554-class DELAY N -> delayn bug blocking AC6.4.

Design: docs/design-plans/2026-05-13-macros.md
Implementation plan: docs/implementation-plans/2026-05-13-macros/

Test Plan

Automated coverage is exhaustive at the engine / datamodel / serialization / round-trip layer and is gated by the pre-commit hook (Rust fmt+clippy+test, TS lint+build+tsc+test, pysimlin) -- green on every commit on this branch.

Human verification (full plan: docs/test-plans/2026-05-13-macros.md):

  • Phase A -- gated heavy-corpus opt-in runs (C-LEARN import, SSTATS multi-output materialization, metasd expansion tier): zero macro-attributable diagnostics
  • Phase B -- diagram UI: macro-marked models absent from the model-reference dropdown and not standalone-navigable; main diagrams render without crash
  • Phase C -- cross-tool / cross-format fidelity: MDL/XMILE export byte-stable, multi-output simlin: extensions present, optional Vensim DSS numeric comparison

15 follow-up issues filed for out-of-scope / design-deferred items (#551-#563; #554 is fixed in this branch). The metasd simulation tier and DSS-grade .vdf references are documented prerequisites (GH #561), not coverage gaps.

bpowers added 30 commits May 14, 2026 07:54
The design phase for Vensim macro support: the validated design plan, a
first-party reference document, and a 14-model macro test corpus from
the MetaSD model library.

The design reuses the engine's existing module machinery rather than
adding a new primitive. Simlin already implements its stock-and-flow
builtins (SMTH1, DELAY3, and friends) as small models instantiated as
modules on function-call syntax; this generalizes that mechanism from a
fixed build-time stdlib registry to a per-project, data-driven one. A
macro definition becomes a Model carrying a new optional MacroSpec
marker; a single-output invocation stays as function-call equation text
expanded at compile time; only the multi-output ':' form is materialized
in the datamodel. The compiler and VM are unchanged.

Key decisions:
- One additive protobuf field on Model; no new datamodel primitive
- XMILE supported symmetrically via the simlin: extension namespace
- Macros shadow builtins, eliminating a current silent-wrong-result bug
- 7 implementation phases, C-LEARN as the hero validation

Includes docs/reference/vensim-macros.md (implementation reference) and
test/metasd/ (14 macro-using models, CC BY 3.0, trimmed to .mdl/.vdf
sources with per-model provenance). Two of the bundled .vdf fixtures
trip the Rust VDF slot-table scanner's known under-count; they are added
to the vdf_structural_invariants exemption list and tracked in #549.
Introduce a MacroSpec struct and an Option<MacroSpec> field on
datamodel::Model. Some marks the model as a callable macro template
whose ordinary variables are the macro body; MacroSpec records the
calling convention (formal parameters, primary output, and the
:-list additional outputs from Vensim multi-output macros).

This is the in-memory representation only -- no protobuf, JSON, or
import/export wiring yet (those follow in later tasks). Adding the
field requires macro_spec: None on every existing datamodel::Model
struct literal across the workspace; the stdlib.gen.rs generator
(gen_stdlib.rs) emits it too, since no stdlib model is a macro.

The xmile reader's From<datamodel::Model> destructure ignores
macro_spec with an explicit `_` binding: XMILE has no macro concept,
so it is dropped on export until the later XMILE phase.
Add a MacroSpec protobuf message and Model.macro_spec (field 7) to
project_io.proto, regenerate project_io.gen.rs, and wire the
datamodel::MacroSpec <-> project_io::MacroSpec conversions into
serde.rs alongside the existing LoopMetadata impls.

A singular proto3 message field is presence-tracked, so prost
generates Model.macro_spec as Option<MacroSpec>; the two Model
conversion impls map over that Option. test_model_with_macro_spec_
roundtrip covers macros.AC1.4 (protobuf half): a macro-bearing
model with non-empty parameters, a primary output, additional
outputs, and a body variable round-trips losslessly, and an
ordinary model still round-trips with macro_spec None.

Regenerating the prost bindings also adds the field to
project_io::Model, so the libsimlin protobuf-fixture tests that
build project_io::Model literals get macro_spec: None.
Mirror the MacroSpec datamodel type into json::Model exactly as the
LoopMetadata nested-struct precedent does, using the optional-singular
serde idiom from json::Model.sim_specs (skip_serializing_if =
Option::is_none, default) since macro_spec is Option<MacroSpec> rather
than a Vec. additionalOutputs uses the is_empty_vec skip helper so an
ordinary single-output macro serializes compactly.

The json_proptest model_strategy gains a macro_spec_strategy wired
through prop::option::of, so the existing json/datamodel/protobuf
roundtrip property tests and generated_json_validates_against_schema
now exercise random MacroSpec values against the regenerated schema.
docs/simlin-project.schema.json is regenerated by the
generate_and_write_schema test and now describes MacroSpec under
$defs plus a macroSpec property on Model.

simlin-mcp-core's build_empty_project_with_specs constructs a
json::Model literal; the compiler flagged it for the new field, so it
gets macro_spec: None (an empty project is never a macro template).
json-types.ts is the hand-maintained TypeScript mirror of json.rs; add
JsonMacroSpec with the same camelCase keys and optionality as
json::MacroSpec (parameters and primaryOutput always present,
additionalOutputs skip-serialized when empty) and a macroSpec? field on
JsonModel. datamodel.ts gains the readonly MacroSpec interface plus
macroSpecFromJson/macroSpecToJson converters mirroring the LoopMetadata
precedent, and macroSpec is wired through modelFromJson/modelToJson.

This proves the TypeScript half of macros.AC1.4: a MacroSpec round-trips
losslessly, and an empty additionalOutputs is omitted from JSON and
restored as [].
json.rs is the source of truth for the Python json mirror. Add a
MacroSpec dataclass to json_types.py (all fields defaulted, mirroring
LoopMetadata) and a macro_spec field to the Model dataclass. cattrs
centralizes (de)serialization in json_converter.py: a structure_macro_spec
hook reads the camelCase keys (primaryOutput, additionalOutputs),
structure_model wires the optional macroSpec key, and MacroSpec is added
to additional_type_required_fields so parameters and primary_output
always serialize at their defaults while additional_outputs is omitted
when empty -- matching json::MacroSpec's serde attributes exactly.

This proves the Python half of macros.AC1.4: a macro-bearing Model
round-trips losslessly through the json_converter using camelCase keys.
The SDAI JSON bridge constructs datamodel::Model with macro_spec: None
in both conversion directions (SdaiModel -> Project import and the
Project -> SdaiModel fallback empty model). A bare macro_spec: None in
a conversion is non-obvious -- a reader cannot tell if it is a
deliberate drop or an oversight. Mirror the rationale style already
used in xmile/model.rs: SDAI is a lossy AI-augmentation bridge format
with no macro concept, so macro_spec is intentionally not carried.
Correct for Phase 1 of Vensim macro support; revisit if SDAI ever
needs macro awareness. Comment-only; no behavior change.
The MDL ConversionContext pipeline already turns a list of MdlItems into a
datamodel::Model, but it was hardwired to (a) drain its items from an
EquationReader and (b) emit exactly one model named "main". Phase 2's macro
import needs to run the same passes over a macro body with a scoped symbol
table, so this factors out both hardwirings without changing any observable
behavior.

new_with_data now delegates struct assembly to a new new_from_items
constructor that takes a caller-supplied item list plus the parent's
already-built dimensions/data_provider/formatter (XmileFormatter gains a
cheap Clone so a sub-context can reuse the parent's subrange-name state).
build_project is split into build_model(name) (the model-building core,
producing a Model with sim_specs/macro_spec None) plus the Project assembly;
build_groups loses its unused &mut self. The "main" conversion is
byte-for-byte unchanged and the public convert_mdl/convert_mdl_with_data
signatures are untouched.
Each parsed MacroDef now becomes a macro-marked datamodel::Model alongside
the "main" model, instead of being silently discarded. A new convert/macros
pass (run after stock/flow linking, before build_project consumes self)
builds each macro body via a scoped ConversionContext over the body
equations -- reusing Task 4's new_from_items with the parent's shared
dimensions/data_provider/formatter -- so single-aux, multi-equation, and
stock INTEG bodies all convert through the existing passes for free.

The format-agnostic half -- synthesizing a port Variable per formal
parameter and attaching the MacroSpec -- lives in a new pub(crate)
Model::new_macro on datamodel::Model so the XMILE reader can reuse it
verbatim in a later phase. A synthetic placeholder equation is prepended
per parameter before the scoped conversion so link_stocks_and_flows assigns
each port the correct stock/flow/aux kind itself: the INTEG-rate parameter
becomes a Flow port and the INTEG-initial parameter an Aux port, mirroring
stdlib delay1/smth1. new_macro then flips can_be_module_input on those
pipeline-built ports without disturbing their kind, which
collect_module_idents requires to treat a macro model (an ordinary
non-stdlib sub-model) as having input slots.

Macro name, parameters, and additional outputs are canonicalized to the
variable-ident form (lowercase then space-to-underbar) so MacroSpec entries
are byte-identical to the body variables they name and to the synthesized
port idents. Additional colon-list outputs are body-computed and so are not
synthesized as ports. Single-output macro invocations are left as ordinary
equation text (materialized in a later phase).
A `$`-suffixed time reference in a macro body (`Time$`, `TIME STEP$`,
`INITIAL TIME$`, `FINAL TIME$`, `DT$`) is Vensim's escape, valid only inside
a macro body, for reaching the caller's global time variables; after lexing
it is an Expr::Var whose name still includes the trailing `$`. The engine
already resolves the bare canonical time idents inside a module body at any
nesting depth (they are zero-arity builtins), so the only work is a
front-end name translation.

A new pub(super) rewrite_dollar_time helper in convert/helpers.rs
recursively rewrites every Expr::Var whose name -- lowercased,
space-normalized, trailing `$` stripped -- matches a Vensim time variable to
the canonical engine ident (time / time_step / initial_time / final_time /
dt), recursing through Op1/Op2/Paren and the args and output_bindings of
App. The macro-conversion routine applies it to each body equation's
expression before the scoped conversion formats it; it is scoped to macro
bodies only (the global formatter and non-macro equations are untouched), so
a `$`-time reference outside a macro body -- which is meaningless -- is out
of scope, as is a non-time `$`-suffixed reference.

The shared XMILE formatter then renders the canonical idents in its uniform
time-name form (TIME / DT / ...), exactly as it does for an ordinary
non-macro equation that references Time / TIME STEP; the import-level test
cross-checks byte-equality against that non-macro baseline to prove the
escape was translated to the same canonical idents, not merely stripped.
The debug_assert! guarding empty output_bindings in the XMILE
formatter's App arm used a Unicode em-dash in its message string.
Project guidelines favor ASCII in code and comments, so replace it
with " -- ". Message-string-only change: the assertion condition and
guard placement are unchanged.
Generalize the model.rs stdlib pre-classification to also recognize
project-macro calls, and make the per-project macro registry available
at the compile entry points so a macro-using model classifies its
caller variables correctly and an invalid macro set fails the compile
with a clear error.

The registry is a salsa-tracked query (db_macro_registry::
project_macro_registry) keyed on the project's SourceModels: macro_spec
is now carried on the SourceModel salsa input (mirroring Compat), so
editing a non-macro variable does not invalidate it.
equation_is_stdlib_call becomes equation_is_module_call, takes the
registry, and returns true for a macro call too; the change is threaded
through collect_module_idents and module_ident_context_for_model (which
gains a SourceProject parameter, since the registry is a project-level
concept) and on to the one simlin-cli caller.

Registry-build validation (recursion cycle / duplicate macro name /
macro-model name collision) must run on the datamodel Vec<Model>: the
SourceProject.models map is name-keyed, so two same-named macros -- or a
macro colliding with a model -- collapse and become indistinguishable
post-sync. The build is therefore run at sync time on the datamodel and
its error message stored on a new SourceProject field; the salsa query
surfaces it both as a project-level diagnostic (via collect_all_
diagnostics) and through compile_project_incremental as sim_err!(
NotSimulatable, msg). For a valid project every model name is unique, so
the resolution registry is rebuilt exactly from the deduplicated models.

The registry query plus the sync-time error helper live in a new
db_macro_registry top-level module (a sibling of db, like db_ltm_ir)
to keep db.rs under the per-file line cap.

A macro call still hits UnknownBuiltin in BuiltinVisitor after this
commit -- expansion is Task 3. No-macro projects are byte-for-byte
unchanged (full lib suite and the simulate integration tests green).

A small TestProject::from_datamodel constructor wraps an open_vensim /
convert_mdl project so the compile/run/diagnostic helpers apply to a
multi-model (macro-bearing) project unchanged.
Generalize the stdlib-hardcoded module rewrite in BuiltinVisitor into a
descriptor-driven helper and consult the macro registry, so a macro
invocation expands into a synthetic Variable::Module exactly the way a
stdlib call does -- the compiler, module-instantiation machinery, and
VM are unchanged.

expand_module_function takes a &ModuleFunctionDescriptor: model_name,
the ModuleReference dst ports, and the replacement Var("<module>·
<primary_output>") all come from the descriptor (was format!("stdlib⁚
{func}"), stdlib_args, and a hardcoded ·output). stdlib primary_output
is "output" and the synthetic-name / arg-hoisting / A2A-subscript
logic is reused verbatim, so stdlib expansion is byte-for-byte
identical (builtins_visitor + simulate suites unchanged). A macro is
strict-arity (args.len() must equal parameter_ports.len(), else
BadBuiltinArgs over the call span); stdlib stays lenient.

walk()'s App arm now resolves the registry at the very top -- before
alias normalization / modulo / previous / init / is_builtin_fn / the
stdlib lookup -- so a project macro shadows an identically named
builtin or stdlib func (Vensim's rule; fixes the latent SSHAPE /
RAMP FROM TO mis-resolution). The stdlib branch swaps stdlib_args-else-
UnknownBuiltin for stdlib_descriptor-else-UnknownBuiltin, so a genuinely
unknown name still fails UnknownBuiltin (AC5.6).

contains_stdlib_call becomes contains_module_call, threaded with the
registry and macro-aware, so an arrayed macro invocation enters the
per-element apply-to-all path (Phase 4 exercises it end-to-end).

The registry is threaded from the Task 2 salsa parse query
(parse_source_variable_with_module_context, which has SourceProject)
down through parse_source_variable_impl / parse_var_with_module_context
to instantiate_implicit_modules. parse_var and the ~40 macro-free /
test callers pass None -> a shared 'static empty registry (no extra
allocation, no Option handling at resolve_macro sites); LTM synthetic
equations are engine-generated and never carry macro calls so they pass
None too. The one simlin-cli model_module_ident_context caller is
updated for the Task 2 SourceProject parameter.

module_functions.rs's module-wide #![allow(dead_code)] is removed
entirely -- after Tasks 2-3 every pub(crate) item has a real production
caller and clippy is clean without it in all feature configs.

Verified: macros.AC2.1 (smoke), AC5.1 (arity), AC5.4 (SSHAPE +
RAMP FROM TO shadow), AC5.6 (unknown name), plus a structural synthetic-
module test and a contains_module_call macro-awareness test; the Task 2
AC5.2/AC5.3 end-to-end tests still pass; builtins_visitor (22) and the
simulate integration suite (55) confirm stdlib is unchanged.
`project_macro_registry` previously recovered the diagnostic's `ErrorCode`
by inspecting the build-error message shape
(`message.starts_with("recursive macro")` -> `CircularDependency`, else
`DuplicateMacroName`). The code is already known at build time --
`MacroRegistry::build` returns an `Error` carrying the correct typed
`code` -- so re-deriving it from prose downstream was fragile internal
string-coupling: a reword of either build error message in
`module_functions.rs` would silently mis-tag the diagnostic with no test
catching it.

Thread the typed code end-to-end instead. `macro_registry_build_error`
now returns `Option<(ErrorCode, String)>` taken directly from the `Err`;
`SourceProject::macro_registry_build_error` stores the pair; and
`project_macro_registry` tags the accumulated diagnostic with that exact
code. The user-facing message text and the project-level `NotSimulatable`
compile failure are unchanged. A new `db_macro_registry::tests` module
locks the propagation in with an oracle assertion (stored code ==
`MacroRegistry::build`'s own `Err.code`), verified red-green to fail if
the code is mis-tagged.
A Vensim multi-output macro invocation (total = add3(a, b, c : minv,
maxv)) has no plain-text equivalent -- a call returns several named
values at once -- so it cannot ride the single-output text-then-expand
path. It is now materialized at MDL import, before build_equation / the
XMILE formatter ever sees the multi-output Expr::App (the formatter
asserts output_bindings.is_empty()).

A new convert/multi_output.rs pass runs after the macro-marked models
are built (so their MacroSpecs are available) and before build_project's
per-symbol loop. For each main-model symbol whose top-level RHS is an
Expr::App with non-empty output_bindings it emits: one input-only
Variable::Module named {lhs}_macro (collision-safe and
serialization-stable -- deliberately not the compile-time-synthetic
prefix, since this is round-tripped), a primary-output binding Aux
replacing the LHS (reads {module}.{primary_output}), and one
additional-output binding Aux per :-list entry (the call-site name
becomes the ident, reading {module}.{macro_internal_output}). The
ASCII-period reference text canonicalizes to U+00B7 at compile-time
parse and resolves through the existing general get_submodel_offset
machinery. Unknown-macro and arity mismatches are ConvertError::Other
naming the macro.

Real Vensim multi-output models do not separately declare the :-list
names (the call creates them); the materializer additionally adds those
idents to skip_symbols defensively so a stray same-named declaration
cannot shadow the binding reference.
Phase 3 made instantiate_implicit_modules's apply-to-all path
macro-aware (contains_module_call), so an arrayed macro invocation rides
the existing per-element module-expansion machinery with no new
mechanism. This task is verification: a stockless arrayed fixture
(macro_arrayed: out[Region] = SCALE(inp[Region], factor)), an
expansion-level assertion that the invocation expands into one
independent synthetic Variable::Module per Region element (asserted
through the full compile pipeline by inspecting the per-element
$⁚out⁚n⁚scale⁚{elem}·scale body slots in Results.offsets, not a shared
instance), and an inline arrayed-stock test
(ACCUM(rate, init) = INTEG(rate, init), total[Region] =
ACCUM(rate[Region], 0), rate = [1, 3]) asserting each element integrates
its OWN rate independently (total[r1] = 0,1,2,3,4 vs
total[r2] = 0,3,6,9,12) -- proving per-element persistent state.

No arrayed-macro-specific gap was found: the existing path handles both
the stockless and stock-bearing cases, so no builtins_visitor.rs change
was needed. SCALE/ACCUM each take two parameters because a 1-arg MDL
call NAME(arg) is rewritten to LOOKUP before macro resolution (GH #553).
… models

Tests-only early gate (the full tiered corpus harness + Vensim-reference
comparison is Phase 7). THEIL (test/metasd/theil-statistics/Theil_2011.mdl):
asserts the 2-input/13-output THEIL multi-output invocation materialized
(one Variable::Module + 1 primary + 13 additional binding auxes),
compiles, and runs to the end; ~40ms so it is a regular (non-ignored)
test. SSTATS (the COVID model, #[ignore]d): asserts BOTH 2-input/10-output
SSTATS invocations materialized (2 modules + 22 binding auxes) and that
no macro-specific compile diagnostic fires -- the model's only blockers
are its unrelated unresolved *_data GET-DIRECT references (no
DataProvider supplied), so the assertion is narrowed per the phase plan
and the GET-DIRECT-data blocker is reported for Phase-7 scope. C-LEARN
(~53k lines, #[ignore]d): asserts all four macros (SAMPLE UNTIL, SSHAPE,
RAMP FROM TO, INIT) import as macro-marked models with the correct
MacroSpecs.

C-LEARN surfaced a real, separately-tracked macro bug: its uninvoked
INIT(x) = INITIAL(x) macro trips a FALSE recursive macro: init -> init
in MacroRegistry::build. The MDL importer renames the INITIAL builtin to
INIT (the engine's canonical initial-value intrinsic name), so the
stored body becomes INIT = INIT(x); collect_called_macros then treats
the renamed-builtin call as a recursive call to the same-named macro and
fails the whole registry build, which cascades (no registry => SSHAPE /
SAMPLE UNTIL / RAMP FROM TO stop shadowing builtins => BadBuiltinArgs /
UnknownBuiltin at their call sites). In Vensim the body wrote INITIAL (a
distinct name), so there is no recursion and C-LEARN runs; the clash is
manufactured by the necessary rename. This contradicts the design's
macros.AC6.2 / AC1.7 and needs a fix at the macro-recursion detector +
shadowing precedence, which is out of this tests-only task's scope. The
C-LEARN test asserts only the verifiable import facts plus the current
buggy behavior (so a regression / the eventual fix surfaces here) and
documents the blocker for follow-up.
Addresses three Phase 4 code-review findings on the MDL multi-output
convert path.

Important: the converter only detected a multi-output macro call when the
`output_bindings`-bearing `Expr::App` was the *entire* RHS. The parser,
however, syntactically accepts the invalid nested form
(`y = 1 + ADD3(p, q, r : lo, hi)`) -- the inner App keeps its
output_bindings. Nothing converted that to an error, so it reached the
XMILE formatter: a debug/test build PANICKED on the Phase-2
`debug_assert!(output_bindings.is_empty())`, and a release build SILENTLY
formatted `ADD3(p, q, r)` and dropped the `:`-list outputs with no
diagnostic -- a debug-panic / release-data-loss split. multi_output.rs's
comment asserted "a multi-output call cannot legally be a sub-expression"
as an UNENFORCED invariant. `find_nested_multi_output_call` now walks each
selected equation's expression tree (variant coverage mirrors the Phase-2
`rewrite_dollar_time` recursion) and rejects any output_bindings-bearing
App in a non-top-level position with a `ConvertError` naming the macro and
conveying the whole-RHS-only rule, run as a guard before
`detect_multi_output_call`. The legitimate whole-RHS App is not flagged
(only its args/`:`-outputs are descended for *nested* calls). This
enforces the invariant the comment only asserted; the comment is rewritten
to describe the guard rather than state a false unconditional invariant.

Minor: cross-reference the now-filed GitHub issue #554 in the C-LEARN
false `init -> init` recursion pin (comment-only; assertions unchanged) so
a future engineer who fixes #554 finds and upgrades the test. Tighten
`count_materialized_macro` to match the EXACT `{module}.{output}` binding
form (first-period split, exact module-ident prefix, single non-empty
output segment) instead of a `starts_with("{module}.")` prefix that could
over-count an unrelated aux merely referencing a module output; the exact
THEIL (==14) and SSTATS (==22) count assertions still pass unchanged.
…t predicate

Two Phase-4 cycle-2 re-review Minors, both completeness/accuracy fixes
to code introduced earlier in Phase 4.

The nested-multi-output guard in materialize_multi_output_invocations
scanned only select_equation's single representative pick (PurgeAFOEq
returns non_empty[0]). An arrayed variable carries one FullEquation per
element override and build_variable_with_elements formats every valid
per-element equation, so a nested multi-output call (`y = c + ADD3(...:
lo,hi)`) in a *later* per-element equation bypassed the guard, reached
the XMILE formatter, and tripped the Phase-2 debug_assert! (debug/test
panic, release silent :-output drop) -- the exact failure mode the
cycle-1 fix was meant to eliminate. The guard now scans every non-empty
equation (same is_empty_rhs predicate the build pass filters on); the
legitimate whole-RHS detection still keys off select_equation so the
cycle-1 scalar materialization behavior is preserved exactly.

The is_module_output_binding test helper's rustdoc claimed it rejects an
aux that merely references a module output inside a larger expression
(`mod.out + 1`), but the suffix-has-no-period check accepted it. Tighten
the predicate so the suffix must be a single bare identifier token
(ASCII alphanumeric or `_`) -- the verbatim form materialization emits
(`format!("{module}.{output}")`, output a bare macro-output ident) --
making the rustdoc accurate. THEIL (14) and SSTATS (22) binding counts
are unchanged, confirming the materialized form is genuinely bare.
Flesh out the previously-empty xmile::Macro stub into a real serde
type mirroring the xmile::Model/xmile::Var patterns: a name attribute,
ordered <parm> children (each an optional default), an expression-form
<eqn>, an optional <variables> body, an optional per-macro <sim_specs>,
<doc>, and a namespace attribute. The Eq derive is dropped (the new
variables/sim_specs fields transitively contain f64). A <views> field
is deliberately omitted -- a macro body's diagram is inert and
quick_xml silently drops the unknown element on read.

convert_file_to_project becomes fallible and, after building models
from file.models, bridges each file.macros entry to a macro-marked
datamodel::Model. The body is the <variables> if present, else the
<eqn> normalized into a macro-named Aux (the AC1.3 expression-form
requirement). Port synthesis and MacroSpec construction reuse the
shared Model::new_macro helper -- identical to the MDL path, not
re-implemented. Body variable idents are canonicalized to the engine
ident form so they stay byte-identical to the canonical MacroSpec
names and Model::new_macro's port matching, mirroring the MDL path's
invariant. A non-empty per-macro <sim_specs> is the documented
unsupported limitation and is rejected with a clear BadSimSpecs error.
Add a hand-written ToXml impl for xmile::Macro mirroring the
Model::write_xml style (<macro name>, <eqn> with the primary-output
name, <parm>s with optional default attrs, <variables> looping
Var::write_xml, <doc>), and a From<datamodel::Model> for Macro bridge
that excludes the synthesized parameter ports from <variables> (they
are reconstructed from the <parm>s on re-import, so re-emitting them
would break round-trip stability). File::write_xml now iterates the
macros after the models.

From<datamodel::Project> for File partitions models by
macro_spec.is_some(): macro-marked models become top-level <macro>
siblings, the rest stay <model>s. Whenever the project contains a
macro, the <uses_macros recursive_macros="false"
option_filters="false"/> header option is emitted -- this required
adding the previously-absent <options>/feature writing to
Header::write_xml plus a write_tag_empty_with_attrs helper for the
self-closing form. The deterministic fixed "false"/"false"
attributes keep the byte-stable round-trip stable.

Fix the latent Feature::UsesMacros serde bug: the recursive_macros /
option_filters fields lacked #[serde(rename="@...")] so they
parsed as child elements instead of the spec-required attributes --
the reader and the new writer would have disagreed. A single-output
macro emits a plain standard <macro> with no simlin: extension.
Standard XMILE has no multi-output-port or multi-output-call concept.
A multi-output macro definition's additional output ports already
round-trip via the <simlin:additional-outputs names="..."/> child on
<macro> (wired in Tasks 1-2 through MacroSpec.additional_outputs).

This adds the second extension: a multi-output *invocation*. Phase 4
materializes total = add3(a,b,c : minv, maxv) as an input-only
Variable::Module plus the LHS primary-output binding Aux and one Aux
per additional :-output. Standard <module> references a <model>, not a
<macro>, so the cluster round-trips through a single
<simlin:macro-invocation> element (module/macro names, input wirings,
and primary + additional output bindings) recorded on the <model>.

The project-level From<datamodel::Project> for File builds a macro
registry and, before the per-model bridge runs, extracts each
materialized cluster (a Variable::Module whose model_name resolves to
a macro-marked model, plus the binding auxes whose scalar equation is
exactly {module}.{output}) into the extension and removes the cluster
variables. The reader reconstructs the cluster exactly (ASCII-period
module-output separator, the Phase-4 datamodel convention), so an
XMILE->datamodel->XMILE round-trip is byte-stable. The extension is
emitted only for multi-output macros; single-output projects stay
standards-clean. Verified end-to-end including a cross-format
.mdl->XMILE->datamodel test on the macro_multi_output fixture.
Wires the four .xmile macro fixtures (macro_expression,
macro_multi_expression, macro_multi_macros [two <macro> elements],
macro_stock [stock-bearing macro body]) into simulate.rs's TEST_MODELS,
so each runs through import -> simulate-vs-output.tab -> protobuf
round-trip -> byte-stable XMILE round-trip. Adds a single-output
cross-format test (.mdl -> datamodel -> .xmile -> datamodel) asserting
the macro-marked Model + MacroSpec and the invocation equation survive
the conversion (macros.AC4.4).

Wiring the fixtures surfaced a real reader gap: the XMILE <macro>
import path (macro_to_datamodel / Model::new_macro) did not order the
macro-marked model's variables by canonical ident, whereas the <model>
import path (From<xmile::Model> for datamodel::Model) does, and the
protobuf serialization (From<Model> for project_io::Model) re-sorts
every model's variables by that same key for deterministic encoding.
A protobuf serialize -> deserialize round-trip is therefore identity
only for projects already in canonical order; Model::new_macro appends
the synthesized parameter ports after the body variables, so the macro
model's round-trip was lossy and tripped simulate_path_with's
assert_eq!(datamodel_project, datamodel_project2). Fixed at the root by
sorting the macro model's variables by canonical ident in the reader,
mirroring the <model> path exactly. Also corrected the Task 3
multi-output byte-stable test, which compared to_xmile of a hand-built
(non-canonically-ordered) project against its reader-normalized
round-trip; it now asserts the genuine fixed-point property a
reader-produced project must satisfy (the same invariant
simulate_path_with enforces).
…guard)

Three Minor code-review follow-ups from Phase 5; no behavior change.

convert_file_to_project deep-cloned the entire parsed File only to move
file.macros out afterward, copying every non-macro model on every XMILE
import. From<File> for datamodel::Project never reads file.macros, so we
std::mem::take the macros first and pass the rest of the File by move.

extract_macro_invocations builds a first-wins (or_insert_with) scalar-
equation index, so when two auxes share a "{module}.{primary_output}"
binding equation only the first becomes the extracted binding and any
aliases remain plain auxes in the residual model. That still round-trips
(the module output is reconstructed on re-import), but the nuance was
undocumented; added a note to the rustdoc.

reconstruct_macro_invocation hard-codes default documentation/units/
Compat for the reconstructed module and additional-output binding auxes.
That is correct because the Phase-4 materializer (multi_output.rs) is the
only producer and emits those defaults, but a future materializer change
that added module-level doc/units/compat would silently lose them through
the XMILE round-trip with no test catching it. Added cross-reference
comments at both sites and debug_assert!s at the producer so a lockstep
violation is caught in debug/test builds.
bpowers added 14 commits May 15, 2026 03:24
Remove the now-stale `macros (:MACRO:) -- the writer rejects them`
exclusion line and add all 8 macro fixtures to TEST_MDL_MODELS: the 6
bundled macro_* fixtures plus the Phase-4-authored macro_multi_output
and macro_arrayed. Each now flows through mdl_to_mdl_roundtrip's
parse_mdl -> project_to_mdl -> parse_mdl -> assert_semantic_equivalence
loop.

Extend assert_model_equivalence to also compare Model.macro_spec
(PartialEq) and Model.name. Without this a malformed-but-present
MacroSpec would slip through: the body variables live in the macro
model, so a missing macro model only changes the model count (already
checked) but a wrong MacroSpec (bad parameters / outputs) would not.
The comparison is a no-op for the non-macro fixtures (the main model
is "main" with macro_spec: None on both passes) and was confirmed
non-vacuous: dropping :MACRO: blocks fails all 8 fixtures, and
corrupting only MacroSpec.parameters trips `macro_spec differs` with
the model count unchanged.

All 8 fixtures round-trip: macro_cross_reference / macro_multi_macros
exercise multiple :MACRO: blocks, macro_stock a macro-body stock,
macro_trailing_definition a block emitted before its invocation,
macro_multi_output the `:` multi-output reconstruction (the
materialized Variable::Module cluster survives), macro_arrayed an
arrayed single-output invocation that stays as equation text.
…rationale

main_model is a pub(crate) "safe lookup" helper, but its fallback was
`.unwrap_or(&project.models[0])` -- a panicking index. It is unreachable
on every real path (both callers run after project_to_mdl's reject gate,
which rejects when the non-macro model count != 1, including the empty
0 != 1 case), but a safe-lookup helper should not carry a panicking-index
fallback even when unreachable. Return `Option<&Model>` and have the two
post-gate callers `.expect(MAIN_MODEL_EXPECT)`, turning the unreachable
panic into a loud, non-indexing assertion that names the invariant.
Observable behavior on every real (post-gate) path is unchanged.

Also expand the scalar_auxes first-wins comment in
build_multi_output_reconstructions to record the reviewer's
documented-intentional finding: the `or_insert` first-wins is unreachable
for binding-aux detection (a module's distinct outputs yield distinct
`{module_ident}.{output}` keys, so two bindings of one module never
collide), and it is retained only to mirror the documented first-wins of
xmile::model::extract_macro_invocations. Comment-only; zero logic change.
A Vensim macro whose body invokes an opcode-backed engine intrinsic
(init, previous) whose canonical name equals the macro's own name
triggered a false self-recursion cycle in MacroRegistry::build, which
failed the entire macro registry and -- because an empty registry
un-shadows every other macro -- blocked expansion of ALL of that
model's macros (GH #554).

Concretely: the MDL importer NECESSARILY renames the Vensim INITIAL
builtin to INIT (xmile_compat.rs; Expr1 lowering recognizes only the
short opcode name `init`, not `initial`), so C-LEARN's uninvoked
`:MACRO: INIT(x) ... INIT = INITIAL(x)` is stored as the datamodel
body `init = init(x)`. collect_called_macros canonicalized that
renamed-builtin call to `init`, collided it with the registered `init`
macro, and recorded a spurious `init -> init` self-edge -> a false
CircularDependency that cascaded SSHAPE/SAMPLE UNTIL/RAMP FROM TO into
BadBuiltinArgs/UnknownBuiltin.

The fix is two coordinated halves sharing one predicate
(module_functions::is_renamed_opcode_intrinsic, the set {init,
previous} -- the only opcode-backed intrinsics with dedicated
temp-arg routing in BuiltinVisitor::walk, and the rename targets of
xmile_compat.rs's INITIAL->INIT / SAMPLE IF TRUE->PREVIOUS):

Part A (collect_called_macros): do not record the macro self-edge
when the called name canonicalizes to the enclosing macro's own
canonical name AND that name is a renamed opcode intrinsic. Scoped
strictly to the self-edge case: a genuinely self-recursive
non-intrinsic macro (`foo = foo(x)`) and mutual recursion by
non-intrinsic names are still rejected (macros.AC5.2 unweakened); a
different macro merely named after an intrinsic still produces a real
edge (`init -> previous -> init` still a rejected cycle).

Part B (BuiltinVisitor::walk): when expanding a macro body, a call
matching that same predicate resolves to the intrinsic instead of the
macro-shadows-everything precedence -- without this an INVOKED such
macro would infinite-loop (the body's `init(x)` would re-resolve to
the macro forever). The enclosing macro's name is threaded via a new
salsa-tracked macro_body_owner map (SourceVariable.model_name is a
Module variable's referenced target, empty for non-Module vars, so it
could not answer "which macro owns this body variable").

The Phase 4 C-LEARN pin (corpus_clearn_macros_import) is upgraded from
pinning the buggy false-recursion to asserting the corrected behavior
(four macros import + no macro-registry CircularDependency cascade);
it stays #[ignore]d as the Phase 7 C-LEARN macro-expansion regression
guard.
…macro

Two code-review follow-ups to the #554 fix (b0ef57a), both zero-risk to
the core fix logic.

Minor #1 (doc-only): the defensive `continue` in
`MacroRegistry::check_for_recursion` (skip a macro-marked model absent
from `self.macros` -- only possible for an already-rejected duplicate)
predates #554. Now that `from` doubles as the #554 `enclosing` self-edge
carve-out fed to `collect_called_macros`, the comment was incomplete.
Added one clause noting the skip fires BEFORE `collect_called_macros` is
reached, so the (impossible) dropped model contributes no edges and
`from` is never misused as the carve-out for it -- correct behavior, the
comment just now says why. No logic change (the guard is byte-identical).

Minor #2 (test-only): the existing end-to-end #554 coverage
(`issue_554_invoked_macro_wrapping_own_init_intrinsic_compiles_and_runs`)
only exercised Part B for `init`; `previous` was covered only at the
registry-build level (Part A). Added the faithful `previous` mirror,
`issue_554_invoked_macro_wrapping_own_previous_intrinsic_compiles_and_runs`:
a macro canonically named `previous` whose body wraps its own `PREVIOUS`
intrinsic (the importer's `SAMPLE IF TRUE` -> `PREVIOUS(SELF, init)`
rename target), invoked alongside a sibling macro. PREVIOUS's verified
signature is `PREVIOUS(input, initial)` (first step returns `initial`
since `prev_values` is not yet valid, then `input`'s previous-timestep
value -- vm.rs `LoadPrev`/`use_prev_fallback`, cross-checked against
test/previous/output.tab); with constant port `x=9` and `k=4` over the
t=0,1,2 run the series is `[4, 9, 9]`, hand-verifiable.

The test passes with the committed #554 fix as-is: Part B's shared
`is_renamed_opcode_intrinsic` predicate ({init, previous}) already
covers `previous` end-to-end, so no production gap was surfaced and no
core #554 logic was touched. The test's teeth were confirmed by
temporarily restricting the Part-B predicate to `init` only -- the new
test then failed with the expected infinite-recursion symptom (a salsa
model_module_map dependency cycle) -- then restored. macros.AC5.2 stays
unweakened (the adjacent genuine/mutual-recursion guards still pass).
Phase 7 Task 1 (macros.AC6.2, macros.AC6.3). Extends the #554-upgraded
corpus_clearn_macros_import regression guard with the AC6.2 compile +
diagnostics step: C-LEARN is open_vensim'd, synced and compiled via the
salsa path, every diagnostic is collected, and a shared
macro_attributable_diagnostics classifier asserts NONE is
macro-attributable. The classifier (also used by the metasd corpus
harness in Task 2) flags exactly three shapes -- a project-level
macro-registry build error (the #554 cascade class), an Error-severity
diagnostic inside a macro template body, and a macro-resolution-failure
code on a macro model / project-level / co-occurring with a registry
error -- and deliberately does NOT flag C-LEARN's allowed non-macro
blockers (model-logic circular deps, dimension mismatches, unit
warnings, and Phase 3's non-time $ reference, which surfaces as an
ordinary unresolved-reference diagnostic). A focused classifier unit
test pins this separation so neither harness can degrade into 'flags
everything' or 'flags nothing'.

Adds three focused C-LEARN-macro isolation fixtures
(test/test-models/tests/macro_clearn_{sample_until,sshape,ramp_from_to}/)
whose :MACRO: blocks are copied verbatim from C-LEARN and invoked with
known constant inputs; each output.tab is hand-computed from the macro
body formula, grounded in the engine's STEP/RAMP/INTEG semantics and
documented in a README. INIT is not invoked anywhere in C-LEARN so it
needs no focused model (AC6.2 covers the defined-but-never-invoked
case).

simulates_clearn stays #[ignore]d, but its comment no longer blames
macro expansion: the macros now expand with zero macro-attributable
diagnostics. The remaining blockers are C-LEARN's non-macro issues
(compile_vm fails with NotSimulatable before the VM is built -- a
model-logic CircularDependency on
main.previous_emissions_intensity_vs_refyr, dimension mismatches, an
unknown dependency, a non-time $ reference, plus unit warnings). These
are out of scope for the macro work and tracked separately per the
design.
Phase 7 Task 2 (macros.AC6.1, macros.AC6.4).

AC6.1 (confirmation): verified all eight test/test-models/tests/macro_*
fixtures are already wired and green -- the .mdl variants in simulate.rs
(Phase 3) and mdl_roundtrip.rs (Phase 6), the four .xmile variants in
simulate.rs (Phase 5). No earlier-phase wiring gap was found, so no
change to simulate.rs / mdl_roundtrip.rs was needed.

AC6.4: adds src/simlin-engine/tests/metasd_macros.rs, a tiered corpus
harness over all 17 macro-using metasd .mdl files (14 directories,
annotated TEST_SDEVERYWHERE_MODELS-style with per-model tier status).
The expansion tier (open_vensim -> sync -> compile ->
collect_all_diagnostics) asserts NO macro-attributable diagnostic; it is
split into a fast default test (light models) plus #[ignore]d opt-in
tests for the heavy real-world models and the full 17 (sum of compiles
~10s exceeds the per-test budget per docs/dev/rust.md). All 16 models
that do not hit the known bug below produce zero macro-attributable
diagnostics even though most have unrelated non-macro blockers
(engine-unsupported RANDOM NORMAL in pink_noise bodies, model-logic
issues, MDL-parse errors) -- those are surfaced as per-model annotations,
not flagged, because the macro still expands (correct MacroSpec) and the
body just hits the same engine gap a main equation would.

The corpus uses a narrower AC6.4 macro-attributable classifier than Task
1's C-LEARN one (registry-build error or macro/model name collision
only, NOT 'any Error in a macro body'), because metasd macro bodies
legitimately use engine-unsupported builtins; the divergence and its
rationale are documented at length in the module header.

KNOWN MACRO BUG, ESCALATED: thyroid-2008-d.mdl hits a genuine
macro-attributable failure -- :MACRO: DELAYN with body
DELAYN = DELAY N(...) produces a false-positive 'recursive macro:
delayn -> delayn' macro-registry CircularDependency. This is
structurally the #554 false positive, but #554's is_renamed_opcode_
intrinsic fix is deliberately scoped to the opcode intrinsics
init/previous and explicitly excludes stdlib-module-backed builtins like
DELAY N (the #554 termination argument does not extend to them without a
follow-up change to both #554 halves). It is NOT masked: excluded from
the expansion-tier assertion explicitly (the other 16 are asserted) and
pinned by a dedicated thyroid_known_macro_bug_is_macro_attributable test
that flips when the engine fix lands. This needs a focused #554-follow-up
engine fix and is escalated for issue tracking.

Simulation tier: no metasd macro model is simulation-tier-eligible as of
this phase (Theil_2011 compiles+simulates with zero errors but has no
checked-in reference output -- a documented prerequisite; the models that
carry a sibling .vdf, FREE/free 6.mdl and the groupon models, have heavy
unrelated non-macro blockers). Every non-eligible model carries a
documented, asserted-non-empty reason so a tracked blocker can never be
silently dropped. A corpus-integrity test pins the list at exactly 17
files / 14 directories.
Phase 7 Task 3 (macros.AC6.5, macros.AC6.6).

AC6.6: adds isMacroModel(model: Model): boolean to module-navigation.ts
next to isStdlibModel (the model-level gating precedent) -- it returns
model.macroSpec !== undefined -- and uses it in getAvailableModels'
loop over project.models so a macro-marked model is never offered as a
selectable module-reference target (it is also removed from the stdlib
group in the unlikely case a user macro shadows a stdlib name). A
macro-marked model is an ordinary project.models entry after import (the
diagram receives it via projectFromJson automatically), but the engine
materializes macro invocations directly -- inlined for single-output
macros, a Variable::Module + binding auxes for multi-output ones -- so
the diagram must not let a user point a module at a macro template.
getAvailableModels is confirmed the only model-LIST surface (its sole
consumer is ModuleDetails.tsx's <select data-testid=model-ref-select>;
navigation is otherwise a drill-in stack with no flat list), so the one
filter is sufficient.

AC6.5: confirmed a macro-bearing project opens without crashing with NO
production change. A new editor-open-project test drives
openEngineProject with a project whose models include a macro-marked
model (macroSpec + synthesized port variables) alongside main and
asserts the open resolves, state.activeProject is defined, no error is
surfaced, and the macro model round-trips through projectFromJson with
its macroSpec intact. (TDD surfaced an initial fixture bug -- JsonAuxiliary
uses , not  -- not a real crash; the open path handles
macro-marked models transparently as the architecture predicted.)

Tests: module-details-utils.test.ts gains an isMacroModel describe block
and getAvailableModels macro-filter cases (makeModel extended with an
optional macroSpec); editor-open-project.test.ts gains the
macro-bearing-project open test. All 871 @simlin/diagram tests pass;
pnpm build and pnpm tsc are green.
Extends the #554 renamed-builtin self-call mechanism to the
stdlib-module-backed builtins. #554 fixed the false-positive macro
recursion where a macro whose canonical name equals an importer-renamed
OPCODE-backed intrinsic (init/previous) and whose body calls that name
was wrongly flagged as recursive; it was deliberately scoped to
opcode-backed only because for init/previous the skipped self-call falls
through to a terminal LoadInitial/LoadPrev opcode.

The metasd thyroid-2008-d.mdl harness surfaced the same class for a
stdlib-module-backed builtin: :MACRO: DELAYN(...) ... DELAYN = DELAY
N(...). The MDL importer rewrites the body Vensim DELAY N(...) to the
single-token XMILE DELAYN(...), colliding with the enclosing macro's
canonical name; collect_called_macros recorded a false delayn -> delayn
self-edge that failed the whole MacroRegistry::build and un-shadowed
every other project macro (the #554 cascade), blocking AC6.4.

The corrective behavior is unambiguous: a macro DELAYN whose body is
DELAY N(...) is not recursive (Vensim macros cannot recurse and the
source wrote the distinct builtin name). The fix adds
is_renamed_stdlib_module_builtin (delegating to the authoritative
builtins::is_stdlib_module_function so the suppression set cannot drift
from the names that actually resolve to a stdlib module) and a combined
is_renamed_builtin_macro_collision used by BOTH the Part A recursion
check (collect_called_macros self-edge suppression) and the Part B
builtins_visitor macro-shadows-everything precedence, keeping a single
shared source of truth so the two sites agree by construction (the #554
design property, preserved and extended).

Termination (verified against builtins_visitor::walk): the skipped
self-call falls through to rewrite_alias_module_call/stdlib_descriptor
and resolves to a Variable::Module whose model_name is stdlib<U+205A>...
(delay1/delay3/...). The U+205A separator is not a legal Vensim
identifier character and the importer never mints that prefix for a user
model, so the stdlib model is necessarily distinct from the user macro's
model; its fixed body never references the user macro, so compiling it
does not re-enter the macro and the expansion terminates. An invoked
such-macro compiles, runs, and computes DELAY N's defined behavior.

AC5.2 is unweakened and #554 (init/previous, registry-level and invoked
e2e) is not regressed: a genuinely self-recursive macro whose name is
not a renamed builtin still records its self-edge, and a different macro
merely named after a builtin still produces a real edge (A->B->A cycles
stay rejected). thyroid-2008-d.mdl now passes the expansion tier with
zero macro-attributable diagnostics (17/17), unblocking AC6.4; its
residual in-body UnknownBuiltin (DELAY N with a macro-port order is the
orthogonal pre-existing stdlib constant-order limitation, not a
macro-handling failure) is the same gap a main equation hits.
Addresses three Phase 7 Tasks 1-3 code-review findings (1 Critical, 2
Minor) for the Vensim macro epic.

CRITICAL -- metasd_macros missing required-features=["file_io"]. Cargo
auto-discovers tests/metasd_macros.rs as a test target, but its
simulation tier calls simlin_engine::load_dat / load_csv, both
#[cfg(feature = "file_io")]-gated. Unlike every sibling file_io test
(simulate, simulate_ltm, simulate_systems, systems_roundtrip,
vdf_alias_decoder -- each carrying an explicit [[test]]
required-features=["file_io"] block) it had none, so `cargo build -p
simlin-engine --all-targets` and `cargo test -p simlin-engine --no-run`
WITHOUT file_io failed with error[E0425] (load_dat/load_csv configured
out). CI passed only by the fragile cross-crate feature-unification
accident that simlin-cli requests simlin-engine/file_io. Adds the
mirroring [[test]] entry so Cargo correctly skips the target when
file_io is off; verified the single-package build/test now succeed
without the feature and are unchanged with it.

MINOR -- SAMPLE UNTIL fixture did not exercise the freeze. With a
constant input=7 the INTEG stock reached 7 after step 1 and held, so
the (1-STEP(1,lastTime)) gate force-zeroed an already-zero flow;
sampled=[2,7,7,7,7,7] would be byte-identical with or without the
lastTime gate, so the fixture only pinned the init->7 jump, not SAMPLE
UNTIL's defining behavior (sample a CHANGING signal, then FREEZE it).
The :MACRO: block is kept byte-verbatim from C-LEARN (verified with a
diff against C-LEARN v77); only the invocation changed: the input is now
the time-varying 5+RAMP(1,0,10) and lastTime=4 (init 99, a distinct
sentinel), so the sample tracks the rising input through t=3 then
freezes at 8 while the input keeps rising to 13 -- the frozen value is
now distinct from both the init and every later input. output.tab and
README were recomputed by hand from the verbatim macro body and the
engine's actual STEP/RAMP/forward-Euler-INTEG semantics (vm.rs::step is
time + dt/2 > step_time; vm.rs::ramp is 0 at time<=start; macro_stock
pins the INTEG Euler convention), then confirmed by the existing
simulate.rs test against the engine's VM output. A scratch check
confirmed a model without the gate would now produce a different series
(differs at t=5..8), so the fixture genuinely discriminates the freeze.

MINOR -- the narrower metasd AC6.4 classifier had no non-vacuity pin.
Unlike simulate.rs's classifier (pinned by
macro_attributable_classifier_separates_macro_from_nonmacro) the
intentionally narrower metasd copy (project-level registry-build error +
name collision only -- narrower because metasd macro bodies legitimately
hit unrelated engine gaps like RANDOM NORMAL) had no independent pin;
thyroid_produces_no_macro_attributable_diagnostics only exercises the
registry-error-ABSENT path, so the classifier could silently degrade
with no test failing. Extracted the inline closure into a named
narrower_macro_attributable_diagnostics (behavior byte-identical;
mirrors simulate.rs's named-function + pin structure) and added
narrower_classifier_flags_registry_error_but_not_in_body_unknown_builtin,
which drives REAL diagnostics through the same salsa path the corpus
harness uses: (a) a directly-recursive macro must be flagged
(project-level Model CircularDependency, the #554 cascade class); (b)
the literal metasd RANDOM-NORMAL-in-a-macro-body UnknownBuiltin must NOT
be flagged. Verified genuinely non-vacuous: mutating the classifier to
flag-everything fails (b) and to flag-nothing fails (a) (both observed,
then reverted). The de-duplication of the two classifiers across
compilation units is out of scope (tracked as GH #563).
Aligns the direction-(b) rustdoc fragment with the actual RANDOM NORMAL
test construct per the comment-accuracy standard; the old NOTAFUNCTION/B
example was a leftover draft never updated when the body became the real
RANDOM NORMAL builtin. Comment-only, zero behavior change.
The macro work (Phases 1-7) added MacroSpec/Model.macro_spec as a
first-class datamodel concept threaded through every serialization
layer, two new top-level engine modules (module_functions.rs,
db_macro_registry.rs), MDL/XMILE macro read+write, and a diagram-side
gate. Update only the CLAUDE.md files whose documented module map,
contracts, or known-gaps statements were made stale by this:

- src/simlin-engine/CLAUDE.md: its own maintenance note mandates the
  module map stay current; added the two new sibling-of-db modules,
  the new mdl/convert submodules, the SourceProject/SourceModel salsa
  inputs, datamodel MacroSpec/Model::new_macro, the renamed
  module-call pre-scan predicates, XMILE/MDL macro round-trip, the
  new test harness/unit-test module, and the file_io gating.
- src/simlin-engine/src/mdl/CLAUDE.md: removed the now-false 'macro
  conversion not implemented' Known Gap and listed convert/macros.rs
  and convert/multi_output.rs.
- src/core/CLAUDE.md: documented the new Model.macroSpec contract and
  its round-trip helpers.
- src/diagram/CLAUDE.md: documented isMacroModel and the macro-marked
  model exclusion from module-reference targets.

Root CLAUDE.md, docs/README.md, and the engine/pysimlin CLAUDE.md
files were left unchanged: they document no contract the macro work
invalidated, and the macro design/reference docs they already link
predate this branch.
The macro epic renamed equation_is_stdlib_call -> equation_is_module_call
and contains_stdlib_call -> contains_module_call, but the shared rustdoc
on is_stdlib_module_function still named the old functions. Caught by the
project-context verification pass; the project comment standard requires
nearby comments to be updated in lockstep with renames.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 80530bae03

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1502 to +1505
let Some(&(primary_ident, primary_doc, primary_units)) =
scalar_auxes.get(primary_key.as_str())
else {
continue;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject malformed macro clusters instead of dropping invocations

When MDL export sees a macro-backed Module without a matching primary-output binding aux, this branch continues and leaves the module unreconstructed. Later, write_variable_entry always skips Variable::Module, so the invocation is silently omitted from the exported .mdl instead of failing fast. This can happen if a user edits or partially patches a materialized multi-output macro cluster (e.g., deletes/renames one binding aux), and it causes silent model corruption in exported files.

Useful? React with 👍 / 👎.

@claude
Copy link
Copy Markdown

claude Bot commented May 15, 2026

Code review: PR #564 (Vensim/XMILE macro support)

I reviewed the core logic of this PR (the unified ModuleFunctionDescriptor/MacroRegistry resolver, the macro inliner in builtins_visitor.rs, the MDL/XMILE import + round-trip, protobuf/JSON serialization, and the diagram gating). Protobuf field numbering and round-trip fidelity check out; the macro_spec: None defaulting and the diagram macro-exclusion are correct. One finding:

[P2] Macro recursion check misses calls in multi-element arrayed macro bodies

src/simlin-engine/src/module_functions.rs:326-333 (MacroRegistry::check_for_recursion)

Each body variable's call edges are collected by parsing equation.source_text() once with Expr0::new. For an Equation::Arrayed body variable with two or more element equations (or one element plus an EXCEPT default), source_text() returns the element formulas joined by \n (datamodel.rs:223-229), which is not a single parseable expression, so Expr0::new fails and the let Ok(Some(ast)) = ... else { continue } silently drops the variable. The adjacent comment's premise -- that an unparseable body equation "cannot introduce a (resolvable) macro call edge" -- does not hold for the arrayed case: each element equation parses fine on its own and can contain the macro call that closes a recursion cycle; only the \n-joined concatenation fails to parse. A directly- or mutually-recursive macro whose closing call sits in a per-element arrayed body variable therefore passes MacroRegistry::build. Since this check is the only guard against macro recursion (the expander in builtins_visitor.rs has no depth limit -- see its own note at lines 616-618 that an invoked recursive macro would "infinite-loop / form a salsa module-map cycle"), compiling such a model hangs or overflows the stack instead of reporting CircularDependency. This only triggers on invalid input (Vensim macros cannot legally recurse), but it defeats the guard that exists precisely to convert that invalid input into a clean diagnostic, so a crafted/malformed model becomes a hang rather than an error. Walking each element equation (and the default) of Equation::Arrayed separately, instead of parsing the \n-joined source_text(), closes the hole.


Overall correctness: correct, with one non-blocking robustness gap. Single-output and scalar multi-output paths, serialization, and round-trip are sound and well-tested for valid models. The recursion-check blind spot above is the only substantive issue and is reachable only via invalid (recursive) macro input; it does not affect results for valid models.

Records four process/CI observations surfaced while reviewing the macro
epic: the missing CI feature matrix (which let a no-file_io build break
propagate), CLAUDE.md changelog decay, back-loaded corpus validation,
and the implementation plan being untracked during execution. Filed per
the project's track-discovered-issues convention; the separator-fragility
hazard went to GH #565 and the DSS-oracle policy gap to a #561 comment.
@claude
Copy link
Copy Markdown

claude Bot commented May 15, 2026

Review: Add Vensim macro (:MACRO:) support

I reviewed the core logic across the engine resolver/registry (module_functions.rs, db_macro_registry.rs), BuiltinVisitor macro expansion, MDL import (parser.rs, convert/macros.rs, convert/multi_output.rs, convert/mod.rs, convert/helpers.rs), MDL/XMILE export and round-trip (mdl/writer.rs, mdl/mod.rs, xmile/mod.rs, xmile/model.rs), serialization (serde.rs/proto, json.rs, json_sdai.rs, TS datamodel.ts/json-types.ts, pysimlin), and the diagram model-reference gating.

Findings

No blocking issues found.

Notable correctness points that check out:

  • Protobuf backward compatibility: MacroSpec is a new optional message on a new field number (Model.macro_spec = 7); old serialized instances decode to None. Consistent with the repo rule that only protobufs need back-compat.
  • engine: macro whose body wraps a same-canonical-name intrinsic (INIT = INITIAL(x)) causes false recursion cycle, blocking C-LEARN macro expansion #554 / engine: macro whose body wraps a same-canonical-name intrinsic (INIT = INITIAL(x)) causes false recursion cycle, blocking C-LEARN macro expansion #554-follow-up: the renamed-builtin self-wrap suppression is keyed off a single shared predicate (is_renamed_builtin_macro_collision) used by both the recursion-edge scan (collect_called_macros) and the walk-time expansion exception, so the two halves cannot drift. Genuine self/mutual recursion (AC5.2) remains rejected, with non-vacuous tests in both directions.
  • Salsa wiring: registry-build validation is computed from the datamodel Vec<Model> at sync (where duplicate/colliding names survive) and the typed ErrorCode is threaded through verbatim rather than re-derived from prose; project_macro_registry/macro_body_owner are keyed on SourceProject so non-macro edits don't invalidate them.
  • Multi-output materialization: the whole-RHS-only invariant is enforced by find_nested_multi_output_call over all non-empty per-element equations before detection, closing the debug-assert-panic / release-silent-drop path at the XMILE formatter.
  • Round-trip: MDL (:MACRO: block reconstruction + :-call reconstruction) and XMILE (<macro> + simlin:macro-invocation/simlin:additional-outputs extensions + <uses_macros> with the @-attribute serde fix) are inverse-paired; serialization across Rust/JSON/TS/Python/proto consistently maps macro_spec.
  • A few intentional, documented scoping limitations (multi-output call inside a macro body; arrayed multi-output invocation) match pre-existing arrayed-stdlib behavior and are not regressions.

Overall correctness verdict

Correct. The patch is internally consistent, backward-compatible at the only serialization boundary that requires it (protobuf), and the macro feature paths are covered by non-vacuous automated tests gated by the pre-commit hook. No blocking bugs identified.

project_to_mdl's reject gate admits any Variable::Module whose model_name
resolves to a macro-marked model, on the assumption the writer will
reconstruct it into the Vensim ':' call syntax. But that assumption only
holds for a pristine Phase-4 materialized cluster. The datamodel is
mutable post-import (MCP ProjectPatch delete/rename/upsert; JSON/protobuf
round-trip then edit), and a single realistic edit -- deleting or
re-equationing one binding aux, dropping a ModuleReference -- leaves the
cluster unreconstructable. build_multi_output_reconstructions then hit one
of three continue paths AFTER confirming the module is macro-backed, the
module was not added to the suppress set, and write_variable_entry's
Variable::Module(_) => return silently emitted nothing: the invocation
vanished and the surviving binding auxes dangled on an unwritten module --
a corrupt .mdl with no error. Per Simlin's mission, AI agents patch models
programmatically via MCP, so this is a reachable path for the primary
user, not a theoretical one.

The asymmetry was the defect: the same gate already hard-rejects an
ordinary Variable::Module with a clear error, and the design rejects
recursive macros explicitly rather than mishandling them. A malformed
macro cluster being silently dropped is the same failure mode the design
everywhere else refuses to allow; the phase_06 'harmless fallback' note
was written for the well-formed case and never contemplated post-import
mutation.

build_multi_output_reconstructions now records each unreconstructable
macro-backed cluster (with an actionable message naming the module, the
macro, and the missing argument/primary/additional binding) instead of
silently skipping it; write_equations_section returns Result and rejects
before emitting any output, so write_project (already Result-returning)
propagates a clear error. Detection stays co-located with reconstruction
-- single source of truth, no gate/writer drift. Addresses the PR #564
codex review P1.
@bpowers
Copy link
Copy Markdown
Owner Author

bpowers commented May 15, 2026

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5b200eab8a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/simlin-engine/src/mdl/parser.rs Outdated
Comment on lines +1294 to +1296
// Only the Symbol branch handles this: a `:` in a
// `CallKind::Builtin` call is not valid Vensim, so the
// Function branch deliberately does not parse it.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Parse ':' output lists for builtin-token macro calls

This parser now accepts macro definitions whose names shadow builtins, but call-site : output bindings are only parsed in the TokenKind::Symbol branch. If a multi-output macro is named like a builtin (for example SMTH1), its invocation tokenizes as Function, and SMTH1(a, b : lo, hi) fails because the Function path never consumes : bindings before expecting ). That makes valid multi-output macro calls unimportable whenever the macro name collides with a builtin, despite the rest of this change explicitly supporting builtin-name macro shadowing.

Useful? React with 👍 / 👎.

@claude
Copy link
Copy Markdown

claude Bot commented May 15, 2026

Code review — Vensim macro support

Reviewed the substantive surface (engine MDL/macro conversion, the unified module_functions/MacroRegistry resolver + salsa query, builtin-visitor inlining, protobuf/JSON/XMILE/MDL serialization round-trip, and the diagram/TS macro-exclusion filtering). The macro/registry/serialization machinery is careful and well-tested; the recursion-fix scoping, shared collision predicate, additive proto field, and round-trip symmetry all check out. Two issues in the multi-output materializer:

[P2] Whole-RHS multi-output call on a subscripted LHS is silently materialized as a scalar

src/simlin-engine/src/mdl/convert/multi_output.rs:386-405

detect_multi_output_call destructures MdlEquation::Regular(_lhs, expr) and never inspects _lhs.subscripts. For an arrayed variable whose element equation is a whole-RHS multi-output invocation (e.g. y[a1] = ADD3(a, b, c : lo, hi), with a sibling y[a2] = 5), select_equation returns the first non-empty equation and detect_multi_output_call matches it, materializing y as a scalar binding keyed on canonical_name(symbol_key) (= y). The [a1] subscript and any sibling element equations are silently discarded because the whole y symbol's normal build is skipped via skip_symbols.insert(call.lhs_canonical). The comment at lines 157-164 asserts "an arrayed variable is not a valid multi-output materialization site, and the all-equations guard above has already rejected [it]", but the only guard (find_nested_multi_output_call) catches nested calls, not a whole-RHS multi-output call sitting in a per-element equation — so the documented invariant is unenforced and the failure mode is silent model corruption (no diagnostic, wrong simulation) rather than the actionable ConvertError the module's design uses everywhere else. Triggering input is unusual (an arrayed/subscripted caller bound to a :-macro), so this is P2, but a subscript-bearing detect_multi_output_call hit should produce a ConvertError like the nested-call guard does.

[P3] Output-binding aux idents are not canonicalized, inconsistent with the rest of the cluster

src/simlin-engine/src/mdl/convert/multi_output.rs:210-214

The :-output binding idents use quoted_space_to_underbar(name) on the raw name, while Var arguments use variable_ident(name) (= quoted_space_to_underbar(to_lower_space(name)), line 237) and lhs_ident uses canonical_name (line 228). A binding Min Val therefore mints aux ident Min_Val (case preserved) while every other ident in the same materialized cluster is lowercased. Compile-time resolution is case-insensitive so simulation results are unaffected, but it breaks the module's stated serialization-stability goal (the test plan calls for byte-stable MDL/XMILE export) and is inconsistent with the lockstep XMILE reconstruction. Using variable_ident(name) here would make it uniform.


Overall correctness verdict: essentially correct. No P0/P1 issues; the macro registry, recursion fix, protobuf/JSON/XMILE round-trip, and UI filtering are sound. The P2 above is a real silent-data-loss path for an uncommon arrayed-caller input and an unenforced documented invariant the author would likely want closed (with a ConvertError); the P3 is a low-risk consistency cleanup.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 16, 2026

Codecov Report

❌ Patch coverage is 86.18557% with 201 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.82%. Comparing base (b03931f) to head (2026d57).

Files with missing lines Patch % Lines
src/simlin-engine/src/mdl/convert/multi_output.rs 66.99% 67 Missing ⚠️
src/simlin-engine/tests/metasd_macros.rs 70.89% 55 Missing ⚠️
src/simlin-engine/src/builtins_visitor.rs 88.79% 13 Missing ⚠️
src/simlin-engine/src/mdl/convert/macros.rs 80.88% 13 Missing ⚠️
src/simlin-engine/src/xmile/mod.rs 93.19% 10 Missing ⚠️
src/simlin-engine/src/module_functions.rs 93.84% 8 Missing ⚠️
src/simlin-engine/src/test_common.rs 20.00% 8 Missing ⚠️
src/simlin-engine/src/db.rs 82.14% 5 Missing ⚠️
src/simlin-engine/src/variable.rs 60.00% 4 Missing ⚠️
src/simlin-engine/src/datamodel.rs 95.00% 2 Missing ⚠️
... and 12 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #564      +/-   ##
==========================================
+ Coverage   82.69%   82.82%   +0.13%     
==========================================
  Files         247      252       +5     
  Lines       65692    67464    +1772     
==========================================
+ Hits        54322    55877    +1555     
- Misses      11370    11587     +217     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Four genuine findings from the PR #564 automated reviews, each fixed
test-first. The Codex P1 on writer.rs ("reject malformed macro
clusters") was already closed by 5b200ea and is left untouched
(verified by its three project_to_mdl_rejects_macro_cluster_* tests);
a distinct adjacent gap -- a Variable::Module inside a macro-body
model is silently dropped on MDL export because the project_to_mdl
gate scans only the main model -- is out of scope here and tracked as
GitHub #566.

- parser.rs: the Function branch now parses an optional ':' output
  list. A multi-output macro whose name shadows a builtin tokenizes
  as Token::Function (the normalizer has no macro-registry
  awareness), so such a macro was definable but its multi-output call
  site was a hard parse error. A ':' in a call is unambiguously a
  macro invocation (no Vensim builtin uses ':'), so the call re-tags
  as CallKind::Symbol, exactly what the Symbol branch produces.
- module_functions.rs check_for_recursion: scan each arrayed element
  formula (and the EXCEPT default) individually instead of the
  '\n'-joined Equation::source_text(), which does not reparse as one
  expression and silently dropped every macro-call edge of an arrayed
  body variable -- a recursion cycle closed in a per-element equation
  passed registry build and then hung/overflowed the depth-limit-free
  expander instead of reporting CircularDependency.
- multi_output.rs: a whole-RHS multi-output call on a subscripted
  (arrayed) LHS now yields a ConvertError instead of being silently
  materialized as a scalar binding (which discarded the subscript and
  every sibling per-element equation). Arrayed multi-output
  invocation is a documented scoping limitation, so it must be an
  actionable diagnostic like the nested-call guard, not silent
  corruption.
- multi_output.rs: ':'-output binding idents use variable_ident (the
  lowercased canonical form) like every other ident in a materialized
  cluster, instead of a case-preserving quoted_space_to_underbar,
  restoring byte-stable MDL/XMILE export.
@claude
Copy link
Copy Markdown

claude Bot commented May 16, 2026

Review: PR #564 — Vensim/XMILE macro support

Reviewed the ~6,400-line core change (49 source files) across the engine resolver/inlining core, MDL import/convert, MDL+XMILE serialization round-trips, and the datamodel/serde/JSON/TS/Python surface. The implementation is unusually defensive and well-tested: the protobuf change is wire-compatible (new tag 7, no field reuse), serde/JSON/TS/Python/schema representations are symmetric, salsa invalidation is wired through tracked queries, and the macro-recursion / renamed-builtin-collision predicates are consistent between the pre-scan and walk-time paths. One low-severity finding.

[P3] Multi-output guard only inspects MdlEquation::Regular; a Data/WithLookup RHS multi-output call bypasses it and reaches the formatter

src/simlin-engine/src/mdl/convert/multi_output.rs:512-514 (same Regular-only early-return at the sibling guards :434 and :470)

find_nested_multi_output_call, subscripted_lhs_multi_output_call, and detect_multi_output_call each do let MdlEquation::Regular(..) = eq else { return None };, so they ignore every non-Regular equation. The parser attaches output_bindings during general expression parsing, so it also accepts a multi-output (:-list) macro call as the RHS expression of a Data (x := add3(a,b,c : lo,hi)) or WithLookup (x = WITH LOOKUP(add3(a,b,c : lo,hi), (...))) equation. Those forms slip past all three guards and reach XmileFormatter::format_expr (variables.rs:679/691, xmile_compat.rs:97), which panics on debug_assert!(output_bindings.is_empty()) in debug builds and silently drops the :-list outputs in release — precisely the failure mode this module exists to prevent (per the documented purpose of find_nested_multi_output_call: turning what would otherwise be a debug-build panic / release-build silent :-output loss into a clean ConvertError). Severity is low because the trigger requires a data/lookup equation whose RHS is a multi-output macro call, which is not valid Vensim — but the parser accepts it syntactically and this module is specifically designed to convert such parser-accepted-but-invalid forms into a clean ConvertError rather than panic/corrupt. Suggested fix: have the guard scan also cover the Data(_, Some(expr)) and WithLookup(_, input, _) expression positions, mirroring the variant coverage already implemented two files over in convert/macros.rs::rewrite_equation_dollar_time (lines 155-163).

Overall correctness: correct. No P0–P2 issues found. Existing code/tests are not broken and the protobuf change is backward-compatible. The single P3 is a defensive-hardening gap on a contrived, non-valid-Vensim input path, not a blocking defect.

@bpowers bpowers merged commit 792113e into main May 16, 2026
15 checks passed
@bpowers bpowers deleted the macros branch May 16, 2026 05:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant