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.
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:
The MDL importer splits this into the dt equation
PREVIOUS(a * 2, 0)andcompat.active_initial = "PREVIOUS(b * 3, 0)"(mdl/convert/variables.rs,extract_active_initial). Compiling it fails withThe same refusal fires for
SMTH1(driver * 2, 3)with anACTIVE INITIALof
SMTH1(driver * 100, 7), for the apply-to-all form of either, and for anXMILE
Equation::Arrayedelement 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 INITIALtakes twoarbitrary expressions, and an initial expression that differs from the
active one is the whole point of the construct.
Cause
variable::parse_varrunsparse_and_lower_eqntwice per variable -- oncefor the dt phase, once for the initial phase -- and each run drives
instantiate_implicit_moduleswith aBuiltinVisitorwhose walk counterstarts at zero.
capture::synthetic_ident(parent, n, part, suffix)has nophase component, so both passes mint
$⁚x⁚0⁚arg0for their first hoistedargument. When the two bodies differ,
capture::insert_implicit_varrefusesthe 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 INITIALis common in real Vensim models, and any model that usesit with a delay/smooth/
PREVIOUS/INITon both sides is refused outright.$⁚x⁚0⁚arg0, a compiler-internal spelling. A user whowrote
xonce and no duplicate cannot act on it.Arrayed-with-init route is plain XMILE, not a Vensimimport artifact, so the gap is not confined to
mdl/.Components affected
src/simlin-engine/src/capture.rs--synthetic_ident(the singlestatement of a helper's spelling) and
insert_implicit_var(the refusal).src/simlin-engine/src/variable.rs--parse_var's dt/initial merge, whosecomment documents the counter restarting at zero.
once and they all sort by it:
db::query::model_implicit_var_info,db::layout::compute_layoutandflattened_offsets, the runlists, andsymbolic bytecode's
VarRef.Possible approach
Add a phase discriminator to
synthetic_identfor helpers minted on theinitial pass (an
initpart, or a distinct counter namespace), so the twopasses 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:
CaptureKind. A dt-passINIT(...)capture and aninitial-pass
PREVIOUS(...)capture are different phases with differentkinds; 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.
insert_implicit_varmust keep workingfor the
Arrayed-without-init route, where the initial pass re-parses thesame text and every element's copy is the same helper.
When fixed, flip
an_active_initial_that_collides_a_helper_name_is_refusedto assert thethree routes compile and that each phase reads its own body (the
scalar_same_deprow is the one that reds if the dt phase silently readsthe 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 thatbranch. The loud refusal (rather than last-wins) is already on that branch;
this issue is the follow-up that makes the shape compile.