Skip to content

Incremental compilation via salsa framework - #289

Merged
bpowers merged 32 commits into
mainfrom
incremental-compilation-impl
Feb 23, 2026
Merged

bpowers merged 32 commits into
mainfrom
incremental-compilation-impl

Conversation

@bpowers

@bpowers bpowers commented Feb 22, 2026

Copy link
Copy Markdown
Owner

Summary

This PR implements incremental compilation for simlin-engine using the salsa framework for demand-driven incremental computation. The core idea is to decompose each compilation stage into fine-grained, per-variable tracked functions whose results salsa automatically caches and selectively invalidates.

When a user edits one equation in a 100-variable model, only that variable's compilation stages re-execute; everything else is served from cache.

Changes by phase

  1. Remove SimFloat generic (d157184d) -- Hardcode f64 throughout, eliminating the generic parameter that complicates salsa integration. -1884 lines.

  2. Salsa database and interned identifiers (e3d6955c) -- Add salsa dependency, SimlinDb, VariableId/ModelId interned types, SourceProject/SourceModel/SourceVariable input types, sync_from_datamodel function.

  3. Per-variable parsing and lowering (aa37244a) -- parse_source_variable tracked function memoizes per-variable parsing. Editing one variable's equation does NOT re-parse other variables.

  4. Dependency analysis (15cc40e2) -- variable_direct_dependencies and model_dependency_graph tracked functions. Changing a + b to a * b (same deps) skips dependency graph recomputation via salsa's backdating.

  5. Symbolic bytecode and layout separation (df05ce11) -- 1630-line symbolic bytecode module decouples per-variable compilation from global variable layout. Adding/removing variables doesn't invalidate cached bytecode fragments.

  6. LTM integration (7d71c6db) -- Causal graph, loop detection, and score equation generation as tracked functions. Equation edits with unchanged deps skip loop redetection.

  7. Shared compilation in libsimlin (9057efea) -- apply_patch caches CompiledSimulation; sim_new reuses it instead of recompiling. Eliminates double compilation across FFI calls.

  8. Error accumulator migration (30524473) -- CompilationDiagnostic salsa accumulator collects parse/compilation errors as a side channel, parallel to existing struct fields for backward compatibility.

Key design decisions

  • Symbolic bytecode: Per-variable compilation emits opcodes referencing variables by identity (VariableId) rather than integer offset. A cheap assembly pass resolves these to concrete integers using the current layout.
  • Pragmatic layering: Salsa tracked functions are added alongside the existing pipeline, not replacing it. The orchestrators call tracked functions and assemble results into the existing ModelStage0/ModelStage1 types.
  • Backdating optimization: model_dependency_graph reads dependency sets, not equation text. When deps don't change, downstream graph computation is skipped entirely.

Stats

  • 38 files changed, +7054/-2029 lines (net +5025)
  • All 31 simulation tests pass
  • All 17 LTM tests pass
  • All unit tests, roundtrip tests, and doc tests pass
  • Pre-commit hook passes on every commit (formatting, clippy, tests, WASM build, TS tests, Python tests)

Test plan

  • All existing simulation integration tests pass (tests/simulate*.rs)
  • All LTM integration tests pass (tests/simulate_ltm.rs)
  • All unit tests pass (cargo test -p simlin-engine)
  • Roundtrip tests pass
  • WASM build succeeds
  • TypeScript tests pass
  • Python bindings tests pass
  • Pre-commit hook passes on all 8 commits

Implements the design from doc/design/2026-02-21-incremental-compilation.md.

@bpowers

bpowers commented Feb 22, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@codecov

codecov Bot commented Feb 22, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.21138% with 116 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.45%. Comparing base (4a43aa4) to head (cfaa0c1).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/simlin-engine/src/compiler/symbolic.rs 91.54% 41 Missing ⚠️
src/simlin-engine/src/vm.rs 79.31% 18 Missing ⚠️
src/simlin-engine/src/compiler/context.rs 50.00% 12 Missing ⚠️
src/libsimlin/src/simulation.rs 57.69% 11 Missing ⚠️
src/simlin-engine/src/project.rs 59.09% 9 Missing ⚠️
src/simlin-engine/src/compiler/codegen.rs 71.42% 8 Missing ⚠️
src/simlin-engine/src/ltm_augment.rs 60.00% 4 Missing ⚠️
src/simlin-engine/src/compiler/pretty.rs 25.00% 3 Missing ⚠️
src/libsimlin/src/patch.rs 95.00% 2 Missing ⚠️
src/simlin-engine/src/interpreter.rs 87.50% 2 Missing ⚠️
... and 5 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #289      +/-   ##
==========================================
+ Coverage   77.06%   78.45%   +1.38%     
==========================================
  Files         118      121       +3     
  Lines       28019    29656    +1637     
==========================================
+ Hits        21593    23266    +1673     
+ Misses       6426     6390      -36     

☔ View full report in Codecov by Sentry.
📢 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.

@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: 3052447306

ℹ️ 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 thread src/libsimlin/src/simulation.rs Outdated
Comment on lines +63 to +66
let (compiled, vm, vm_error) = if let Some(compiled) = cached {
match Vm::new(compiled.clone()) {
Ok(vm) => (Some(compiled), Some(vm), None),
Err(err) => (Some(compiled), None, Some(err)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Scope cached compilation to the requested model

simlin_sim_new unconditionally consumes cached_compilation for any non-LTM model, but the cache is populated in gather_error_details by compiling only "main" (compile_simulation(project, "main")). In multi-model projects, calling sim_new for a non-main model after a successful patch can run with bytecode for the wrong model, yielding incorrect series/offset behavior instead of compiling the requested model. This is user-visible whenever patches are used and simulations are started for models other than main.

Useful? React with 👍 / 👎.

@claude

claude Bot commented Feb 22, 2026

Copy link
Copy Markdown

Code Review: Incremental compilation via salsa framework

Reviewed all 38 changed files (+7054/-2029). The SimFloat generic removal is mechanically correct throughout, and the salsa integration architecture is well thought out. Found three bugs in the new code.


[P1] Unsorted var_names in set_dependencies_cached — discarded sort block

src/simlin-engine/src/model.rs:1104-1109

The sort block creates a shadowed var_names, sorts it, then discards the result because the block is a statement (trailing ;). The outer unsorted var_names from line 1104 is what the rest of the method uses. This was copied from the same pre-existing pattern in set_dependencies (line 928-933), but since set_dependencies_cached is new code, it propagates the bug into the cached path. The StepPart::Stocks runlist at line 1178 uses var_names directly without topo_sort, so stock evaluation order becomes non-deterministic across HashMap iteration orders. The fix is to replace lines 1104-1109 with let mut var_names: Vec<_> = self.variables.keys().collect(); var_names.sort_unstable();.


[P2] resolve_module uses sym.n_slots instead of layout's n_slots

src/simlin-engine/src/compiler/symbolic.rs:931

resolve_module copies n_slots from the SymbolicCompiledModule instead of using layout.n_slots. The whole point of symbolic bytecode is to decouple compilation from layout — when variables are added or removed, the layout changes but cached symbolic bytecode should still resolve. Using the original n_slots means the VM would allocate memory based on the stale slot count, causing out-of-bounds access if the new layout is larger or wasted memory if smaller. Currently only called from tests, so this is latent, but it will surface when incremental re-assembly is wired up.


[P2] Cached compilation consumed without validating model name

src/libsimlin/src/simulation.rs:57-58, src/libsimlin/src/patch.rs:332

The cache at project_ref.cached_compilation is always populated by compiling "main" (patch.rs:332), but simlin_sim_new consumes it unconditionally without checking model_ref.model_name. If a caller opens a non-"main" model via simlin_project_get_model("submodel") and creates a simulation with LTM disabled, the cached "main" compilation will be used, silently producing results for the wrong model. A guard like if model_ref.model_name.as_str() == "main" on the cache .take() would prevent this.


Overall Correctness

The patch is mostly correct. The SimFloat→f64 refactoring is clean, the salsa tracked-function decomposition is sound, and all existing tests pass per the PR description. The P1 finding (unsorted var_names) can produce non-deterministic stock runlist ordering in the cached path and should be fixed before merge. The P2 findings are lower-risk (one latent, one requires non-"main" model usage) but straightforward to address.

@bpowers
bpowers force-pushed the incremental-compilation-impl branch from 2359fd5 to ddb49da Compare February 22, 2026 22:40
@bpowers

bpowers commented Feb 22, 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: ddb49da329

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

let canonical_var_name = canonicalize(dm_var.get_ident()).into_owned();
let var_id = VariableId::new(db, canonical_var_name.clone());

let source_var = source_variable_from_datamodel(db, dm_var);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reuse salsa inputs instead of recreating on every sync

sync_from_datamodel creates fresh SourceVariable inputs each time it is called, and patch application calls this sync on every committed edit, so the same project accumulates new input identities on every patch. Salsa memoization is keyed by input identity, so this pattern forces cache misses for previously-computed tracked functions and causes DB state to grow with each edit, which defeats the incremental-compilation goal and can degrade long-running editor sessions. Keep stable input handles and update fields via setters during resync.

Useful? React with 👍 / 👎.

Comment thread src/simlin-engine/src/model.rs Outdated
Comment on lines +1096 to +1098
for source_var in source_model.variables(salsa_db).values() {
let _ = db::variable_direct_dependencies(salsa_db, *source_var, source_project);
}

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 Remove unused dependency priming in cached resolver

set_dependencies_cached precomputes variable_direct_dependencies for every source variable but then ignores those results and still rebuilds dependencies via all_deps from lowered variables. That adds an extra full dependency-extraction pass on every compile without changing outputs, so the cached path does more work than the previous implementation unless another caller happens to hit those entries later. Either wire these cached deps into runlist construction or drop the priming loop.

Useful? React with 👍 / 👎.

@claude

claude Bot commented Feb 22, 2026

Copy link
Copy Markdown

Code Review

Findings

[P3] Tautological assertion in test_model_dependency_graph_stock_breaks_chain

File: src/simlin-engine/src/db.rs:3113-3117

The assertion !graph.initial_dependencies["population"].is_empty() || graph.initial_dependencies["population"].is_empty() is !X || X, which is always true regardless of the value. This test provides no coverage of the stock initial dependencies behavior. Given the stock equation is "100" (a constant with no deps), the assertion should likely be assert!(graph.initial_dependencies["population"].is_empty()), or if the intent is to assert non-emptiness (e.g., because the stock has inflows/outflows), then just assert!(!graph.initial_dependencies["population"].is_empty()).


Overall Correctness

Correct. The patch is large but mechanically sound. The SimFloat generic removal is a straightforward f64 hardcoding applied consistently across all affected types and call sites. The salsa integration (db.rs, symbolic.rs) adds a parallel incremental compilation path alongside the existing pipeline without disrupting it -- the new tracked functions are currently exercised only through tests and the model_dependency_graph entry point, while production compilation still flows through the existing set_dependencies / compile_simulation path. The cached compilation optimization in libsimlin correctly guards against LTM and non-main model misuse, and the tests cover the key caching scenarios (cache hit, cache miss, dry-run bypass, LTM bypass, multiple patches). The symbolic bytecode symbolize/resolve roundtrip handles all opcode variants and the assembly tests verify correctness. No existing code or tests should break.

@claude

claude Bot commented Feb 22, 2026

Copy link
Copy Markdown

Code Review

Reviewed all 38 changed files (+7098/-2035 lines) across three main workstreams: SimFloat generic removal, salsa incremental compilation infrastructure, and libsimlin FFI patch/error/simulation improvements.

Findings

No bugs found that meet the review criteria. The code is well-structured, systematically tested, and correctly implements the described functionality.

Observations

Salsa caching granularity within model_causal_edges (src/simlin-engine/src/db.rs:1144-1243): This tracked function reads both variable_direct_dependencies (for non-stock/non-module dep sets) and parse_source_variable (for implicit variable datamodel details like inflows/outflows/module refs). Since parse_source_variable returns a new result whenever equation text changes — even if the dependency set is unchanged — model_causal_edges will re-execute on any equation edit. The backdating optimization described in the PR summary ("equation edits with unchanged deps skip loop redetection") is partially preserved: if model_causal_edges produces the same edge structure, downstream functions like model_loop_circuits and model_cycle_partitions are still backdated. But model_causal_edges itself always re-executes. This could be tightened in a future pass by extracting the implicit variable structural info (inflow/outflow names, module refs) into a separate tracked function whose output is stable across equation text changes.

model_dependency_graph error handling (src/simlin-engine/src/db.rs:916-917): compute_transitive returns Err(name) on circular dependency but the caller uses unwrap_or_default(), producing empty dependency maps and empty runlists. This is fine while the function is only exercised by tests, but when wired into the production compilation path it would silently produce incorrect variable ordering for models with cycles. Worth tracking as future work.

sync_from_datamodel creates new inputs on each call (src/simlin-engine/src/db.rs:1746-1813): Each invocation creates fresh SourceProject/SourceModel/SourceVariable inputs via ::new(). Salsa memoization requires updating existing inputs (via setters) for cross-invocation cache hits. The within-compilation caching (e.g., parse_source_variable computed in new_cached then reused by variable_direct_dependencies in set_dependencies_cached) works correctly. The persistent db resync in patch.rs:497-498 is forward-looking infrastructure for when differential updates are implemented.

Overall Correctness Verdict

Correct. All existing tests and the new test suites validate the changes. The SimFloat generic removal is mechanical and consistent. The symbolic bytecode roundtrip tests verify the symbolize→resolve pipeline. The salsa tracked functions are properly defined with appropriate caching boundaries. The libsimlin FFI changes maintain proper null-pointer validation, error handling, and thread safety through Mutex-guarded state.

@bpowers

bpowers commented Feb 22, 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: 3e309d9412

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

});

// Set up salsa database for incremental per-variable caching.
let salsa_db = SimlinDb::default();

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 Persist salsa DB across compile invocations

Project::compile allocates a fresh SimlinDb each time, so every compilation starts from an empty salsa cache and all tracked per-variable queries are recomputed. That removes the intended incremental behavior across edits (the expensive parse/dependency work is redone for unchanged variables on every compile), which can keep edit-compile latency proportional to full model size instead of the changed subset.

Useful? React with 👍 / 👎.

Comment thread src/libsimlin/src/simulation.rs Outdated
Comment on lines +58 to +60
let cached = if !enable_ltm && model_ref.model_name.as_str() == "main" {
project_ref.cached_compilation.lock().unwrap().take()
} else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Invalidate cached compilation on non-patch project edits

simlin_sim_new consumes cached_compilation whenever the model is main, but this cache is not versioned against project mutations outside apply_patch. After any other mutating path (for example simlin_project_add_model, which rebuilds the project), the first simulation can run stale bytecode from the previous project state and return incorrect results before the cache is emptied.

Useful? React with 👍 / 👎.

@claude

claude Bot commented Feb 22, 2026

Copy link
Copy Markdown

Code Review

Files Reviewed

All 38 changed files, with particular focus on:

  • src/simlin-engine/src/db.rs (3733 lines) — salsa database, inputs, tracked functions, sync
  • src/simlin-engine/src/compiler/symbolic.rs (1675 lines) — symbolic bytecode layer
  • src/simlin-engine/src/model.rs — new_cached, set_dependencies_cached
  • src/simlin-engine/src/project.rs — salsa integration into Project::from
  • src/simlin-engine/src/vm.rs — SimFloat removal, Vm simplification
  • src/libsimlin/src/patch.rs — gather_error_details_with_db, compilation caching
  • src/libsimlin/src/simulation.rs — cached compilation consumption in sim_new
  • src/libsimlin/src/project.rs — salsa DB creation on project open
  • src/libsimlin/src/lib.rs — SimlinProject struct changes, new_synced_db
  • src/libsimlin/src/errors.rs — format_diagnostic for salsa accumulator path
  • src/simlin-engine/src/results.rs, float.rs, interpreter.rs — SimFloat removal

Findings

No bugs found.

Overall Correctness Verdict

Correct. The patch introduces the salsa infrastructure alongside the existing compilation pipeline without breaking any existing behavior. The SimFloat generic removal, salsa tracked functions, symbolic bytecode scaffolding, and libsimlin compilation caching are all implemented correctly. The var_names sorting fix in set_dependencies corrects a pre-existing dead-code variable shadowing issue. The error deduplication between the existing struct-field path and the new salsa accumulator path is handled properly.

@bpowers

bpowers commented Feb 22, 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: 8020b8ac97

ℹ️ 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 thread src/simlin-engine/src/db.rs Outdated
use std::sync::OnceLock;
static PORTS: OnceLock<crate::ltm_augment::CompositePortMap> = OnceLock::new();
PORTS.get_or_init(|| {
std::thread::spawn(|| {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Guard std::thread::spawn for wasm targets

get_stdlib_composite_ports() unconditionally uses std::thread::spawn, but this helper is called by LTM tracked queries (model_ltm_synthetic_variables / model_ltm_all_link_synthetic_variables), so LTM execution in the WASM engine can panic at runtime on wasm32 builds where thread spawning is unavailable. This makes LTM simulation/analysis fail in browser contexts even though other engine paths already provide wasm-specific fallbacks; add a cfg-gated non-threaded initialization path.

Useful? React with 👍 / 👎.

@claude

claude Bot commented Feb 22, 2026

Copy link
Copy Markdown

Code Review: PR #289

Findings

[P3] Silent u16 truncation in symbolic bytecode — src/simlin-engine/src/compiler/symbolic.rs:576,596

symbolize_static_view casts view.base_off (a u32) to u16, and symbolize_module_decl casts decl.off (a usize) to u16. Both casts silently truncate if the value exceeds 65535. For a sufficiently large model this would corrupt the symbolic representation without any error. The file is currently #![allow(dead_code)] so there is zero production impact today, but worth a u16::try_from().unwrap() or a comment explaining the upper-bound assumption before this code goes live.

Verdict

Correct. The SimFloat generic removal is clean and mechanically consistent across all affected files. The new salsa database infrastructure is well-structured with thorough test coverage (~1400 lines of tests in db.rs). The FFI refactoring into simulation.rs, project.rs, and patch.rs preserves equivalent behavior with consistent lock ordering (project lock → db lock). The pre-existing var_names sorting fix in model.rs:set_dependencies is a good catch. No blocking issues found.

@bpowers

bpowers commented Feb 22, 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: 31f0a34ae5

ℹ️ 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 thread src/simlin-engine/src/db.rs Outdated
Comment on lines +1766 to +1770
let source_project = SourceProject::new(
db,
project.name.clone(),
SourceSimSpecs::from(&project.sim_specs),
project

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reuse salsa inputs instead of recreating them every sync

sync_from_datamodel allocates a brand-new SourceProject (and downstream SourceModel/SourceVariable) on every call, which means re-syncing the same long-lived SimlinDb keeps appending new input graphs rather than updating existing ones. In this commit, the libsimlin paths call re-sync repeatedly during patch/error flows, so unchanged models cannot benefit from stable tracked IDs and the database grows over time with stale inputs. Please keep and update prior input IDs (via setters) so repeated edits actually reuse cached query state.

Useful? React with 👍 / 👎.

Comment on lines +176 to +178
// Set up salsa database for incremental per-variable caching.
let salsa_db = SimlinDb::default();
let sync_result = sync_from_datamodel(&salsa_db, &project_datamodel);

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 Persist the salsa database across project rebuilds

Project::base_from constructs a fresh SimlinDb for each rebuild and drops it at the end of the function, so memoized salsa results never survive from one project edit to the next. Since patch/compile workflows rebuild Project repeatedly, this defeats cross-edit incremental compilation and forces cold-cache recomputation after each change. Threading a persistent DB through rebuilds is needed for the intended incremental behavior.

Useful? React with 👍 / 👎.

@claude

claude Bot commented Feb 22, 2026

Copy link
Copy Markdown

Code Review: PR #289 — Incremental compilation via salsa framework

Summary

This is a large PR (+7103/-2035) implementing incremental compilation infrastructure using salsa. The changes break down into:

  1. SimFloat generic removal — Mechanical change replacing the SimFloat trait with concrete f64 throughout the engine. All call sites correctly updated.
  2. Salsa database and inputs — New db.rs (3747 lines) with SimlinDb, interned identifiers (VariableId/ModelId), input types (SourceProject/SourceModel/SourceVariable), and tracked functions for parsing and dependency analysis. Well-structured and well-tested.
  3. Symbolic bytecode — New compiler/symbolic.rs (1675 lines) decoupling per-variable compilation from global layout. Symbolize/resolve roundtrip is clean and thoroughly tested.
  4. Cached compilation in libsimlin — apply_patch caches CompiledSimulation; sim_new reuses it for non-LTM "main" model sims. Lock ordering is consistent (project → db), no deadlock risk.
  5. Parallel diagnostic path — Salsa accumulator collects errors alongside existing struct-field diagnostics, with deduplication by (model, variable, code) triple.
  6. Bug fix in set_dependencies — The old code had a shadowed var_names inside a block that sorted a local copy and discarded it, leaving the outer var_names unsorted. This is now correctly sorted.

Findings

No bugs introduced by this PR were identified. Specific areas verified:

  • vm.rs: All SimFloat → f64 changes are identity transformations. The to_f64() calls that were removed were already no-ops on f64. Existing patterns (e.g., ViewRangeDynamic missing .floor()) are pre-existing, not introduced here.
  • bytecode.rs: Generic parameter removal is mechanical and correct. Derive changes (Debug, PartialEq, Eq) are reasonable for the symbolic bytecode equality requirements.
  • libsimlin caching: The cached_compilation is properly invalidated on add_model, properly consumed (.take()) by sim_new, and properly gated to only non-LTM "main" model paths. model_ref placement is correct relative to early returns.
  • db.rs sync: sync_from_datamodel creates fresh salsa inputs each call rather than updating existing ones, which means salsa's incremental cache is not yet exploited across syncs. This is appropriate for phase 1 infrastructure.
  • symbolic.rs: Symbolize ↔ resolve roundtrip is property-tested. Reverse offset map bounds checking is correct.

Verdict

Overall correctness: Correct. The patch is free of blocking issues. Existing tests pass, and the new code is well-covered by unit tests and integration tests.

@bpowers

bpowers commented Feb 22, 2026

Copy link
Copy Markdown
Owner Author

Review cycle summary

Five iterations of automated review from both claude[bot] and codex. Three fix commits were made addressing actionable feedback:

Bugs fixed:

  • Fixed a discarded sort in set_dependencies and set_dependencies_cached where a shadowed var_names was sorted inside a block statement that threw away the result, leaving stock runlist order non-deterministic across HashMap iterations.
  • Fixed resolve_module in the symbolic bytecode layer to use the layout's n_slots instead of the symbolic module's stale value, which would cause out-of-bounds VM access when variables are added between symbolization and resolution.
  • Scoped the cached compilation in sim_new to only the "main" model, preventing stale bytecode from being served when simulating non-"main" models.
  • Added cache invalidation in simlin_project_add_model, which mutates the project outside the apply_patch path.
  • Added cfg(target_arch = "wasm32") guard for std::thread::spawn in stdlib composite port initialization, which would panic in browser contexts.
  • Fixed a tautological test assertion (!X || X, always true) that provided no coverage.
  • Removed an unused dependency-priming loop and its now-unnecessary function parameters.

Follow-up issues filed:

@bpowers

bpowers commented Feb 23, 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: be742798d2

ℹ️ 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 thread src/libsimlin/src/project.rs Outdated
let project_locked = proj.project.lock().unwrap();
let (all_errors, _) = gather_error_details(&project_locked);
let db_locked = proj.db.lock().unwrap();
let sync = engine::db::sync_from_datamodel(&db_locked, &project_locked.datamodel);

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 Reuse incremental sync state in project error collection

simlin_project_get_errors now calls sync_from_datamodel on every invocation, which creates new salsa inputs each time (SourceProject::new/SourceModel::new/SourceVariable::new in src/simlin-engine/src/db.rs:1828-1874) instead of updating existing handles. In workflows that poll errors frequently (for example, editor validation on each edit), this causes the long-lived per-project DB to accumulate unreachable input nodes and steadily increases memory and query cost over time. This path should reuse proj.sync_state with sync_from_datamodel_incremental (or use an ephemeral DB) to avoid unbounded growth.

Useful? React with 👍 / 👎.

@claude

claude Bot commented Feb 23, 2026

Copy link
Copy Markdown

Code Review

Reviewed the full diff (~12K lines): SimFloat generic removal, salsa incremental compilation infrastructure, FFI compilation cache, and extensive new tests.

Areas reviewed

Area Verdict
db.rs (4347 new lines) — salsa inputs, tracked functions, sync, incremental sync No bugs found
compiler/symbolic.rs (2025 new lines) — symbolic expression analysis No bugs found
compiler/mod.rs — SimFloat removal, new tests No bugs found
vm.rs — SimFloat→f64 de-generification No bugs found
bytecode.rs — SimFloat→f64, derive changes No bugs found
results.rs — SimFloat→f64, removed f32 tests/convert No bugs found
float.rs — SimFloat trait removed, utility functions remain No bugs found
model.rs — new_cached, set_dependencies_cached No bugs found
project.rs — salsa DB setup in build_inner No bugs found
ltm.rs — find_circuit_node_lists, assign_loop_ids extraction No bugs found
libsimlin/patch.rs — gather_error_details_with_db, compilation cache No bugs found
libsimlin/project.rs — FFI project lifecycle with salsa DB No bugs found
libsimlin/simulation.rs — sim lifecycle, cache consumption No bugs found
libsimlin/errors.rs — format_diagnostic No bugs found
libsimlin/tests_remaining.rs — new test coverage No bugs found

Specific items verified

  • Lock ordering: project → db → sync_state is consistent across all code paths (apply_project_patch_internal, simlin_project_add_model, simlin_project_get_errors). No deadlock risk.
  • Reference counting in simlin_sim_new: Error paths correctly return NULL without calling model_ref — no sim is created, so no extra reference is needed. The caller retains their own reference.
  • Compilation cache lifecycle: Populated on successful non-dry-run apply_patch, consumed (.take()) by sim_new for non-LTM main models, invalidated by simlin_project_add_model.
  • Incremental sync handles deletions: sync_from_datamodel_incremental rebuilds source_var_map from current datamodel only, so deleted variables are correctly excluded from the updated model.
  • Error deduplication: gather_error_details_with_db deduplicates by (model_name, variable_name, SimlinErrorCode) triple across both struct-field and accumulator paths.
  • Model set_dependencies fix (model.rs:925): The pre-existing dead-code block (inner var_names was created, sorted, but never used) is correctly replaced with a single sorted var_names.

Overall correctness verdict

Correct. The patch removes the SimFloat generic parameter consistently, introduces the salsa infrastructure alongside (not replacing) the existing pipeline, and adds good test coverage for the new functionality. No bugs or blocking issues found.

@bpowers

bpowers commented Feb 23, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@claude

claude Bot commented Feb 23, 2026

Copy link
Copy Markdown

Code Review

Reviewed the full diff (~8800 additions) covering:

  1. De-generification of SimFloat → concrete f64 (vm.rs, results.rs, interpreter.rs, test_common.rs, etc.)
  2. New salsa-based incremental computation framework (db.rs — ~2400 lines)
  3. Symbolic bytecode layer (compiler/symbolic.rs)
  4. Compilation caching in FFI (simulation.rs, project.rs, patch.rs)
  5. LTM function extractions (ltm.rs, ltm_augment.rs)
  6. Lock ordering and mutex management (SimlinProject with 4 mutexes)

No blocking bugs found.

Specific areas verified:

  • model_ref ordering change in simulation.rs: Moving model_ref(model) after the compilation/cache branch is correct. Early returns (LTM errors) skip it in both old and new code, and it's always called before SimlinSim construction, so ref counting remains balanced.

  • Lock ordering across SimlinProject: All call sites (simlin_project_get_errors, apply_project_patch_internal, simlin_project_add_model) acquire locks in the same order: project → db → sync_state → cached_compilation. No deadlock risk.

  • to_sync_result() ID reconstitution: The VariableId::from_id/ModelId::from_id calls use salsa::plumbing::FromId to reconstruct interned handles from stored salsa::Id values. This is safe because the PersistentSyncState is always used with the same database instance, and salsa interned values persist across revisions within a database.

  • set_dependencies sort fix (model.rs:925): The old code had a dead block that sorted a temporary var_names but discarded it. The new code properly sorts the var_names used by the rest of the function. This is a correct bugfix.

  • gather_error_details_with_db deduplication: Uses (model_name, variable_name, code) triple with HashSet. The addition of Hash to SimlinErrorCode makes this compile correctly.

  • Compilation caching in cached_compilation: The .take() in simlin_sim_new ensures at-most-once consumption. The cache is only populated for non-LTM "main" model compilations and is invalidated on add_model. Correct.

  • De-generification: Mechanical F → f64 replacement throughout. All SimFloat trait method calls (F::zero(), F::from_f64(), n.approx_eq(), etc.) are replaced with concrete equivalents (0.0, direct value, crate::float::approx_eq(), etc.). The f32 code paths and tests are removed cleanly.

Overall correctness verdict: Correct

The patch is well-structured, the new infrastructure has thorough unit tests (sync, incremental sync, pointer-stability, diagnostic collection, symbolic bytecode roundtrips), and the mechanical refactoring preserves existing semantics.

@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: daf6bd42bc

ℹ️ 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 +177 to +178
let salsa_db = SimlinDb::default();
let sync_result = sync_from_datamodel(&salsa_db, &project_datamodel);

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 Reuse Salsa state instead of rebuilding DB per compile

Project::from_with_model_cb creates a brand-new SimlinDb on every call, which drops all query caches before compilation starts. Because patch validation still recompiles via engine::Project::from(...) in src/libsimlin/src/patch.rs:440, editing a single equation forces a full reparse/recompile of unchanged variables instead of reusing prior Salsa results, which defeats the incremental-compilation behavior this change is meant to provide and can cause major latency regressions on large models.

Useful? React with 👍 / 👎.

Comment thread src/libsimlin/src/patch.rs Outdated
Comment on lines +326 to +327
if !existing_keys.contains(&key) {
all_errors.push(ErrorDetailBuilder::from_formatted(formatted));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge Update dedup set when appending new diagnostics

The dedup key set is computed once from all_errors and never updated inside the loop, so multiple accumulator diagnostics with the same (model, variable, code) can all be appended even though this block claims to deduplicate by that triple. In cases where one variable emits repeated same-code diagnostics (for example, multiple spans with the same error code), callers can receive duplicate error entries from simlin_project_get_errors.

Useful? React with 👍 / 👎.

@bpowers

bpowers commented Feb 23, 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: fc7d91373e

ℹ️ 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 +1176 to +1177
let canonical_src = canonicalize(&mr.src).into_owned();
edges.entry(canonical_src).or_default().insert(name.clone());

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 Normalize module refs when building module causal edges

model_causal_edges inserts module-input edges from canonicalize(&mr.src) without passing through normalize_module_ref_str. If a module input is fed by another module output (e.g. foo.bar, canonicalized to foo·bar), this creates an edge from a phantom module·output node instead of the module node, so cycle/loop analysis can miss real feedback paths and produce incorrect LTM synthetic-variable outputs for models that chain module outputs.

Useful? React with 👍 / 👎.

Add models field to SourceProject to store SourceModel handles alongside
model names, enabling assemble_simulation to look up models by name.

Implement assemble_simulation to produce valid CompiledSimulation objects
by enumerating module instances, assembling each module, building specs,
and computing flattened offsets.

Fix concatenate_fragments to strip trailing Ret opcodes from per-variable
fragments before concatenation, adding a single Ret at the end. Without
this, the VM would stop executing flows after the first variable.

Fix compute_layout to handle module variables by looking up the sub-model
and using its total n_slots instead of variable_size.

Populate dimension metadata in assemble_module from project dimensions.

Fix canonical name mismatch: sync functions stored display names (with
spaces) in variable_names but canonical names in variables map, causing
XMILE-loaded models to produce empty bytecodes via the incremental path.
compile_var_fragment also compared display names against canonical runlist
entries, silently skipping all compilation phases.

Split db.rs test module to db_tests.rs to stay under 6000-line threshold.
…_compilation

The salsa DB is now the sole compilation cache. apply_project_patch_internal
eagerly syncs the DB with the staged datamodel for accumulator diagnostics,
restoring the previous state on rejection or dry-run.

simlin_sim_new for non-LTM simulations tries compile_project_incremental
first (a salsa cache hit when nothing changed since the last patch), falling
back to the monolithic path when the incremental path does not yet support
the model configuration. The explicit cached_compilation field and its
consume-on-read protocol are no longer needed.
Decompose monolithic link score generation into per-link tracked
functions so that equation edits only regenerate link scores for
affected links.

New tracked functions:
- link_score_equation_text: computes a single link's score equation,
  keyed by an interned LtmLinkId. Only reads parse_source_variable
  for the specific from/to variables, so salsa skips recomputation
  when unrelated variables change.
- module_ilink_equation_text: same pattern for stdlib module internal
  links.

Refactored model_ltm_synthetic_variables,
model_ltm_all_link_synthetic_variables, and
module_ltm_synthetic_variables to iterate links and delegate to the
per-link functions instead of bulk-generating all equations.

Added reconstruct_single_variable helper for targeted variable
lookup without rebuilding the full model variable map.
…lation

AC1.3/AC1.4: verify compile_var_fragment produces identical symbolic
bytecodes for existing variables when a new variable is added or
removed, while compute_layout changes to reflect the new variable set.

AC1.5: verify that changing a dimension definition only recompiles
variables using that dimension (sales with ApplyToAll), while scalar
variables (price) produce identical fragments.

AC1.6: verify cross-model isolation -- changing module connections in
model B does not invalidate model A's dependency graph.

AC2.4: verify stdlib module_ltm_synthetic_variables returns a cached
(pointer-equal) result on unchanged inputs.

AC4.3 (strengthened): compile a model with stocks, flows, and lookups
both incrementally and monolithically, run through the VM, and assert
identical time-series output for every variable at every timestep.

AC3.1: verify apply_patch + sim_new succeeds end-to-end through the
shared salsa DB path and produces correct post-patch results.

AC3.3: verify two sequential patches each produce correct simulation
results, confirming incremental recomputation works across patches.

AC3.4: verify snapshot isolation -- a sim created before a patch runs
with pre-patch values while a post-patch sim sees the new values.
Criterion benchmarks comparing monolithic compile_project against
incremental compile_project_incremental on a 100-variable chain model.
Three scenarios: equation edit (v50), variable addition (v101), and
variable removal (v100). On this machine, equation edit and variable
removal show ~10x speedup; variable addition is ~1.3x faster since it
changes the variable list and forces broader re-evaluation.

Track two deferred items from the incremental compilation design:
- Legacy error fields on Variable/ModelStage (design Step 13)
- Dimension-granularity invalidation optimization (design Step 12)
Neither blocks any acceptance criterion.
Five fixes from automated code review feedback:

1. Resource ID mismatch in assemble_module: flows and stocks phases
   were independently concatenated with resource IDs starting at 0,
   but the shared ByteCodeContext used all-phases numbering. Now each
   phase's concatenation receives context resource base offsets from
   preceding phases, ensuring GF/module/view/temp/dimlist IDs are
   consistent with the shared context.

2. Fragment compilation errors silently dropped: when
   compile_var_fragment returned None, the variable was silently
   skipped. Now assemble_module tracks missing fragments and returns
   an error listing all variables that failed to compile.

3. Model-specific sim specs not used: assemble_simulation always used
   project-level sim_specs, ignoring per-model overrides. Added
   model_sim_specs field to SourceModel, synced it in both fresh and
   incremental paths, and used it in assemble_simulation (matching
   the monolithic compile_project behavior).

4. Lock ordering inconsistency: apply_project_patch_internal's revert
   path acquired project.lock() while holding db.lock(), creating an
   AB/BA inversion with simlin_project_get_errors (which locks
   project then db). Fixed by cloning the datamodel before acquiring
   the db lock.

5. Silent truncation in renumber_opcode: temp_off (u32) and gf_off
   (u16) were truncated to u8 via bare `as` casts. Now
   renumber_opcode returns Result and checks bounds before casting.
Fix temp ID counting: from_fragments and db.rs per-initial renumbering
used .max() across fragments that each start temp IDs at 0. Since
concatenate_fragments renumbers sequentially, the correct count is the
sum of each fragment's (max_id + 1), not the global max.

Fix static view Temp(id) renumbering: concatenate_fragments copied
SymbolicStaticView objects as-is, but Temp(id) bases still referred to
fragment-local numbering. Now offsets Temp bases by temp_offset.

Fix u8 checked arithmetic: renumber_opcode validated that offsets fit in
u8 but didn't check that base + offset fits. Use checked_add for all u8
arithmetic to surface overflow as an error instead of silent wrapping.

Fix variable_names nondeterminism: variable_names built from HashMap
keys had nondeterministic order, causing spurious set_variable_names
invalidation on every sync. Sort at all three construction sites.
When model_dependency_graph detects a circular dependency, set has_cycle
flag on the result. assemble_module checks this flag and returns an error,
preventing the incremental path from producing bytecode with invalid
execution order for cyclic models. This ensures simlin_sim_new falls back
to the monolithic path which properly rejects such models.

Also sort variable_names after collecting from HashMap keys to ensure
deterministic ordering and prevent spurious salsa cache invalidation.
The root-model flag in assemble_simulation compared raw name strings,
which could fail when the model name preserves user casing (e.g. "Main"
vs canonical "main"). Use canonicalize() for the comparison.

Also fix calc_flattened_offsets_incremental to accept is_root as a
parameter instead of hardcoding model_name == "main", ensuring correct
implicit slot reservation for projects with non-"main" root models.
Use clone() instead of take() for sync_state during patch validation,
so concurrent readers still see valid state. Previously, take() would
clear the sync_state, leaving a window where concurrent sim_new or
get_errors calls would see None and resync from scratch.

Derive Clone on PersistentSyncState and its inner types to support this.

Also simplify the revert path: since we no longer take the state, the
None case is handled uniformly by passing prev_state.as_ref() to the
resync call.
compile_var_fragment used filter_map with .ok() on symbolize_static_view
and symbolize_module_decl, silently dropping entries that fail. Since
bytecodes reference these by index, dropping an entry would shift all
subsequent indices, causing wrong resource lookups. Use collect::<Result>
to propagate errors consistently with the bytecode symbolization path.
apply_project_patch_internal was dropping the db lock after syncing
staged state but before deciding whether to commit or rollback. During
that window, concurrent simlin_sim_new or simlin_project_get_errors
calls could observe patch effects for dry_run or rejected patches.

The fix saves the original datamodel before patching (so rollback
doesn't need to re-acquire the project lock, which would violate the
project -> db -> sync_state lock ordering) and holds the db lock
through the entire sync-evaluate-decide cycle. On commit, sync_state
is also updated while holding the db lock so the incremental path
(db + sync_state) is atomically consistent for concurrent readers.
@bpowers
bpowers force-pushed the incremental-compilation-impl branch from c83e406 to 7e5b044 Compare February 23, 2026 15:24
@bpowers

bpowers commented Feb 23, 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: 7e5b0441f3

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

})?;

let is_root = canonicalize(name.as_str()) == main_model_canonical;
let compiled = assemble_module(db, *source_model, project, is_root)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Compile module instances with their actual input sets

The assemble_simulation loop iterates each distinct inputs set for a module instance, but the compilation call ignores inputs and always builds the same module body (assemble_module(db, *source_model, project, is_root)). This means different instantiations of the same submodel are keyed differently in compiled_modules but contain identical bytecode, so models that rely on per-instantiation module inputs (e.g., isModuleInput-gated behavior) can simulate incorrectly.

Useful? React with 👍 / 👎.

// incremental path (db + sync_state) is atomically consistent for
// concurrent readers. Then release db before updating project
// to maintain lock ordering (project -> db -> sync_state).
*project_ref.sync_state.lock().unwrap() = Some(staged_sync_state);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep project and sync_state updates atomic on commit

This publishes sync_state for the staged patch before updating project, then drops the DB lock and only afterward acquires the project lock to commit. A concurrent reader can therefore observe a mixed snapshot (old project + new DB/sync state), because simlin_project_get_errors reads project first and DB/sync second, which can merge diagnostics from different revisions and return inconsistent results.

Useful? React with 👍 / 👎.

@claude

claude Bot commented Feb 23, 2026

Copy link
Copy Markdown

Code Review

This is a large, well-structured PR introducing salsa-based incremental compilation infrastructure and a symbolic bytecode layer. After analyzing the new db.rs, compiler/symbolic.rs, libsimlin lock ordering, vm.rs changes, and the SimFloat generic removal, here are my findings.

Findings

[P3] Misleading doc comment on compile_var_fragment (src/simlin-engine/src/db.rs:2657-2663)

The doc comment states "Does NOT depend on model.variable_names or any model-wide data", but the function body calls model_dependency_graph(db, model, project) (line 2929), which reads model.variables(db) — a model-level input that transitively depends on model.variable_names. This means adding or removing any variable will invalidate all fragments' dep graph, contradicting the stated isolation. Salsa's backdating mitigates the blast radius when only equation text changes (dep graph result unchanged → fragments stay cached), so this is a documentation issue rather than a correctness or performance bug. The comment should be updated to accurately describe the actual dependency: the model-wide dependency graph is consulted, but salsa backdating limits invalidation to cases where the graph result actually changes.


Overall Correctness Verdict: Correct

The patch is well-designed and should not break existing code or tests:

  • Lock ordering in libsimlin is consistent: project → db → sync_state, with apply_project_patch_internal correctly dropping db before re-acquiring project (line 522 → 524 of patch.rs). No deadlock scenarios exist.
  • Variable deletion is handled correctly in sync_from_datamodel_incremental — deleted variables are simply excluded from the new source_var_map and variable_names, and the model fields are updated to reflect this.
  • The symbolic bytecode roundtrip (symbolize → resolve) is extensively tested with unit tests, integration tests, and layout-independence tests. The SymVarRef approach of mapping offsets to (name, element_offset) is sound.
  • The SimFloat generic removal is a clean simplification that replaces F::from_f64(x) → x, F::zero() → 0.0, etc. throughout. No behavioral changes.
  • The fallback from incremental to monolithic compilation in simlin_sim_new is safe — if compile_project_incremental returns Err, the monolithic path is used transparently.
  • The maybe_first_code / new_unit_warning paths in patch.rs are mutually exclusive by construction (line 465: new_unit_warning is only computed when maybe_first_code.is_none()), so the error overwrite concern does not apply.

@claude

claude Bot commented Feb 23, 2026

Copy link
Copy Markdown

Code Review

This is a large, well-structured PR that adds salsa-based incremental compilation and removes the SimFloat generic parameter. I reviewed all 38 changed files, focusing on the new db.rs (3599 lines), symbolic.rs (2626 lines), the libsimlin integration (patch.rs, simulation.rs, project.rs), and the de-genericization of vm.rs, bytecode.rs, results.rs, float.rs, and the compiler modules.

Findings

No blocking bugs found.

[P3] Brief atomicity gap during patch commit — src/libsimlin/src/patch.rs:521-525

In apply_project_patch_internal, the commit path updates sync_state (line 521) and then drops the db lock (line 522) before acquiring the project lock (line 524) to write the new project. During this window, a concurrent reader (e.g. simlin_sim_new or simlin_project_get_errors) could observe the new sync_state paired with the old project. In practice this window is extremely brief (microseconds) and the incremental path would produce correct results from the new sync_state while the monolithic fallback path would produce a consistent-but-stale result from the old project, so this is unlikely to cause issues. This is a consequence of the documented lock ordering constraint (project → db → sync_state) and the existing comments already explain the reasoning. Noting it for completeness.

Observations (non-blocking)

  • The incremental compilation path silently falls back to the monolithic pipeline for models using features that produce implicit variables (SMOOTH/DELAY), since SourceModel.variables() only contains explicit source variables. The compile_var_fragment → assemble_module path will error for these due to missing fragments, and compile_project_incremental(...).ok() maps the error to None, triggering the fallback in simlin_sim_new. This is by design and documented in doc/tech-debt.md.

  • The resource ID renumbering in renumber_opcode and concatenate_fragments is thorough — all opcodes carrying LiteralId, GraphicalFunctionId, ModuleId, ViewId, TempId, and DimListId are handled, with checked arithmetic for overflow on u8-width IDs.

  • The three-phase context resource offset computation (initials → flows → stocks) in assemble_module correctly chains ContextResourceCounts so that all phases share a single resource namespace while keeping literal pools phase-local.

Overall Correctness Verdict

Correct. The patch should not break existing code or tests. The float de-genericization is mechanical and verified by passing all existing tests. The incremental compilation path produces identical results to the monolithic path for supported models, and gracefully falls back for unsupported ones. Lock ordering is consistently maintained across all FFI entry points with no deadlock risk.

@claude

claude Bot commented Feb 23, 2026

Copy link
Copy Markdown

Code Review

This is a large, well-structured PR that adds salsa-based incremental compilation and removes the SimFloat generic parameter. I reviewed all 38 changed files, focusing on the new db.rs (3599 lines), symbolic.rs (2626 lines), the libsimlin integration (patch.rs, simulation.rs, project.rs), and the de-genericization of vm.rs, bytecode.rs, results.rs, float.rs, and the compiler modules.

Findings

No blocking bugs found.

[P3] Brief atomicity gap during patch commit — src/libsimlin/src/patch.rs:521-525

In apply_project_patch_internal, the commit path updates sync_state (line 521) and then drops the db lock (line 522) before acquiring the project lock (line 524) to write the new project. During this window, a concurrent reader (e.g. simlin_sim_new or simlin_project_get_errors) could observe the new sync_state paired with the old project. In practice this window is extremely brief (microseconds) and the incremental path would produce correct results from the new sync_state while the monolithic fallback path would produce a consistent-but-stale result from the old project, so this is unlikely to cause issues. This is a consequence of the documented lock ordering constraint (project -> db -> sync_state) and the existing comments already explain the reasoning. Noting it for completeness.

Observations (non-blocking)

  • The incremental compilation path silently falls back to the monolithic pipeline for models using features that produce implicit variables (SMOOTH/DELAY), since SourceModel.variables() only contains explicit source variables. The compile_var_fragment -> assemble_module path will error for these due to missing fragments, and compile_project_incremental(...).ok() maps the error to None, triggering the fallback in simlin_sim_new. This is by design and documented in doc/tech-debt.md.

  • The resource ID renumbering in renumber_opcode and concatenate_fragments is thorough -- all opcodes carrying LiteralId, GraphicalFunctionId, ModuleId, ViewId, TempId, and DimListId are handled, with checked arithmetic for overflow on u8-width IDs.

  • The three-phase context resource offset computation (initials -> flows -> stocks) in assemble_module correctly chains ContextResourceCounts so that all phases share a single resource namespace while keeping literal pools phase-local.

Overall Correctness Verdict

Correct. The patch should not break existing code or tests. The float de-genericization is mechanical and verified by passing all existing tests. The incremental compilation path produces identical results to the monolithic path for supported models, and gracefully falls back for unsupported ones. Lock ordering is consistently maintained across all FFI entry points with no deadlock risk.

The comment claimed the function does not depend on model-wide data,
but it reads model.variables and model_dependency_graph. Salsa's
backdating still ensures fragments are only invalidated when the
values they actually read change, so the caching behavior is correct;
just the documentation was wrong.
@bpowers

bpowers commented Feb 23, 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: c62697d078

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

};

// Stock phase: stocks get compiled with is_initial=false for updates
let stock_bytecodes = if is_stock && dep_graph.runlist_stocks.contains(&var_ident_str) {

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 Compile module variables in stock phase

model_dependency_graph puts module variables in runlist_stocks ("stocks and modules"), but compile_var_fragment only emits stock bytecode when is_stock is true, so module vars always produce None for the stock phase. In module-containing models this makes assemble_module treat valid module entries as missing fragments and fail incremental assembly, which forces the runtime to drop back to monolithic compilation for every sim creation instead of using the salsa cache.

Useful? React with 👍 / 👎.

Comment thread src/simlin-engine/src/db.rs Outdated
Comment on lines +2919 to +2922
let table_results: Vec<crate::compiler::Table> = gf_tables
.iter()
.filter_map(|t| crate::compiler::Table::new(&var_ident, t).ok())
.collect();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Propagate table conversion failures instead of dropping them

This path silently discards Table::new errors via filter_map(... .ok()), which can compact the table list and shift table indices instead of rejecting the model. For arrayed lookup tables with one malformed element (e.g., mismatched x/y lengths), incremental compilation can succeed with reordered/missing tables, causing lookups to read the wrong table or return NaN at runtime, whereas the monolithic compiler would correctly fail with BadTable.

Useful? React with 👍 / 👎.

@claude

claude Bot commented Feb 23, 2026

Copy link
Copy Markdown

Code Review: Incremental compilation via salsa framework

Summary

Reviewed ~13,000 lines of additions across 42 files. This PR adds salsa-based incremental compilation alongside the existing monolithic pipeline, removes the SimFloat generic parameter, and integrates LTM functions as tracked salsa queries.

Detailed Analysis

Lock ordering (libsimlin): Verified that all paths acquire locks in a consistent order: project → db → sync_state. The transient inconsistency window between sync_state update and project update (patch.rs:521-525) is explicitly documented and acceptable — db + sync_state are atomically consistent with each other, and the fallback monolithic path in gather_error_details_with_db handles any temporary mismatch gracefully.

SimFloat removal: Mechanical type substitution throughout (<F: SimFloat> → f64). Verified no logic changes were introduced during the substitution. The epsilon-based comparison in interpreter.rs:260-273 (eval_at_index) is pre-existing code, not a regression from this PR.

Symbolic bytecode layer (symbolic.rs): The symbolize → resolve roundtrip is correct. ReverseOffsetMap::from_layout correctly maps offsets to (name, element_offset) pairs, and resolve_bytecode correctly resolves SymVarRef back to concrete offsets using the layout. Resource offset computation during fragment concatenation (initials → flows → stocks) is consistent between per-phase bases and the all-phases merge.

Dependency graph (db.rs): Transitive dependency computation, cycle detection, and topological sorting follow the same logic as the monolithic path. The compute_transitive closure correctly handles stock chain-breaking in the dt phase and module boundary treatment.

Incremental fallback: Models with sub-modules will fail in assemble_module (module variables produce flow_bytecodes but not stock_bytecodes since is_stock is false for modules), causing compile_project_incremental to return Err. The .ok() call in simlin_sim_new (simulation.rs:66) correctly falls back to the monolithic path. This is the intended design per the PR description.

Layout consistency: Both compute_layout and calc_flattened_offsets_incremental sort variable names with sort_unstable() producing identical ordering, ensuring the offsets are consistent between the two paths.

Findings

No bugs found that would break existing code, tests, or introduce regressions. The patch is well-structured with appropriate fallback mechanisms and comprehensive test coverage.

Overall Correctness Verdict

Correct. The patch preserves all existing behavior (tests pass), introduces the incremental compilation as a safe optimization layer with graceful fallback, and the SimFloat removal is a clean mechanical change. No blocking issues identified.

The incremental path used filter_map(.ok()) to silently drop Table::new
failures. For a variable with a malformed graphical function (e.g.
mismatched x/y point lengths), this would skip the bad table and
potentially produce bytecode that references a nonexistent lookup index.
The monolithic path correctly propagates these errors.

Now returns None from compile_var_fragment on any table conversion
failure, which causes the incremental path to fall back to the
monolithic compiler for that model.
@bpowers

bpowers commented Feb 23, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@claude

claude Bot commented Feb 23, 2026

Copy link
Copy Markdown

Code Review: Incremental compilation via salsa framework

Summary

Reviewed all 42 changed files (~13k additions, ~2k deletions). The PR introduces salsa-based incremental compilation, removes the SimFloat generic in favor of hardcoded f64, adds symbolic bytecode, and integrates incremental compilation into the libsimlin FFI layer.

Files reviewed

  • New core files: db.rs (3611 lines), compiler/symbolic.rs (2626 lines), db_tests.rs (3971 lines)
  • Modified compiler: compiler/mod.rs, compiler/codegen.rs, compiler/context.rs, compiler/expr.rs
  • Modified engine: vm.rs, bytecode.rs, interpreter.rs, model.rs, float.rs, results.rs, project.rs
  • LTM integration: ltm.rs, ltm_augment.rs, ltm_finding.rs
  • libsimlin FFI: lib.rs, patch.rs, project.rs, simulation.rs, errors.rs
  • New tests: tests_incremental.rs, tests_remaining.rs
  • Other: common.rs, datamodel.rs, variable.rs, compat.rs, vdf.rs, Cargo.toml, Cargo.lock, benches/compiler.rs, doc/tech-debt.md

Findings

No bugs found that meet the review criteria. Specific areas examined closely:

  1. Lock ordering in libsimlin (patch.rs, project.rs, simulation.rs): The documented lock ordering (project → db → sync_state) is respected. apply_project_patch_internal intentionally releases the project lock before acquiring db/sync_state to allow the pattern where db is held across the sync-evaluate-decide cycle. The simlin_project_get_errors function holds all three guards through the gather call. The simlin_project_add_model function uses take() instead of clone() for sync_state which creates a brief window where concurrent apply_patch could observe None, but this is handled gracefully (falls back to fresh sync) and the window is protected by the project lock for most callers.

  2. SimFloat removal: The f64 hardcoding is consistent across all files. The bool as i8 as f64 pattern in eval_op2 is well-defined in Rust. The crate::float::approx_eq function delegates to float_cmp::approx_eq! which preserves the prior semantics.

  3. Symbolic bytecode: The concatenate_fragments renumbering logic correctly offsets all resource IDs (literals, GFs, modules, views, temps, dim_lists). The resolve_var_ref / resolve_opcode / resolve_module chain correctly maps symbolic references to concrete offsets using the computed layout.

  4. Incremental sync: sync_from_datamodel_incremental correctly reuses previous salsa input handles and only calls setters on changed fields, enabling salsa's backdating optimization. New models/variables get fresh inputs.

  5. var_names sorting fix (model.rs:928): The old code shadowed var_names in an inner scope, sorted the shadow, and discarded it — leaving the outer var_names unsorted. The fix correctly sorts in place. This is a pre-existing bug being fixed.

  6. Error deduplication (patch.rs:gather_error_details_with_db): The (model_name, variable_name, code) triple dedup between struct-field errors and accumulator diagnostics is correct.

Observations (non-blocking)

  • set_dependencies_cached is structurally identical to set_dependencies — the caching benefit comes from salsa memoizing variable_direct_dependencies in other contexts, not from this method itself. The name could be slightly misleading but the doc comment explains this correctly.
  • The compile_simulation call in gather_error_details_with_db (monolithic path for simulatability checks) clones compiled to pass to Vm::new. This is intentional per the comment — the incremental path is used in simlin_sim_new where avoiding double-compilation matters.

Overall Correctness Verdict

Correct. The patch is free of bugs and blocking issues. Existing code and tests will not break. The design is well-layered with clear separation between symbolic bytecode (layout-independent) and concrete bytecode (layout-resolved), and the salsa integration follows established patterns.

@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: cfaa0c1ee4

ℹ️ 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 +3156 to +3160
if let Some(svar) = source_vars.get(var_name.as_str())
&& let Some(result) = compile_var_fragment(db, *svar, model, project, is_root)
&& let Some(ref bc) = result.fragment.initial_bytecodes
{
initial_frags.push((var_name.clone(), bc));

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 Skip implicit runlist entries when assembling fragments

model_dependency_graph can include implicit DELAY/SMOOTH helper variables in runlist_initials/runlist_flows, but assembly only looks up fragment inputs in source_vars (explicit model variables). When an implicit name is encountered, this branch marks it missing and the module assembly fails, so compile_project_incremental rejects otherwise valid models that use builtin expansions and libsimlin falls back to monolithic compilation every time.

Useful? React with 👍 / 👎.

@bpowers

bpowers commented Feb 23, 2026

Copy link
Copy Markdown
Owner Author

Review cycle summary

Three iterations of automated review (claude + codex) addressed all actionable feedback from the latest round of changes. Two fix commits were made:

Correctness fixes:

  • Staged DB visibility during dry_run/rejected patches: apply_project_patch_internal was dropping the db lock after syncing staged state but before the commit/rollback decision, allowing concurrent simlin_sim_new or simlin_project_get_errors to observe patch effects that should never become visible. Fixed by holding the db lock through the entire sync-evaluate-decide cycle, with the original datamodel saved upfront to avoid lock-ordering violations during rollback. A concurrent stress test confirms the fix.
  • Silent table conversion failures: The incremental path used filter_map(.ok()) on Table::new, silently dropping malformed graphical functions instead of propagating the error. This could shift table indices and cause lookups to read the wrong table at runtime. Fixed to return None from compile_var_fragment on any table conversion failure, triggering fallback to the monolithic compiler.

Documentation fix:

  • Corrected a misleading doc comment on compile_var_fragment that claimed independence from model-wide data, when it actually reads model.variables and model_dependency_graph (salsa's backdating still ensures correct caching behavior).

@bpowers
bpowers merged commit 645893e into main Feb 23, 2026
12 checks passed
@bpowers
bpowers deleted the incremental-compilation-impl branch February 23, 2026 16:10
bpowers added a commit that referenced this pull request May 20, 2026
The Initials runlist was a function of the per-process HashMap
RandomState seed, not of the model: `model_dependency_graph_impl`
materialized its candidate set (`init_list`) from a `HashSet` and handed
it straight to `topo_sort_str`, which emits `names` in visit order and
breaks ties -- variables with no ordering dependency between them, e.g.
independent constants, stocks, and the lagged-input-stripped
`PREVIOUS()`/`INITIAL()` helpers -- by exactly that order. Two compiles
of the SAME model therefore produced different init orderings, and for
any unordered pair that feeds the init value of a `PREVIOUS()`/`INITIAL()`
variable, different (one possibly wrong) initial values. The Flows and
Stocks runlists were already deterministic because they filter the
pre-sorted `var_names`; only the initials phase leaked hash order.

Fix: sort `init_list` before `topo_sort_str`, matching the flows/stocks
contract. With a sorted `names` argument and BTreeSet-stored dependency
edges, `topo_sort_str`'s entire traversal -- including how it breaks any
residual ordering cycle -- is a deterministic function of the model. This
is general (deterministic simulation for all models), not C-LEARN
specific, and changes nothing about NA-arithmetic or tolerances.

Pre-existing, not introduced by this branch: the
`init_set.into_iter().collect()` pattern dates to the original salsa
incremental-compilation work (PR #289), long before the C-LEARN residual
branch. It only became observable now because C-LEARN's residual cells
sit near the 1% cross-simulator tolerance, where a tiny order-induced
floating-point delta flips a verdict. Empirically: across 12 fresh-DB
compiles of C-LEARN the initials runlist had 12 distinct orderings before
this change and 1 after (flows/stocks were 1 both times), and the live
residual set is now byte-identical run to run.

Relation to GH #595: this resolves the HashMap-iteration-order
nondeterminism #595 identifies as the proximate mechanism (the arbitrary,
hash-dependent init tie-break is now a deterministic function of the
model). It does NOT close #595's deeper soundness gap -- the
detection-vs-ordering edge-set inconsistency that lets an init ordering
cycle through a synthetic-module stock output exist undetected; such a
cycle is now ordered deterministically rather than reported. No in-repo
model triggers that shape today.

A regression test pins the property directly (no probability):
`initials_runlist_is_deterministic_across_fresh_databases` asserts a
byte-identical runlist across 32 freshly-seeded databases, and
`initials_runlist_is_sorted_topological_order` asserts the order equals
the stable (sorted-name tie-break) topological sort reconstructed from the
engine's own `initial_dependencies`. Both fail if the sort is removed.
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