Repository navigation
engine: handle array reducer polarity in ltm analyzer - #491
Conversation
1fc7708 to
f4ae3f1
Compare
[P2] MEAN polarity is order-dependent for variadic args
Overall correctnessIncorrect. The variadic MEAN polarity combining logic is order-dependent and can produce a confidently wrong polarity (Positive/Negative) where it should be Unknown. The Sum / Max / Min / Stddev / Rank arms and the unrelated |
Review[P2] MEAN variadic form: dependent-Unknown on an early arg is silently droppedIn Concrete trigger:
The single-argument array form ( Overall correctnessIncorrect — the variadic-MEAN polarity case is buggy as written; the rest of the patch (SUM, MAX/MIN array form, STDDEV/RANK, and the |
bpowers
left a comment
There was a problem hiding this comment.
Adversarial review
Polarity fix is on the right track for the headline cases in #480 (SUM, single-arg array MAX/MIN, STDDEV, RANK), but the variadic MEAN combiner has a real correctness asymmetry that I was able to reproduce. Other than that, this is clean: scope discipline good, prep commit is strictly better than #489's version, follow-up #492 is well-scoped, all verifications pass locally.
Verification (run locally)
cargo test -p simlin-engine --lib-> 3156 passed, 0 failedcargo test -p simlin-engine --features file_io --test simulate_ltm-> 48 passed, 0 failed, 1 ignoredcargo test -p simlin-serve --tests-> all greencargo clippy -p simlin-engine --all-targets --no-deps-> cleancargo fmt --all -- --check-> clean- New test
test_analyze_expr_polarity_array_reducersverified failing on unmodified production code (checked by reverting only the new match arms):assertion left == right failed: SUM(x) should pass through positive polarity / left: Unknown / right: Positive.
Important
1. MEAN multi-arg combiner is asymmetric with Add (ltm.rs:2084-2103).
The comment promises "we combine arg polarities the same way Add does (any disagreement collapses to Unknown)", but the implementation does not. When the FIRST iterated arg returns Unknown AND references from_var (e.g. ABS(x), SIN(x)), the (Unknown(combined), Unknown(arg_pol)) match takes the first arm and leaves combined = Unknown without ever checking expr_references_var on that arg. A later arg with known polarity then sets combined via the same first arm. Net effect: the prior arg's "Unknown but depends on from_var" is silently dropped.
Reproducer (I added this test temporarily and it fails):
let abs_x = Expr2::App(BuiltinFn::Abs(Box::new(x_var_expr)), None, Loc::default());
let mean = Expr2::App(BuiltinFn::Mean(vec![abs_x, x_var_expr.clone()]), None, Loc::default());
analyze_expr_polarity_with_context(&mean, &x_var, LinkPolarity::Positive, None);
// returns Positive; the equivalent `Add(ABS(x), x)` returns Unknown.Suggested fix (lifts the references_var check before the match so it covers the first arg too):
for arg in args {
let arg_pol = analyze_expr_polarity_with_context(arg, from_var, current_polarity, variables);
if arg_pol == LinkPolarity::Unknown && expr_references_var(arg, from_var) {
return LinkPolarity::Unknown;
}
match (combined, arg_pol) {
(LinkPolarity::Unknown, pol) => combined = pol,
(_, LinkPolarity::Unknown) => {} // prev branch already proved no from_var ref
(a, b) if a == b => {}
_ => return LinkPolarity::Unknown,
}
}For the single-arg MEAN(x[*]) shape that #480 actually points at, this code path is correct (one iteration, returns the arg polarity). But the variadic scalar MEAN(a, b, c) form is also Vec-shaped at the AST level and routes through this same loop, so the bug is reachable in practice.
2. The new test does not cover the multi-arg MEAN path.
Both mean_pos and mean_neg construct BuiltinFn::Mean(vec![single_inner]) -- the loop runs exactly once, so the asymmetry above is not exercised. Adding at least one MEAN(positive_arg, negative_arg) -> Unknown case (and ideally the MEAN(ABS(x), x) case) would have caught this. The TDD claim "each new case was confirmed failing on unmodified code before the fix landed" is true but only for cases the test set actually covers.
Minor
3. Commit message uses "Fixes #480".
Project guidance in CLAUDE.md says explicitly: DO NOT use "fixes"/"resolves" or emoji in commit messages. PR body has the same trailer. Easy fix on the next force-push: drop the trailer (PR body already has the gh-supported "Fixes #480" trailer for auto-close, so the commit message version is redundant anyway).
Praise
- Builtin variant arities match
builtins.rsexactly:Sum(Box<Expr>),Mean(Vec<Expr>),Max/Min(Box<Expr>, Option<Box<Expr>>),Stddev(Box<Expr>),Rank(Box<Expr>, Box<Expr>). No tuple-arity bugs. - Single-arg
Max(_, None)/Min(_, None)correctly distinguished from the existing scalar two-argMax(_, Some(_))/Min(_, Some(_))arm at line 2043. The two arms cannot collide. - Each non-MEAN reducer test covers BOTH positive and negative argument polarity, so a buggy "always Positive" fallthrough would have failed.
- STDDEV and RANK explicitly return Unknown; both arms exercised by tests.
- Test is colocated with other
analyze_expr_polarity_*tests, runs in <1ms. - Issue #492 is real, well-scoped to the GF strict-monotonicity epsilon (NOT the reducer cases, which are correctly fixed here), references #480 and #491, carries the
ltmlabel. - Scope discipline is excellent: only the polarity fix and the prep commit; no drive-by churn.
- No emoji, no
Co-Authored-By, no "Generated with" trailers.
Cross-branch coordination on the GIT_* prep commit
I diffed against the version of this commit on ltm/312-module-pathway-depth (PR #489, sha 323744d7):
- The shared files (
src/simlin-serve/src/git.rs,src/simlin-serve/tests/git_integration.rs) are byte-identical:git diff 323744d7 16c59b07 -- src/simlin-serve/src/git.rs src/simlin-serve/tests/git_integration.rsreturns empty. - This branch's version (16c59b0) additionally extends the env-stripping to
tests/watcher_git::must_git. That extension is genuinely needed --tests/watcher_git.rslines 129-137 dogit init,git config user.email/user.name,git add,git commiton aTempDir, which is exactly the path that breaks under the project pre-commit hook. - The commit message on this branch correctly mentions
tests/watcher_git::must_gitand pluralizes"workspace test failures". So the message and the diff agree, no yellow flag there.
Concrete recommendation: Land this PR's prep commit first (or extract it to a standalone PR), then rebase PR #489 to drop the duplicate. PR #489's version is a strict subset; landing it first would either force this branch to rebase (clean -- the shared diff is already identical) or to carry the watcher_git extension as a follow-up commit. The former is fine; the latter loses the bisect-friendliness of having one prep commit own the whole fix.
For reference: PRs #490 and #493 do NOT carry this prep commit (contrary to the review brief's expectation). Their diffs only touch engine sources, so they don't cross the simlin-serve/git.rs -> pre-commit-hook landmine. Once #491's prep commit lands on main, #489 rebases cleanly and #490/#493 are unaffected.
Verdict
Comment-only, changes-required (Important #1 should be fixed before merge -- the bug is small but real, the analyzer is supposed to be conservative and this branch slightly weakens that, and the fix is one extra line). Once the MEAN combiner is corrected and the test set is extended to cover the multi-arg path, this is ready.
bpowers
left a comment
There was a problem hiding this comment.
Review feedback addressed
Important (variadic MEAN): fixed in 46b3f73. Added the suggested test cases (MEAN(ABS(x), x) -> Unknown, MEAN(x, x) -> Positive, MEAN(x, -x) -> Unknown). Verified the reproducer test fails on the prior commit and passes here.
Minor ("Fixes #480" in commit message): noted. Per CLAUDE.md "create a new commit rather than amending" rule, the prior commit stands; the PR body's "Fixes #480" is the canonical auto-close.
PR #491 (`ltm/480-array-reducer-polarity`) extended the original GIT_* env-stripping fix to `tests/watcher_git::must_git`; that extension was missing here. Without it, `cargo test --workspace` running inside the outer pre-commit hook inherits `GIT_DIR` / `GIT_WORK_TREE` into the test's `git commit -q -m initial` call, which then operates on the outer repository instead of the freshly-`git init`'d temp dir and recursively triggers the outer pre-commit hook. This commit's diff is a verbatim copy of the watcher_git portion of PR #491's fix (commit 16c59b0 on the 480 branch); whichever PR lands first carries the canonical version and the other rebase can drop this hunk.
[P1] Inner
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #491 +/- ##
==========================================
+ Coverage 82.39% 82.43% +0.03%
==========================================
Files 243 243
Lines 61533 61861 +328
==========================================
+ Hits 50701 50993 +292
- Misses 10832 10868 +36 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
|
@codex review |
bpowers
left a comment
There was a problem hiding this comment.
Latest review feedback addressed
P1 (Subscript not handled): confirmed the bug -- new test exercising
Sum(Subscript(x, [Wildcard])) returned Unknown on the prior commit.
Fixed in 70fa369 by adding an Expr2::Subscript arm mirroring
the Var handler. Added unit tests covering the real parsed shape
(SUM(x[]) Positive/Negative pass-through, no-match Unknown,
MEAN/MAX/MIN(x[]) pass-through, STDDEV/RANK(x[*]) Unknown). Also
extended test_cross_dim_sum_algebraic with a link-polarity assertion
(population -> total_pop must be Positive) and a detected-loop check
(no loop should remain Undetermined); both fail on the prior commit
and pass here.
The two earlier P2 comments about variadic MEAN order-dependence
were already addressed in 46b3f73.
PR is now marked ready for review.
|
Reviewed the changes. Findings: No bugs found. Overall correctness: correct. Notes from the review:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 70fa3696a6
ℹ️ 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".
| for (key, _) in std::env::vars() { | ||
| if key.starts_with("GIT_") { | ||
| cmd.env_remove(&key); |
There was a problem hiding this comment.
Use vars_os to avoid panic when clearing GIT_ env*
The new sanitization loop iterates std::env::vars(), which panics if any inherited environment key or value is non-Unicode; on Unix this is possible and would make git probing fail before git is even spawned. This is a regression from the previous implementation (which did not enumerate env vars) and can break simlin-serve in real shells/launchers that inject non-UTF8 env entries. Use std::env::vars_os() (or Command::env_clear + selective re-add) so the probe remains robust.
Useful? React with 👍 / 👎.
Plans the tiered enumeration that runs Johnson on the variable graph first, classifies each cycle by RefShape composition, and emits pure-A2A and pure-scalar cycles as Loops directly without entering the element-level enumerator. Only mixed and cross-element cycles flow through element-level Johnson, and only on the subgraph induced by their nodes -- not the full element graph. Cost becomes additive in N for pure-A2A loops where today it is multiplicative; the auto-flip gate's domain narrows to the cross-element / mixed slice. The full assessment, fixture predictions, and stage breakdown are in the design plan.
Adds `model_edge_shapes`, a salsa-tracked function that classifies every
variable-level edge `(from, to)` by the set of `RefShape` values seen at
any AST reference site of `from` in `to`'s equation. The result is the
input the upcoming tiered loop enumerator needs to decide whether each
variable-level cycle is pure-A2A (every edge has only Bare references),
pure-scalar, or cross-element / mixed (any Wildcard, DynamicIndex, or
FixedIndex shape).
Structural flow->stock edges have no AST reference, so they map to
`{Bare}` directly -- semantically a same-element diagonal, matching
the classification `model_element_causal_edges` already uses for the
same case. Module input edges and unable-to-reconstruct fallbacks
likewise map to `{Bare}` (modules are scalar nodes; the fallback is
defensive).
`RefShape` gains a derived `Ord` so the result type can use
`BTreeSet<RefShape>` for deterministic iteration. The walker reuses
the existing `collect_reference_shapes` helper; the per-target AST
parse results are already cached by salsa, so the second walk that
`model_element_causal_edges` performs is cheap.
This is Stage 1 of the variable-level loop enumeration plan
(docs/design-plans/2026-05-06-ltm-482-variable-level-loop-enumeration.md);
behavior on existing fixtures is unchanged because the new function
is not yet consumed by any downstream code path.
Adds `model_loop_circuits_tiered`, the salsa-tracked tiered loop enumerator that runs Johnson on the variable graph first and only descends to element-level Johnson for cycles that actually need it. The classifier (`classify_cycle`) labels each variable-level cycle as PureScalar, PureSameElementA2A, or CrossElementOrMixed by reading the per-edge RefShape map from `model_edge_shapes`. Pure-scalar and pure-A2A cycles materialize directly into a single Loop without entering element-level enumeration; only mixed/cross-element cycles flow through Johnson on the slow-path subgraph induced by their nodes. The slow-path subgraph is a strict subset of the full element graph on any model where pure cycles exist. For purely scalar models (e.g. WRLD3) and purely A2A models (e.g. arrayed_population_ltm), the slow-path subgraph is empty and zero element-level circuits are enumerated. The cost asymmetry that made pure-A2A cycles inflate multiplicatively in N is gone. The new function is salsa-tracked separately from `model_element_loop_circuits`; the latter is unchanged because no caller has been wired to the tiered representation yet. Stage 4 will update `build_element_level_loops` in db_ltm to consume the tiered result and emit Loops directly for fast-path cycles.
Wires `model_ltm_variables` to consume `model_loop_circuits_tiered` via a new `build_loops_from_tiered` helper. Pure-A2A and pure-scalar cycles are now emitted directly as Loops without paying for element-level Johnson; only cross-element / mixed cycles flow through the legacy element-grouping logic on the slow-path subgraph. The legacy `build_element_level_loops` helper is preserved and is still called by `build_loops_from_tiered` to handle slow-path element circuits. The merged Loop list (fast-path + slow-path) gets one final `assign_loop_ids` pass so loop ID numbering is consistent across both paths. Existing fixtures continue to pass: `assign_loop_ids` sorts loops by content-derived key (sorted distinct variable names) before numbering, so r1/b1/u1 IDs are stable regardless of which path produced each Loop. The 48 `tests/simulate_ltm.rs` integration tests pass without modification. Stage 4 of `docs/design-plans/2026-05-06-ltm-482-variable-level-loop-enumeration.md`.
… graph
Restructures the LTM auto-flip gate from a single check on the full
element-level graph SCC into two phased checks:
1. Variable-level SCC (cheap Tarjan on the variable graph). If
this exceeds MAX_LTM_SCC_NODES, auto-flip immediately -- no
variable-level Johnson runs.
2. Slow-path subgraph SCC (computed inside
model_loop_circuits_tiered). When the cross-element / mixed
subgraph's SCC exceeds the threshold, the tiered enumerator
skips Johnson on that subgraph and emits an empty slow_path;
the caller flips to discovery mode and emits a diagnostic.
After the per-reference element graph refactor (#448), pure-A2A
models already have N small element-level SCCs (one per element),
so the legacy gate didn't actually trip on them. The structural
benefit of the new gate is that gate cost is now proportional to
the variable graph and slow-path subgraph rather than the full
N-times-inflated element graph, and TieredCircuitsResult exposes
slow_path_largest_scc so the gate logic is local and explicit.
The headline performance win for #482 is the elimination of
redundant per-element circuit enumeration on pure-A2A loops:
`tests::pure_a2a_eliminates_per_element_circuit_redundancy`
asserts that the legacy element-level Johnson enumerates 30
circuits on a 30-element pure-A2A model while the tiered
enumerator emits one fast-path cycle and zero slow-path Johnson.
FastPathCircuit.dimensions stores canonical dimension names as
Vec<String> rather than Vec<Dimension> so the salsa-tracked result
type's Debug derive doesn't depend on the engine's debug-derive
feature (off by default in libsimlin's simlin-engine dependency).
Stages 5 and 6 of the design plan.
Same fix as commit e534590 (`serve: clear inherited GIT_* env in git probe and tests`) extended to the watcher_git integration test suite. The `must_git` helper there was not previously updated and inherits the parent's GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE when running under the project pre-commit hook (`cargo test --workspace`), which makes the inner git operate on the outer repository instead of the freshly-`git init`'d temp dir.
Stage 7 of the variable-level loop enumeration work: documentation updates and measurement capture. - design-plan postscript records var/element SCC, var-level circuit count, legacy element-level circuit count, fast-path count, slow-path count, and slow-path SCC for the cross_element_ltm and arrayed_population_ltm fixtures. - engine CLAUDE.md describes `model_edge_shapes`, `model_loop_circuits_tiered`, `classify_cycle`, `TieredCircuitsResult`, and the dual-gate auto-flip strategy. - LTM design doc rewrites the loop-score section to describe the PureScalar / PureSameElementA2A / CrossElementOrMixed classification and the slow-path subgraph that drives element-level Johnson on the remaining mixed cycles. The two-tier auto-flip strategy (variable-level early gate, slow-path subgraph late gate) is documented in the divergence-from-papers section. - tech-debt #25 marked RESOLVED with pointers to the design plan and the resolving stages of this PR. - new `measurement_postscript_*` integration tests in simulate_ltm.rs pin the post-refactor numbers so future regressions surface as test failures.
Stage 7 follow-up: the exhaustive-mode flow in docs/design/ltm--loops-that-matter.md still described `model_loop_circuits` directly enumerating elementary circuits. After the tiered refactor, `model_loop_circuits_tiered` is the primary entry point used by `model_ltm_variables`; the design doc now describes the fast / slow path partition and how each is materialized into Loops.
The slow-path subgraph in model_loop_circuits_tiered is induced on every variable that appears in any CrossElementOrMixed cycle. That is intentionally broad -- a variable participating in even one cross-element cycle must keep its element nodes in the subgraph -- but when a pure-A2A cycle shares variables with a cross-element cycle (e.g., "a -> grow -> stock -> b -> a" co-existing with "a -> grow -> stock -> b -> c -> a" where c[r] = b[NYC] + ...), the pure-A2A cycle's nodes get pulled into the subgraph too. Johnson then re-finds the per-element reflections of that pure-A2A cycle, which build_element_level_loops re-collapses into an A2A Loop, duplicating the Loop the fast path already emitted. Filter slow-path circuits whose stripped variable-level node sequence matches a fast-path circuit's node sequence (rotation-invariant via canonical_cycle_rotation). This drops only the per-element reflections; genuine cross-element cycles (which traverse extra variables and therefore have a different stripped sequence) survive the filter. Add a permanent regression fixture in tests/simulate_ltm.rs that constructs the reviewer's reproducer (a/grow/stock/b/c with FixedIndex through b[NYC]) and asserts exactly 2 distinct loops are emitted. The unfixed branch produces 3 (r1, r2, u1).
…string The post-#482 LTM pipeline routes through model_loop_circuits_tiered; model_element_loop_circuits is retained only for diagnostic / measurement-postscript tests and external consumers that want the unfiltered element-level circuit list. Mark it #[deprecated] so new LTM callers get an automatic warning if they wire it up by accident, while keeping it callable for the existing measurement helpers in tests/simulate_ltm.rs, the internal unit tests in db_element_graph_tests and db_ltm_unified_tests, and the ltm_full_bench example. Each remaining caller carries an #[allow(deprecated)] with a comment explaining why it intentionally targets the legacy surface. The salsa::tracked macro expansion references the function by name in its generated code, so the inner #[allow(deprecated)] is required on the function definition itself; an outer-only deprecation lint would fire inside the macro expansion and there is no #[allow] we can apply inside the salsa-generated code. Update MAX_LTM_SCC_NODES's docstring to describe the post-#482 two-granularity gate: variable-level SCC (cheap pre-Johnson) and slow-path subgraph SCC (computed inside the tiered enumerator). Pre-#482 the docstring described a single element-graph SCC gate, which no longer exists.
Tighten three loose ends from the PR #496 review of the variable-level loop enumeration refactor: - build_loops_from_tiered's canonical-name-to-datamodel-name mapping silently fell back to the canonical (lower-cased) string when a fast- path A2A cycle's dimension name didn't appear in dm_dims. Replace with unreachable! that lists the known dimensions, since the only way the fall-back fires is an internal dim-cache or synthesizer mismatch -- a bug we want surfaced at the cause site rather than as a parse error on the downstream loop-score equation. - Fix measurement_postscript_cross_element_ltm's docstring: the post- #482 measurement is var_scc=5 / elem_scc=10, not 4/8. The births flow closes the population<->births A2A pair so it sits in the same SCC as the migration variables. - Mark tech-debt #25 as 'RESOLVED (pending #496 merge)' so the entry re-flips to RESOLVED automatically when the PR lands and doesn't read as resolved on main while the PR is still in review.
The previous serve: clear inherited GIT_* env... commit stripped every env var beginning with GIT_, including author/committer identity (GIT_AUTHOR_*/GIT_COMMITTER_*), transport (GIT_SSH_COMMAND), credential helpers (GIT_ASKPASS), and interaction flags (GIT_TERMINAL_PROMPT). The original motivation only requires neutralizing the path-targeting vars (GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE, GIT_OBJECT_DIRECTORY, GIT_COMMON_DIR) that override the per-call current_dir(cwd). Restrict the strip to that set and update the explanatory comment so future code that uses run_git to commit, push, or pull still inherits author/transport/credential config from the calling environment. Promote the constant + helper (strip_git_path_env / GIT_PATH_TARGETING_VARS) to a single source of truth in simlin_serve::git and have both integration tests (tests/git_integration.rs, tests/watcher_git.rs) plus the in-crate tests::must_git helper call it, so the rule cannot drift between sites.
build_loops_from_tiered previously hit unreachable! when a canonical dimension name didn't appear in the datamodel's dimension list, panicking the whole LTM pipeline. The legacy build_element_level_loops path falls back to the canonical name as a recoverable warning -- mirror that behavior so incremental or partially-invalid model states surface as analysis warnings rather than hard crashes.
Add MostlyReinforcing and MostlyBalancing (Rux/Bux) loop polarity
variants and a polarity_confidence f64 (range 0..=1) on
DetectedLoop. from_runtime_scores no longer collapses any sign
change to Undetermined; loops with one dominant polarity above the
configured threshold are classified mostly-R/B with their
confidence ratio.
Threshold POLARITY_CONFIDENCE_THRESHOLD = 0.99 follows
Schoenberg & Eberlein (2020) "Seamlessly Integrating LTM into the
Modeling Process" (section 13.7 of docs/reference/ltm--loops-that-matter.md
and section 7.6 of the thesis summary), which uses the same
|r - |b|| / (r + |b|) ratio to distinguish predominantly-R/B
loops from genuinely mixed ones in simplified CLDs. The papers
describe the cutoff verbally as "above 0.99" while section 13.7's
formula is inclusive; this implementation uses confidence >=
threshold to match the formula and avoid surfacing high-dominance
loops as Undetermined. Filtering on !is_finite() rather than just
is_nan() keeps Inf/-Inf from poisoning the denominator and
producing NaN confidence (which clamp would not repair).
Existing matches on LoopPolarity / DetectedLoopPolarity were made
exhaustive: src/simlin-engine/src/{analysis.rs,db_analysis.rs,
layout/mod.rs,ltm/{graph,types,tests}.rs,ltm_finding.rs},
src/libsimlin/src/analysis.rs (C ABI keeps R/B/U; mostly-R/B
coalesce to their dominant cousins since the FFI struct has no
confidence field today), and the exhaustive test match in
tests/simulate_ltm.rs.
The Python LoopPolarity.from_runtime_scores reimplementation was still using the pre-#485 semantics (collapse any sign change to UNDETERMINED), so Rust and Python silently disagreed on Rux/Bux loops once the engine landed the new variants. Bring the Python port up to the same algorithm: compute the polarity-confidence ratio |r - |b|| / (r + |b|) and classify mostly-* when one polarity dominates above the POLARITY_CONFIDENCE_THRESHOLD = 0.99 cutoff that mirrors the Rust constant. The C FFI struct still has no confidence field, so structural loops delivered through the FFI continue to surface as REINFORCING/BALANCING/ UNDETERMINED (values 0-2). The new MOSTLY_REINFORCING and MOSTLY_BALANCING variants take values 3 and 4 and only arise from the Python-only from_runtime_scores path; downstream consumers like Run._calculate_dominant_periods bucket them with their dominant pure cousin so existing dominance heuristics keep working.
The MCP instructions and the loop-dominance skill markdown still claimed the JSON polarity field is exactly one of three values, but to_loop_summary in simlin-engine emits five strings now (reinforcing, balancing, undetermined, plus mostly_reinforcing / mostly_balancing). Enumerate all five in instructions.md and the skill, and explain when the mostly_* variants are produced (Rux/Bux: dominant polarity with >=0.99 confidence per Schoenberg & Eberlein 2020) so AI agents reading either resource don't treat the new strings as garbage. Note that Model.loops (structural-only) still reports just the legacy three because the MOSTLY_* variants require simulated runtime scores; the skill text spells this out so callers know which surface they're on.
PR #490 implements the Eberlein/Schoenberg polarity-confidence ratio (`|r - |b|| / (r + |b|)`) and the new MostlyReinforcing/MostlyBalancing variants on DetectedLoop. The tech-debt entry was last reviewed before the implementation landed; refresh the description to point at the in-flight branch and bump the review date so the entry will be marked RESOLVED with the merge SHA once the PR lands.
The polarity analyzer treated SUM, MEAN, single-arg MAX/MIN, STDDEV, and RANK applications as Unknown via the catch-all App arm. Each of these reducers is monotone in every input element (or, for STDDEV/RANK, explicitly non-monotone in a way the user expects to surface as Unknown), so propagate inner polarity for the monotone reducers and return Unknown explicitly for STDDEV / RANK so a future reducer addition cannot accidentally pick up the wrong default. Add an Expr2::Subscript arm that mirrors Expr2::Var so the production case SUM(x[*]) -- which lowers to Sum(Subscript(x, [Wildcard], _, _)), not Sum(Var(x, ...)) -- actually exercises the new reducer arms. Without this, the reducer arms only fire on synthetic Sum(Var) shapes that never appear in real models. Hoist the self-reference + Unknown short circuit ahead of the per-arg combiner in the variadic MEAN path so MEAN(ABS(x), x) collapses to Unknown regardless of arg order, mirroring how BinaryOp::Add handles the same case. Without the hoist, a leading ABS(x) seeded `combined` with Unknown and then any later known-polarity arg silently overwrote it. Remove the trailing `_ => LinkPolarity::Unknown` catch-all: every Expr2 variant is now matched explicitly, so a future variant addition becomes a compile error rather than a silent miscategorization.
Three new unit tests in src/simlin-engine/src/ltm/tests.rs: - test_analyze_expr_polarity_array_reducers exercises each new reducer arm (SUM, MEAN, single-arg MAX/MIN, STDDEV, RANK) on a plain Expr2::Var argument so the reducer logic is verified independently of the Subscript handling. - test_analyze_expr_polarity_array_reducers_subscript_wildcard drives the production-shape Sum(Subscript(x, [Wildcard], _, _)) and the parallel MEAN/MAX/MIN/STDDEV/RANK shapes through the analyzer. This is the headline case for issue #480: without an Expr2::Subscript arm the new reducer arms produce Unknown for any real SUM(x[*]) call. - test_variadic_mean_polarity_with_non_monotone_arg pins the variadic MEAN(ABS(x), x) ordering bug. The hoisted self-reference + Unknown short circuit must collapse the result to Unknown regardless of arg order; this test covers ABS first, x first, both-x agree, and x/-x disagreement.
test_cross_dim_sum_algebraic now goes beyond the per-element link score sanity checks and asserts the structural polarity of the population -> total_pop edge. SUM(population[*]) is monotone-positive in every population element, so compute_link_polarities must surface Positive there; the resulting reinforcing loop (population -> growth -> population, with total_pop as a downstream consumer) must classify as Reinforcing rather than Undetermined. This is the headline integration check for issue #480: without the Expr2::Subscript arm in the polarity analyzer the parsed Sum(Subscript(...)) shape would fall through to Unknown despite the new reducer arm, and a model whose link is structurally monotone would still be classified as Undetermined.
70fa369 to
78a1233
Compare
bpowers
left a comment
There was a problem hiding this comment.
Rebased onto PR #490 (stacked PR)
This PR is now stacked on top of #490 (ltm/485-polarity-confidence). When #490 merges, GitHub auto-updates this PR's base.
- The polarity analyzer arms for SUM, MEAN, array MAX, array MIN, STDDEV, RANK now live in
src/simlin-engine/src/ltm/polarity.rs(post-#498 module split). - The variadic MEAN fix (hoisted
expr_references_varcheck) is preserved. - The
Expr2::Subscripthandler that finally delivered the headlineSUM(x[*])case is preserved with its dedicated tests. - The catch-all
_ => Unknownarm removal is preserved (allExpr2variants now matched). - Unit tests split between
ltm/polarity.rs's tests sub-mod andltm/tests.rsper the new layout. tests/simulate_ltm.rs::test_cross_dim_sum_algebraicpolarity assertions preserved.- Older GIT_* prep commits dropped; supersession chain is
#496 narrowed allowlist > #489 vars_os > older vars().
mergeable: MERGEABLE on top of base ltm/485-polarity-confidence.
ReviewI reviewed the polarity analyzer changes ( FindingsNo blocking issues found. A few observations worth noting (not blocking):
Overall correctness verdictCorrect. The patch is a focused, well-tested fix for an understated polarity case. The reducer arms are mathematically sound, the |
## Summary The `Expr2::Subscript` arm in `analyze_expr_polarity_with_context` (added in #491) returned `current_polarity` whenever the array's identifier matched `from_var`, without inspecting the index expressions. For self-indexing patterns like `arr[INT(arr[i])]`, the relationship between `arr` and the subscripted value is non-monotone -- changing `arr` shifts both the lookup target AND the index. The arm now walks index expressions via `expr_references_var` and returns `Unknown` when any index references `from_var`. The dominant cases (`SUM(arr[*])`, `arr[Region]`, indices that reference other variables) are unaffected -- those indices do not reference `from_var`, so the existing behavior of returning `current_polarity` is preserved. ## Why now Surfaced by a code review on #491 (the array reducer polarity PR) after that PR merged. The reviewer flagged it as non-blocking but worth generalizing because the arm's docstring claimed the case was fully handled. This PR follows up to make the docstring's claim true. ## Test plan - [x] New unit tests covering: literal index, wildcard, var-not-from_var, var-from_var (NEW), nested self-indexing (NEW), other-array. - [x] Existing polarity / cross-dim tests pass unchanged. - [x] cargo clippy --workspace --all-targets -- -D warnings clean - [x] cargo fmt --check clean Co-authored-by: test <test@example.com>
Summary
Why
Variables computed via SUM(x[]) or MEAN(x[]) now contribute meaningful polarity, so loops crossing them are no longer misclassified Undetermined when the polarity is in fact recoverable.
Tests
Note on the second commit
The first commit on this branch (
serve: clear inherited GIT_* env in git probe and tests) is a prerequisite that fixes pre-existing test isolation bugs insimlin-serve's git tests. Those testsgit inita tempdir and rungit commit, but inherit the outerGIT_DIR/GIT_INDEX_FILEenv vars set by the project's pre-commit hook, causing them to operate on the outer repo and recursively trigger the outer hook. Without this fix the pre-commit hook fails on every commit attempt that touches anything else. The fix is a port of the same change living unmerged onltm/308-canonical-cycle-dedup, extended here to also covertests/watcher_git::must_git(which was missed in the original).Fixes #480