Skip to content

Module editing: fix dangling/cyclic/mis-wired reference bugs found by adversarial audit#807

Merged
bpowers merged 7 commits into
mainfrom
module-editing-audit-fixes
Jun 23, 2026
Merged

Module editing: fix dangling/cyclic/mis-wired reference bugs found by adversarial audit#807
bpowers merged 7 commits into
mainfrom
module-editing-audit-fixes

Conversation

@bpowers

@bpowers bpowers commented Jun 23, 2026

Copy link
Copy Markdown
Owner

Why

Commit c1c4c95 fixed a WASM panic where a freshly-drawn module (empty model_name) crashed unit inference. That was one instance of a broader dangling / empty / cyclic / mis-wired module-reference bug class. An adversarial audit of the module-editing feature (engine patch/compile pipeline, the JSON/FFI boundary, MCP, and the diagram controller) surfaced five more production-reachable issues plus one severe lurking bug; this PR fixes all of them, each with tests that reproduce the failure end to end.

Fixes

engine: keep module references consistent under edits — module wiring stores a parent src and a module-qualified module·port dst, which the patch layer was not keeping consistent across structural edits:

  • apply_delete_variable pruned deleted flows from stocks and groups but left module references whose src named the deleted variable, so deleting a wired-in parent variable left a dangling dependency and the whole project failed to compile with a confusing "missing variable" error. It now prunes those references too.
  • apply_rename_variable rewrote module references only within the renamed variable's own model. Renaming a child model's input port left every parent module's dst pointing at the old name, silently feeding the renamed port its default value (wrong numbers, no error). The rename now retargets the qualified dst suffix of every module instance that targets the renamed model.
  • canonicalize_module canonicalized only the module ident, so a non-canonical src/dst arriving via the FFI apply_patch (pysimlin upsert_module) disagreed with the canonical idents every consumer compares against. It now canonicalizes the endpoints, mirroring canonicalize_stock.

engine: reject cyclic module graphs instead of panicking — nothing checked that the module-instance graph is acyclic, so a self-referential or mutually-recursive module drove the recursive salsa queries (model_module_map, compute_layout) into a dependency-graph cycle, which salsa turns into an unrecoverable panic — reached from MCP edit_model/read_model and from importing a hand-authored recursive model, aborting WASM. A new project_module_cycle query detects the cycle with flat reads + an iterative DFS (no recursive salsa) and the three compile / diagnostic / analysis entry points fail cleanly with a CircularDependency error instead. This is the production-reachable cousin of the from_salsa panic class tracked in #806#806's own dead legacy sites are not reachable from production and are left as tracked; the live path is what this guards.

engine: warn on unresolved module input wiringbuild_module_inputs silently drops a reference whose dst does not match an existing child input, so the port reads its default and the simulation is quietly wrong with no error. The legacy compile path returned BadModuleInputDst/BadModuleInputSrc; the salsa path had dropped the check. A new diagnostic pass re-adds it as a Warning (partial-result philosophy — a mis-wired input should not block the rest of the model), deliberately conservative to avoid false positives (empty placeholder rows skipped, only existing target models checked, qualified/synthetic srcs left alone).

diagram: persist module input dst in the engine-qualified form (the lurking bug) — the wiring UI dropdown yields a bare child port name, and ModuleDetails persisted that bare value straight into the reference dst. But the engine wires inputs by the canonical module·port form and silently drops a dst that lacks the prefix, so every module input wired through the editor failed to connect. XMILE import already produces the qualified form, so this was an editor-only divergence from the datamodel contract. The fix qualifies the port on persist and strips it on display (pure qualifyDst/unqualifyDst helpers), tolerating a legacy . separator on read.

core: cover module serialization edge cases — the only Module round-trip test exercised a fully-populated module. Added cases for the empty-modelName freshly-drawn state (the c1c4c95 panic shape; the empty name is a required Rust String), placeholder references with empty src/dst, and a module-qualified dst whose middot must survive the JSON round trip.

Non-obvious decisions

  • Cycle detection lives in the engine, not the frontend. The diagram's wouldCreateCycle only filters the model-reference dropdown and is bypassed by MCP and file-import, so the engine must own the guard for every entry point.
  • The wiring check is a Warning, not a hard error, matching the engine's partial-result philosophy and avoiding breaking existing models with benign dangling references; the structural fixes (delete-prune, rename-retarget, editor qualification) close the ways these arise in the first place.
  • dst is qualified in the editor, not bare. The canonical datamodel/XMILE representation is module·port; making the editor conform (rather than making the engine accept bare dst) keeps a single representation across import/export/edit.

Testing

Every fix ships with a test that reproduces the original failure: the engine patch tests compile and simulate a wired module (asserting the value flows, or cleanly drops to the port default); the cycle tests assert a clean error across compile, diagnostics, and analysis (and that valid nesting does not false-positive); the wiring-diagnostic tests pin bare/dangling dst and src warnings while a correct wiring and empty rows stay clean; the diagram tests cover the qualify/unqualify helpers and the UI persist/display round trip; and the core tests cover the serialization edge cases. Each commit was gated by the full pre-commit suite (Rust fmt/clippy/test, TypeScript lint/build/tsc/test, pysimlin).

Related: #806 (the production-reachable cousin of that dangling-reference panic class is fixed here; #806's specific dead from_salsa sites remain tracked).

bpowers added 5 commits June 22, 2026 21:54
Module wiring carries reference endpoints (a parent variable `src`, a
module-qualified `module·port` `dst`) that the patch layer was not keeping
consistent across structural edits:

- apply_delete_variable pruned deleted flows from stock in/outflows and
  group members, but left module references whose `src` named the deleted
  variable. The dangling `src` became a dependency on a non-existent
  variable, so the whole project failed to compile with a confusing
  "missing variable" message. Prune those references too, mirroring the
  existing stock-flow/group cleanup.

- apply_rename_variable rewrote module references only within the renamed
  variable's own model. Renaming a child model's input port therefore left
  every PARENT module's `dst` pointing at the old port name, and the parent
  silently fed the renamed port its default value -- wrong numbers, no
  error. Retarget the qualified `dst` suffix of every module instance that
  targets the renamed model.

- canonicalize_module canonicalized only the module ident, leaving the
  reference endpoints verbatim. A non-canonical `src`/`dst` arriving via the
  FFI apply_patch (pysimlin upsert_module) then disagreed with the canonical
  idents every consumer compares against. Canonicalize the endpoints too,
  mirroring canonicalize_stock; empty placeholder endpoints are preserved.

Tests drive each fix end to end (compile + simulate), asserting the wiring
still carries the expected value (or cleanly drops to the port default).
A module instantiates another model by name (`Variable::Module.model_name`).
Nothing in the engine checked that this instance graph is acyclic, so a
self-referential module (a model containing a module that targets itself) or a
mutually-recursive pair drove the recursive salsa queries `model_module_map`
and `compute_layout` into a dependency-graph cycle -- which salsa turns into an
unrecoverable panic. Reached from MCP edit_model/read_model and from importing a
hand-authored recursive model, that panic aborts WASM and trips the same
recursive-mutex cascade c1c4c95 fixed for the empty-model_name sibling of this
class (the reachable cousin of GH #806; the frontend wouldCreateCycle only
filters the dropdown and is bypassed by every non-UI path).

Add project_module_cycle: a salsa-tracked query that builds the explicit
module-instance adjacency with flat reads (no recursive salsa) and runs an
iterative 3-color DFS, returning a typed CircularDependency error naming the
cycle. The three production entry points consult it first and fail cleanly:
assemble_simulation returns Err (mapped to NotSimulatable), collect_all_
diagnostics surfaces one project-level diagnostic and skips the per-model
passes, and analyze_model degrades to an analysis_error with the snapshot
intact. Implicit SMOOTH/DELAY/stdlib modules only reference leaf stdlib models,
so they cannot close a user cycle and are omitted.

Tests cover a self-referential module, a mutually-recursive pair, and a valid
nested-module project (the guard must not false-positive on acyclic nesting),
each across compile, diagnostics, and analysis.
A module reference is `{ src, dst }` where `dst` is the module-qualified
`{module}·{port}` form naming an input of the target model. `build_module_inputs`
SILENTLY DROPS a reference whose `dst` does not match an existing child input --
the port then reads its default and the simulation is quietly wrong, with no
error. The legacy monolithic compile path returned BadModuleInputDst /
BadModuleInputSrc here; the salsa path had dropped the check, so a stale `dst`
left by deleting a child input port, a hand-authored typo, or an unqualified
`dst` produced a plausible-but-wrong run.

Add model_module_wiring_diagnostics, a salsa-tracked pass driven by
model_all_diagnostics, that validates each explicit module variable's references
and emits a Warning (partial-result philosophy -- a mis-wired input should not
block the rest of the model) for a `dst` that resolves to no child input or a
bare `src` that names no variable in the enclosing model. It is deliberately
conservative to avoid false positives: empty placeholder endpoints (the new-row
UI pattern) are skipped, only an EXISTING target model is checked (an empty /
dangling model_name is a separate concern and the empty name is the normal
freshly-drawn state), and a `src` is checked only when it is a bare, non-
synthetic ident -- a qualified cross-module output or `$⁚` temporary is left to
the equation checker.
The module input-wiring UI dropdown yields a bare child port name (e.g.
`input_food`), and ModuleDetails persisted that bare value straight into the
reference `dst`. But the engine wires inputs by the canonical module-qualified
form `{moduleIdent}·{port}` -- `build_module_inputs` strips that prefix to
recover the port and SILENTLY DROPS a `dst` that lacks it. So every module input
wired through the editor failed to connect: the child port kept its default and
the simulation was quietly wrong, with no error. XMILE import already produces
the qualified form (`xmile::model` builds `module.port`), so this was an
editor-only divergence from the datamodel contract.

Add pure qualifyDst/unqualifyDst helpers (functional core) and use them in
ModuleDetails: persist `qualifyDst(moduleIdent, port)` on selection and display
`unqualifyDst(dst)` so the dropdown still shows the bare port. unqualifyDst
tolerates a legacy `.` separator for a model imported straight from XMILE that
has not yet round-tripped through a patch (the engine canonicalizes `.`->`·`).

The engine-side companion commits make this verifiable end to end: the patch
tests simulate a module wired with the qualified dst to a non-default value, and
the new wiring diagnostic warns when a dst (e.g. the old bare form) resolves to
no child input. Unit tests cover the helpers and the UI persist/display round
trip.
The only Module round-trip test exercised a fully-populated module, so the
states that actually surface bugs were uncovered. Add cases for: a freshly-drawn
module with an empty modelName and no references (the c1c4c95 panic state -- the
empty modelName is a required Rust String, so a dropped field would be an FFI
hard error), placeholder references with empty src/dst (the partially-filled
wiring rows the UI persists), and a module-qualified `{moduleIdent}·{port}` dst
(the canonical reference form, whose middot must survive the JSON round trip).

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

ℹ️ 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/assemble.rs Outdated
Comment on lines +1927 to +1928
if let Some((_code, msg)) = &project_module_cycle(db, project).cycle_error {
return Err(msg.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 Restrict module-cycle checks to the requested root

Because compile_project_incremental assembles only the named main_model_name, this project-wide cycle check now rejects simulations that would never traverse the cycle. For example, a project with a valid main model and an unused draft pair a -> b -> a will return NotSimulatable here before enumerate_module_instances starts from main, even though the recursive queries that can panic are only reachable for modules under the requested root. Please scope the cycle guard to models reachable from main_model_name for compilation.

Useful? React with 👍 / 👎.

const updated = updateReferenceDst(variable.references, index, newDst);
// The dropdown yields a bare child port; persist the canonical
// module-qualified `{moduleIdent}·{port}` form the engine wires against.
const updated = updateReferenceDst(variable.references, index, qualifyDst(variable.ident, newDst));

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 Retarget stored dst prefixes when the module is renamed

When a user wires a module input and later renames that module variable, this persisted dst keeps the old {moduleIdent}· prefix. The rename path only rewrites exact/self-qualified references, so after renaming m to n the engine builds inputs with prefix , drops the stale m·port reference, and the child port falls back to its default. Please update module-reference dst prefixes during module-variable renames or otherwise avoid storing a prefix that can go stale.

Useful? React with 👍 / 👎.

@codecov

codecov Bot commented Jun 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.70833% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.87%. Comparing base (c1c4c95) to head (91ac88c).

Files with missing lines Patch % Lines
src/simlin-engine/src/patch.rs 98.31% 5 Missing ⚠️
src/simlin-engine/src/db/query.rs 94.28% 4 Missing ⚠️
src/simlin-engine/src/db/diagnostic.rs 98.71% 1 Missing ⚠️
src/simlin-engine/src/model.rs 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #807      +/-   ##
==========================================
+ Coverage   90.84%   90.87%   +0.03%     
==========================================
  Files         224      224              
  Lines      142647   143120     +473     
==========================================
+ Hits       129581   130059     +478     
+ Misses      13066    13061       -5     

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

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

@claude

claude Bot commented Jun 23, 2026

Copy link
Copy Markdown

Reviewed the full diff. The patch is correct and the implementation aligns with the documented intent.

Verification of each fix:

  • apply_delete_variable correctly prunes module references whose bare src matches the deleted ident (qualified outputs intentionally left alone, matching the existing flow/group cleanup pattern).
  • retarget_parent_module_dst correctly walks all models, gates on the target model name, builds a per-module canonical prefix, and rewrites only matching qualified dst references — including self-targeting modules in the renamed model.
  • canonicalize_module mirrors canonicalize_stock's endpoint canonicalization, with empty placeholders correctly preserved as empty.
  • The iterative 3-color DFS in find_module_reference_cycle maintains stack/path synchronization, correctly identifies back-edges, returns the path slice as a closed cycle, and is byte-stable thanks to the sorted BTreeMap adjacency. The three production entry points (compile, diagnostic collection, analysis) all gate on it cleanly.
  • model_module_wiring_diagnostics is appropriately conservative: empty placeholders skipped, missing target model skipped, qualified/synthetic src skipped — and emits Warnings (not Errors), matching the partial-result philosophy.
  • qualifyDst/unqualifyDst preserve empty placeholders and tolerate the legacy . separator; the round-trip with the persist/display path is correct.

Test coverage is comprehensive — each fix ships with a regression test that reproduces the original failure end-to-end (delete simulates the bare-default fallback, rename verifies the new dst survives + still wires through to the reader, cycle tests cover all three production entry points, wiring diagnostics cover bare/dangling/empty cases).

Verdict: correct. No blocking issues. ✅

Two issues the automated review flagged on the module-editing fixes:

- The module-cycle guard was project-wide, so a valid `main` model was rejected
  (NotSimulatable) merely because an unused draft pair elsewhere (`a -> b -> a`)
  formed a cycle that main never reaches. assemble_simulation only recurses into
  modules instantiated from main, so the salsa panic the guard prevents is only
  reachable for modules under the requested root. Replace the project-wide
  ModuleCycleResult / project_module_cycle with project_module_graph +
  ModuleReferenceGraph::cycle_reachable_from(root): compile and analyze scope the
  check to the requested root, and collect_all_diagnostics checks per model
  (reporting the cycle for affected models, running normal passes for the rest)
  so a valid model's diagnostics are no longer hidden by an unrelated draft
  cycle.

- Renaming a module VARIABLE left its own input references' dst carrying the old
  `{module}·` prefix. The engine rebuilds inputs under the new prefix and drops
  the stale reference, silently unwiring the input. apply_rename_variable now
  reprefixes the renamed module's own reference dsts (complementing the existing
  parent-dst retarget for renamed child input ports).

Regression tests cover an unused draft cycle not blocking a valid main (with
analysis of the cyclic model still degrading to an analysis_error), and renaming
a wired module variable keeping its input wired.
@bpowers

bpowers commented Jun 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: 585b67da06

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

if let Variable::Module(module) = var {
module
.references
.retain(|reference| canonicalize(reference.src.as_str()) != ident);

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 Strip leading main-model dots before pruning sources

When a module input source is stored in the main-model form ·foo (a shape explicitly accepted by build_module_inputs), deleting foo will keep that reference because this comparison checks canonical ·foo against foo. The retained reference is later interpreted as foo again, so the dangling dependency and compile failure this cleanup is meant to avoid still occur for XMILE/main-model references using the leading middot form.

Useful? React with 👍 / 👎.

…refs

GH #806 flagged two unguarded panics on the legacy `Project::from_salsa`
dependency-resolution path that fire on a dangling or empty module `model_name`
(a freshly-drawn module, or a reference to a deleted model): `module_deps`'
initial-branch `ctx.models[model_name]` HashMap index, and `topo_sort`'s
"internal compiler error: unknown ident" panic (plus its trailing
`assert_eq!(runlist_len, result.len())`, which a dangling dep also trips).

This path is NOT dead code -- it builds the monolithic `Project`/`Module` that
`TestProject::compile()` / `build_module()` and ~20 unit-inference tests run on,
and it serves as an independent correctness ORACLE for the production salsa path
(e.g. `db::invariance`'s `salsa_and_monolithic_paths_agree`). So rather than
delete the oracle, guard the two sites #806 names, exactly as the issue proposes:
`module_deps` skips a missing submodel's deps via `.get()` (mirroring the already-
guarded twin in `module_output_deps`), and `topo_sort` treats an ident with no
dependency entry as a dangling reference it drops from the runlist instead of
panicking. Valid runlists are unaffected (`result == runlist`), so the oracle and
every existing test keep their behavior; only the crash on bad input is removed.

The production salsa path already rejects a dangling/cyclic module graph up front
(unit inference was guarded in c1c4c95; cycle detection landed earlier in this
branch), so this is defense-in-depth for the test-only path. A regression test
drives `Project::from` with a module pointing at a missing model and asserts it
constructs without panicking.
@bpowers

bpowers commented Jun 23, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@claude

claude Bot commented Jun 23, 2026

Copy link
Copy Markdown

Code Review

I performed a thorough review of this PR, tracing through the engine changes (patch.rs delete/rename/canonicalize, project_module_graph cycle detection, model_module_wiring_diagnostics, the cycle gates in assemble_simulation / analyze_model / collect_all_diagnostics) and the diagram changes (qualifyDst / unqualifyDst, the ModuleDetails persist/display round trip).

No blocking issues found. Specifically:

  • The iterative 3-color DFS in cycle_reachable_from correctly tracks gray nodes via path parallel to the stack, and the path-position back-edge extraction yields a well-formed cycle.
  • Ident canonicalization is idempotent at every conversion boundary (FFI, root lookup, edge map keys, retarget prefix construction).
  • apply_delete_variable prunes references where src named the deleted parent variable, mirroring the existing flow/group cleanup; the rename path covers both the renamed-module own prefix and parent modules dst retargeting; retarget_parent_module_dst correctly gates by module.model_name == target_model_name so unrelated models with like-named variables are untouched.
  • The wiring diagnostic correctly skips empty placeholder rows, qualified src values (the middot-contains check covers main-scope leading-middot references), and engine synthetics; canonicalize preserves the middot separator in dsts.
  • The reachability-scoped cycle gate properly addresses the Codex feedback: a valid main compiles even with an unrelated draft cycle, while collect_all_diagnostics still reports the cycle per affected model and runs normal passes on unaffected models.
  • Stdlib models (verified no module tags) cannot close user cycles, so the documented omission of implicit modules from the graph is correct.

Overall correctness: Correct. The patch is well-factored, comprehensively tested (every fix ships with a reproducing test), and the Codex-feedback follow-up commit addresses the two specific gaps it identified.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: 91ac88c474

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

@claude

claude Bot commented Jun 23, 2026

Copy link
Copy Markdown

Code Review

I reviewed this PR end-to-end. No bugs to flag.

The five fixes hang together coherently:

  • Cycle gate scoping (ModuleReferenceGraph::cycle_reachable_from) is correctly per-root, so an unrelated draft cycle does not block compiling a valid main; the iterative 3-color DFS reconstructs the cycle path from the live Gray frames correctly (including the self-loop [X, X] degenerate case), and the adjacency is built deterministically from sorted models/vars so reported cycles are stable across processes.
  • Three production entry points (assemble_simulation, collect_all_diagnostics, analyze_model) consult the same graph; a cycle in the requested-root reachable subgraph short-circuits cleanly with CircularDependency instead of driving the recursive salsa queries into a panic.
  • Delete-prune of dangling module src mirrors the existing stock-flow/group cleanup; rename retargets BOTH the renamed module's own {old}·port prefix (the Codex-review finding) AND every parent module's dst whose port suffix names the renamed variable (via retarget_parent_module_dst, gated on module.model_name == target_model_name so unrelated like-named variables in other models are not touched).
  • canonicalize_module now mirrors canonicalize_stock's inflow/outflow canonicalization on src/dst.
  • Wiring diagnostic is conservatively defensive (empty placeholders skipped, only existing child models checked, qualified/synthetic srcs deferred to the equation checker) and ships as a Warning consistent with the partial-result philosophy.
  • Editor qualify/unqualify correctly persists the engine-canonical {moduleIdent}·{port} form while tolerating a legacy . separator on read.
  • The topo_sort panic-to-skip change is correctly scoped to the legacy from_salsa path (the production salsa path uses topo_sort_str which rejects dangling/cyclic graphs up front); for well-formed runlists where every ident has a deps entry, the result is unchanged. The debug_assert!(result.len() <= runlist_len) is the right invariant.

Test coverage is thorough — each fix ships with a regression test exercising the original failure mode, plus the unused_draft_cycle_does_not_block_valid_main and rename_module_variable_retargets_its_own_dst_prefix cases addressing the Codex review findings.

Overall correctness: correct

@bpowers bpowers merged commit 40c85bf into main Jun 23, 2026
15 checks passed
@bpowers bpowers deleted the module-editing-audit-fixes branch June 23, 2026 23:02
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