Skip to content

engine: standalone lookup-only variable is lowered to gf(Time) (unit error; phantom Time-indexed series for a bare graphical function) #606

@bpowers

Description

@bpowers

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

  1. 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.
  2. 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.
  3. 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."

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