Skip to content

engine: macro whose body wraps a same-canonical-name intrinsic (INIT = INITIAL(x)) causes false recursion cycle, blocking C-LEARN macro expansion #554

@bpowers

Description

@bpowers

Summary

A Vensim macro whose body invokes an opcode-backed engine intrinsic (init, previous) whose canonical name equals the macro's own canonical name triggers a false self-recursion cycle in MacroRegistry::build, which fails the entire macro registry and thereby blocks expansion of all of that model's macros.

Concretely: C-LEARN (test/xmutil_test_models/C-LEARN v77 for Vensim.mdl) defines an uninvoked macro :MACRO: INIT(x) ... INIT = INITIAL(x) ... :END OF MACRO:. The engine reports a spurious CircularDependency ("recursive macro: init -> init"), even though no recursion exists in the original Vensim source (the body literally wrote INITIAL, a name distinct from the macro INIT).

This is a HIGH-priority, macro-specific correctness bug. It is NOT one of the design's carved-out "unrelated blockers" (model-logic circular deps, dimension mismatches, unit errors).

Root cause (fully traced via systematic debugging)

  1. The MDL importer necessarily renames the Vensim INITIAL builtin to INIT at src/simlin-engine/src/mdl/xmile_compat.rs:520, because the engine's Expr1 lowering (src/simlin-engine/src/ast/expr1.rs:288) only recognizes the opcode name "init", not "initial". So the stored datamodel body of the C-LEARN macro becomes INIT = INIT(x) (the builtin call is renamed to the same token as the macro name).

  2. MacroRegistry::check_for_recursion / collect_called_macros (src/simlin-engine/src/module_functions.rs:247-291) canonicalizes the renamed-builtin call INIT to init, which collides with the registered init macro name, producing a false self-edge init -> init. find_cycle then returns CircularDependency with message "recursive macro: init -> init".

  3. This makes the entire MacroRegistry::build fail (not just the INIT macro).

  4. Cascade: a failed/empty macro registry means the BuiltinVisitor "macro-shadows-everything" precedence (src/simlin-engine/src/builtins_visitor.rs:557) no longer resolves C-LEARN's other macros (SSHAPE, SAMPLE UNTIL, RAMP FROM TO) to their macro definitions. Those call sites fall through to is_builtin_fn / stdlib_descriptor and fail with BadBuiltinArgs (e.g. the 3-arg SSHAPE builtin rejecting the 2-arg call the macro would accept) or UnknownBuiltin (SAMPLE UNTIL is not a builtin). So this single false-positive recursion blocks ALL of C-LEARN's macro expansion.

Why it matters (severity: HIGH)

In real Vensim there is no recursion -- the macro body wrote INITIAL (distinct from the macro INIT); the clash is manufactured solely by the engine's necessary INITIAL -> INIT rename. This directly contradicts the Vensim macro design's acceptance criteria:

  • macros.AC6.2: "C-LEARN's four macros parse, register, and expand with no macro-specific errors."
  • macros.AC1.7: an uninvoked macro like C-LEARN's INIT "imports as a valid macro-marked model and is preserved."

It will block Phase 7 (hero validation of C-LEARN) of docs/implementation-plans/2026-05-13-macros/. It needs design-owner adjudication because the fix interacts with the design's macro-recursion-rejection tier.

Components affected

  • src/simlin-engine/src/module_functions.rs (MacroRegistry::check_for_recursion, collect_called_macros, find_cycle; lines ~247-291) -- where the false init -> init self-edge is manufactured
  • src/simlin-engine/src/builtins_visitor.rs (macro-shadows-everything precedence at ~557; the init/previous intrinsic routing at ~577-619) -- the expansion path that would otherwise infinite-loop
  • src/simlin-engine/src/mdl/xmile_compat.rs:520 (INITIAL -> INIT rename) -- upstream cause of the name collision
  • src/simlin-engine/src/ast/expr1.rs:288 (Expr1 lowering only recognizes opcode "init", not "initial") -- why the rename is necessary

Current state

Phase 4 Task 3's C-LEARN test (corpus_clearn_macros_import in src/simlin-engine/tests/simulate.rs, #[ignore]d) currently pins this buggy behavior -- it asserts the false init -> init blocker so that a future fix or regression is detected. It deliberately does NOT assert the design's intended "no macro-specific errors", because that assertion would currently be false. (Note: this is a different ignored test from the legacy simulates_clearn referenced in #349.)

Recommended remediation (substantive; needs design-owner adjudication before implementing)

A macro whose body invokes an opcode-backed engine intrinsic (init, previous) whose canonical name equals the macro's own canonical name is the "macro wraps the same-named intrinsic" pattern. Since Vensim macros cannot recurse (per design) and Vensim's source used the distinct builtin name, such a same-canonical-name intrinsic call inside the macro's own body must resolve to the intrinsic, not a recursive macro edge.

A correct fix needs coordinated changes to both:

(a) module_functions.rs::collect_called_macros -- suppress the false from -> from self-edge when the called name is an opcode-intrinsic equal to the enclosing macro's own name; AND

(b) the BuiltinVisitor expansion path -- which would otherwise still infinite-loop, because "macro-shadows-everything" resolves the call before the init/previous intrinsic routing at builtins_visitor.rs:577-619.

This is multi-file, semantically delicate, and interacts with the design's macro-recursion-rejection tier, so it needs design-owner adjudication. The Phase 4 C-LEARN test pins the current behavior so the fix/regression is detectable.

Relationship to existing issues (all DISTINCT, not duplicates)

Discovery context

Identified during Phase 4 of the Vensim macro support work (branch macros; design plan docs/implementation-plans/2026-05-13-macros/, design doc commit 86cc7fc), via systematic debugging while authoring Phase 4 Task 3's C-LEARN import test. Out of scope for Phase 4's tests-only Task 3; tracked now, needs design adjudication before Phase 7's C-LEARN hero validation can pass.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions