Summary
The engine lowers a standalone / bare lookup-only variable -- a Vensim graphical function (lookup table) with no functional input, e.g. Historical GDP LOOKUP[COP]((1850, ...), (1851, ...), ...) -- to gf(Time) (evaluate the table at the current simulation time). A graphical function is a table indexed by an explicit input (y = lookup(input)); the input can be anything (a temperature, a population, a ratio), not necessarily time. Lowering a bare lookup to gf(Time) is therefore wrong in general -- it is a unit error whenever the table's x-axis is not time in matching units.
This was introduced by PR #600 / issue #590: those variables previously imported as 0+0 (zeroed), and the chosen fix was the gf(Time) lowering. It only coincidentally produces correct values in C-LEARN, where every such table's x-axis is a calendar year (1850-2100), every consumer calls it as LOOKUP(Time / One year), and One year == 1 with Time already in years -- so gf(Time) and gf(Time / One year) happen to land on the same x. Change the table's x-axis to a temperature, or run Time in months, and gf(Time) is garbage.
Correct behavior
A bare lookup is a table valued only when called with its input; it should not produce a standalone gf(Time) time series at all. Genuine Vensim itself saves no series for such a variable in its VDF output -- only a descriptor record -- precisely because the variable has no series of its own. Its consumers (which call it with a real input) are the variables that have series.
Code
src/simlin-engine/src/compiler/mod.rs:
var_is_lookup_only / is_lookup_only -- the "empty equation (XMILE) or MDL lookup sentinel + has tables" predicate that classifies a variable as lookup-only.
lookup_only_index_expr(loc) -> Expr (around lines 1181-1190) -- returns Expr::App(BuiltinFn::Time, loc), i.e. gf(Time). This is the wrong index expression in general.
LookupOnlyLayout { PerElement, Shared } -- the arrayed-shape layout used by the scalar/A2A/arrayed lowering sites that all funnel through lookup_only_index_expr.
src/simlin-engine/src/lookup_only_tests.rs -- end-to-end tests that currently assert the gf(Time) behavior (e.g. scalar_lookup_only_evaluates_at_time, arrayed_lookup_only_*_evaluates_*_at_time, a2a_lookup_only_shares_one_table_at_time). These encode the buggy behavior and would need to change when the lowering is fixed.
src/simlin-engine/CLAUDE.md documents the gf(Time) lowering as current behavior (the compiler/mod.rs and lookup_only_tests.rs entries) -- update when fixed.
- Importer note: the MDL
MdlEquation::Implicit arm in src/simlin-engine/src/mdl/convert/variables.rs already lowers an unspecified-input gf to equation = "TIME", which the compiler's lookup_only_index_expr mirrors; the importer side may also need to change to stop synthesizing a Time-indexed equation for a bare lookup.
Why it matters / how it surfaced
This is the root cause of the remaining C-LEARN VDF-comparison residual after PR #605 (deterministic VDF slot-table decode + drop lookup-only descriptors). PR #605 makes the VDF reader correctly DROP lookup-only descriptors (they are tables, not series), shrinking EXPECTED_VDF_RESIDUAL (src/simlin-engine/tests/simulate.rs) from 21 to 13. PR #605's own body states the deeper bug is in the engine and is "tracked as a separate engine follow-up" -- this issue is that follow-up.
The 13 that remain include 9 lookup-only tables (the rs_hfc* family -- 8 -- plus ref_global_emissions_from_graph_lookup) where the model-free VDF reader cannot safely distinguish the descriptor from a real owner, so it still emits a ghost column; and the engine still produces a phantom gf(Time) series for those lookup variables, which the comparator flags against the ghosts. Fixing this engine bug (not synthesizing a gf(Time) series for a bare lookup) would remove those 9 from the matched set entirely.
Severity
Correctness. A latent unit error for any model whose standalone lookup's x-axis is not time-in-matching-units; benign in C-LEARN only by coincidence (year-indexed tables, consumers calling LOOKUP(Time / One year), One year == 1, Time in years). No crash -- the engine silently produces a wrong phantom series for the bare lookup variable.
Component(s) affected
src/simlin-engine -- compiler lowering (compiler/mod.rs) and MDL import of standalone Vensim lookups (mdl/convert/variables.rs).
- Tests:
src/simlin-engine/src/lookup_only_tests.rs (assertions encode the bug), src/simlin-engine/tests/simulate.rs (EXPECTED_VDF_RESIDUAL carve-out; fixing this should shrink the C-LEARN residual by the 9 lookup-only tables above).
Possible approaches
- Stop lowering a bare lookup-only variable to a Time-indexed series entirely: treat it as a table-only definition that contributes no standalone runtime series (mirroring Vensim, which saves only a descriptor). Its consumers, which call it with a real input via
LOOKUP(self, input), remain the variables that carry series. This requires deciding what (if anything) a bare lookup variable evaluates to when referenced bare (vs. only as LOOKUP(self, x)), and updating the runlist/layout so the variable does not occupy a phantom output slot.
- If a bare lookup must retain some slot for layout/compat reasons, ensure it is not driven by
Time -- but option 1 (no synthesized series) is the behavior that matches genuine Vensim.
- Update
lookup_only_tests.rs to assert the corrected behavior, and re-tighten / shrink EXPECTED_VDF_RESIDUAL (the 9 lookup-only tables) under the clearn_residual_exactness gate.
Relationship to existing issues (DISTINCT)
Discovery context
Identified during C-LEARN residual work, post-PR #605 (deterministic VDF slot-table decode + drop lookup-only descriptors). PR #605's body explicitly defers the engine-side fix: "The deeper bug is in the engine: lowering a bare lookup to gf(Time) (#590) synthesises a phantom series ... tracked as a separate engine follow-up."
Summary
The engine lowers a standalone / bare lookup-only variable -- a Vensim graphical function (lookup table) with no functional input, e.g.
Historical GDP LOOKUP[COP]((1850, ...), (1851, ...), ...)-- togf(Time)(evaluate the table at the current simulation time). A graphical function is a table indexed by an explicit input (y = lookup(input)); the input can be anything (a temperature, a population, a ratio), not necessarily time. Lowering a bare lookup togf(Time)is therefore wrong in general -- it is a unit error whenever the table's x-axis is not time in matching units.This was introduced by PR #600 / issue #590: those variables previously imported as
0+0(zeroed), and the chosen fix was thegf(Time)lowering. It only coincidentally produces correct values in C-LEARN, where every such table's x-axis is a calendar year (1850-2100), every consumer calls it asLOOKUP(Time / One year), andOne year == 1withTimealready in years -- sogf(Time)andgf(Time / One year)happen to land on the same x. Change the table's x-axis to a temperature, or runTimein months, andgf(Time)is garbage.Correct behavior
A bare lookup is a table valued only when called with its input; it should not produce a standalone
gf(Time)time series at all. Genuine Vensim itself saves no series for such a variable in its VDF output -- only a descriptor record -- precisely because the variable has no series of its own. Its consumers (which call it with a real input) are the variables that have series.Code
src/simlin-engine/src/compiler/mod.rs:var_is_lookup_only/is_lookup_only-- the "empty equation (XMILE) or MDL lookup sentinel + has tables" predicate that classifies a variable as lookup-only.lookup_only_index_expr(loc) -> Expr(around lines 1181-1190) -- returnsExpr::App(BuiltinFn::Time, loc), i.e.gf(Time). This is the wrong index expression in general.LookupOnlyLayout { PerElement, Shared }-- the arrayed-shape layout used by the scalar/A2A/arrayed lowering sites that all funnel throughlookup_only_index_expr.src/simlin-engine/src/lookup_only_tests.rs-- end-to-end tests that currently assert thegf(Time)behavior (e.g.scalar_lookup_only_evaluates_at_time,arrayed_lookup_only_*_evaluates_*_at_time,a2a_lookup_only_shares_one_table_at_time). These encode the buggy behavior and would need to change when the lowering is fixed.src/simlin-engine/CLAUDE.mddocuments thegf(Time)lowering as current behavior (thecompiler/mod.rsandlookup_only_tests.rsentries) -- update when fixed.MdlEquation::Implicitarm insrc/simlin-engine/src/mdl/convert/variables.rsalready lowers an unspecified-input gf toequation = "TIME", which the compiler'slookup_only_index_exprmirrors; the importer side may also need to change to stop synthesizing a Time-indexed equation for a bare lookup.Why it matters / how it surfaced
This is the root cause of the remaining C-LEARN VDF-comparison residual after PR #605 (deterministic VDF slot-table decode + drop lookup-only descriptors). PR #605 makes the VDF reader correctly DROP lookup-only descriptors (they are tables, not series), shrinking
EXPECTED_VDF_RESIDUAL(src/simlin-engine/tests/simulate.rs) from 21 to 13. PR #605's own body states the deeper bug is in the engine and is "tracked as a separate engine follow-up" -- this issue is that follow-up.The 13 that remain include 9 lookup-only tables (the
rs_hfc*family -- 8 -- plusref_global_emissions_from_graph_lookup) where the model-free VDF reader cannot safely distinguish the descriptor from a real owner, so it still emits a ghost column; and the engine still produces a phantomgf(Time)series for those lookup variables, which the comparator flags against the ghosts. Fixing this engine bug (not synthesizing agf(Time)series for a bare lookup) would remove those 9 from the matched set entirely.rs_hfc*(8): the VDF descriptor forward-links to the wider 2-D consumerRS HFC[COP, HFC type](forward width 63 != the descriptor's 7), so PR engine: deterministic VDF slot-table decode + arrayed lookup-only descriptor rebind #605's conservative width gate declines to drop it.ref_global_emissions_from_graph_lookup: its forward link is Time /0.Severity
Correctness. A latent unit error for any model whose standalone lookup's x-axis is not time-in-matching-units; benign in C-LEARN only by coincidence (year-indexed tables, consumers calling
LOOKUP(Time / One year),One year == 1,Timein years). No crash -- the engine silently produces a wrong phantom series for the bare lookup variable.Component(s) affected
src/simlin-engine-- compiler lowering (compiler/mod.rs) and MDL import of standalone Vensim lookups (mdl/convert/variables.rs).src/simlin-engine/src/lookup_only_tests.rs(assertions encode the bug),src/simlin-engine/tests/simulate.rs(EXPECTED_VDF_RESIDUALcarve-out; fixing this should shrink the C-LEARN residual by the 9 lookup-only tables above).Possible approaches
LOOKUP(self, input), remain the variables that carry series. This requires deciding what (if anything) a bare lookup variable evaluates to when referenced bare (vs. only asLOOKUP(self, x)), and updating the runlist/layout so the variable does not occupy a phantom output slot.Time-- but option 1 (no synthesized series) is the behavior that matches genuine Vensim.lookup_only_tests.rsto assert the corrected behavior, and re-tighten / shrinkEXPECTED_VDF_RESIDUAL(the 9 lookup-only tables) under theclearn_residual_exactnessgate.Relationship to existing issues (DISTINCT)
0+0) is the pre-fix framing (vars zeroed; root cause undetermined: importer defect vs missing data). PR Close the C-LEARN simulation residual; add CLI GET DIRECT external-data support #600's fix for engine: C-LEARN data/graph-lookup variables import as 0+0 (zeroed) instead of their lookup/data values #590 was precisely thegf(Time)lowering this issue reports as wrong. This issue tracks the regression introduced by that fix, not the original zeroing.Discovery context
Identified during C-LEARN residual work, post-PR #605 (deterministic VDF slot-table decode + drop lookup-only descriptors). PR #605's body explicitly defers the engine-side fix: "The deeper bug is in the engine: lowering a bare lookup to
gf(Time)(#590) synthesises a phantom series ... tracked as a separate engine follow-up."