Skip to content

engine: ACTIVE INITIAL whose dt and initial equations both hoist a helper is refused as duplicate_variable (no phase discriminator in synthetic_ident) #1034

Description

@bpowers

Problem

A variable whose dt equation and initial equation both hoist a helper is
refused at compile time with a message about a duplicate the user never
wrote. The smallest Vensim form:

x = ACTIVE INITIAL(PREVIOUS(a * 2, 0), PREVIOUS(b * 3, 0))

The MDL importer splits this into the dt equation PREVIOUS(a * 2, 0) and
compat.active_initial = "PREVIOUS(b * 3, 0)" (mdl/convert/variables.rs,
extract_active_initial). Compiling it fails with

duplicate_variable -- two different synthesized helpers both claim the name '$⁚x⁚0⁚arg0'

The same refusal fires for SMTH1(driver * 2, 3) with an ACTIVE INITIAL
of SMTH1(driver * 100, 7), for the apply-to-all form of either, and for an
XMILE Equation::Arrayed element that carries its own initial equation --
the three routes enumerated by
db::fragment_determinism_tests::an_active_initial_that_collides_a_helper_name_is_refused.
All are legal in the source tool: Vensim's ACTIVE INITIAL takes two
arbitrary expressions, and an initial expression that differs from the
active one is the whole point of the construct.

Cause

variable::parse_var runs parse_and_lower_eqn twice per variable -- once
for the dt phase, once for the initial phase -- and each run drives
instantiate_implicit_modules with a BuiltinVisitor whose walk counter
starts at zero. capture::synthetic_ident(parent, n, part, suffix) has no
phase component, so both passes mint $⁚x⁚0⁚arg0 for their first hoisted
argument. When the two bodies differ, capture::insert_implicit_var refuses
the collision.

The refusal is correct as far as it goes. The alternative it replaced was a
silent last-wins merge that kept only the initial pass's helper, so the dt
phase evaluated the initial expression every step -- a wrong number rather
than an error. But "refuse" is a bound on the damage, not a fix: the model is
valid and does not compile.

Why it matters

  • ACTIVE INITIAL is common in real Vensim models, and any model that uses
    it with a delay/smooth/PREVIOUS/INIT on both sides is refused outright.
  • The message names $⁚x⁚0⁚arg0, a compiler-internal spelling. A user who
    wrote x once and no duplicate cannot act on it.
  • The per-element Arrayed-with-init route is plain XMILE, not a Vensim
    import artifact, so the gap is not confined to mdl/.

Components affected

  • src/simlin-engine/src/capture.rs -- synthetic_ident (the single
    statement of a helper's spelling) and insert_implicit_var (the refusal).
  • src/simlin-engine/src/variable.rs -- parse_var's dt/initial merge, whose
    comment documents the counter restarting at zero.
  • Every name-keyed stage downstream of the parse, because the name is derived
    once and they all sort by it: db::query::model_implicit_var_info,
    db::layout::compute_layout and flattened_offsets, the runlists, and
    symbolic bytecode's VarRef.

Possible approach

Add a phase discriminator to synthetic_ident for helpers minted on the
initial pass (an init part, or a distinct counter namespace), so the two
passes can never mint one name. Because the spelling is stated exactly once,
every name-keyed stage follows automatically; the cost is that every
initial-pass helper's sort position moves, so the compiled artifact moves
and this needs its own change with its own ledger row (the doc comment on
the pinning test says as much). Two things to keep straight while doing it:

  • Phase is not CaptureKind. A dt-pass INIT(...) capture and an
    initial-pass PREVIOUS(...) capture are different phases with different
    kinds; the discriminator has to record which pass minted the helper, and
    the initials runlist has to schedule the initial-pass helper while the
    flows runlist does not.
  • The same-definition collapse in insert_implicit_var must keep working
    for the Arrayed-without-init route, where the initial pass re-parses the
    same text and every element's copy is the same helper.

When fixed, flip
an_active_initial_that_collides_a_helper_name_is_refused to assert the
three routes compile and that each phase reads its own body (the
scalar_same_dep row is the one that reds if the dt phase silently reads
the initial body -- keep that property). This is distinct from the
cross-parse-context collision pinned by
a_cross_context_helper_name_collision_is_confined_to_a_failing_compile
(two module-input contexts shifting the walk counter), which is GH #372's
territory and stays as it is.

Discovery

Identified during adversarial review of the compiler-unification Phase 7.3
work (branch compiler-unification-v2, engine crate); out of scope for that
branch. The loud refusal (rather than last-wins) is already on that branch;
this issue is the follow-up that makes the shape compile.

Activity

  1. added
    bugSomething isn't working
    engineIssues with the rust-based simulation engine
    on Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingengineIssues with the rust-based simulation engine

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions