Skip to content

engine: add systems format support#398

Open
bpowers wants to merge 29 commits intomainfrom
systems-format
Open

engine: add systems format support#398
bpowers wants to merge 29 commits intomainfrom
systems-format

Conversation

@bpowers
Copy link
Owner

@bpowers bpowers commented Mar 19, 2026

Summary

  • Parser: Line-oriented lexer + recursive descent formula parser for .txt systems format files, producing a SystemsModel IR with stocks, flows (Rate/Conversion/Leak), and left-to-right formula expressions
  • Translator: Converts SystemsModel to datamodel::Project using stdlib module instantiation (systems_rate, systems_leak, systems_conversion), with multi-outflow chaining, waste flow generation, and order-dependent rate rewriting for sequential-drain semantics
  • Writer: Reconstructs .txt format from datamodel via module inspection, chain walking for declaration order recovery, and topological sort for flow group ordering
  • Simulation: 5 integration tests comparing VM and interpreter output against Python systems package reference CSVs
  • ALLOCATE BY PRIORITY: Native engine builtin with full pipeline (builtins, bytecode, codegen, VM, interpreter, MDL translation)
  • Module diagrams: Removed 9 exclusion sites in layout engine to include Variable::Module in diagram generation with SFDP positioning
  • Round-trip: Parse -> translate -> write -> parse -> translate -> simulate produces matching output for all fixtures

54 files changed, 6075 insertions, 206 deletions across 19 commits.

Test plan

  • 2940 automated tests pass (2780 unit + integration + doc tests)
  • 5 simulation integration tests match Python reference CSVs (hiring, links, maximums, projects, extended_syntax)
  • 6 round-trip tests verify write -> re-parse -> simulate cycle
  • 3 ALLOCATE BY PRIORITY tests (VM, interpreter, equivalence with ALLOCATE AVAILABLE)
  • 207 layout tests including module diagram generation
  • Human test plan at docs/test-plans/2026-03-18-systems-format.md

bpowers added 21 commits March 18, 2026 13:20
Completed brainstorming session. Design includes:
- Three stdlib modules (systems_rate, systems_conversion, systems_leak) for
  flow type encapsulation with remaining-chain pattern for sequential debiting
- Two-stage parser (lexer + AST) for the systems text format
- Module-based translator producing datamodel with correct priority ordering
- Round-trip writer that reconstructs .txt from module structure
- ALLOCATE BY PRIORITY as a first-class engine builtin via AST rewrite
- Layout engine extension for module diagram generation
- 7 implementation phases with acceptance criteria
Three new stdlib modules for the systems format flow types:
- systems_rate: moves min(requested, min(available, dest_capacity))
- systems_leak: moves min(floor(available * rate), dest_capacity)
- systems_conversion: drains source, adds floor(src * rate) to dest

Each module is a pure-auxiliary XMILE model with input variables
(available, rate/requested, dest_capacity) and output variables
(actual/outflow, remaining, waste). Regenerated stdlib.gen.rs
brings MODEL_NAMES from 6 to 9 entries.

Includes 12 unit tests covering all specified test cases from the
design plan, verifying numeric behavior via the incremental compiler
and VM pipeline.
Defines the intermediate representation for the systems format parser:
Expr (Int/Float/Ref/Inf/BinOp/Paren), FlowType (Rate/Conversion/Leak),
SystemsStock, SystemsFlow, and SystemsModel. Module structure follows
the MDL reader pattern with mod.rs + submodule files. Parser has a
stub implementation that will be filled in the next commit.
Two-stage parser for the systems format (.txt files):

1. Lexer (lexer.rs): line-oriented tokenizer that classifies lines as
   comments, stock-only declarations, or flow lines. Parses stock refs
   (plain, parameterized, infinite), explicit flow type prefixes
   (Rate/Conversion/Leak, case-insensitive), and formula expressions
   via recursive descent (left-to-right, no operator precedence).

2. Parser (parser.rs): builds SystemsModel IR from lexer output with
   stock deduplication (later non-default values update defaults,
   conflicting non-defaults error), implicit flow type detection
   (Float -> Conversion, everything else -> Rate), and declaration
   order preservation for sequential debiting priority.

40 tests covering all acceptance criteria (AC1.1-AC1.11, AC7.2) plus
all five example files from third_party/systems/examples/.
Implements Expr::to_equation_string() which converts the systems format
AST to equation strings suitable for Equation::Scalar. The key feature
is preserving left-to-right evaluation semantics by parenthesizing the
left operand of a BinOp when it has lower precedence than the outer
operator (e.g., a + b parsed as left operand of * emits "(a + b) * c").

References are canonicalized (lowercased, spaces to underscores), Inf
maps to "inf()", and floats always include a decimal point.
… and chaining

Translates SystemsModel IR into datamodel::Project by instantiating
stdlib modules (systems_rate, systems_leak, systems_conversion) for
each flow, creating actual transfer flows and waste flows, and wiring
stocks with inflow/outflow lists.

Multi-outflow stocks produce chained modules where each module's
available input references the previous module's remaining output.
Flows are processed in reversed declaration order so the last-declared
flow gets highest priority.

The open_systems() entry point in compat.rs provides the top-level
parse-and-translate API with a default 10-round simulation.
Parse, translate, and compile all valid example files (hiring.txt,
links.txt, maximums.txt, projects.txt, extended_syntax.txt) through
the incremental pipeline. For hiring.txt, additionally verify that
Candidates is infinite, rate/conversion/leak modules are selected
correctly, and waste flows exist for conversion flows.
Python script reads example .txt files from third_party/systems/examples/,
runs them through the Python systems package, and writes expected CSV
output to test/systems-format/ for use by Rust integration tests.
Extract ensure_results into shared test_helpers module so both
simulate.rs and simulate_systems.rs can use the same comparison
logic. Add simulate_systems.rs with 5 integration tests that parse
systems format files, translate to datamodel, and run both the
interpreter and VM paths against expected CSV output from the Python
systems package.

Fix several translator bugs discovered by the integration tests:

- systems_rate stdlib module: use MIN(dest_capacity, MAX(0, ...)) to
  match Python's Rate.calculate semantics (allows negative capacity
  for reverse flows, clamps negative requested to 0)

- Deferred dest_capacity creation: account for destination stock
  outflows (capacity = max - stock + outflows) so simultaneous SD
  evaluation matches Python's sequential source-drain semantics

- Rate formula reference rewriting: when a rate formula references
  a stock drained by earlier flows (in reversed declaration order),
  substitute the effective post-outflow value to match Python's
  intermediate state visibility

- Module output chaining: create intermediate aux variables to bridge
  module outputs to module inputs, since ModuleReference bindings
  require outer model variables as sources
The translator uses rewrite_expr_to_equation() (translate.rs) to
combine reference rewriting with equation string emission in one pass,
which also correctly preserves dot-separated module reference strings
(e.g. "module.remaining") without re-canonicalization.

Expr::rewrite_refs was never called after that design was settled,
leaving a parallel but subtly different implementation that would
diverge from translate.rs over time. Remove it and its unused
HashMap import.
The doc comment referenced `Expr::rewrite_refs` which no longer exists.
Update it to reference `Expr::to_equation_string` instead, which is the
actual method it contrasts with.
Add writer.rs that reconstructs the .txt format from a translated
datamodel::Project by inspecting systems module structure, stripping
synthesized variables (modules, waste flows, helper auxes, transfer
flows, effective vars), and recovering original declaration order
via chain walking.

Key design decisions:
- Reverse-rewrites _effective and _remaining references back to
  original stock names in rate expressions
- Topological sort of flow groups based on _effective dependency
  analysis ensures the translator's drained_stocks logic produces
  identical rewrite decisions during round-trip
- Explicit flow type syntax (Rate/Conversion/Leak) always emitted
  to avoid ambiguity from implicit type detection

Also adds round-trip integration tests verifying that parse ->
translate -> write -> parse -> translate -> simulate produces
matching output for all 5 fixture files, plus a compat API test
through open_systems/to_systems.
ALLOCATE BY PRIORITY(request, priority, size, width, supply) is a
Vensim function that allocates a shared supply among competing
requesters based on priority rankings. Previously, MDL import
converted this to the XMILE ALLOCATE() form with complex argument
reordering. Now the engine handles it natively.

The implementation adds AllocateByPriority as a first-class BuiltinFn
variant that flows through the full compilation pipeline (Expr0 ->
Expr1 -> Expr2 -> Expr3 -> bytecode). At runtime (both VM and
interpreter), it constructs rectangular priority profiles
(ptype=1, ppriority=priority[i], pwidth=width, pextra=0) and
delegates to the existing allocate_available algorithm.

The MDL xmile_compat translator now emits allocate_by_priority()
directly instead of rewriting to the complex ALLOCATE() form, and
the MDL writer recognizes both the native and legacy forms for
round-trip fidelity.
Add a writer test for the native XMILE allocate_by_priority(...) form
(AC5.4), which was already handled by recognize_allocate but lacked test
coverage. Update the engine CLAUDE.md to list AllocateByPriority
alongside AllocateAvailable in the descriptions of builtins.rs,
compiler/mod.rs, bytecode.rs, and vm.rs.
Add module_width (55.0) and module_height (45.0) fields matching
existing rendering constants. Include validation clamps following
the pattern for other dimension fields.
Remove the 9 module exclusion sites in layout/mod.rs that previously
skipped Variable::Module during dependency graph construction,
positioning, label placement, and validation. Modules now participate
as first-class layout elements.

Key changes:
- LayoutEngine::new includes modules in display_names and UID seeding
- apply_layout_positions maps Module positions from SFDP results
- create_connectors includes modules in model_var_idents
- apply_optimal_label_placement optimizes module label sides
- apply_feedback_loop_curvature includes modules in uid_to_ident
- validate_view_completeness checks for ViewElement::Module
- compute_metadata records module UIDs, includes modules in all_idents,
  and builds dep_graph edges from module reference bindings
- recalculate_bounds tracks module element extents
- New create_missing_module_elements follows the aux pattern
- Renamed unit test to test_generate_layout_includes_module_elements_and_connectors
…ation

Extend verify_layout helper to include ViewElement::Module in the
view_names set, coordinate positivity checks, and viewbox containment
checks.

Add test_systems_format_layout_with_modules which loads the hiring.txt
systems format fixture, translates it, generates a layout, and verifies:
- Every Variable::Module has a corresponding ViewElement::Module
- All modules have finite coordinates from SFDP positioning
- Full verify_layout validation passes including module elements
Systems format is a new parser/translator/writer subsystem in
simlin-engine that handles line-oriented .txt stock-and-flow models.
Layout engine now generates view elements for modules. New stdlib
modules (systems_rate, systems_leak, systems_conversion) support
the systems format flow types. ALLOCATE BY PRIORITY was also added
as a new engine builtin during this branch.
Three issues addressed from code review:

- Critical: rewrite_expr_to_equation in systems/translate.rs did not
  apply the needs_parens logic from Expr::to_equation_string. When a
  rate formula contained mixed-precedence operators (e.g. A + D * 2,
  which left-to-right parses as (A + D) * 2) and the source stock
  reference was rewritten to a chained remaining variable, the emitted
  equation omitted the required parentheses. Fix: introduce a local
  needs_parens_in_rewrite helper that applies the same precedence check,
  and promote BinOp::precedence to pub(super) so translate.rs can call
  it. Added a regression test verifying the parenthesized output.

- Important: CLAUDE.md described tests/test_helpers.rs as providing
  open_systems_fixture() which does not exist. Updated the description
  to accurately reflect that the file provides ensure_results for CSV
  result comparison.

- Minor: systems_rate.stmx used MAX(0, MIN(requested, available)) without
  explaining why MAX(0) is present. Added an XML comment documenting that
  this is a deliberate defensive guard against negative requested values
  and that it deviates from the original specification.
Include phase-by-phase implementation plans and test requirements
document used during development of the systems format feature.
Copy link

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

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: 7dafe2b2ab

ℹ️ 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".

Comment on lines +81 to +82
if modules.is_empty() {
return Ok(String::new());

Choose a reason for hiding this comment

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

P1 Badge Serialize stock-only models instead of returning empty output

When a translated systems project has no systems_* modules (for example, an input containing only stock declarations like A(10) or [Pool]), this early return emits an empty string and drops every stock declaration. That makes parse→translate→write lossy for a valid syntax class the parser supports (FlowNoRate/stock-only lines), and users will lose model content on export.

Useful? React with 👍 / 👎.

Comment on lines +58 to +62
let stock_idx: HashMap<String, usize> = stocks
.iter()
.enumerate()
.map(|(i, s)| (s.ident.clone(), i))
.collect();

Choose a reason for hiding this comment

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

P1 Badge Detect canonical stock-name collisions during translation

Translation canonicalizes stock names (e.g. A and a both become a) but builds this lookup map with collect() and no collision check, so later entries silently overwrite earlier ones. In mixed-case/whitespace-equivalent inputs this miswires inflow/outflow attachment and can yield duplicate variable idents with incorrect simulation behavior instead of a deterministic import error.

Useful? React with 👍 / 👎.

@claude
Copy link

claude bot commented Mar 19, 2026

Code Review

This is a thorough, well-structured addition of systems format support. The parser, translator, stdlib module approach, ALLOCATE BY PRIORITY builtin, and layout changes are all implemented consistently across the full pipeline (AST lowering, codegen, VM, interpreter, compiler hoisting, patch, symbolic, MDL writer, pretty printer).

Findings

No blocking bugs found. The code is correct.

[P3] Writer find_stock_max uses split that could mismatch on pathological inputs (src/simlin-engine/src/systems/writer.rs:619-624)

The find_stock_max function extracts a stock's max expression by splitting the capacity equation on " - {stock_ident}". The capacity equation is generated as {max_expr} - {dest_stock} (or {max_expr} - {dest_stock} + {outflows}). If the max expression itself contained the substring - {stock_ident} (e.g., a max expression like a - foo where foo is the destination stock ident), split().next() would return only the part before the first occurrence, truncating the max expression. This is very unlikely in practice since systems format max values are typically simple constants or references, and it only affects round-trip fidelity (not simulation correctness). A splitn(2, ...) would be marginally more robust but still imperfect; the real fix would be structured extraction rather than string splitting, but that's not worth it given the low likelihood.

Overall Correctness Verdict

Correct. The patch is free of bugs that would break existing code or tests. The ALLOCATE BY PRIORITY additions are consistent across all compilation stages (AST, codegen, VM, interpreter, symbolic, patch). The systems format pipeline (lexer -> parser -> translator -> writer) is well-tested with integration tests and round-trip verification. The layout engine module support additions follow existing patterns for other element types. No security, performance, or correctness issues found.

@claude
Copy link

claude bot commented Mar 19, 2026

Code Review

Reviewed 54 changed files across 19 commits covering: systems format parser/lexer/translator/writer, ALLOCATE BY PRIORITY as native engine builtin, module layout support, and stdlib modules.

Areas Reviewed in Detail

  • Parser pipeline (systems/lexer.rs, parser.rs, ast.rs): Left-to-right expression parsing, stock deduplication, implicit flow type detection, formula tokenization
  • Translator (translate.rs): Module instantiation, multi-outflow chaining via remaining, effective-variable rewriting, deferred rate/capacity processing with order-dependent rewrites
  • Writer (writer.rs): Chain walking for declaration order recovery, reverse-rewrite map, topological sort with effective_deps/raw_stock_deps constraints (verified both dependency directions match their comments)
  • ALLOCATE BY PRIORITY: Traced through all compilation stages — builtins definition, expr1 parsing, expr2/expr3 lowering, compiler context (wildcard preservation for array args), codegen (view stack push/pop balanced), VM execution, interpreter execution, patch rename, MDL writer, A2A hoisting
  • Stdlib modules (systems_rate.stmx, systems_leak.stmx, systems_conversion.stmx): Verified equations match design spec semantics
  • Layout (layout/mod.rs, config.rs): Confirmed all 9 module exclusion sites were removed and module handling was added consistently (SFDP graph, view element creation, position application, label placement, bounds calculation, view completeness validation)
  • MDL compatibility (xmile_compat.rs, writer.rs): Both native and legacy ALLOCATE forms handled correctly

Findings

No bugs found. The implementation is thorough, well-tested, and correct.

Verdict: Correct

The patch introduces no regressions to existing code or tests. The new functionality is well-structured with comprehensive test coverage across unit tests, integration tests, and round-trip tests.

The translator used a single _effective variable per stock that subtracted
ALL outflows, but cross-stock rate references should only see the outflows
processed so far in reversed declaration order. For example, in a model
with A>B@1, C>D@A, A>E@8, the flow C>D should see A after only A>E drains
it (a-a_to_e), not after both outflows (a-a_to_e-a_to_b).

Replace the monolithic _effective approach with incremental drain variables
(_drained_N) that accumulate outflows as they are processed. Pre-compute
the drain state at each flow's processing point and share it between
rate and capacity equation generation.

Also fix an inaccurate comment in the lexer about flow arrow whitespace,
add a FlowNoRate parser test, and document why conversion remaining is 0.
@claude
Copy link

claude bot commented Mar 19, 2026

Code Review

This is a well-structured PR that adds systems format support through a clean translation-to-stdlib-modules approach, plus a native ALLOCATE BY PRIORITY builtin. The architecture (lexer → parser → AST IR → translator → writer) is solid, the chaining mechanism for multi-outflow priority ordering is elegant, and the incremental drain tracking for cross-stock references is carefully designed.

After thorough review of all 54 changed files, I found no blocking bugs. The implementation is consistent across all compilation paths (codegen, VM, interpreter), the view stack management is correct, and the test coverage is comprehensive.

Minor observations (non-blocking)

implicit_flow_type misses parenthesized float literals — src/simlin-engine/src/systems/parser.rs:43-48 [P3]

implicit_flow_type only matches Expr::Float(_) at the top level. If a user writes A > B @ (0.5), the parser produces Expr::Paren(Float(0.5)), which matches the _ wildcard and classifies as Rate instead of Conversion. This only affects implicit typing (bare formula after @), and the workaround is trivial (@ Conversion(0.5)), so impact is low. Whether this diverges from the Python reference implementation depends on how that lexer classifies parenthesized decimals — worth a quick check.


Overall correctness: CORRECT

The patch is well-tested (5 simulation integration tests against Python reference CSVs, 6 round-trip tests, ALLOCATE BY PRIORITY cross-validation between VM and interpreter), and all code paths are consistently implemented. The stdlib module approach avoids engine-level special-casing, and the writer's chain-walking + topological sort correctly recovers declaration order for round-trips.

Fix three writer issues found during code review:

- Stock-only models (no flows/modules) now emit stock declarations
  instead of silently producing empty output.
- find_stock_max uses exact identifier matching to prevent prefix
  collisions (e.g., stock "a" matching within " - ab").
- Rate equations containing inf() are normalized to bare "inf" to
  match the systems parser's expected syntax.
@claude
Copy link

claude bot commented Mar 19, 2026

Code Review

54 files changed, ~6000 lines — systems format parser/translator/writer, ALLOCATE BY PRIORITY builtin, module diagram support.

This is a well-structured PR with comprehensive test coverage (5 integration tests matching Python reference CSVs, 6 round-trip tests, VM/interpreter allocation tests, 207 layout tests). The stdlib module approach for flow types is elegant — it avoids special-case engine code while letting simlin's existing topological sort handle evaluation order.

After thorough review of the parser, lexer, translator, writer, VM, interpreter, codegen, layout, and supporting changes, I found no blocking bugs. A few observations below:

Observations (non-blocking)

1. [P3] _drained_ pattern matching uses find()src/simlin-engine/src/systems/writer.rs:481-487

else if let Some(pos) = token.find("_drained_") {
    let stock_name = &token[..pos];

find() returns the position of the first occurrence of _drained_ in the token. If a user ever names a stock something containing _drained_ (e.g., foo_drained_bar), the drain variable foo_drained_bar_drained_0 would match at position 3, yielding stock name foo instead of foo_drained_bar. In practice this is extremely unlikely since systems format names are simple identifiers and _drained_N variables are synthetic, but rfind("_drained_") (last occurrence) would be more robust.

2. [P3] Size parameter intentionally unused in ALLOCATE BY PRIORITY — src/simlin-engine/src/compiler/codegen.rs:1125

The _size parameter is parsed and ignored across codegen, interpreter, and VM. The implementation correctly uses requests.len() to determine requester count, which is equivalent. The tests validate correctness against Vensim reference output. This is fine as-is — just noting it for documentation purposes.


Verdict

Overall correctness: Correct. The patch is well-tested, the translation logic is sound, and the round-trip tests provide strong confidence that parse→translate→write→re-parse→simulate produces consistent results. No bugs that would break existing code or tests.

🤖 Generated with Claude Code

The dest_capacity equation was counting ALL outflows from the destination
stock, but in the Python sequential model only outflows already processed
(higher flow_idx in reversed declaration order) have freed capacity.
Unprocessed outflows inflated the capacity estimate, allowing excessive
transfers into capped stocks.

Fix by building a flow-ident-to-index map and filtering destination
outflows to only include those with higher flow_idx than the current flow.

Also reject Leak/Conversion flows from infinite sources with a clear
error, matching the Python systems package's IllegalSourceStock behavior.
@claude
Copy link

claude bot commented Mar 19, 2026

Code Review

[P2] Negative dest_capacity can cause flows to reverse direction

File: stdlib/systems_rate.stmx:31, stdlib/systems_leak.stmx:25, stdlib/systems_conversion.stmx:25, src/simlin-engine/src/systems/translate.rs:428-433

When a destination stock exceeds its maximum (e.g., because multiple inflows independently computed capacity against the same stock value in a prior timestep), dest_capacity evaluates to a negative number. In systems_rate, the formula MIN(dest_capacity, MAX(0, MIN(requested, available))) lets the negative dest_capacity dominate, producing a negative actual — the MAX(0, ...) guard only protects MIN(requested, available), not the outer result. This means the flow reverses direction: the source stock gains and the destination loses. The same issue applies to systems_leak and systems_conversion which use MIN(..., dest_capacity) without any non-negative clamp. Wrapping dest_capacity in MAX(0, dest_capacity) in each stdlib module (or in the translator-generated equation) would prevent reverse transfers.


[P3] Unary minus only recognized before digit characters, not before names or parentheses

File: src/simlin-engine/src/systems/lexer.rs:376-405

The unary-minus branch (line 381) requires chars[i + 1].is_ascii_digit(), so -Recruiters or -(a + b) tokenizes the - as Op(Sub) followed by a name/paren atom. Since parse_atom doesn't handle a leading Op(Sub), these expressions produce a parse error rather than being treated as negation. If the systems format intends to support negating variable references or parenthesized subexpressions, the lexer (or parser) needs to handle this case.


Overall Correctness

The patch is correct for the tested fixtures and the core translation/simulation pipeline is sound. The negative dest_capacity issue is a real semantic bug but only manifests in models where a stock exceeds its declared maximum (possible with multiple unsynchronized inflows), so it does not affect the existing test suite. The unary minus limitation is a feature gap that produces clear parse errors rather than silent misbehavior. Neither issue blocks the PR.

The writer's find_stock_max used find() to locate the " - {stock}"
separator in capacity equations, but the translator always appends this
separator at the end. When the max expression itself references the
destination stock (e.g., max = A - B producing "a - b - b"), the first
match was inside the max expression, truncating it to just "a".

Fix by using rfind() to match the last occurrence, which is always the
translator-generated separator.
@claude
Copy link

claude bot commented Mar 19, 2026

Code Review

I reviewed the full PR (54 files, ~8700 lines) covering:

  • Systems format parser (lexer, parser, AST)
  • Systems-to-datamodel translator with multi-outflow chaining and drain rewriting
  • Systems format writer with chain walking, topological sort, and reverse rewriting
  • ALLOCATE BY PRIORITY as a native engine builtin (builtins, bytecode, codegen, VM, interpreter)
  • Layout engine changes for module diagram generation (9 exclusion sites removed)
  • MDL xmile_compat.rs simplification
  • Integration tests against Python reference CSVs and round-trip tests

Findings

No bugs found that meet the review criteria. Key areas verified:

  • AllocateByPriority _size parameter: Intentionally skipped in codegen and interpreter — the bytecode opcode expects exactly 2 stack values (width, supply), and size is derived from the request array dimensions at runtime. Both VM and interpreter are consistent.
  • rewrite_expr_to_equation parenthesization: Only wraps the LEFT child, which is correct because the systems parser builds left-associative trees and all rewrite targets are simple identifiers (never complex expressions with operators).
  • find_stock_max boundary check: Uses rfind with a leading-space pattern (" - {stock}"), correctly handling cases where the max expression itself references the dest stock.
  • Drain variable logic: Flows processed in reversed declaration order with incremental drain variables correctly indexed by acc.len().
  • Layout module inclusion: All 9 exclusion sites properly updated, create_missing_module_elements follows the established create_missing_auxiliary_elements pattern, and compute_metadata correctly builds module dependencies from reference bindings.
  • Round-trip integrity: The topological sort in the writer correctly recovers declaration order from drain dependency information (effective_deps vs raw_stock_deps).

Overall Correctness Verdict

Correct. The patch is well-structured with comprehensive test coverage including integration tests against Python reference CSVs, round-trip tests, and VM/interpreter equivalence checks. No bugs or blocking issues found.

The Leak/Conversion from infinite source validation only checked
source_ref.is_infinite (the current line's bracket syntax), missing
cases where the stock was declared infinite on an earlier line.

Fix by checking the resolved stock's is_infinite state from the
deduplicated stock table after ensure_stock has processed all
declarations.
@bpowers
Copy link
Owner Author

bpowers commented Mar 19, 2026

Review cycle summary

Five iterations of automated code review (codex + claude) identified and resolved several correctness issues in the systems format translator and writer:

Cross-stock drain semantics (P1): The translator used a monolithic _effective variable that subtracted ALL outflows from a stock, but cross-stock rate references should only see outflows processed so far in reversed declaration order. Replaced with incremental _drained_N variables that accumulate outflows as they are processed, with pre-computed drain state shared between rate and capacity equation generation. Added simulation integration test (cross_stock.txt) verifying against expected Python output.

Destination capacity ordering (P1): The dest_capacity equation counted ALL outflows from the destination stock, but only outflows from flows already processed (higher flow_idx) have freed capacity at that point. Fixed by filtering destination outflows based on processing order via a flow_ident_to_idx map.

Writer robustness (3 fixes): Stock-only models no longer produce empty output. find_stock_max uses rfind instead of find to avoid truncating max expressions that reference the destination stock. Rate equations containing inf() are normalized to inf for parser compatibility.

Input validation: Leak/Conversion flows from infinite sources are now rejected (matching Python's IllegalSourceStock), using the resolved stock state rather than just the current line's bracket syntax.

All fixes follow TDD: failing test first, then minimal code change. Total: 5 review iterations, 4 fix commits, all 2800+ engine tests passing.

@claude
Copy link

claude bot commented Mar 19, 2026

Code Review: engine: add systems format support

Summary

This is a large, well-structured PR that adds native support for Will Larson's systems text format to the simlin engine, along with ALLOCATE BY PRIORITY as a native builtin. The implementation translates each systems flow type (Rate, Leak, Conversion) into stdlib module instantiations, with multi-outflow priority ordering handled through chained available/remaining wiring.

Findings

No bugs found that meet the flagging criteria. The code is thoroughly tested with unit tests, integration tests against Python reference CSVs, and round-trip tests. The ALLOCATE BY PRIORITY additions across the compiler pipeline (builtins, bytecode, codegen, interpreter, VM) consistently follow existing patterns for ALLOCATE AVAILABLE.

Key observations on correctness:

  • Left-to-right parenthesization (ast.rs:needs_parens, translate.rs:rewrite_expr_to_equation): Correctly handles the only case where standard math precedence disagrees with left-to-right evaluation — when the left child of a BinOp has lower precedence than the outer operator. Test coverage is strong.

  • Sequential drain semantics (translate.rs:268-328): The incremental _drained_N variables correctly model the Python package's sequential processing. Each flow sees only the drains processed before it in reversed declaration order. The drain_at_flow map captures the state before each flow's evaluation, which is correct.

  • dest_capacity ordering (translate.rs:395-446): Only counts destination outflows with higher flow_idx (already processed in reversed order). The flow_ident_to_idx map correctly includes waste flow idents for Conversion flows.

  • Stdlib modules: systems_rate applies MAX(0, ...) guard (defensive against negative requested values), while systems_leak and systems_conversion use INT(available * rate) without the guard, consistent with the Python reference where available * rate >= 0 in normal usage.

  • Writer round-trip (writer.rs): The find_stock_max uses rfind for the - {stock} pattern to handle cases where the max expression itself references the dest stock (e.g., max = A - B produces capacity a - b - b).

  • Layout module inclusion (layout/mod.rs): The removal of 9 Variable::Module exclusion sites is well-tested in the layout integration tests.

Overall Correctness Verdict

Correct. The patch is free of bugs and blocking issues. Existing code and tests will not break. The implementation is consistent with the Python systems package reference semantics, and all mechanical additions (ALLOCATE BY PRIORITY across the compiler pipeline) correctly follow established patterns.

Test model verifying destination capacity ordering: stock b has
capacity 5 and drains to c, while a drains to b at rate 10. This
exercises the priority ordering logic when destination stocks have
limited capacity.
@claude
Copy link

claude bot commented Mar 19, 2026

Code Review

Large, well-structured PR adding systems format support and ALLOCATE BY PRIORITY as a native builtin. Reviewed the parser pipeline (lexer → parser → AST → translator), the writer/round-trip logic, the ALLOCATE BY PRIORITY implementation across all compilation stages (builtins, AST lowering, codegen, VM, interpreter), stdlib module semantics, and layout changes for module rendering.

Findings

No bugs found.

Notes

  • The left-to-right parenthesization logic in ast.rs and translate.rs::rewrite_expr_to_equation is correct: only the left operand of a BinOp can be a BinOp (due to left-to-right parsing), so only left-side parenthesization is needed. The test rewrite_preserves_mixed_precedence_parens provides good coverage for this.
  • The _size parameter in ALLOCATE BY PRIORITY is consistently ignored across codegen, VM, and interpreter, matching the Vensim spec where this parameter is vestigial. The AST transformation passes still walk it for completeness.
  • The VM stack discipline for AllocateByPriority correctly mirrors AllocateAvailable: two views pushed/popped via explicit PopView opcodes, two scalars (width, supply) popped by the opcode's stack_delta of (2, 0).
  • The stdlib modules (systems_rate, systems_leak, systems_conversion) correctly apply INT() only for Leak/Conversion (where available * rate produces fractions), not for Rate flows (where the requested amount is the formula result directly).
  • The writer's topological sort based on effective_deps/raw_stock_deps is a clever approach to preserving declaration order through the round-trip, and the chain-walking logic correctly recovers original flow ordering.
  • Layout changes properly include modules in SFDP positioning and derive module dependencies from reference bindings rather than equation parsing.

Verdict

Correct. The patch is well-tested (5 simulation integration tests against Python reference CSVs, 6 round-trip tests, ALLOCATE BY PRIORITY unit tests) and the implementation follows established patterns throughout the codebase. No existing code or tests should break.

Three correctness fixes from code review:

1. Translator: multiple flows between the same source and destination
   now produce disambiguated identifiers (e.g. a_outflows_b, a_outflows_b_2).
   Previously both would get "a_outflows_b", causing the second flow's
   module and transfer variables to overwrite the first.

2. Writer: per-flow topological sort replaces per-group sort. When
   flows from different sources need interleaving for correct drain
   semantics (e.g. A>X, B>Y, A>Z where A>X sees drained B but A>Z
   sees raw B), the writer now correctly interleaves them instead of
   grouping all same-source flows together.

3. Writer: O(n^2) cycle fallback in topological sort replaced with
   HashSet-based O(n) lookup.

Also adds a clarifying comment about the ignored _size parameter in
AllocateByPriority codegen.
@claude
Copy link

claude bot commented Mar 19, 2026

Code Review

[P2] Parser uses case-sensitive stock dedup but translator canonicalizes to lowercase

File: src/simlin-engine/src/systems/parser.rs, lines 147-219 (the ensure_stock function)

The parser's stock_index HashMap uses the raw, case-preserved stock name as the key (line 168: stock_index.get(&stock_ref.name)), while the translator canonicalizes all names to lowercase via canon(). This means inputs like A(5) > B @ 1 followed by a > C @ 2 create two separate parser stocks "A" and "a", both of which the translator maps to ident "a". The result is two StockBuilder entries with the same canonical ident, where stock_idx (a HashMap) silently keeps only the last insertion — orphaning the first stock's initial value and producing two Variable::Stock entries with the same ident in the model. Depending on the input, this can cause either a downstream compilation error (duplicate idents) or silent data loss (the initial value from the first casing variant is dropped). The Python systems package canonicalizes names during parsing, so this diverges from the reference behavior. The fix would be to canonicalize stock_ref.name before using it as the stock_index key.


Overall Correctness Verdict: Correct

The patch is well-structured and thoroughly tested. The parser, translator, writer, ALLOCATE BY PRIORITY builtin, layout changes, and compiler pipeline additions are all internally consistent. The one finding above is a P2 edge case requiring inconsistent casing of stock names in the same file — unlikely in practice and not blocking. The 5 simulation integration tests against Python reference CSVs, 6 round-trip tests, and comprehensive unit tests provide strong confidence in correctness.

Two fixes from the second review iteration:

1. stdlib systems_conversion module: when dest_capacity is finite, the
   module now only consumes the source units needed to fill the capacity
   (max_src_change = floor(dest_capacity / rate)), matching the Python
   reference. Previously it drained the entire source stock regardless
   of the cap, producing wrong waste and remaining values.

2. Parser validation: reject Leak/Conversion from stocks with infinite
   initial value (e.g. A(inf)), not just bracket-declared infinite
   stocks ([A]). The Python reference raises IllegalSourceStock for
   any source whose initial value is infinite.

Also deduplicates the pair-count suffix logic in the translator by
pre-computing flow_suffixes once and reusing in both the main loop
and the drain state computation.
@claude
Copy link

claude bot commented Mar 19, 2026

Code Review

[P2] find_stock_max can falsely attribute a max constraint to the wrong stock

File: src/simlin-engine/src/systems/writer.rs, lines 633–686

find_stock_max iterates over all modules looking for a dest_capacity equation containing - {stock.ident}, but it never verifies that the module actually targets the stock being queried. If a flow targets stock D whose max expression happens to subtract stock B (e.g., D(0, a - b) producing capacity a - b - d), then find_stock_max called for stock B will match rfind(" - b") in that capacity equation and incorrectly return "a" as B's max — even when B has no max constraint at all. This only affects the writer (round-trip to .txt format), not simulation correctness, and requires a max expression that subtractively references another stock's identifier. The fix would be to verify the module's transfer flow actually feeds into the queried stock before inspecting its capacity equation.


Overall correctness: Correct. The patch is well-structured across 54 files. The simulation engine (parser, translator, stdlib modules, VM opcode, interpreter), compiler pipeline (AllocateByPriority across all layers), and layout changes (module element support added consistently at all 9 former exclusion sites) are all consistent and correct. The writer bug above only affects the .txt round-trip path under a specific max-expression pattern and does not impact simulation results or the core translation pipeline.

@bpowers
Copy link
Owner Author

bpowers commented Mar 19, 2026

Review Cycle Summary

Three iterations of automated code review (Codex + Claude) identified and resolved the following issues:

Iteration 1 addressed two correctness bugs and two code quality improvements:

  • Fixed duplicate parallel flows (A>B appearing twice) producing colliding module/flow identifiers in the translator, causing the second flow to silently overwrite the first
  • Restructured the writer from per-group to per-flow topological sort, fixing cross-source flow interleaving. Models like A>X @ B, B>Y @ 5, A>Z @ B now correctly preserve the interleaving needed for drain semantics
  • Replaced O(n^2) cycle fallback in the topological sort with HashSet-based lookup
  • Added clarifying comment about the ignored _size parameter in AllocateByPriority codegen

Iteration 2 fixed a simulation semantics bug and a validation gap:

  • Fixed the stdlib systems_conversion module: when the destination has finite capacity, the module now only consumes the source units needed to produce the capped outflow (matching the Python reference behavior). Previously it drained the entire source stock regardless of the cap.
  • Extended infinite source validation to also check the initial value expression, not just the [bracket] syntax flag. A(inf) > B @ Leak(0.5) is now correctly rejected.
  • Deduplicated the pair-count suffix logic in the translator

Iteration 3 produced no actionable feedback from either reviewer. Codex raised theoretical edge cases (case-sensitive identifiers, cyclic dependency ordering) that either conflict with the engine's core design invariants or represent inherently unsolvable constraint conflicts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant