Skip to content

engine: extend positive-value convention to Mul co-factors - #1008

Merged
bpowers merged 1 commit into
mainfrom
mul-cofactor-convention
Aug 2, 2026
Merged

bpowers merged 1 commit into
mainfrom
mul-cofactor-convention

Conversation

@bpowers

@bpowers bpowers commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Why

The pysimlin README quickstart -- logistic growth, the flagship example -- printed its compounding loop as u1 (R) and needed a paragraph explaining why structural analysis could not sign population -> net_growth. The gap was an asymmetry inside the polarity analyzer, not a fundamental limit: the SD positive-value labeling convention (named quantities are conventionally positive-valued) was already applied by the Div one-side arm (share = pop / total reads as total -> share Negative on every SD diagram), by both both-sides-dependent arms, and by the agg feeder-hop analysis behind a mul_convention flag -- but not by the general Mul one-side arm, which is exactly where net_growth = population * fractional_growth lands.

What

  • The feeder-hop rule becomes the general rule and the mul_convention flag is deleted: a bare named co-factor independent of the link source is positive by convention; a provable co-factor sign (a literal, or a variable whose whole equation is one) is used exactly and can flip; a compound co-factor (1 - pop/K, the logistic class whose value sign genuinely flips mid-run) stays Unknown. The single-equation logistic and non-monotone graphical functions remain Undetermined -- pinned by new tests.
  • The provable-sign predicates are rebuilt on literal_sign / provable_value_sign, which see through unary negation. This closes a shipped mislabel: the lexer takes no leading sign, so k = -5 parses as Op1(Negative, Const(5)) and the old Const-only predicates were blind to every negative constant a user can actually write -- x / k was labeled Positive when it is provably Negative (now pinned in both Div directions).
  • The convention is a labeling decision, not a proof, and runtime loop-score reclassification remains the ground truth: the GH ltm: exhaustive-mode loops never get runtime polarity reclassification (Rux/Bux unreachable) #679 Rux fixtures now demonstrate this directly (their bare co-factor g flips sign mid-run; the structural label is the conventional Reinforcing and the runtime pass downgrades it to MostlyReinforcing).
  • Loop ids derived from newly-signed links churn u{n} -> r{n}/b{n} (logistic growth, fishbanks harvest). Amusingly, the checked-in logistic LTM reference TSV's original export labeled that row R1; it had been renamed to u1 to match our engine, and now matches the source again.
  • pysimlin README: quickstart now prints r1 (R), and the loop-id paragraph explains structural ids vs runtime-classified polarity (including when they can legitimately disagree) instead of apologizing for u1.

Non-obvious decisions

  • The Div convention stays deliberately BROADER than Mul's (it applies to compound independent operands too), because the divisor-cannot-cross-zero argument covers the whole quotient, while a compound Mul co-factor crossing zero is routine. Documented at the Div arm.
  • is_positive_variable/is_negative_variable/is_positive_constant/is_negative_constant had no production callers after the rework and were deleted rather than kept as wrappers; their tests moved to the new helpers.

🤖 Generated with Claude Code

https://claude.ai/code/session_015KzVZykXRTnehv6UY6WZrJ

The static polarity analyzer already applied the SD positive-value
labeling convention in three places -- the Div one-side arm (share =
pop / total labels total -> share Negative without proving pop > 0),
both both-sides-dependent arms (operand_positive_by_convention), and
the agg feeder-hop analysis (the mul_convention flag) -- but not in the
general Mul one-side arm. The asymmetry meant the flagship logistic
model (net_growth = population * fractional_growth) could not sign
population -> net_growth, so the compounding loop every CLD labels R
was named u1 with Undetermined polarity.

