Skip to content

engine: harden the MDL writer (re-parse + semantic-loss + lossiness contract) - #864

Merged
bpowers merged 6 commits into
mainfrom
mdl-writer-hardening
Jul 7, 2026
Merged

bpowers merged 6 commits into
mainfrom
mdl-writer-hardening

Conversation

@bpowers

@bpowers bpowers commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Why

The MDL writer (project_to_mdl, the export path behind simlin-cli convert --to mdl) was the subject of a pre-ship adversarial audit. That audit found two silent-corruption classes and several semantic-loss bugs: a context-free expression printer that emitted unparseable subscripts, raw free text that could terminate a construct early and invent phantom variables on re-import, and a handful of constructs (graphical-function kind, EXCEPT defaults, stock/arrayed initials) that were silently dropped. About 12% of the model corpus exported to files that would not re-parse.

This PR hardens the writer to the point where the corpus re-parses cleanly and the known semantic-loss bugs are fixed, and it adds the test scaffolding (a two-way corpus ratchet plus property tests) that keeps those wins from regressing. The only production caller is the CLI converter, so these are export-quality fixes, not user-file-destruction fixes.

What, in five reviewed steps

Each step was implemented by a fresh sub-agent under strict TDD and then adversarially reviewed by a different fresh sub-agent; findings were folded in before commit.

1. Corpus round-trip ratchet gates (tests/integration/mdl_roundtrip.rs). Two-way allowlists over every .mdl fixture: corpus_reparse_ratchet (writer output must re-parse) and writer_output_idempotence_ratchet (write is a fixpoint). An unlisted failure is a regression; a listed-but-now-passing entry is also a failure, so the allowlists can only shrink. Coverage-floor guards prevent a vacuous pass. This landed first (green) so every subsequent step had an objective, non-regressing scoreboard.

2. Context-aware expression printer. A WriterContext (built per model) threads the variable-ident set, each arrayed variable's declared dimensions, the standalone-extrapolating-lookup idents, and each named dimension's element list into the printer. This resolves what a context-free printer cannot: wildcard subscript recovery a[*] -> a[DimA!] (#847), the pi literal (#850), keyword-shadow resolution for user variables named like builtins (#853), INITIAL vs ACTIVE INITIAL arity at the call site (#852), and quoted-ident preservation (#846). Emptied the re-parse-failure allowlist from 27 to 0.

3. Free-text sanitization choke point (sanitize_free_text). Every modeler-authored free-text sink (units, documentation, group name/doc, 22: tokens) routes through one function. Policy: | -> / (a non-whitespace substitute so a field-final | cannot become an idempotence-breaking trailing space), the four section-terminator runs -> space, per-field separators -> space, line endings normalized losslessly to LF. Fixes the confirmed |-in-doc phantom-variable corruption (#849) and cut the idempotence allowlist from 103 to 80.

4. Semantic-loss fixes + a lossiness contract. project_to_mdl_with_warnings returns (String, Vec<ExportWarning>) and is the new primary entry point; project_to_mdl stays a thin wrapper that discards warnings so existing callers are unchanged. The CLI prints warnings to stderr. Warnings never change the emitted text, so they do not affect the ratchets (#856). On top of the channel: stock and arrayed active_initial / GET DIRECT initials are preserved (#857); a graphical function's Extrapolate kind is preserved by rewriting its LOOKUP call sites to TABXL -- the same marking the importer reads (#854); and an Equation::Arrayed with an EXCEPT default materializes the declared-but-missing elements with the default value instead of silently dropping them to 0 (#858, reopening the loss the #350 element-only fix introduced). The EXCEPT reconstruction was verified with a simulation-equivalence check (except.mdl now yields s[A1]=14/s[A2]=14/s[A3]=13, identical to the original run).

5. Property tests + docs. Four properties (writer_proptest.rs): free text never corrupts the variable set, the same text in the typed units field is structurally safe, equation write is a byte-equal re-parse fixpoint (a grammar aimed at the printer fixes), and whole-model write is idempotent. The review confirmed each is non-vacuous and non-tautological (a reverted fix trips the property). mdl/CLAUDE.md gains a Writing section and an updated Known Gaps list.

Decisions worth calling out

  • Warnings are a side channel, not a signature break for everyone. Keeping project_to_mdl -> Result<String> as a wrapper avoided churning ~40 test callsites while still giving the one production caller (the CLI) a way to surface degradation.
  • GF extrapolate is marked at the call site, not the definition. MDL has no definition-level extrapolate flag; the importer only infers it from a TABXL call. So the faithful inverse is to rewrite the call, which is what this does. An inline WITH LOOKUP Extrapolate GF (no call site to mark) warns instead.
  • EXCEPT is reconstructed by materialization, not by re-emitting :EXCEPT: syntax. The reader materializes EXCEPT coverage into explicit elements, so re-emitting the sugar is non-idempotent (the explicit set grows each write). Materializing the default-covered elements is both value-correct and a re-import fixpoint. It declines with a warning when dimension membership is unavailable or the default references its own dimensions.
  • The units field gets an honest, weaker guarantee. Units is a typed unit expression, not prose; sanitization keeps it structurally safe (never spawns phantom vars) but cannot make arbitrary text a valid unit expression. That asymmetry is documented and pinned by a separate property.

Follow-ups filed (not in this PR)

Closes

Fixes #846
Fixes #847
Fixes #849
Fixes #850
Fixes #852
Fixes #853
Fixes #854
Fixes #856
Fixes #857
Fixes #858

Substantially addresses #851 (the all-corpus re-parse gate, the idempotence ratchet, and the property tests all land here); the general simulation-equivalence oracle it also proposes remains open. #848's free-text non-idempotence class is fixed; its other non-idempotence classes (time-unit drift, TIME BASE rewriting, control-variable substitution) are enumerated in the writer's Known Gaps and remain in the idempotence allowlist.

🤖 Generated with Claude Code

bpowers added 5 commits July 6, 2026 22:41
Adds two corpus-wide regression ratchets for the Vensim MDL writer plus
a coverage floor, and retires three stale exclusions. Groundwork for the
MDL writer hardening work (GH #851): later fixes must visibly shrink
these allowlists.

corpus_reparse_ratchet walks every .mdl fixture under test/ in-process
(no CLI spawn, 200 KB cap skips only C-LEARN), and for each fixture whose
source parses, asserts project_to_mdl output re-parses -- unless the path
is in EXPECTED_REPARSE_FAILURES. The assertion runs both directions: an
unlisted failure is a regression, and a listed entry that now re-parses
is a stale allowlist entry the fix forgot to remove. This makes a padded
allowlist structurally impossible while green. The 27 current entries are
the wildcard-subscript ('*') and PI() collision classes.

writer_output_idempotence_ratchet applies the same two-way contract to
write(parse(write(parse(x)))) == write(parse(x)) over the re-parseable
set. Its 83 entries document previously-uncatalogued writer
non-idempotence: sketch UID/coordinate instability, arrayed element-order
drift, CRLF free-text accretion, SIZE(x) spacing, unary-minus
parenthesization, and control-variable value substitution.

Both gates assert a MIN_CORPUS_FIXTURES floor so a reader/source-side
regression that silently unparsed the corpus fails loudly rather than
passing vacuously (the ratchets only make allowlisted breakage loud).
The walk matches .mdl case-insensitively, which brought the uppercase
teacup-upper.MDL fixture under test.

xidz_zidz, lookups, and lookups_inline now survive the semantic
MDL->MDL roundtrip and move into TEST_MDL_MODELS.
Introduces a WriterContext (the model's variable-ident set + a canonical
ident -> declared-dimensions map) threaded through equation_to_mdl /
expr0_to_mdl / MdlPrintVisitor, closing five MDL-writer defects whose
common root cause was a context-free printer. Empties the corpus
re-parse ratchet's allowlist (EXPECTED_REPARSE_FAILURES 27 -> 0).

- #847 wildcard subscripts: a `*` at subscript position i now recovers
  the referenced variable's declared dimension at i and emits Vensim's
  bang form `Dim!` (was `*`, which the reader rejects). A `*:Dim`
  star-range emits `Dim!` from its own stored ident, preserving a
  subdimension name rather than widening to the parent. No declared
  dimension for the position falls back to a bare `*` (no panic).
- #850 pi: a genuine `pi` builtin emits an f64-precision numeric literal
  (Vensim has no PI builtin and rejects `PI()`).
- #853 reserved-name shadowing: at a zero-arg App, a declared model
  variable wins over the builtin-keyword rewrite, so a user aux named
  "Time Step" or "PI" stays its own identifier instead of silently
  rebinding to the simulation dt / the pi literal.
- #852 INITIAL arity: `init` dispatches by argument count -- 1-arg ->
  INITIAL, 2-arg -> ACTIVE INITIAL (was unconditional ACTIVE INITIAL).
  2-arg `normal(mean, sd)` synthesizes the 5-arg RANDOM NORMAL Vensim
  requires. safediv/ZIDZ/XIDZ was already arity-correct and is untouched.
- #846 quoted-ident interiors: format_mdl_ident detects an already
  quoted ident up front and preserves it verbatim, so `"rate_of_change!"`
  keeps its underscores and `"a_b c"` keeps its quotes.

expr0_to_mdl keeps a back-compat wrapper (default empty context) so
existing callers are unchanged; production emission builds a real
model-scoped context. The writer remains a pure function of the
datamodel (the context maps are lookup-only, never iterated for output
order).

20 formerly-unparseable fixtures now re-parse but are not fixpoints for
pre-existing reasons (CRLF free-text accretion #849, sketch
instability); they move into EXPECTED_NON_IDEMPOTENT. The importer's
cross-process subscript-classification non-determinism surfaced by this
work is tracked separately (#859); rendering both wildcard and
star-range as `Dim!` keeps re-parse stable regardless.
Adds a single sanitize_free_text choke point in the MDL writer and
routes every modeler-authored free-text sink through it: variable
units/documentation, group name/doc banners, and 22: unit-equivalence
tokens. Fixes GH #849 and lets the idempotence ratchet's allowlist
shrink (EXPECTED_NON_IDEMPOTENT 103 -> 80); the re-parse allowlist stays
empty.

The reader unescapes every free-text field, so any character it reads as
structure corrupts the file. The confirmed repro: a documentation string
`cost is 5 | maybe 6 ~ per year` exported and re-parsed as FOUR variables
(the `|` terminates the entry early and the remainder re-parses as
phantom auxes) with no error. The policy, grounded in a read of
reader.rs / lexer.rs / settings.rs and documented on the function:

- line endings normalized losslessly to LF (the file-level LF->CRLF pass
  re-adds clean CRLF), which is what stops a comment carrying a `\r`
  from accumulating a carriage return every round trip -- the dominant
  idempotence-failure class;
- `|` -> `/` (a non-whitespace substitute, so a field-final `|` cannot
  become a trim-sensitive trailing space and break idempotence);
- the section-terminator runs and caller-specified field separators
  (`~` in units, `,` in a 22: token) -> a space;
- substitute-and-preserve, never error (project_to_mdl keeps returning
  Result<String>; the richer warnings channel is a separate change).

The lexer accepts both the canonical three-backslash section terminator
and a two-backslash variant, and the units field is lexer-tokenized, so
the pre-pass neutralizes all four runs -- three-backslash first, since a
canonical run contains the short run as a substring.

`~` is deliberately preserved in comments (the reader's :SUP flag
heuristic is a feature). View titles keep their separate
sanitize_view_title_for_mdl (sketch records use `|` non-structurally).
Group-doc/name semantic loss on re-read is pre-existing and out of scope.
Fixes three silent-loss bugs in the MDL writer and adds the lossiness
contract that lets callers learn an export was degraded.

Warnings channel (#856): project_to_mdl_with_warnings returns
(String, Vec<ExportWarning>) and is the primary entry point;
project_to_mdl stays a thin wrapper that discards warnings so existing
callers are unchanged. compat gains to_mdl_with_warnings. simlin-cli's
`convert --to mdl` prints each warning to stderr (never stdout, which may
be the output file). Warnings cover the known lossy cases: non_negative
dropped, discrete/unrepresentable-extrapolate graphical functions,
one-to-many element mappings collapsed, group name/doc truncated-on-
reread, and an EXCEPT default that cannot be reconstructed. Exact-
duplicate warnings are collapsed (first-seen order preserved).

#857: write_stock_variable now honors stock.compat, so a stock whose
INTEG initial is ACTIVE INITIAL(...) or GET DIRECT survives export
instead of collapsing to the bare initial; wrap_active_initial is also
applied in the Arrayed aux/flow/stock arm (was Scalar/ApplyToAll only).

#854: a graphical function's Extrapolate kind is preserved by rewriting
its LOOKUP call sites to TABXL(table, x) -- the same call-site marking
the importer reads -- so an extrapolating table round-trips instead of
being silently clamped to continuous. Discrete kinds and arrayed/embedded
extrapolate that cannot be TABXL-marked emit a warning and clamp.

#858: an Arrayed equation with has_except_default now materializes the
declared-but-missing elements with the default equation as explicit
entries (declared cartesian minus explicit, via project.dimensions),
mirroring the engine's fill semantics, so elements covered only by the
default keep their value instead of dropping to 0. This reopens the loss
introduced by the #350 element-only fix. The reconstruction is a
re-import fixpoint; it declines with a warning when dimension membership
is unavailable or the default references the variable's own dimensions.

Verified: except.mdl now emits s[A1]=14/s[A2]=14/s[A3]=13 (A1/A2 were
dropped before) with identical simulation output, and re-parses. The
corpus ratchets stay green (re-parse 0 failures; idempotence unchanged).

The lossiness tests are split into writer_lossiness_tests.rs to keep
writer_tests.rs under the per-file line cap (GH #645).
Closes the property-based-testing and documentation gaps of the
MDL-writer hardening work.

writer_proptest.rs adds four properties, each mounted in its own file to
respect the per-file line cap:

- doc free text never corrupts the variable set: an adversarial doc built
  from the structural characters and all four section-terminator runs
  must re-parse to exactly the original variable (guards #849, the
  confirmed |-in-doc phantom-variable corruption).
- units free text is structurally safe: the same adversarial text in the
  typed units field never spawns or drops a variable (a hard unit-grammar
  parse failure is accepted, since units is an expression, not prose).
- equation write is a re-parse fixpoint: an Expr0 grammar aimed at the
  printer fixes (wildcard subscript recovery, the pi literal, INITIAL vs
  ACTIVE INITIAL arity, precedence) must re-parse as MDL and reach a
  byte-equal second-vs-third render fixpoint. A prop_filter restricts the
  grammar to ASTs whose print_eqn output re-parses, excluding the
  separately-tracked print_eqn<->parser asymmetry (#863).
- whole-model write is idempotent on the equations section.

The properties surfaced two non-writer issues, both filed rather than
worked around silently: a garbage-units hard-reimport gap (#862) and the
stacked-unary print_eqn asymmetry (#863). Neither is a regression.

mdl/CLAUDE.md gains a Writing section documenting the context-aware
printer, the sanitization choke point, the warnings channel, TABXL
preservation, and EXCEPT reconstruction, and a Known Gaps update
(wildcard subscripts are no longer a gap; the remaining round-trip
idempotence classes are enumerated).
@codecov

codecov Bot commented Jul 7, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.56119% with 44 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.00%. Comparing base (ffb6673) to head (4a90b9a).

Files with missing lines Patch % Lines
src/simlin-engine/src/mdl/writer.rs 93.91% 35 Missing ⚠️
src/simlin-cli/src/main.rs 0.00% 6 Missing ⚠️
src/simlin-engine/src/compat.rs 0.00% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #864      +/-   ##
==========================================
+ Coverage   90.88%   91.00%   +0.12%     
==========================================
  Files         225      226       +1     
  Lines      145049   145803     +754     
==========================================
+ Hits       131822   132683     +861     
+ Misses      13227    13120     -107     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

No blocking correctness issues found on review.

Verified the eight risk axes:

  • sanitize_free_text terminator constants and replacement order (3-backslash before 2-backslash) are correct; SingleLine mode collapses \r, \n, and \r\n runs correctly and prev_was_break is reset on non-break chars.
  • MdlPrintVisitor canonicalizes lookups into WriterContext consistently and preserves already-quoted idents via is_mdl_quoted_ident before underbar_to_space. walk_index_at falls back to bare * on out-of-range positions rather than panicking.
  • project_to_mdl_with_warnings never mutates emitted text based on warnings; the plain project_to_mdl is a thin wrapper.
  • EXCEPT default reconstruction: plan_except_default guards the .expect("Fill plan implies a default equation is present") via the same filter(|d| !d.trim().is_empty()) precondition; missing-element enumeration is row-major with element keys canonicalized on both sides of the membership comparison.
  • TABXL rewrite is correctly gated on var_is_extrapolating_lookup (Extrapolate + GF present + Equation::Scalar + is_lookup_only_equation), which excludes WITH LOOKUP and non-lookup variables.
  • RANDOM NORMAL 2-arg synthesis produces the required 5-arg form (NEG_INFINITY/INFINITY/mean/sd/0-seed).
  • Warnings are threaded uniformly by &mut Vec<ExportWarning>; the drop-only wrappers (write_variable_entry, write_variable_entry_ctx) are the documented back-compat surface.
  • The corpus ratchet's MIN_CORPUS_FIXTURES = 200 floor and the two-way allowlist rule (unlisted-failure || listed-but-passing both fail) make the gate genuinely non-vacuous.

Overall correctness verdict: correct. The PR is well-scoped: the writer hardening is bounded (only simlin-cli's convert path is the production caller), the corpus round-trip ratchets and property tests keep the wins from regressing, and the project_to_mdl / project_to_mdl_with_warnings split preserves existing callers.

🤖 Generated with Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f1b0269b02

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

match gf.kind {
Discrete => warnings.push(discrete_gf_warning(name)),
Extrapolate if !is_standalone => warnings.push(embedded_extrapolate_gf_warning(name)),
Continuous | Extrapolate => {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Warn for unreferenced extrapolating lookup tables

When a standalone Extrapolate lookup has no simple LOOKUP(table, ...) call that the printer rewrites to TABXL—for example an unused table—the emitted MDL contains only the lookup definition, and the importer only restores GraphicalFunctionKind::Extrapolate from an explicit definition flag or a TABXL use. This branch suppresses the warning anyway, so the table silently re-imports as Continuous; please warn unless a preserving TABXL call was actually emitted, or preserve the kind another way.

Useful? React with 👍 / 👎.

An Extrapolate graphical function on a standalone lookup-only table is
preserved on export by rewriting its LOOKUP call sites to TABXL (MDL has
no definition-level extrapolate flag; the importer only infers the kind
from a TABXL use). That preservation only happens when a call site
exists. A standalone extrapolating table with no LOOKUP reference -- a
dead or not-yet-wired table -- had no TABXL emitted, so its kind silently
clamped to Continuous on re-import while the writer suppressed the
warning on the assumption a preserving TABXL was written. That is a
silent loss, contrary to the export lossiness contract (#856).

WriterContext now scans the model's equations for the LOOKUP call sites
the printer rewrites (only when the model has any extrapolating lookup,
so the common no-lookup model pays nothing) and records which
extrapolating tables are actually referenced. warn_unrepresentable_gf_kinds
warns for a standalone extrapolating table absent from that set instead
of suppressing it. A referenced table still round-trips silently
(pinned by extrapolate_lookup_roundtrips_via_tabxl), so the change adds
no false positives.

Reported by Codex on #864.
@bpowers

bpowers commented Jul 7, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4a90b9a3fc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// rebind to the simulation dt / lose its definition
// (#853, #850).
if self.ctx.is_variable(&crate::common::canonicalize(func)) {
return format_mdl_ident(func);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Quote shadowed time keywords before emitting them

When the declared variable is one of the time-control names (time, time_step, initial_time, etc.), format_mdl_ident emits an unquoted bare identifier such as time step. On re-import the MDL converter treats those names case-insensitively as control variables (xmile_compat::format_name maps time step to DT, and convert::is_variable_equation skips TIME STEP definitions), so a model that actually has a user aux named time_step still loses/rebinds it despite this shadowing branch. Quote these reserved identifiers when they are variables.

Useful? React with 👍 / 👎.

@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Reviewed the writer changes end-to-end (sanitization, WriterContext propagation, TABXL preservation, EXCEPT reconstruction, warnings plumbing, ratchets). No blocking findings.

Overall correctness: correct

A couple of very minor doc/edge-case notes that are not worth blocking on:

  • The PI_LITERAL comment in src/simlin-engine/src/mdl/writer.rs and the writer section of src/simlin-engine/src/mdl/CLAUDE.md describe the literal as "17 significant digits", but 3.141592653589793 is 16 significant digits. The literal is still the minimum-length round-trip form for f64::consts::PI, so behavior is fine; only the wording is off.
  • In write_variable_entry_ctx_warn (src/simlin-engine/src/mdl/writer.rs around L1445-1449), warn_unrepresentable_gf_kinds is called with the raw gf before effective_gf is downgraded to None when compat.data_source is Some. A variable that carries both a GET DIRECT data source AND a Discrete / Extrapolate graphical function would therefore emit a spurious warning about a GF that is not actually written. This combination is not observed in the corpus and is safe to defer.
  • The arrayed-stock write path in write_arrayed_stock_entries still ignores compat.data_source (pre-existing gap not introduced by this PR); this PR correctly plumbs through ACTIVE INITIAL for arrayed stocks and leaves GET DIRECT on arrayed stocks as a separate follow-up.

The corpus re-parse ratchet dropping to an empty allowlist plus the pinned property tests are a strong safety net for future changes.

@bpowers
bpowers merged commit aebf890 into main Jul 7, 2026
15 of 17 checks passed
@bpowers
bpowers deleted the mdl-writer-hardening branch July 7, 2026 15:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment