Skip to content

engine: robust (partial-result) unit inference and checking#618

Merged
bpowers merged 7 commits into
mainfrom
fix-unit-inference-partial-results
May 22, 2026
Merged

engine: robust (partial-result) unit inference and checking#618
bpowers merged 7 commits into
mainfrom
fix-unit-inference-partial-results

Conversation

@bpowers
Copy link
Copy Markdown
Owner

@bpowers bpowers commented May 22, 2026

Summary

Makes the unit subsystem partial-result throughout, so one bad declaration or one dimensional conflict never blinds the rest of the model. Closes #614 and the long-standing units.rs:263 TODO (its context-layer parallel), plus two robustness/semantic fixes surfaced along the way.

Five commits, each TDD'd with the full pre-commit gate green:

  1. gen_constraints is total. It only ever returned Ok, so the UnitResult<Units> was vestigial and its .unwrap() in gen_all_constraints was a latent panic. Now returns Units. This also closes a propagation gap: the None-arm continue skipped the declared-units constraint for equation-less variables, so a variable with declared units but no equation (the editor's "units typed, equation not yet written" state) never informed inference of its dependents.

  2. RANK results are dimensionless. A rank is an ordinal index, not the units of the ranked array; fixed in both inference and checking.

  3. infer returns partial results (engine: unit inference is all-or-nothing -- one conflict discards all inferred units for the model (parallels units.rs:263 TODO) #614). InferenceResult { resolved, conflicts }. Solving continues past a conflict keeping the first binding -- a contradiction is confined to its connected component of the constraint graph, since substitution only flows along shared metavariables, so this can never corrupt an independent component -- and find_constraint_mismatches collects every residual contradiction rather than the first. check_model_units keeps the resolved units (so the rest of the model is still checked) and surfaces conflicts as one umbrella warning rather than one-per-conflict.

    Keeping the resolved units unmasked a pre-existing leak the old all-or-nothing behavior was accidentally suppressing: a Vensim macro can annotate its body variables' units with the formal parameter names (~ xfrom inside RAMP FROM TO) -- a polymorphic unit, not a concrete base unit. Inference recurses into macro instances, so those names leaked as literal units into every instantiation and conflicted with the real argument units. ModelStage0/ModelStage1 gain an is_macro flag and inference now skips declared-units constraints for macro bodies, letting them be inferred polymorphically from the equation and cross-module parameter bindings (mirroring check_model_units, which already skips macro models). This restores C-LEARN to its documented 14-diagnostic residual.

  4. Context::new returns partial context. It built the full context then discarded it on any duplicate/conflicting declaration; project_units_context then fell back to an empty context, losing project-wide alias normalization (yr/year, person/people) and re-creating a spurious mismatch flood. Now returns (Context, Vec<errors>): the context always holds every valid declaration, with the conflicting ones reported alongside.

  5. Docs. Engine CLAUDE.md Unit analysis section updated to describe the new contracts.

PL framing

Units form a free abelian group, so a unit-constraint set is a linear system over the integers and an inconsistency is a single unsatisfiable row -- it says nothing about the other rows. The fix mirrors how production type systems accumulate errors and keep a partial result rather than aborting at the first failure (rustc error tainting, GHC deferred type errors, the Validation applicative); the natural unit of fault isolation here is the connected component of the constraint graph, which the substitution mechanism already respects.

Testing

New tests, each written RED then GREEN: partial-results-survive-conflict (+ a second independent conflict still reported), declared-units-without-equation propagation, macro-polymorphic-units (reproduces the C-LEARN xfrom leak in miniature), RANK dimensionless in inference and in checking, and partial-context-on-conflict. Full cargo test --workspace passes; the #[ignore] C-LEARN flood guard (tests/clearn_unit_errors.rs) and the wrld3 unit tests pass unchanged, with C-LEARN holding at its documented 14-diagnostic residual (deterministically).

bpowers added 5 commits May 21, 2026 21:08
gen_constraints only ever returned Ok, threading a vestigial
UnitResult<Units> whose .unwrap() in gen_all_constraints was a latent
panic the day any arm became fallible. Constraint generation is total:
you can always walk a well-formed AST and emit linear constraints;
dimensional inconsistency is detected later, during solving. Change
gen_constraints to return Units, deleting the unwrap and the dead Err
arms in the arrayed path.

This also closes a propagation gap: the None => continue arm (no parsed
equation) skipped the rest of the loop body, including the var.units()
declared-units constraint. A variable with declared units but no
equation -- e.g. the editor's units-typed-but-equation-not-yet-written
state -- now still contributes its units to inference so dependents
resolve.
RANK returns an ordinal position (a 1-based index), which is a
dimensionless quantity -- like a comparison result -- not the units of
the array being ranked. Both the inference and checking paths previously
inherited the ranked array's units, so a RANK result silently took on
e.g. dollars and declaring it dimensionless was a false-positive
mismatch. Walk the ranked array for its internal constraints, but return
an explicit empty unit map for the RANK result itself.
units_infer::infer returned a single Result, so the first dimensional
conflict aborted inference and discarded every unit it had already
resolved; db_units then fell back to an empty inferred-units map,
blinding the per-variable unit check for the rest of the model (GH #614).

infer now returns InferenceResult { resolved, conflicts }: solving
continues past a conflict keeping the first binding -- a contradiction is
confined to its connected component of the constraint graph, since
substitution only flows along shared metavariables, so this can never
corrupt an independent component -- and find_constraint_mismatches
collects every residual contradiction rather than the first.
check_model_units keeps the resolved units (so the rest of the model is
still checked) and surfaces conflicts as one umbrella warning rather than
one-per-conflict, since a macro-heavy model can produce hundreds of
internal contradictions.

Keeping the resolved units unmasked a pre-existing leak: a Vensim macro
may annotate body variables' units with the formal parameter names (e.g.
`~ xfrom` inside RAMP FROM TO) -- a polymorphic unit, not a concrete base
unit. Inference recurses into macro instances, so those parameter names
leaked as literal units into every instantiation and conflicted with the
real argument units (C-LEARN's xfrom/xto storm). ModelStage0/1 gain an
is_macro flag and inference now skips declared-units constraints for
macro bodies, letting those units be inferred polymorphically from the
equation and cross-module parameter bindings (mirroring check_model_units,
which already skips macro models). This restores C-LEARN to its
documented 14-diagnostic residual.
Context::new accumulated unit-definition errors in a Vec but then threw
away the fully-built context whenever that Vec was non-empty, returning
Err; project_units_context then fell back to Default::default() -- an
empty context with no units. So a single duplicate or contradictory unit
declaration anywhere in a project lost ALL alias normalization (yr/year,
person/people, model-defined equivalences) project-wide, re-creating a
spurious unit-mismatch flood. This is the context-layer parallel of the
inference all-or-nothing discard -- the long-standing units.rs:263 TODO,
the same shape as GH #614.

new/new_with_builtins now return (Context, Vec<...>): the context is
always the one built from the valid declarations, with any conflicting
declarations reported alongside it. project_units_context keeps the
context and accumulates the errors as diagnostics. Callers that expected
success take .0; the tests that exercise the conflict path assert on the
returned error vec.
Update the engine CLAUDE.md Unit analysis section and the Last-updated
note to describe the partial-result contracts: infer ->
InferenceResult, Context::new -> (Context, errors), gen_constraints
total, RANK dimensionless, and the is_macro polymorphic-units skip.
Copy link
Copy Markdown

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

Choose a reason for hiding this comment

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

💡 Codex Review

pending.substitute(&var, &units, &sources);
finalized.substitute(&var, &units, &sources);

P1 Badge Check conflicting bindings before applying substitution

When a metavariable is seen a second time with incompatible units, the solver now records a conflict and keeps going, but it substitutes the new candidate units into pending/finalized before verifying whether that binding conflicts with an existing one. In models where the conflicted variable appears in other equations, those downstream constraints are rewritten using the rejected units, so later inferred units can become order-dependent and inconsistent with the documented “keep the first binding” behavior.

ℹ️ 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
Copy link
Copy Markdown

claude Bot commented May 22, 2026

Review: robust (partial-result) unit inference and checking

I reviewed all 17 files. The change is well-structured and the core reasoning is sound: making gen_constraints total, collecting the full conflict set, keeping partial resolved units across a conflict, returning a partial Context, treating RANK as dimensionless, and skipping declared-units constraints for macro bodies. The fault-isolation argument (a contradiction is confined to its connected component because substitution only flows along shared metavariables) holds — ConstraintSet::substitute only touches constraints containing the shared metavar, so independent components are unaffected. The API change to (Context, Vec<errors>) / InferenceResult is applied consistently across every caller in the crate, and check_model_units does early-return for macro models (db_units.rs:181), so the inference macro-skip is consistent.

Findings

[P3] Non-deterministic example conflict in the umbrella warningsrc/simlin-engine/src/db_units.rs:280-287

gen_all_constraints iterates model.variables (a HashMap, model.rs:63), so the order of inference.conflicts is run-dependent. The conflict set and count are order-independent (the explicit goal of find_constraint_mismatches), and the diagnostic count is stable because conflicts collapse into one umbrella warning — but the first: {} example embedded in the warning's details is conflicts[0], which can differ between otherwise-identical compiles. Since the diagnostic value then varies, this can show up as a flaky message string (and a non-stable salsa diagnostic payload). Picking a deterministic representative (e.g. conflicts.iter().min() / sort before formatting) would make the surfaced text reproducible. Low priority: it only affects the displayed sample, not which-or-how-many diagnostics are emitted.

Non-blocking notes (no action needed)

  • The deduped loop in infer is O(n²) in the conflict count; bounded and on the error path, so fine. UnitError: Eq + Hash already, if it ever needs tightening.
  • find_constraint_mismatches Case 2 compares each member against the group's first residual, so k distinct residuals yield k-1 mismatches (and equal-but-differently-sourced residuals aren't deduped). This can slightly over-count the "N conflicts" number but is harmless given the umbrella collapse, and the comment documents it.

Overall correctness: correct

No blocking issues. Existing tests and the C-LEARN/wrld3 guards should hold (the PR reports cargo test --workspace green), and the only finding is a low-priority determinism nit in user-facing message text, not a behavioral bug.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 22, 2026

Codecov Report

❌ Patch coverage is 86.91589% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.67%. Comparing base (a8cd392) to head (16d00b6).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/simlin-engine/src/units_infer.rs 80.55% 14 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #618      +/-   ##
==========================================
- Coverage   82.86%   82.67%   -0.20%     
==========================================
  Files         261      264       +3     
  Lines       69836    70047     +211     
==========================================
+ Hits        57871    57911      +40     
- Misses      11965    12136     +171     

☔ 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.

Addresses the PR #618 review: unify substituted the newly-derived units
into the remaining constraints BEFORE checking whether the metavariable
already had a kept binding, so a conflicting re-derivation would have
propagated the rejected units -- inconsistent with the documented keep-
the-first-binding behavior. Move substitution into the first-resolution
branch so only the accepted binding is ever propagated.

This is a defensive correctness fix: the conflict arm is currently
unreachable -- substitute removes the metavariable from every remaining
constraint, so a metavariable is solved at most once and resolved_fvs
never already contains it here; a probe across the full lib suite +
C-LEARN + wrld3 confirmed zero hits. A genuine over-constraint surfaces
instead as a residual concrete contradiction reported by
find_constraint_mismatches. No behavior change on any reachable path;
the reorder keeps keep-first correct if that invariant ever changes.
@bpowers
Copy link
Copy Markdown
Owner Author

bpowers commented May 22, 2026

Addressed in 26dfffc. The ordering critique is correct: unify substituted the newly-derived units before deciding whether to keep them, which would propagate the rejected units on a conflicting re-derivation. I moved substitution into the first-resolution branch so only the accepted binding is ever propagated.

Worth noting for context: the conflict arm is currently unreachable. substitute removes the metavariable from every remaining constraint (and the metavar index), so a metavariable becomes a single_fv at most once and resolved_fvs never already contains it at that check. A genuine over-constraint (the same metavariable forced to two different units) instead reduces to a residual concrete contradiction (e.g. meter == second) that find_constraint_mismatches reports. I verified this empirically with a probe in the conflict arm: zero hits across the full lib suite (3609 tests), the C-LEARN model (380 conflicts), and the wrld3 unit tests.

So there's no observable bug to reproduce with a test today, but the reorder is the correct robustness fix — it keeps "keep the first binding" sound if that invariant ever changes (e.g. a future change to the substitution strategy). The follow-up of removing the now-provably-redundant in-unify conflict detection entirely (relying solely on find_constraint_mismatches) is noted for a separate cleanup.

@claude
Copy link
Copy Markdown

claude Bot commented May 22, 2026

Code review

I reviewed the partial-result rework across units.rs, units_infer.rs, units_check.rs, db_units.rs, db.rs, and model.rs, plus the API migration. The change is well-structured and the contracts hold up:

  • The Context::new/new_with_builtins -> (Context, Vec<errors>) and infer -> InferenceResult migrations are complete — every caller across the engine, libsimlin, pysimlin, simlin-mcp, tests, and benches is updated; no caller still uses the old Result API.
  • is_macro is set on every ModelStage0/ModelStage1 construction site, including the cross-module map in check_model_units, so a macro submodel reached by recursion correctly skips its declared-units constraints.
  • The unify reorder (last commit) is a no-op on all reachable paths: substitute removes the metavariable from every remaining constraint, so the conflict arm is genuinely unreachable and the kept-binding invariant is preserved.
  • RANK returning Units::Explicit(empty) (dimensionless) rather than Units::Constant is the correct choice in both inference and checking — it won't adopt a neighbor's units the way a Constant would.

[P3] Stale function name in comment — units_infer.rs:407

The gen_constraints doc comment refers to find_constraint_mismatch, but that function was renamed to find_constraint_mismatches in this PR. Minor, non-blocking.

        // (`unify`/`find_constraint_mismatches`), never here -- so this function

Overall correctness: correct

No blocking issues. The API migration is complete, is_macro propagation is consistent, and the partial-result logic is sound. One non-blocking observation worth noting (not a defect in this PR): because find_constraint_mismatches compares each member of a signature group against the first-encountered residual and variable iteration order is HashMap-driven, the count of reported conflicts can in principle vary when a signature has the same residual appearing in multiple constraints with distinct sources. This is the order-sensitivity already tracked in GH #474 and acknowledged in the code comments; it does not affect the validated C-LEARN/wrld3 residuals.

The previous commit established that unify's in-loop conflict detection
is unreachable: substitute removes a metavariable from every remaining
constraint, so it is solved at most once and resolved_fvs never already
contains it at that check. That detection was therefore dead and fully
redundant with find_constraint_mismatches, which already reports every
over-constraint as a residual concrete contradiction.

Remove it for the simpler end state: unify returns (resolved, leftover)
-- no conflicts vec, no resolved_sources -- and keeps the first binding
via a vacant-entry guard that also never substitutes a rejected
re-derivation. infer collects conflicts from find_constraint_mismatches
alone (a single source now, so the dedup just drops identical findings).
Behavior is unchanged on every reachable path; full suite + C-LEARN
green, C-LEARN holds its documented 14-diagnostic residual.
@bpowers bpowers merged commit 14fed2a into main May 22, 2026
12 of 13 checks passed
@bpowers bpowers deleted the fix-unit-inference-partial-results branch May 22, 2026 14:00
@claude
Copy link
Copy Markdown

claude Bot commented May 22, 2026

Review: robust (partial-result) unit inference and checking

I reviewed the partial-result refactor across units.rs, units_check.rs, units_infer.rs, and db_units.rs. The logic is sound:

  • unify's Entry::Vacant guard is behaviorally equivalent to the old conflict detection for the resolved map — a solved metavariable is substituted out of every remaining pending/finalized constraint, so it can never reappear as a single_fv, making the occupied branch genuinely defensive. Keeping partial results past a conflict is the intended change and is confined to its connected component as documented.
  • The None-arm change in gen_all_constraints correctly falls through to the var.units() declared-units constraint instead of continue-skipping equation-less variables.
  • RANK-as-dimensionless is consistent across units_infer and units_check.
  • is_macro is propagated to every ModelStage0/ModelStage1 construction site, and macro models are present in the models_s1 map so the recursion-skip works.
  • Every emitted unit diagnostic remains Warning severity, so the now-unmasked partial results cannot block a model that previously compiled.

Findings

[P3] Duplicated docstring on find_constraint_mismatchessrc/simlin-engine/src/units_infer.rs:286-314. The rewritten docstring (lines 299-314) was inserted without removing the previous one (lines 286-298), so the function now carries two stacked doc blocks: two redundant "1./2." mismatch-type lists and two O(n)-vs-O(n^2) explanations. Deleting the older block (lines 286-298) leaves the newer, more complete version and removes the confusion of reading the same contract twice.

Overall correctness verdict: correct

No behavior-affecting bugs found; existing code/tests should not break. The only issue is the minor docstring duplication above.

bpowers added a commit that referenced this pull request May 22, 2026
A Vensim macro annotates its body variables' units with the formal
parameter NAMES (a polymorphic idiom, e.g. `~ xfrom`, `~ xfrom/tstart`
inside C-LEARN's RAMP FROM TO). GH #618 contained the resulting
`xfrom`/`xto` error storm by skipping declared-units constraints for
macro bodies entirely -- but that neither resolved the parameter-named
units to the actual arguments nor checked the body's declared signature.

Replace the skip with a precise treatment: `gen_all_constraints` lowers
each parameter-named unit identifier to that parameter's per-instantiation
metavariable (`lower_macro_unit_to_metavars`), keeping genuine base units
(`dmnl`) as-is. Because each instantiation already walks the macro body
under a unique prefix, the parameter metavar is the same one the module's
input binding ties to the actual argument -- so the unit resolves to the
argument per instantiation and the storm cannot return, while a genuine
declared-vs-equation inconsistency now surfaces as a conflict. The
canonical formal-parameter names are threaded onto ModelStage0/ModelStage1
as `macro_params` (from `MacroSpec`, via `model::macro_param_idents`).

End users are modelers, not software developers, so a conflict that
touches a synthetic instantiation name (`$⁚{var}⁚{n}⁚{func}·…`) is
rewritten by `clarify_macro_conflict`/`synthetic_owner_and_func` into a
plain-language message naming the function and the variable using it,
instead of synthetic-name/`@`-metavariable text. This also de-gibberishes
stdlib-module instantiation conflicts.

Checking is at instantiation granularity; checking a macro body once at
its definition (free metavars, attributing errors to the macro itself)
is left as a follow-up.
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.

engine: unit inference is all-or-nothing -- one conflict discards all inferred units for the model (parallels units.rs:263 TODO)

1 participant