Skip to content

Symbolic growth domain + Pareto search: M1–M3 (#1075, #1078, #1076)#1083

Draft
isPANN wants to merge 17 commits into
mainfrom
1075-growth-domain
Draft

Symbolic growth domain + Pareto search: M1–M3 (#1075, #1078, #1076)#1083
isPANN wants to merge 17 commits into
mainfrom
1075-growth-domain

Conversation

@isPANN

@isPANN isPANN commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Closes #1075, #1078, #1076, #788. Milestone M1–M3 of "Symbolic Growth Domain & Pareto Search", built incrementally on one branch (3 commits, one per issue).

M1 (#1075) — src/growth.rs: the growth domain

Asymptotic normal form computed bottom-up over Expr in one linear pass, no monomial expansion (the #1069 blow-up).

  • GrowthTerm (exp/poly/logs maps) and Growth (Terms antichain | absorbing Unknown), deterministically sorted → platform-stable PartialEq/serde.
  • from_expr with upward widening (subtraction→addition, constants dropped, linear exponents→base-2 exp rates, factorial/nonlinear/negative exponents→Unknown). Pow(base,k) raises each term to power k (tight by AM-GM; makes sqrt((n-m)^2) ≍ n+m hold).
  • dominates: purely symbolic partial order (per-variable lexicographic exp→poly→log); 1.001^n ≻ n^100, which the old numerical-sampling heuristic got wrong.
  • to_expr renders back to a display Expr (de-normalizing exp bases: 2^n/3^n/exp(n)).

M2 (#1078) — wire big_o_normal_form to it; delete canonical.rs

M3 (#1076) — measured Pareto label-setting search (closes #788)

  • New src/rules/pareto.rs: PathLabel trait + CostLabel (Dijkstra-equivalent scalar) and MeasuredLabel (concrete-instance, 4-step pruning stack: symbolic pre-flight guard → execute+measure → branch-and-bound → measured-size dominance).
  • graph.rs: scalar dijkstra replaced by a generic pareto_search kernel (per-node antichain bags, predecessor pointers, hop cap 16, bag cap 32, deterministic tie-breaks). find_cheapest_path* keep signatures (run CostLabel); new find_measured_best_path* return a reusable reduction chain. best_path_to_ilp now ranks ILP variants by real measured size.
  • OOM is structurally impossible during selection: the pre-flight guard evaluates the overhead formula at the measured size and never starts a 2^num_vertices-style construction over budget.

Honest caveats (please eyeball in review)

  • [Enhancement] ILP path selector over-prioritizes step count over final output size #788 known-answer: the issue's literal chain HC→HP→ConsecutiveOnesSubmatrix→ILP=60 no longer exists (nothing reduces into ConsecutiveOnesSubmatrix on today's graph). The test pins the current measured optimum (HC→LongestCircuit→ILP, total 232). On today's graph the old selector coincidentally also lands on 232, so the genuine behavioral proof of the path-dependent-cost fix is the diamond negative control (old Dijkstra returns the worse path; Pareto search returns the better one).
  • Panic-as-infeasible: measured extend executes candidate reductions; a precondition-violating reduction panics (macro dispatch downcast/unwrap), caught and treated as "edge infeasible". A once-installed global panic hook gated on a thread-local flag keeps these off stderr without affecting other threads. Pragmatic given the current reduction API (returning Result instead would be cleaner but is out of scope).
  • Coverage: the bag-cap(32) truncation branch is defensive and not exercised by a test.

Pow rendering deviation (carried from M1)

(n+m)²·(n+m)² renders as the 3-term O(m²·n² + m⁴ + n⁴) (raise-each-term, tight) rather than the design doc's 5-term binomial — both valid degree-4 bounds. Documented in the module.

Verification

cargo test green (5265 lib + 75 integration + 495 CLI); cargo clippy --all-targets --features ilp-highs -- -D warnings clean; cargo fmt --check clean. grep canonical_form → nothing. pred-sym big-o spot checks pass (O(n^2), O(1.5^num_subsets * num_subsets^2), instant degree-4). New tests: 14 growth, 24 big_o, 4 pareto (incl. #788 known-answer, OOM guard, diamond negative control).

Also commits the shared batch design doc (docs/design/symbolic-growth-domain.md).

🤖 Generated with Claude Code
https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR

Implement the growth domain: a dedicated asymptotic normal form that
computes Big-O bottom-up in a single pass over `Expr`, without the
exponential monomial expansion in `canonical.rs` that was the root cause
of issue #1069.

- `GrowthTerm`: one growth monomial over `exp` / `poly` / `logs` maps.
- `Growth`: an antichain of pairwise-incomparable dominant terms, or the
  absorbing `Unknown` sentinel; both are deterministically sorted for
  platform-stable equality and serialization.
- `from_expr`: transfer functions for Var/Const/Add/Mul/Pow/Exp/Log/Sqrt
  with upward widening (subtraction -> addition, constants dropped,
  linear exponents -> base-2 `exp` rates, nonlinear exponents /
  factorial / negative exponents -> `Unknown`).
- `dominates`: purely symbolic partial order (per variable, lexicographic
  on exp rate / poly degree / log power) that replaces the foolable
  numerical sampling heuristic.
- Antichain cap 32 with upward widening to the componentwise-max term.
- Serde support (`Serialize` derived; `Deserialize` hand-written to leak
  string keys to `&'static str`, matching `Expr`'s parser convention).

This module changes no existing behavior; `big_o.rs` / search rewiring is
scoped to later issues in the milestone, so the module is `#[allow(dead_code)]`
for now.

Also commits the shared batch design doc referenced by the milestone.

Includes the six named verification cases plus the negative control from
the issue, and extra coverage. `cargo test growth` and
`cargo clippy -- -D warnings` pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR
@codecov

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.21040% with 59 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.00%. Comparing base (cb54ea0) to head (daa61df).

Files with missing lines Patch % Lines
src/unit_tests/growth.rs 95.65% 19 Missing ⚠️
src/unit_tests/rules/pareto.rs 97.26% 19 Missing ⚠️
src/growth.rs 96.71% 12 Missing ⚠️
src/rules/graph.rs 98.54% 5 Missing ⚠️
src/rules/pareto.rs 98.03% 3 Missing ⚠️
src/solvers/ilp/solver.rs 97.14% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1083      +/-   ##
==========================================
- Coverage   98.00%   98.00%   -0.01%     
==========================================
  Files        1044     1046       +2     
  Lines      107366   108566    +1200     
==========================================
+ Hits       105228   106399    +1171     
- Misses       2138     2167      +29     

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

…1078)

Swap `big_o_normal_form`'s implementation to the growth domain and delete
the exponential-cost expansion machinery. One trusted asymptotic engine
now backs all Big-O queries.

- `big_o.rs`: `big_o_normal_form` is now `Growth::from_expr(expr).to_expr()`,
  mapping `Growth::Unknown` to the existing `Unsupported` error. Signature
  unchanged; the 360-line canonical projection is gone.
- `growth.rs`: add `Growth::to_expr()` rendering the antichain back to a
  display `Expr`, de-normalizing exp rates to readable bases
  (`{n:1} -> 2^n`, `{n: log2 3} -> 3^n`, `{n: log2 e} -> exp(n)`, with
  float-snapping so `1.5^x` renders cleanly).
- Delete `canonical.rs` (incl. the `MAX_CANONICAL_TERMS` stopgap) and its
  tests, the `asymptotic_normal_form` wrapper, the now-dead
  `CanonicalizationError`, and their `lib.rs` re-exports.
- `pred-sym`: drop the `canon` subcommand; `compare` narrows to Big-O
  equivalence via the growth domain.
- `analysis.rs`: `prepare_expr_for_comparison` stops canonicalizing (returns
  the expr clone); the full analysis-to-growth rewire is a separate issue.

Behavioral changes from the stronger semantics, reflected in updated tests:
the #1069-shaped `((a+b+c+d)^4)^4` now returns a real degree-16 bound
instantly instead of erroring; `-1 * n` widens to `n` (constant factor
dropped) instead of being rejected; `2^sqrt(n)` (nonlinear exponent) is now
unsupported; exp/sqrt structural identities in the overhead comparator
report Unknown until the analysis rewire lands.

Verification (all from the issue): `cargo test` green; `grep canonical_form`
returns nothing; `pred-sym big-o` prints `O(n^2)` / an exp*poly bound /
a degree-4 bound instantly; `factorial(n)` and `canon` fail loudly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR
@isPANN isPANN changed the title Add symbolic growth domain (src/growth.rs) (#1075) Symbolic growth domain: build it and wire big_o_normal_form to it (#1075, #1078) Jul 13, 2026
)

Implements M3 (F3b) of the Symbolic Growth Domain & Pareto Search milestone.

The scalar Dijkstra in `ReductionGraph` had a path-dependent-cost hole: when
two paths reached the same node, only the cheaper-so-far label's size was kept,
so a cheaper-but-larger intermediate state could poison downstream choices
(issue #788). This replaces it with a generic multi-label Pareto search plus a
measured concrete-instance label domain.

- New `PathLabel` trait (`extend` + `dominates` + `cost`) and a generic
  `pareto_search` kernel over per-node antichain bags with predecessor pointers,
  branch-and-bound, deterministic safety caps (hop cap 16, bag cap 32 with a
  deterministic tie-break), and a deterministically ordered Pareto front.
- `CostLabel`: scalar formula label reproducing Dijkstra behavior for the
  existing `PathCostFn` cost functions; `find_cheapest_path*` keep their
  signatures and now run the kernel with it.
- `MeasuredLabel` (`src/rules/pareto.rs`): the concrete-instance label. Its
  `extend` runs a four-part pruning stack in order — (1) symbolic pre-flight
  guard (evaluated in f64 so a `2^num_vertices` prediction is refused without
  executing, making OOM structurally impossible), (2) execute + measure the real
  target size, (3) branch-and-bound, (4) componentwise measured-size dominance
  (disabled by the `exhaustive` flag; guards 1-3 stay sound). A caught panic from
  a reduction whose preconditions the instance violates prunes that edge.
- `MeasuredPath` carries the constructed reduction chain (via `Rc`) so downstream
  solve/witness extraction reuses it without re-executing.
- `ILPSolver::best_path_to_ilp` now uses `find_measured_best_path_to_name`,
  ranking ILP variants by real measured size instead of step count / formula.

Verification (all green): #788 known-answer test (HC on the prism graph selects
the measured optimum), OOM pre-flight guard test (64-vertex HighlyConnectedDeletion
refused in <1ms, exponential construction never started), and a hand-built diamond
negative control where scalar cost selection commits to P1 while the Pareto search
returns the strictly-better-final-size P2.

Closes #788.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR
@isPANN isPANN changed the title Symbolic growth domain: build it and wire big_o_normal_form to it (#1075, #1078) Symbolic growth domain + Pareto search: M1–M3 (#1075, #1078, #1076) Jul 13, 2026
isPANN and others added 11 commits July 13, 2026 19:42
…CP front (#1080)

Implements milestone M3 (F3a): the asymptotic, instance-free label domain
`GrowthLabel` and its surfacing through `pred path` / MCP `find_path`. Closes the
2-year-old research issue #15 (multi-variable shortest path over polynomials).

- `GrowthLabel` (src/rules/pareto.rs): each current-node size field mapped to a
  `Growth` in the source problem's size variables. `extend` composes an edge's
  overhead by substituting each current field's rendered growth (`Growth::to_expr`)
  into the overhead `Expr` and reducing via `Growth::from_expr` (reuses M1+M2, no new
  growth primitive). Fields depending on an `Unknown` growth stay `Unknown` — never a
  fabricated bound. `dominates` is componentwise search-sense (smaller growth better),
  with `Unknown` as top so any label with an `Unknown` field is dominated by a fully
  known one (undecidable paths rank last). `cost()` is a monotone magnitude scalar with
  a position tiebreak so the kernel's scalar branch-and-bound keeps incomparable,
  equal-magnitude front members. Plugs into the existing `pareto_search` kernel.

- `Growth::magnitude` (src/growth.rs): deterministic monotone scalar for search
  ordering only (dominance stays exact). `Growth` re-exported for CLI/MCP.

- `ReductionGraph::asymptotic_front`: builds the initial label from the source's size
  fields, runs `pareto_search`, orders the front by (hops, lexicographic node names).

- CLI: bare `pred path S T` (no `--cost`/`--size`, no `--all`) now prints the
  asymptotic Pareto front, each path annotated with `O(...)` per target size field
  (`O(?)` for unbounded). `--cost` opts into the unchanged single-best mode; `--all`
  unchanged. MCP `find_path` returns the same front with structured `Growth` serde.

- Fix `find_paths_up_to_mode_bounded` to apply the mode filter *before* `take(limit)`
  (was after), so `--all` truncation no longer depends on enumeration order.

- Tests: GrowthLabel extend/dominance/Unknown/isotonicity, the incomparable-front
  negative control (O(n^2)/O(m) vs O(n)/O(m^2), both kept), CLI determinism/golden for
  `pred path KSatisfiability QUBO`, and an MCP asymptotic-front test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR
The shared Pareto kernel prunes any label whose scalar `cost()` already meets
the best completed path's cost (branch-and-bound). That is sound for the scalar
measured/formula labels, but WRONG for the asymptotic `GrowthLabel`: growth over
multiple size fields is a partial order, so a scalar summary can rank one
genuinely incomparable Pareto-optimal path above another and prune it — silently
under-reporting the front, which is the exact thing this feature must not do.

The previous mitigation (an epsilon tie-break in `GrowthLabel::cost`) only
rescued the *equal-magnitude* case; asymmetric incomparable fronts (e.g. one
path O(n^2)/O(m), another O(n)/O(m^3), magnitudes 3 vs 4) still lost the larger
one whenever the smaller completed first.

Root-cause fix: add `PathLabel::BRANCH_AND_BOUND` (default true; false for
`GrowthLabel`) and gate the kernel's two B&B checks on it. The asymptotic search
now relies solely on the exact `dominates` partial-order pruning (plus the hop
and bag caps), so incomparable paths always survive. Removed the epsilon crutch
from `cost()`.

New test `test_growth_asymmetric_incomparable_front_complete` pins the
asymmetric case; verified it fails with B&B re-enabled (front drops path B) and
passes with the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR
After removing the scalar branch-and-bound from `GrowthLabel` search (8944ae8), the
asymptotic front over-reported: `GrowthLabel::dominates` requires a strictly-better
field, so two paths with *equal* growth vectors never prune each other, and the front
accumulated every redundant route (e.g. `pred path MVC ILP` printed 32 paths, most
with identical Big-O — only ~3 distinct growth profiles among them).

Fix: `ReductionGraph::asymptotic_front` now collapses the front to one representative
per distinct growth vector. `GrowthLabel` derives `PartialEq` over its field → growth
map, so the front is sorted by (hops, lexicographic node names) — putting each
equal-growth group's deterministic best first — then linearly deduplicated keeping the
first (best) of each group. Deduplication is purely by the growth vector, so paths
reaching different target variants (e.g. `ILP/bool` vs `ILP/i32`) with the same
composed Big-O collapse to a single representative — the endpoint variant is not part
of the asymptotic identity. Documented on the method.

Completeness is preserved: genuinely incomparable growth vectors are never equal, so
they all survive (both `test_growth_asymmetric_incomparable_front_complete` and
`test_growth_negative_control_incomparable_front` still pass, using the raw kernel).

`pred path MVC ILP` now prints 3 paths (one per distinct Big-O profile).

Tests: `test_asymptotic_front_dedups_by_growth_vector` (real graph: no duplicate growth
vectors, ≤ 4 entries, and the raw kernel front is strictly larger — proving collapse)
and `test_path_front_dedups_by_growth_vector` (CLI: MVC → ILP small handful, no dup
vectors).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR
…tter alias (#1080)

Root cause of the `pred path MinimumFeedbackVertexSet ILP` bug (asymptotic `num_vars`
composed to `O(num_variables)` instead of `O(num_vertices)`): the `ILP<i32> → ILP<bool>`
binary-encoding cast declared its overhead as `num_vars = "31 * num_variables"`. ILP's
size field is named `num_vars`; `num_variables()` is only a getter *alias* for it. The
`#[reduction]` macro validates overhead variables against getter *methods*, so the alias
compiled, and both instance-mode sizing (`evaluate_output_size` calls the getter) and
raw-overhead Big-O rendering resolve it — the mistake was invisible there.

But asymptotic growth composition (`GrowthLabel::extend`) threads size-field *names*: the
label key at the ILP node is `num_vars`, so the variable `num_variables` was unmapped and
leaked through unchanged as `num_vars = O(num_variables)`. The path's arrow display had
also masked this by collapsing the hidden `ILP/i32 → ILP/bool` cast step.

Fix: `31 * num_variables` → `31 * num_vars` (semantically identical — 31 bits per integer
variable — and numerically unchanged, since both getters return `num_vars`).

Effects: `MFVS → ILP` now composes `num_vars = O(num_vertices)`. `MVC → ILP` drops from 3
to 2 front paths — the corrected feedback-vertex-set route
(`num_constraints = O(num_edges + num_vertices), num_vars = O(num_vertices)`) is now
correctly Pareto-dominated by the direct route and pruned.

Test: `test_asymptotic_front_uses_only_source_variables_mfvs_ilp` pins
`num_vars = O(num_vertices)` and asserts every composed field's growth references only
MinimumFeedbackVertexSet's own size variables — a general "source-variables-only"
invariant on the front label.

Note: this is one instance of a getter-alias-vs-field-name mismatch. A separate, broader
class of leaks remains in other reductions (e.g. `ClosestVectorProblem`'s `num_encoding_bits`
and `CircuitSAT`'s `tseitin_num_vars`/`tseitin_num_clauses`, which surface in KSat→QUBO);
those are genuine field-name inconsistencies between a node's incoming and outgoing
reductions, outside the growth-composition logic, and are left for separate scoping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR
`compute_source_size` iterates every same-source-name reduction and calls each
one's `source_size_fn` on the instance to merge all size fields. A reduction for
a different source variant downcasts and panics (`Option::unwrap` on a
type-mismatch); these are expected and were already caught — but by a plain
`catch_unwind` that does not suppress the panic hook, so `pred solve` on a simple
instance (e.g. MIS on a sparse graph, with several unmatched i32-variant
reductions) printed ~8 scary "thread 'main' panicked" lines to stderr while
succeeding.

Route the probe through the measured search's existing thread-local panic
silencer (`pareto::catch_reduction`, now `pub(crate)`), so these caught,
expected panics stay off stderr. No behavior change beyond suppressing the noise.

Surfaced by an end-to-end `pred solve` use case; the measured-search `extend`
path was already silenced, this was the one remaining unsilenced probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR
Cross-validate the symbolic growth domain (src/growth.rs) against numeric
evaluation (Expr::eval) over a large seeded input space, in the spirit of
the repo's verify-reduction adversarial culture. Three contracts, each
exercised well past 5000 meaningful checks with a hand-rolled deterministic
SplitMix64 RNG (no wall-clock/entropy, byte-reproducible across platforms):

- Upper-bound soundness: eval(e,s) <= C*eval(render(growth(e)),s) at sizes
  larger than the 2^6 anchor from which C is calibrated (14500 checks, 0
  violations). Numeric artifacts (inf from exp-under-log; negative values
  outside the nonnegativity axiom) are skipped as indeterminate, not flagged.
- Idempotence: growth(render(growth(e))) == growth(e), compared up to
  rendering float precision (18960 checks).
- Dominance soundness: when dominates(b,a), the numeric ratio does not shrink
  and exceeds 1 at the larger size (36050 single-term dominating pairs). The
  evaluation window is chosen per pair from the exponent-gap regime so the
  crossover is numerically reachable, independent of the assertion outcome.

Negative control: the same upper-bound harness run against a deliberately
broken transfer (Add keeping only its first operand) detects 3343 violations,
proving the harness can fail.

Findings surfaced and handled precisely (not weakened): to_expr base-snapping
makes idempotence hold only up to ~1e-10 float drift; log-nested exponentials
overflow eval mid-computation though the true growth is tame; and the domain's
nonnegativity precondition must be respected when generating inputs.

Runs in <2s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR
Cleanup pass over the milestone diff (no behavior change):

- Add canonical `Growth::to_big_o()` in the lib; both the CLI (`commands/graph.rs`)
  and MCP (`mcp/tools.rs`) front renderers now call it instead of each hand-rolling
  the `Growth -> "O(...)"` mapping. The two had already drifted on the `Unknown`
  string; they now agree on `O(?)`. Adds a `to_big_o` unit test.
- `size_le` (MeasuredLabel dominance): drop the provably redundant second `.all()`
  pass — nonnegative sizes with missing-field-as-0 make one pass sufficient.
- `GrowthLabel::extend`: hoist the loop-invariant substitution map out of the
  per-output-field loop; it depends only on the rendered source label.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR
Replace the bespoke polynomial comparison engine in analysis.rs
(Monomial, NormalizedPoly, normalize_polynomial, poly_leq,
monomial_dominated_by, prepare_expr_for_comparison) with the shared
symbolic growth domain. compare_overhead now decides each common field
via Growth::from_expr + Growth::dominates (reflexive, so equal fields
pass), returning Unknown only when a field's growth is Growth::Unknown.

Outer semantics of find_dominated_rules are unchanged. The rewire loses
no prior detection (all 9 previously-dominated rules retained) and gains
one newly-decided pair: PartitionIntoPathsOfLength2 -> ILP{bool}, whose
composite path carried a num_vertices/3 constant divisor that the old
engine rejected as a negative-exponent power (Unknown); the growth domain
drops constant divisors, making both fields asymptotically equal to the
direct edge. Unknown comparisons dropped from 89 to 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR
Delete the `O(<raw expr>)` fallback in `big_o_of` and route rendering
through the growth domain's canonical `Growth::to_big_o()` — bounded
classes render as `O(<expr>)`, genuinely unbounded growth (nonlinear
exponent / factorial) as the honest `O(?)`, never a raw multi-thousand-
char expression. Gate the `--all` text rendering so `--json` and file
modes no longer build it (both named in #1069).

Add three in-process regression tests (no `pred` subprocess) named so
`cargo test issue_1069` selects them:

1. Reconstruct #1069's KSat→QUBO exploding path by name from the live
   graph (via `find_all_paths`, order-independent) and assert every
   composed size field yields a genuine `big_o_normal_form` (not the
   deleted raw fallback) that is bounded and actually reduced.
2. Whole-graph render budget over the complete path set of hot pairs
   (KSat→QUBO, MIS→QUBO): every field renders bounded, well under 5 s —
   the "can't OOM/hang again" guard.
3. Byte-exact golden for the named path's rendered text, with a
   negative control that swaps two terms and asserts the comparison
   fails. Goldening one name-selected path keeps the fixture robust to
   build/inventory/link ordering.

Closes #1069.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR
…#1079)

`path_all` sorted enumerated paths by length alone, so same-length paths kept
`find_paths_up_to`'s discovery order — which depends on inventory/link iteration
and thus varies across builds. Add a full name+variant signature as a secondary
sort key so the displayed ordering is reproducible.

Note: this determinizes the ordering of the fetched path set; when the total
path count exceeds --max-paths (e.g. KSat->QUBO has 108, capped to 20), *which*
same-length paths survive truncation still depends on discovery order. Fully
fixing that needs fetch-all-then-truncate, which risks enumeration blowup on hub
nodes and is left as a separate concern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR
`sort_by` recomputed the allocating `path_signature` closure O(n log n) times
(once per comparison, per side). `sort_by_cached_key(|p| (p.len(), path_signature(p)))`
computes each key once — same total order, fewer allocations, and matches the
file's existing `sort_by_key` convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR
@GiggleLiu

Copy link
Copy Markdown
Contributor

Review result: request changes.

I found the following correctness blockers and significant regressions:

  1. [P1] Mixed multivariate logarithms are underestimated. src/growth.rs:583 turns log(2^n * m) into O(n), dropping the required log(m) term. This invalid upper bound can corrupt Pareto dominance and pruning.

  2. [P1] Measured branch-and-bound is unsound. src/rules/graph.rs:566 and :603 prune according to the current measured problem size, but reductions can shrink that size. A pruned partial route may therefore have produced the optimal final result, including with exhaustive=true.

  3. [P1] Scalar cheapest-path dominance is still unsound for path-dependent costs. src/rules/pareto.rs:187 compares only accumulated cost, although future edge costs depend on current size. A cheap-but-large prefix can incorrectly delete the globally optimal cheap-and-small continuation.

  4. [P1] Asymptotic fronts leak intermediate-only variables. pred path KSatisfiability QUBO --json returned bounds involving num_encoding_bits, num_vertices, and tseitin_* even though these are not KSatisfiability size fields. src/rules/pareto.rs:397 passes missing intermediate fields through as if they were source variables, making the bounds unevaluable and Pareto comparisons unreliable.

  5. [P2] path --all truncates before sorting. With KSatisfiability to QUBO and max-paths 20, the response omitted a three-step route while returning ten-step routes. The cap is applied in discovery order at src/rules/graph.rs:856 / problemreductions-cli/src/commands/graph.rs:735 and only then sorted. CLI and MCP also return different truncated route sets.

  6. [P2] The documented saved-path workflow is broken. The help still advertises pred path MIS QUBO -o path.json for reduce --via, but bare path now writes a front envelope without a path array. reduce --via exits with Path file missing path array.

  7. [P2] src/growth.rs:493 classifies 0.5^(-n) as O(1), although it equals 2^n, because bases in (0,1] are handled before the exponent sign.

  8. [P2] The claimed memory/OOM guarantee is not established. Dominated measured instances remain owned by the permanent arena, while preflight relies on uncalibrated asymptotic formulas; the bag cap therefore does not bound retained instance memory.

Verification: make test passed, including 5,386 library tests plus CLI, integration, and doc tests. Focused CLI/MCP scenarios and git diff --check also passed. The green suite does not cover the counterexamples above.

isPANN and others added 2 commits July 14, 2026 15:53
…nd-trip

P1 correctness fixes:
- growth: log_term sums all factor classes, so log(2^n * m) = n + log m
  instead of dropping log m (an invalid upper bound)
- growth: fractional bases fall through to the sign-aware rate filter,
  so 0.5^(-n) classifies as 2^n instead of O(1)
- pareto: MeasuredLabel opts out of branch-and-bound (measured size can
  shrink, so its cost is non-monotone and B&B pruning was unsound, even
  with exhaustive=true)
- pareto: CostLabel dominance is componentwise over (cost, size) - a
  cheap-but-large prefix can no longer evict the globally optimal
  cheap-and-small continuation under path-dependent costs
- pareto: GrowthLabel taints target fields referencing variables absent
  from the label, so asymptotic fronts no longer leak intermediate-only
  variables (tseitin_*, num_encoding_bits) as fake source variables

P2 fixes:
- graph: find_paths_up_to_mode_bounded enumerates length-first via
  iterative deepening with a deterministic library-owned order, so
  path --all truncation keeps shortest routes and CLI/MCP return the
  identical route list
- cli/mcp: the asymptotic front envelope carries top-level steps/path
  (best front element, format_path_json shape), restoring the documented
  `pred path S T -o path.json` -> `pred reduce --via` round-trip
- graph: pareto_search frees evicted labels immediately (Option<L> +
  take on bag eviction), so BAG_CAP genuinely bounds retained measured
  instance memory; OOM claims in docs corrected to the real guarantee

Each fix carries a targeted regression test (mixed-log/fractional-base
growth cases, shrink-late diamond under exhaustive, path-dependent-cost
diamond, absent-variable taint, shortest-route truncation, bare-path
reduce --via round-trip, peak-live-labels DropToken bound).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
find_paths_up_to_mode_bounded enumerated paths via iterative deepening,
running a full all_simple_paths DFS once per length level (up to
node_count levels). For --all queries with fewer paths than the limit
(the common case on a sparse graph) that meant ~170 redundant zero-yield
traversals per call.

Replace with a single DFS pass feeding one bounded max-heap keyed by
(node count, order key): it retains exactly the `limit` shortest-then-
lexicographically-smallest paths in O(limit) memory. Output is byte-
identical to the iterative-deepening version (verified across queries and
limits), and this folds in the redundant `limit == 0` guard and the
manual into_vec+sort (now into_sorted_vec).

Also drop a stale sentence in the MeasuredLabel memory rustdoc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@isPANN isPANN marked this pull request as draft July 14, 2026 08:51
The previous fix disabled B&B for MeasuredLabel via a per-label
`BRANCH_AND_BOUND` associated const (default `true`). That default is a
footgun: any future PathLabel whose `cost` is non-monotone inherits
unsound B&B pruning unless its author remembers to opt out — exactly the
mistake that made measured search unsound in the first place.

Remove the mechanism instead of gating it. Only CostLabel ever used B&B,
where it was a marginal early-termination optimization on a 179-node
graph; dominance pruning (always sound for every label domain) plus
HOP_CAP/BAG_CAP already bound the search. Drop the `BRANCH_AND_BOUND`
const, the `best_final` tracking, and both prune sites. `cost` is now
purely a frontier-ordering / tie-break heuristic and need not be
monotone.

Result-preserving: `find_cheapest_path*` returns the min-cost element,
which sound B&B never affected — verified `--cost` output is byte-
identical across queries and cost functions. The kernel is now dominance-
only, so no label can reintroduce this class of bug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.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

2 participants