Skip to content

engine: graphical-function tables with unsorted/NaN x-coordinates are accepted silently and produce undefined lookup results #715

Description

@bpowers

Summary

No layer between a source file and the VM validates that a graphical function's (GF / lookup-table) x-coordinates are non-decreasing (and finite). A GF whose x points are out of order -- e.g. an XMILE <xpts>0,10,5,20</xpts> -- is accepted verbatim all the way through to simulation, where the VM's binary-search lookup assumes sorted x. On an unsorted table the binary search returns an arbitrary (though mutually self-consistent) interpolated value with no diagnostic. NaN x-coordinates are similarly unguarded at the import layers.

This is a pre-existing input-validation gap, distinct from every currently-tracked GF issue (see "Relationship to existing issues" below). It was discovered during adversarial review of the GF lookup hot-path perf optimization (#602): rather than fix the validation gap inline, the perf change gates its optimization on a per-table sortedness check at Vm::new -- so the optimization is correct, but the underlying "unsorted table reaches the VM at all" gap remains.

The problem

Every layer that constructs a GF's x-vector passes the x points through verbatim, with no check that x[i] <= x[i+1] for all i (and no finiteness check):

  • XMILE <xpts> / points import: src/simlin-engine/src/xmile/dimensions.rs (~line 364, where x_points is read from gf.x_pts).
  • protobuf / DB load: src/simlin-engine/src/serde.rs.
  • parse_table: src/simlin-engine/src/variable.rs:295 -- the single choke point all the import paths ultimately funnel a GF through; it builds Table { x, y, .. } by cloning gf.x_points (or synthesizing them from x_scale) with no ordering check.
  • Table::new: src/simlin-engine/src/compiler/expr.rs:20.
  • Compiler::new: src/simlin-engine/src/compiler/codegen.rs:55.

The consumer that assumes sorted x:

  • VM lookup: src/simlin-engine/src/vm.rs:3181 -- binary search (while low < high { ... if table[mid].0 < index ... }). It guards empty tables, NaN index, and below-first / above-last index, but assumes the table's x-axis is non-decreasing. On an unsorted table the binary search lands on an arbitrary segment and lookup_tail interpolates against it -- a silently wrong result, not a crash.
  • wasmgen lookup helpers: src/simlin-engine/src/wasmgen/lookup.rs (lookup_interp / lookup_forward / lookup_backward) reproduce the same sorted-x assumption.

A user-authored XMILE GF with <xpts>0,10,5,20</xpts> reaches simulation as-is and produces undefined output.

Why it matters

  • Correctness: silently wrong simulation output for any model whose GF has out-of-order (or NaN) x-coordinates -- no crash, no diagnostic, just wrong numbers that cascade through every downstream variable. The result is internally self-consistent (deterministic for a given table), which makes it harder to notice than a crash would be.
  • Developer experience / robustness: malformed input should be rejected at the boundary with an equation-level diagnostic, not silently miscomputed deep in the VM.

Component(s) affected

  • src/simlin-engine -- GF import (xmile/dimensions.rs, serde.rs), the parse_table choke point (variable.rs), the compiler GF construction (compiler/expr.rs, compiler/codegen.rs), and the VM/wasmgen lookup consumers (vm.rs, wasmgen/lookup.rs).

Suggested fix direction

Validate at the single choke point all import paths funnel through -- parse_table (variable.rs) and/or Table::new (compiler/expr.rs) -- and emit an equation-level diagnostic when a GF's x-coordinates are not non-decreasing (or contain a NaN/non-finite value). Two decisions are needed before implementing, which is precisely why this was NOT fixed inline during the perf work:

  1. Warning vs Error, and conformance semantics. Check the XMILE spec's GF semantics (docs/reference/xmile-v1.0.html) to decide whether unsorted x is invalid input (emit an Error) or whether the importer should sort the points on import (and whether sorting x must co-permute y). The Vensim/XMILE conformance behavior should drive this, not an ad-hoc choice.
  2. Back-compat for existing DB-stored projects. Some serialized projects in the DB may already contain unsorted GF x-points; an Error gate would suddenly fail to load them. Decide whether to sort-on-load, warn-and-sort, or hard-error, accounting for the protobuf back-compat constraint (the repo's only hard back-compat requirement is serialized protobuf instances).

A regression test should cover: (a) a sorted table still compiles/simulates unchanged, (b) an unsorted table produces the chosen diagnostic (and/or is sorted), and (c) a NaN/non-finite x is rejected or handled.

Relationship to existing issues (DISTINCT)

Discovery context

Identified during adversarial review of the GF lookup hot-path perf optimization (#602). The perf change correctly gates its optimization behind a per-table sortedness check at Vm::new; this issue tracks the pre-existing upstream gap that an unsorted/NaN-x GF reaches the VM at all with no diagnostic.

Severity

Medium (silent correctness gap on malformed input; no crash, requires malformed source to trigger).

Activity

  1. added
    engineIssues with the rust-based simulation engine
    bugSomething isn't working
    on Jun 8, 2026
  2. bpowers commented on Aug 10, 2026

    @bpowers
    OwnerAuthor

    A corpus census of every graphical function the engine can read (558 tables across 64 files, via open_vensim/open_xmile so the shapes are the ones the compiler sees) turns up exactly one unsorted table, and it is a live instance of this issue rather than a near-miss:

    test/test-models/tests/subscripted_lookups/test_subscripted_lookups.mdl — a 3-point table whose x-coordinates are [2.0, 1.0, 0.0], i.e. strictly descending. It reaches the VM with no diagnostic and produces an arbitrary interpolation today.

    Two facts that matter for prioritizing:

    • Nothing covers it. That model is not in the gated TEST_MODELS corpus, which is why this has never surfaced as a failure. So the current behaviour is not merely undefined, it is unobserved.
    • 557 of 558 corpus tables are strictly ascending (0 have duplicate x). So validation would reject or repair exactly one known model, not a long tail — the back-compat surface for stored projects is the open question, not the corpus.

    For the census record, since it bears on any fix that wants a fast path: 483 tables (86.6%) are exactly uniform in x, 26 (4.7%) are uniform only to within a 1e-9 relative tolerance — the 0.1, 0.2, 0.3 binary-floating-point case, which includes 8 of WORLD3's 55 tables — and 48 (8.6%) are genuinely non-uniform.

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