Make the feeder-hop rule the general rule and delete the mul_convention
flag: a bare named co-factor independent of the link source is positive
by convention (passing the dependent side's derivative sign through),
a provable co-factor sign is used exactly, and a compound co-factor
(1 - pop/K -- the logistic class whose value sign genuinely flips
mid-run) stays Unknown. The convention is a labeling decision, not a
proof: runtime loop-score reclassification remains the ground truth and
overrides it, which the Rux fixtures now demonstrate directly (their
bare co-factor g flips sign mid-run, and the runtime pass downgrades
the conventional R to MostlyReinforcing).

The provable-sign predicates are rebuilt on literal_sign /
provable_value_sign, which see through unary negation. That closes a
real mislabel that shipped: the lexer takes no leading sign, so k = -5
parses as Op1(Negative, Const(5)) and the old Const-only predicates
were blind to every negative constant a user can write -- x / k was
labeled Positive by the Div convention when it is provably Negative.

Loop ids derived from newly-signed links churn u{n} -> r{n}/b{n}
(logistic growth, fishbanks harvest); the fishbanks regeneration loop
pins that a non-monotone graphical function still yields Undetermined.
The pysimlin README quickstart now prints r1 (R) and its loop-id
paragraph explains structural ids vs runtime polarity instead of
apologizing for u1.
@claude

claude Bot commented Aug 2, 2026

Copy link
Copy Markdown

Code Review

Reviewed the changes to src/simlin-engine/src/ltm/polarity.rs, the polarity tests, and the docs/tests updates that follow from the convention rule now applying in the general Mul one-side arm.

The refactor is sound: literal_sign / provable_value_sign / cofactor_value_sign correctly see through chains of Op1(Negative, ..) (with the docstring accurately noting that this handles the parser's shape for -5), the one-side Mul arm's dispatch (expr_references_var guard → cofactor_value_sign) matches the documented three-way split (provable / bare-named-positive / compound-Unknown), and the Div arm's value_sign closure through provable_value_sign closes the -5/y and x/-5 mislabels. operand_positive_by_convention's rewrite is behaviorally equivalent for reachable both-sides arms — the fixed edge (a variable whose equation parses as Op1(Negative, Const(5))) is unreachable there because a -5 equation cannot reference from_var — but the fix is worth having as defense-in-depth given how is_negative_variable was silently blind to it.

Test coverage on the shape-level decisions is comprehensive: compound co-factor stays Unknown (both one-side and single-equation logistic both-sides), provably negative co-factor flips (Mul and Div), inline negated bare co-factor flips by convention, ABS(x) still poisons the product, subscripted named co-factor is positive by convention, and the Rux/harvest/logistic fixtures pin the loop-id and polarity churn end-to-end. The recursion cannot loop on a variable cycle (each recursion strips one Negative or terminates at literal_sign), and the internal recursion of cofactor_value_sign(Op1(Negative, ..)) is safe because provable_value_sign is consulted at each level before the compound arm.

Overall correctness: correct. No findings.

🤖 Reviewed with claude-opus-4-7

@codecov

codecov Bot commented Aug 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.40260% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.86%. Comparing base (74fd6d2) to head (54761f4).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
src/simlin-engine/src/ltm/polarity.rs 97.40% 2 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #1008   +/-   ##
=======================================
  Coverage   91.86%   91.86%           
=======================================
  Files         247      247           
  Lines      159174   159159   -15     
=======================================
- Hits       146224   146211   -13     
+ Misses      12950    12948    -2     

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

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

@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: 54761f4cdc

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

Expr2::Op1(crate::ast::UnaryOp::Negative, inner, _, _) => {
cofactor_value_sign(inner, variables).map(|sign| !sign)
}
Expr2::Var(..) | Expr2::Subscript(..) => Some(true),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Resolve subscripted constants before applying the convention

For a model such as arr[dim] = -2 and y = x * arr[d1], provable_value_sign never examines an Expr2::Subscript, so this fallback treats the co-factor as positive and reports x -> y as Positive even though its derivative is negative. This produces confidently inverted structural link and loop polarities (and potentially different loop IDs) for negative constant-valued array parameters; resolve the referenced array equation's sign before falling back to the positive-value convention.

Useful? React with 👍 / 👎.

Comment on lines +781 to +784
Expr2::Var(ident, _, _) => {
let var = variables?.get(ident)?;
if let Some(Ast::Scalar(var_expr)) = var.ast() {
literal_sign(var_expr)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Exclude stock initializers from constant sign proofs

When ident names a stock, Variable::ast() returns the stock's init_ast, not an equation proving its value is constant. Consequently, a stock initialized with -1 is treated as a provably negative co-factor for the entire model, even if its inflows later make it positive, and links such as x -> y in y = x * stock receive a confidently wrong structural polarity. Restrict this proof to value-bearing Variable::Var equations, or separately prove that a referenced stock cannot change sign.

Useful? React with 👍 / 👎.

@bpowers
bpowers merged commit b92f8d1 into main Aug 2, 2026
18 checks passed
@bpowers
bpowers deleted the mul-cofactor-convention branch August 2, 2026 05:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant