Skip to content

engine: handle array reducer polarity in ltm analyzer - #491

Merged
bpowers merged 20 commits into
mainfrom
ltm/480-array-reducer-polarity
May 8, 2026
Merged

bpowers merged 20 commits into
mainfrom
ltm/480-array-reducer-polarity

Conversation

@bpowers

@bpowers bpowers commented May 7, 2026

Copy link
Copy Markdown
Owner

Summary

  • Add explicit polarity cases for SUM, MEAN, array MAX, array MIN (pass-through of arg polarity).
  • STDDEV and RANK explicitly return Unknown (non-monotone).
  • Previously all array reducers fell through Expr2::App(_, _, _) and contributed Unknown.

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

  • New unit tests in src/simlin-engine/src/ltm.rs exercising each reducer case.
  • Existing simulate_ltm tests pass unchanged.

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 in simlin-serve's git tests. Those tests git init a tempdir and run git commit, but inherit the outer GIT_DIR / GIT_INDEX_FILE env 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 on ltm/308-canonical-cycle-dedup, extended here to also cover tests/watcher_git::must_git (which was missed in the original).

Fixes #480

@bpowers
bpowers force-pushed the ltm/480-array-reducer-polarity branch from 1fc7708 to f4ae3f1 Compare May 7, 2026 05:22
@claude

claude Bot commented May 7, 2026

Copy link
Copy Markdown

[P2] MEAN polarity is order-dependent for variadic args

src/simlin-engine/src/ltm.rs lines 2089–2099: the comment claims the variadic MEAN form combines arg polarities "the same way Add does", but the first match arm (LinkPolarity::Unknown, pol) => combined = pol does not check whether the prior Unknown polarity came from an arg that references from_var. As a result, when an Unknown-polarity, from_var-dependent arg appears before a known-polarity arg, the dependency is silently overwritten. For example, MEAN(ABS(x), x) returns Positive, while MEAN(x, ABS(x)) correctly returns Unknown — same expression, different result depending on argument order. Add at lines 2123–2130 handles this correctly by guarding the first arm with expr_references_var(left, from_var); the MEAN loop needs the equivalent guard (something like: if arg_pol == Unknown and expr_references_var(arg, from_var), return Unknown immediately). The bug is narrow in practice because variadic scalar MEAN(a, b, c) is uncommon in SD models, and the new tests only exercise the single-arg array form, so the case is not covered.

Overall correctness

Incorrect. 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 simlin-serve GIT_* env-stripping change look fine.

@claude

claude Bot commented May 7, 2026

Copy link
Copy Markdown

Review

[P2] MEAN variadic form: dependent-Unknown on an early arg is silently dropped

In src/simlin-engine/src/ltm.rs:2084-2102, the per-arg state machine doesn't actually mirror Add's "any disagreement collapses to Unknown" semantics that the comment claims. When the first iteration's arg_pol == Unknown, it always takes the (LinkPolarity::Unknown, pol) arm and leaves combined = Unknown without ever checking whether the offending arg references from_var. A subsequent arg with a known polarity then overwrites combined, so a non-monotone dependency on from_var is silently lost.

Concrete trigger: MEAN(ABS(x), x) (variadic scalar form) with current_polarity = Positive.

  1. iter 1, arg = ABS(x) → arg_pol = Unknown; (Unknown, Unknown) matches the first arm, combined = Unknown. The fact that ABS(x) references x is never checked.
  2. iter 2, arg = x → arg_pol = Positive; (Unknown, Positive) matches the first arm, combined = Positive.
  3. Returns Positive, but the true result is Unknown since ABS(x) is non-monotone in x.

The single-argument array form (MEAN(x[*])) is fine because only one iteration runs; the bug only manifests in the variadic form, which the comment specifically calls out as covered. A simple fix is to short-circuit on any Unknown that references from_var before updating combined (matching what BinaryOp::Add does on each operand independently). The new test only exercises array-form MEAN, so the variadic case is also untested.

Overall correctness

Incorrect — the variadic-MEAN polarity case is buggy as written; the rest of the patch (SUM, MAX/MIN array form, STDDEV/RANK, and the simlin-serve GIT_* env-stripping) looks correct.

@bpowers bpowers left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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 failed
  • cargo test -p simlin-engine --features file_io --test simulate_ltm -> 48 passed, 0 failed, 1 ignored
  • cargo test -p simlin-serve --tests -> all green
  • cargo clippy -p simlin-engine --all-targets --no-deps -> clean
  • cargo fmt --all -- --check -> clean
  • New test test_analyze_expr_polarity_array_reducers verified 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.rs exactly: 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-arg Max(_, 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 ltm label.
  • 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.rs returns 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.rs lines 129-137 do git init, git config user.email/user.name, git add, git commit on a TempDir, 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_git and 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 bpowers left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

bpowers pushed a commit that referenced this pull request May 7, 2026
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.
@claude

claude Bot commented May 7, 2026

Copy link
Copy Markdown

[P1] Inner Expr2::Subscript still returns Unknown, so SUM(x[*]) / MEAN(x[*]) polarity isn't actually recovered

File: src/simlin-engine/src/ltm.rs:2081-2118

The new arms for Sum, Mean, and array Max/Min recurse into their argument via analyze_expr_polarity_with_context, but that function has no match arm for Expr2::Subscript and falls through to _ => LinkPolarity::Unknown (line 2280). The production case this PR targets — SUM(x[*]) / MEAN(x[*]) — parses to Sum(Box::new(Subscript(x, [Wildcard], ...))) (see src/simlin-engine/src/parser/tests.rs:118-126 and the Expr1::Subscript lowering at src/simlin-engine/src/ast/expr2.rs:832-894, which preserves the Subscript variant), so the inner recursion immediately returns Unknown and the x → total_pop link is still misclassified Undetermined. The new unit tests pass only because they wrap a plain Expr2::Var(x_var) rather than a Subscript; the existing tests/simulate_ltm.rs::test_cross_dim_sum_algebraic covers SUM(population[*]) but doesn't assert on the resulting polarity, so this gap isn't caught. To deliver the benefit promised in the PR description, analyze_expr_polarity_with_context also needs an Expr2::Subscript(ident, _, _, _) arm that returns current_polarity when ident (or its module-normalized form) matches from_var.


Overall correctness verdict: not correct as written — the patch is internally consistent and its tests pass, but the stated user-visible improvement (fixing #480 for SUM(x[*]) / MEAN(x[*])) doesn't materialize because the inner Subscript reference still resolves to Unknown. The simlin-serve git env-stripping commit looks fine.

@codecov

codecov Bot commented May 7, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.21739% with 36 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.43%. Comparing base (957b20f) to head (78a1233).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
src/simlin-engine/tests/simulate_ltm.rs 84.09% 14 Missing ⚠️
src/simlin-engine/src/db_analysis.rs 94.01% 10 Missing ⚠️
src/simlin-engine/src/db_ltm.rs 79.59% 10 Missing ⚠️
src/simlin-engine/src/analysis.rs 50.00% 2 Missing ⚠️
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.
📢 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.

@bpowers
bpowers marked this pull request as ready for review May 7, 2026 14:21
@bpowers

bpowers commented May 7, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@bpowers bpowers left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

@claude

claude Bot commented May 7, 2026

Copy link
Copy Markdown

Reviewed the changes. Findings:

No bugs found.

Overall correctness: correct.

Notes from the review:

  • The new Expr2::Subscript arm correctly mirrors the Var handler — only the array identifier is compared to from_var, and the comment accurately notes that reducer monotonicity makes the indices irrelevant for the reducer paths. Cases where from_var appears only in the indices (e.g. y[x]) still collapse to Unknown via the downstream expr_references_var checks in Add/Sub/Div/Mean, which do walk index expressions.
  • The variadic Mean combiner correctly hoists the "Unknown + references from_var" short-circuit ahead of the per-arg combiner. Without that hoist, a first-arg ABS(x) would seed combined = Unknown and then be silently overwritten by a later known-polarity arg, producing a wrong Positive — test_variadic_mean_polarity_with_non_monotone_arg exercises both orderings.
  • Removing the wildcard _ => LinkPolarity::Unknown arm makes the match exhaustive on Expr2. This is an improvement: future variants will now produce a compile error instead of silently being treated as Unknown.
  • STDDEV/RANK explicit-Unknown arms are necessary (they sit before the Expr2::App(_, _, _) => Unknown fallthrough but are kept for clarity and to document the non-monotonicity reason).
  • The GIT_* env-stripping in simlin-serve is a clean fix; tests configure user.name/user.email locally via git config, so stripping inherited GIT_AUTHOR_*/GIT_COMMITTER_* is safe.

@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: 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".

Comment thread src/simlin-serve/src/git.rs Outdated
Comment on lines +266 to +268
for (key, _) in std::env::vars() {
if key.starts_with("GIT_") {
cmd.env_remove(&key);

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 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 👍 / 👎.

bpowers and others added 12 commits May 8, 2026 07:44
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.
test and others added 8 commits May 8, 2026 07:48
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.
@bpowers
bpowers force-pushed the ltm/480-array-reducer-polarity branch from 70fa369 to 78a1233 Compare May 8, 2026 15:32
@bpowers
bpowers changed the base branch from main to ltm/485-polarity-confidence May 8, 2026 15:32

@bpowers bpowers left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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_var check) is preserved.
  • The Expr2::Subscript handler that finally delivered the headline SUM(x[*]) case is preserved with its dedicated tests.
  • The catch-all _ => Unknown arm removal is preserved (all Expr2 variants now matched).
  • Unit tests split between ltm/polarity.rs's tests sub-mod and ltm/tests.rs per the new layout.
  • tests/simulate_ltm.rs::test_cross_dim_sum_algebraic polarity 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.

@claude

claude Bot commented May 8, 2026

Copy link
Copy Markdown

Review

I reviewed the polarity analyzer changes (src/simlin-engine/src/ltm/polarity.rs) and tests (src/simlin-engine/src/ltm/tests.rs, src/simlin-engine/tests/simulate_ltm.rs).

Findings

No blocking issues found.

A few observations worth noting (not blocking):

  • The new Expr2::Subscript arm doesn't recurse into the index expressions. For the dominant production case Subscript(from_var, [Wildcard]), this is correct, and for Subscript(other_var, [expr_referencing_from_var]) the surrounding combiners (Add/Sub/Div/Mul/Max/Min/Mean) correctly use expr_references_var to detect the dependency. There is one narrow edge case the arm could mis-classify: Subscript(from_var, [expr_referencing_from_var]) (e.g. arr[INT(arr[i])]), where the arm returns current_polarity while the index expression makes the relationship genuinely non-monotone. This is an uncommon pattern in SD models and not a regression; flagging only because the arm's docstring claims this case is fully handled.

  • The variadic Mean hoist correctly mirrors Add's self-reference handling. The match arm ordering ((_, Unknown) first) means an Unknown that doesn't reference from_var is silently skipped after the hoist guard, which matches the documented behavior.

  • The Stddev/Rank arms returning Unknown are independently correct (variance has no fixed sign, rank depends on the rest of the array). Removing the trailing _ => Unknown catch-all is safe: Expr2's 7 variants are all matched explicitly, so future variant additions become a compile error rather than silent miscategorization, as the commit message intends.

  • Test coverage is thorough: synthetic shape (Sum(Var(x))), production shape (Sum(Subscript(x, [Wildcard]))), variadic Mean order-independence with ABS(x), and an integration assertion in test_cross_dim_sum_algebraic that exercises compute_link_polarities end-to-end.

Overall correctness verdict

Correct. The patch is a focused, well-tested fix for an understated polarity case. The reducer arms are mathematically sound, the Expr2::Subscript arm is necessary for the patch to actually fire on real models, and the existing simulate_ltm tests continue to pass.

Base automatically changed from ltm/485-polarity-confidence to main May 8, 2026 16:08
@bpowers
bpowers merged commit 6a8528c into main May 8, 2026
14 checks passed
@bpowers
bpowers deleted the ltm/480-array-reducer-polarity branch May 8, 2026 16:09
bpowers added a commit that referenced this pull request May 8, 2026
## 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>
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.

ltm: polarity analysis returns Unknown for all array reducers

1 participant