Skip to content

feat(pixelflow-search): budget saturation against LIVE e-classes, not allocated slots - #1114

Draft
jppittman wants to merge 3 commits into
mainfrom
claude/class-cap-live
Draft

feat(pixelflow-search): budget saturation against LIVE e-classes, not allocated slots#1114
jppittman wants to merge 3 commits into
mainfrom
claude/class-cap-live

Conversation

@jppittman

@jppittmanjppittman commented Sep 2, 2026

Copy link
Copy Markdown
Owner

What this is

Two commits: the measurement that found the defect, and the fix it recommended, A/B'd on the same corpus.

EGraph::union (graph.rs) merges through the union-find and never removes an entry from self.classes. So num_classes()self.classes.len() — is ALLOCATED class slots and only ever grows, while the live graph is class_ids(): canonical AND non-empty. The production budget check was self.classes.len() > max_classes. Consequence: the classical tier's 5 000-class cap trips at a median 1 352 live classes — 3 648 classes of slack below its own budget, spent on ghosts.

The honest tension, which the A/B settles rather than assumes: the cap's stated purpose is memory protection, and memory is consumed by allocated classes — so counting allocated was defensible for memory while being wrong for quality. The fix is to stop making one number mean two populations.

The change

  • EGraph::live_classes — canonical AND non-empty, exactly class_ids().count(), maintained in O(1) at the only three sites that can move it (add, union, rebuild_budgeted's drain/refill). Each site computes its delta from the emptiness it actually observes, not from "canonical implies non-empty" — which is false precisely inside rebuild's mem::take window, where congruence closure runs. Underflow is checked_sub().expect(), never a wrap.
  • ClassCeiling::{Live, Allocated} / ClassCeilings { live, allocated } — the live budget is the search knob; the allocated ceiling is the memory guard, held at the existing HARD_CLASS_LIMIT (100 000, from fix(pixelflow-search): the over-budget e-graph cannot be mistaken for a class id (L1) #1107). They are grouped in a struct so a call site cannot silently swap them. SaturationStop::ClassCap and ScanStop::ClassCap now carry which ceiling fired; telemetry emits class_cap_live vs class_cap_allocated.
  • Budget::Explicit and Limits name both ceilings; OptimizerStats reports both counts. That is what lets the A/B run the old policy exactly (classes: HARD_CLASS_LIMIT, allocated_classes: preset.max_classes reduces every check to the old classes.len() > max_classes) through the public API, in the same process as the new one.

Drift is checked, not argued: debug_assert_live_count() holds the counter against class_ids().count() at the top of every saturation round, and tests/live_class_count.rs asserts it after every mutation — including interleaved rule batches with partial rebuilds, which is the path that exercises the drain window.

How the two ceilings relate to HARD_CLASS_LIMIT: unchanged in value and role. It was the clamp on the single cap; it is now the clamp on the allocated ceiling, and production sets the allocated ceiling to exactly it. On this corpus it never fires (max allocated observed: 10 660, 10.7% of the guard) — the live budget is what bounds every run.

A/B — 206 real kernels + 200 synthetics, Optimizer::production(), CostModel::latency_prior()

Both arms run back to back in one process. Deterministic metrics reproduced bit-identically across three runs. Full data: docs/results/2026-09-02-class-cap-live-ab.{md,csv,json}.

groupncost mediancost p90improvedregressedlive medianallocated medianalloc worstbytes-proxy medianapps medianwall mediancapped before→after
glyph1695+2.09%+6.27%7511703→24534718→72252.14x804 KB→1.22 MB2.03x1.48x89→88
glyph3295+2.09%+6.27%7511703→24534718→72252.14x804 KB→1.22 MB2.03x1.47x89→88
shader12+0.00%+8.82%51311→4061794→36482.57x304 KB→618 KB1.61x1.33x9→9
psychedelic1−5.35%−5.35%01946→25614847→98852.04x823 KB→1.67 MB1.90x1.70x1→1
cellgrid3+0.00%+0.00%001261→17634583→80341.75x779 KB→1.36 MB1.67x1.41x3→3
synthetic200+0.00%+0.00%3667→67161→1612.51x27 KB→27 KB1.00x0.98x40→37
REAL (all)206+2.03%+6.27%15541379→21724688→72202.57x798 KB→1.22 MB2.00x1.47x191→189
REAL cap-hit191+2.14%+6.45%15541703→24534718→72982.57x804 KB→1.23 MB2.06x1.48x191→189
ALL406+0.00%+4.60%15810420→5622884→47242.57x495 KB→807 KB1.54x1.24x231→226

Memory (allocated classes, nodes and memo entries are all monotone across a run, so end-of-run is peak): median allocated 4 688 → 7 220 on real kernels, worst-case ratio 2.57x; the largest graph any kernel allocates goes 5 226 → 10 660 classes, i.e. 878 KB → 1.83 MB by the documented byte proxy. The 100 000-class guard fired on zero kernels. The docs/.../class-cap-ghosts.md recommendation warned of a possible ~12x worst case; measured, it is 2.57x, because the live budget stops the run long before the ghost ratio compounds.

Compile-time cost — this is the part #1109 taught us to put in the body rather than discover later. Rule applications, the deterministic proxy, go 2.00x median / 3.35x p90 on real kernels (61 015 → 107 314 at the max). Aggregate wall clock over the real corpus is 7.78 s → 23.71 s (3.05x) — context only, the machine was loaded (load avg ~5), never a metric.

Why this is DRAFT, not armed for auto-merge

The pre-agreed gate was: auto-merge only if no real kernel regresses in cost and compile-time cost is under 2x. Both fail:

kernelgroupbeforeafterdelta
glyph16:U+004Bglyph1610471121+74
glyph32:U+004Bglyph3210471121+74
psychedelicpsychedelic766807+41
shader:julia_setshader716728+12

The regressions are not a bug in the counter — they are the honest shape of the change. Extraction is a greedy DP over a static cost prior, not an optimum, so a larger e-graph does not monotonically produce a cheaper term: more equalities can move the greedy choice onto a worse branch. Four of 206 real kernels land that way; 155 improve.

So the decision this PR is asking for is a trade, stated plainly: +2.03% median extracted cost on real kernels, for ~2x saturation work and ~2x peak e-graph, with 4/206 kernels getting worse. That is JP's call, not a green-CI call.

Three ways to shrink the ask, if the trade as measured is too expensive:

  1. Keep the live budget but lower max_classes for the classical tier, so the effective search is closer to today's and the fix is purely a correctness one (the budget then means what it says). Cheap to measure with the same harness.
  2. Land the counter and the typed ceilings now (which are correct regardless) but keep production budgeting on Allocated behind Budget::Explicit, deferring the policy flip.
  3. Chase the 4 regressions first — they are an extraction-quality question (greedy DP vs. cost prior), not a budget question, and they will recur for any change that grows the graph.

Gates

cargo build --workspace, cargo test --workspace, cargo clippy --workspace --all-targets -D warnings, cargo fmt --check, and both no-default-features checks (pixelflow-ir, pixelflow-core) all pass.

… fix (#1106 follow-up)
Read-only measurement, no production behavior change. THE FINDING:
EGraph::union merges through the union-find and never removes an entry
from self.classes; num_classes() returns self.classes.len() (allocated,
monotonically increasing) while the live graph is class_ids() (canonical
AND non-empty). The production 5,000-class cap (saturate_bounded,
self.classes.len() > max_classes) is checked against allocated, not live.
Corpus: 206 real kernels (190 glyph + 13 shader/psychedelic + 3 cell-grid,
dumped via the existing #[ignore]d dumper tests) + 200 size-stratified
synthetic expressions = 406 total, run through the exact production
regime (Optimizer::production()).
Headline numbers (docs/results/2026-09-02-class-cap-ghosts.md):
- allocated/live ratio: median 2.61x, q1 1.81x, q3 4.91x, worst 10.07x
- 231/406 kernels hit the class cap; 163/231 are ghost-bound (live < cap/2)
- upper-bound recovery (cap raised to 5000 * ratio-at-cap-hit, rerun):
median +2.35% / p90 +17.4% extracted-cost improvement, but 212/231
(91.8%) STILL hit the raised cap — the ratio compounds as saturation
runs longer, so a one-shot multiplier is not a durable fix
- worst-case allocated-class growth from the raised cap: 12.27x
Recommendation: (b) an incrementally live-counted budget (O(1) per union/
add, debug_assert-checked against class_ids().count()) + a separate
allocated ceiling for memory, over (a) compaction (correct but requires
actively rebasing every EClassId in the Provenance log across a
mid-saturation compaction boundary — a silent-corruption risk) or (c)
just raising the cap (measured above to not hold: 91.8% re-hit).
EGraph::memo_len() (pub(crate)) added so this measurement can read the
memo table's size without exposing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@github-actions

Copy link
Copy Markdown

🤖 Hi @jppittman, I've received your request, and I'm working on it now! You can track my progress in the logs for more details.

… allocated slots
`EGraph::union` merges through the union-find and never frees a class
slot, so `num_classes()` counts every class ever minted and only grows.
The production budget was checked against it, which meant the classical
tier's 5 000-class budget stopped runs at a median 1 352 LIVE classes —
the budget was spent on ghosts (docs/results/2026-09-02-class-cap-ghosts.md).
Split the one number into the two populations it was standing in for:
- `EGraph::live_classes` — canonical AND non-empty, exactly what
`class_ids()` enumerates, maintained in O(1) at the three sites that
move it (`add`, `union`, `rebuild_budgeted`'s drain/refill). Each site
computes its delta from the emptiness it observes rather than assuming
"canonical implies non-empty", which is false inside `rebuild`'s
drain window. Underflow is a `checked_sub().expect()`, not a wrap.
- `ClassCeiling::{Live, Allocated}` and `ClassCeilings { live, allocated }`
— the live budget is the search knob, the allocated ceiling is the
memory guard, and `HARD_CLASS_LIMIT` (#1107) is what production holds
the latter at. `SaturationStop::ClassCap` / `ScanStop::ClassCap` now
carry which ceiling fired, so telemetry can tell "search budget spent"
from "memory guard tripped".
- `Budget::Explicit` and `Limits` name both ceilings; `OptimizerStats`
reports both counts.
Drift is not argued, it is checked: `debug_assert_live_count()` holds the
counter against `class_ids().count()` at the top of every saturation
round and after every mutation in `tests/live_class_count.rs`.
A/B over 206 real kernels + 200 synthetics, both arms in one process
through `Optimizer::production()`:
docs/results/2026-09-02-class-cap-live-ab.{md,csv,json}.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jppittmanjppittman changed the title feat(pixelflow-search): quantify the class-cap ghost gap and choose a fixfeat(pixelflow-search): budget saturation against LIVE e-classes, not allocated slotsSep 2, 2026
@jppittman
jppittman marked this pull request as draft September 2, 2026 10:14
@jppittman

Copy link
Copy Markdown
OwnerAuthor

HOLD for JP — not armed for auto-merge, by the pre-agreed gate.

The gate was: arm squash auto-merge only if no real kernel regresses in extracted cost AND the compile-time cost is under 2x. Measured, both fail — 4 of 206 real kernels regress (largest +74 on glyph16/32:U+004B), and rule applications go 2.00x median / 3.35x p90 on real kernels (aggregate wall 3.05x, loaded, context only).

So this is a judgement call, and the numbers are all in the body. The short version:

  • The defect is real and confirmed in code: the budget counted allocated class slots, and union never frees one, so a 5 000-class budget stopped at a median 1 352 live classes.
  • The fix is correct and cheap: an O(1) live counter maintained at the three sites that move it, checked against class_ids().count() by debug_assert on every saturation round and after every mutation in the new test. The typed ClassCeiling::{Live, Allocated} split is right regardless of which policy we ship.
  • The payoff is +2.03% median extracted cost on real kernels (155 of 206 improve), and the price is ~2x saturation work and ~2x peak e-graph (worst kernel 878 KB → 1.83 MB; the 100 000-class memory guard never fires).
  • The 4 regressions are not a counter bug — they are greedy extraction over a static cost prior on a bigger graph, which is not monotone.

Three smaller asks are listed at the end of the body if the trade as measured is too expensive; option 2 in particular lands the counter and the typed ceilings (correct either way) while deferring the policy flip.

@jppittman

Copy link
Copy Markdown
OwnerAuthor

Orchestrator synthesis. The HOLD is right, and the reason is bigger than this PR.

What this measured. Live-counted budget: real kernels median +2.03% extracted cost (p90 +6.27%), 155 improved / 4 regressed, live classes at stop 1,379 → 2,172, memory 2.57x worst case (the ghost doc feared 12x), HARD_CLASS_LIMIT never fired. Price: rule applications 2.00x median / 3.35x p90.

The pattern across three independent changes tonight. Every change that gives saturation more room shows the same signature — most kernels improve, a minority regress, and the psychedelic shader is in the regressing minority every time:

changeimprovedregressedpsychedelic
numeric-first rule order (#1101)146/20421/2041.044 (worse)
remove the cap-break (#1109 arm B)140/20416/204unchanged
live-counted cap (this PR)155/2064/206−5.35% (worse)

This PR's own concerns section names the mechanism, and it is correct: "extraction is a greedy DP over a static cost prior, so a larger e-graph is not monotonically better — more equalities can move the greedy choice onto a worse branch. Any change that grows the graph will hit this."

So the binding constraint is extraction, not the saturation budget. Issue #1111 established that extract_dag is not argmin twice over — it sums tree costs (sharing is never priced) and it is a single DFS rather than a fixpoint, so a class whose child is still on the stack is scored at CYCLE_COST and never revisited — plus it reports a total_cost computed before repair_choices_well_founded mutates the choices. Every "give saturation more room" change is currently gated by that, and pays 2-3x compile time to buy a couple of percent through a lossy chooser.

Recommendation: hold this PR (as it is), and size the extraction gap first — how much cost is left on the table by the greedy DP versus an exact reference on kernels small enough to solve exactly. If the extraction gap is large, fixing it may deliver more than 2% at no compile-time cost, and it would very likely turn these three regression minorities into improvements. If it is small, then the 2%-for-2x trade here is the real trade and JP can take it or leave it on its own terms.

Not armed; the pre-agreed gate failed on both clauses (4 regressions, 2.00x compile) and it should stay failed until the sequencing question is settled.

🤖 Held by Claude Code

jppittman pushed a commit that referenced this pull request Sep 2, 2026
Records the structural finding this day's work produced, which matters more than
any individual merge: reconciling a branch against the saturation seam has a
half-life measured in hours.
Evidence. The board went 5/15 clean to 7/15 as #1054, #1109 and #1087 were
fixed, then back to 5/14 the moment #1087 merged — its landing re-conflicted
#1109 and #1114, both clean an hour earlier. #1109 needed reconciling twice in
one day for the same end-of-file append collision. #1114 was clean at 10:10 and
needs an API port by 11:00 (Budget gained a field, SaturationStop::ClassCap
changed arity, reused helpers went private). And the #1087/#1101 collision this
document predicted materialized exactly as described.
One mechanism behind all of it: egraph/graph.rs, egraph/saturate.rs and
runtime.rs are one hot seam with ten branches queued on it, and each landing
invalidates the rest. The fix is not more reconciliation — it is to rebase the
queue as a batch after each seam landing, or freeze the seam until it drains.
Corollary: reconcile in landing order and land promptly, because a
reconciled-but-unlanded branch is a wasting asset. #1087 is the counter-example
in the good direction, reconciled and merged the same hour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV5ZT2mxTnBC66unvBE7FF
Reconciles with #1087, which landed on runtime.rs after this branch was last
current. Both sides append modules at end-of-file, so main's file is taken whole
and this branch's two modules (class_cap_live_ab, class_cap_ghosts) re-appended
intact rather than resolved hunk-by-hunk — git interleaves them into each
other's bodies otherwise and produces an unclosed delimiter.
This branch's own change is a type extension — SaturationStop::ClassCap and
ScanStop::ClassCap now name WHICH ceiling bound, via ClassCeiling{Live,
Allocated}, and Budget::Explicit gains allocated_classes. That is additive and
survives; what the merge needed was propagating it to the sites main gained
since:
- Five ClassCap sites in main's congruence_gap_probe used the unit form. The
by-equality `count` closure cannot work once the variant carries a payload,
so it now counts by kind — those columns mean "stopped on the class cap at
all", across both ceilings, and a comment says so rather than leaving the
next reader to infer it from a matches! pattern.
- production_telemetry's Budget::Explicit needed allocated_classes. Set to
HARD_CLASS_LIMIT, matching what Budget::Production resolves to, so the
telemetry rows keep describing production's real ceiling; only this branch's
own A/B module varies it, which is the experiment.
- The four helpers class_cap_live_ab imports from congruence_gap_probe, plus
category_of, are pub(super) on this branch and reverted to private when
main's file was taken. Re-applied — this branch's own change, not a new
visibility decision.
Gates: cargo test -p pixelflow-search 197+4+2+6+7+7 passed / 0 failed,
including all 7 of saturation_stop.rs and all 7 of optimizer_laws.rs; cargo
build --workspace --tests clean; clippy --all-targets -D warnings clean; cargo
fmt --all --check clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV5ZT2mxTnBC66unvBE7FF
jppittman pushed a commit that referenced this pull request Sep 2, 2026
…arately
Explains why this seam is contested rather than merely busy. #1083 (landed),
#1109, #1114 and #1101 are each answering the same design question — what it
means for saturation to stop at the class cap — and answering it differently:
#1083 ends the run on it; #1109 separates classification from termination and
measures the break costing a more expensive arena on 140 of 204 real kernels;
#1114 says it should name which ceiling bound, Live or Allocated; and #1101's
saturate_until_applications reported Quiesced where main's saturate_bounded
reports ClassCap for the same run.
That last one surfaced as a test failure, not a merge defect. This pass ported
#1101's harness onto Optimizer/Budget::Explicit mechanically — every rename
resolved, workspace built — and its own clamped_rows_freeze_the_final_state
test then failed with left: ClassCap, right: Quiesced. The test is the branch's
own and passes on the branch, so it is detecting a real behavioural difference
between the entry point it was written against and the one that replaced it.
Consequence: #1101 cannot be reconciled without deciding whose semantics the
measurement runs under, and that decision is shared with #1109 and #1114.
Answering it once unblocks three branches; answering it per-branch guarantees
the results disagree. Nothing pushed to #1101 pending it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV5ZT2mxTnBC66unvBE7FF
jppittman pushed a commit that referenced this pull request Sep 2, 2026
…ct reference
`extract_dag` is not argmin under its own cost model three ways over (issue
#1111): it sums a TREE cost so sharing is never priced, it is a single DFS
rather than a fixpoint so a class whose child is `on_stack` is scored
`CYCLE_COST` and never revisited, and `total_cost` is read (extract.rs:1636)
before `repair_choices_well_founded` may change the choices (:1639). That is
why every budget-loosening change (#1101, #1109, #1114) improves most kernels
and regresses a minority: a larger e-graph is not monotonically better under a
lossy chooser, even though the exact optimum over it can only fall.
This measures the gap. Two references, both under `CostModel::latency_prior()`
and `Optimizer::production()`, so the comparison is chooser-vs-chooser:
- Knuth's algorithm (Dijkstra generalized to AND-OR graphs) — the EXACT
minimum tree cost, in polynomial time, on every kernel. That is the
objective the DP names, so it isolates the DP failing its own objective
from the objective being the wrong one.
- A branch-and-bound over per-class choices for the true DAG optimum, with
dominance filtering, a chain lower bound, and reduced-cost fixing. NP-hard,
so it is budgeted and reports UNSOLVED rather than a truncated answer; a
brute-force enumeration cross-check pins it on every instance small enough
to enumerate.
302 kernels (206 real arena dumps + a synthetic size ladder). Headline: the DP
misses its own tree optimum by p90 3.26% and worst 26.79%, attaining it on
230/302; the exact DAG optimum closes on 89 and greedy is optimal on 86 of
those. `total_cost` fails to describe the returned term on 132/302. And on
glyph16/32:U+004B — one of #1114's regressions — the roomier e-graph provably
CONTAINS a term at 1013 while greedy returns 1132 against 1047 on the smaller
graph: an extraction failure, not a budget failure.
Measurement only; no production behavior changes. The probe lives in its own
file rather than growing runtime.rs (2,631 lines and flagged for size), and
the arena-dump loader both probes share moves to `arena_corpus`.
Results: docs/results/2026-09-02-extraction-gap.{md,csv,json}
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jppittman pushed a commit that referenced this pull request Sep 2, 2026
Measured what actually depends on the deleted saturate_until_applications
entry point: all seven conflicted saturation branches do, at 6 to 27 call sites
each, plus 3 to 7 files each using run_anytime_curve. main replaced it with
Budget::Applications, and the two disagree on when a class cap ends a run —
which #1101's own clamped_rows_freeze_the_final_state test detects.
So the conflict count is misleading. This is not eight independent
reconciliations; it is one unresolved design decision with seven branches queued
behind it, plus #1072, which is unrelated and structural. Porting the difference
per-branch is how seven registered experiments end up measured under seven
slightly different stopping rules.
Answering it once — does an application-budgeted anytime run stop at the class
cap, or record it and continue? — converts all seven from needing judgement into
the mechanical recipe already written down here, which this pass executed end to
end three times (#1087, #1109, #1114).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV5ZT2mxTnBC66unvBE7FF
jppittman pushed a commit that referenced this pull request Sep 2, 2026
…trades
The measurement landed in the previous commit; this is what a sequencing
decision needs from it, written into the same doc.
Headline, with its caveats attached: the greedy DP leaves **0.258% pooled /
14.557% worst case** against the TREE optimum — the objective `extract_dag`
itself names — over all 302 kernels with nothing excluded, and that is a floor.
The true DAG optimum is lower but NP-hard: it closed on 89 of 302, and exactness
stops at ~100-200 reachable classes while a production glyph saturates to a
median 1,755. The two references also describe different populations — all 68
kernels the tree optimum beats are real (64 glyph, 4 shader), zero synthetic —
so no single averaged ratio is honest.
Mechanism split, and what each fix buys:
- (i) single DFS, not a fixpoint — 5% of the proved loss, but **0.258% pooled
over all 302 with no time limit, 68 kernels improved, 14.557% worst**, and it
is the cheapest of the three. FIRST.
- (ii) tree cost instead of DAG cost — 95% of the proved loss, but that is a
small-kernel statement: beyond the tree optimum the entire certified headroom
on this corpus is **0.028% pooled**. A research direction, not a fix.
- (iii) `total_cost` read before `repair_choices_well_founded` — recovers no
cycles at all, and is worse than "stale": of the 132/302 mismatches **not one**
is ordinary-magnitude. 92 report exactly `usize::MAX` (the `.unwrap_or` at
extract.rs:1636, taken when the DFS never resolves the root) and 40 report 1x,
2x or 3x `usize::MAX / 4`, the `Dwrt` sentinel from cost.rs:292. No shipped
result is corrupted by it — #1101/#1109/#1114's harnesses re-score the arena
(`arena_static_cost`) and `guide_headroom.rs:395`'s corpus holds no sentinel —
so it is a live trap, not a live wound. Free to fix.
Sequencing: fix extraction before taking #1101, #1109 or #1114. The two
`U+004B` regressions in #1114 are extraction failures, not budget failures — the
roomier e-graph provably contains a term at 1013 while greedy returns 1132 — so
the fixpoint turns them into improvements, which no budget tuning can do.
`psychedelic` stays unresolved rather than exonerated.
Cost: extraction runs ONCE per compile; saturation runs 8,729,067 rule
applications over 800 expressions in the guide-headroom corpus, and #1114
doubles that for +2.03%. A log factor on the once-per-compile pass is the cheap
place to spend.
Cross-links #1111, §1.L5 of docs/plans/2026-09-02-optimizer-api.md, #1101,
#1109, #1114. Docs only; no code touched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jppittman added a commit that referenced this pull request Sep 2, 2026
… exact (#1115)
Three independent budget-loosening changes each improved most real
kernels and regressed a minority — #1101 (numeric-first rule order, 146
up / 21 down), #1109 (removing the class-cap break, 140/16), #1114
(live-counted class budget, 155/4, +2.03% for 2.00x applications). The
mechanism named in #1114 and established in #1111 is that `extract_dag`
is a greedy DP over a static prior and is **not argmin** (§1.L5 of
`docs/plans/2026-09-02-optimizer-api.md`), so a larger e-graph is not
monotonically better even though the *optimum* over it can only fall.
This PR answers the question that sequences those trades: **how much is
the greedy chooser leaving on the table?** Measurement + docs only — no
production behavior changes.
## Method
Two references, both under `Optimizer::production()` and
`CostModel::latency_prior()` — the same cost function the DP uses — so
the comparison is chooser-vs-chooser, never cost-model-vs-cost-model.
302 kernels: 206 real `.arena` dumps + a 96-point synthetic size ladder.
1. **Knuth's algorithm** (Dijkstra generalized to AND-OR graphs) — the
**exact minimum tree cost, in polynomial time, on every kernel**, no
time limit. That is the objective `extract_dag` *names*, so it separates
"the DP fails its own objective" from "the objective is the wrong one".
2. **Branch and bound** for the true DAG optimum (each chosen class
priced once) with dominance filtering, an admissible chain lower bound,
and reduced-cost fixing. NP-hard, so it is budgeted and reports
`UNSOLVED` rather than a truncated answer; cross-checked against
brute-force enumeration on every instance small enough to enumerate.
## Headline
| reference | scope | pooled recovery vs greedy | per-kernel |
|---|---|---|---|
| **tree optimum** (exact, polynomial, no limit) | **all 302, nothing
excluded** | **0.258%** (real-only 0.284%; shaders 2.62%) | median
0.000%, p90 0.403%, worst **14.557%**; 68 kernels strictly improved |
| **DAG optimum** (4s cap) | 89 of 302 proved | 0.228% on those 89 |
greedy already exactly optimal on **86 of 89**; worst ratio 1.1212 |
| **certified floor** (cheapest term the search exhibited) | all 302 |
0.286% | strictly beats greedy on **81** |
Caveats that travel with the number: it is against the *tree* optimum;
exact DAG extraction stops being computable at ~100–200 reachable
e-classes while a production glyph saturates to a median **1,755**, so
213/302 are UNSOLVED and excluded from the exact statistic; and all 68
kernels the tree optimum beats are **real** (64 glyph, 4 shader) — zero
synthetic — so the two references describe different populations.
## Mechanism split — which of the three to fix
| # | defect (#1111 / §1.L5) | share of proved loss | what fixing it
recovers | cost |
|---|---|---|---|---|
| (i) | single DFS, not a fixpoint (`CYCLE_COST`, never revisited) |
10/195 (5%) | **0.258% pooled, all 302, p90 0.403%, worst 14.557%, 68
kernels** — and both `U+004B` regressions | small |
| (ii) | tree cost, not DAG cost (sharing unpriced) | 185/195 (95%) |
**0.028% pooled beyond the tree optimum** | NP-hard |
| (iii) | `total_cost` read at `extract.rs:1636` before repair at
`:1639` | 0 | no cycles at all | trivial |
The 95/5 split is a **small-kernel statement** — it is computed only
where the branch and bound closed. Over the full corpus the picture
inverts, and (i) is both the larger measurable recovery and the cheaper
fix. Cycle-priced classes are not rare: 238/302 kernels have at least
one (median 10, max 284), and on 203/302 the best term the search holds
*uses* one.
(iii) is worse than "stale": of the 132/302 mismatches **not one** is an
ordinary-magnitude discrepancy. 92 report exactly `usize::MAX` (the
`.unwrap_or` at `:1636`, taken when the DFS never resolves the root
class) and 40 report 1x, 2x or 3x `usize::MAX / 4` — the `Dwrt` sentinel
from `cost.rs:292`, summed into a number typed as a cost. **No shipped
result is corrupted by it** — #1101/#1109/#1114's harnesses re-score the
returned arena (`arena_static_cost`, `runtime.rs:1278`) and
`guide_headroom.rs:395`'s 800-expression corpus holds no sentinel — so
it is a live trap, not a live wound.
## The finding that settles the sequencing
On **`glyph16:U+004B` and `glyph32:U+004B`** — two of #1114's four
regressions — the roomier e-graph **provably contains a term costing
1013** while greedy returns **1132** on it and **1047** on the smaller
production graph. The graph got strictly better and the chooser gave up
8.1% anyway: **those regressions are extraction failures, not budget
failures.** `shader:julia_set` leaves **5.6%** on the table at the
production budget with no budget change in play. `psychedelic` (766 →
816) exhibited no cheaper term inside the limit — **unresolved, not
exonerated**.
## Sequencing
**Fix extraction before taking #1101, #1109 or #1114.** The fixpoint is
free at run time, recovers more on real kernels than #1114 buys for 2x
the rule applications where they overlap, and turns #1114's `U+004B`
regressions into improvements — which no budget tuning can do.
Cost of a fixed extractor (estimate, not benchmarked): Knuth is `O(Σ
arity · log V)` against the DFS's `O(Σ arity)` — a small constant and a
log, not an order — and it completed on all 302 kernels including the
4,847-class `psychedelic`. **Extraction runs once per compile;
saturation runs 8,729,067 rule applications over 800 expressions** in
the guide-headroom corpus (median 194.5, p90 31,197, max 996,047), and
#1114 doubles that for +2.03%. This is the cheap place to spend.
## Shape
- Probe in its own file, `#[cfg(test)]` + `#[ignore]`d — `runtime.rs`
was already 2,631 lines and flagged for size.
- The shared `.arena` loader moved out of `runtime.rs` into
`arena_corpus` (net −124 lines there), so the two probes that read the
corpus do not carry two copies of the dumpers' inverse.
- No public API added; no production path touched.
Results: `docs/results/2026-09-02-extraction-gap.{md,csv,json}`.
Gates: build, `cargo test --workspace`, `clippy --workspace
--all-targets -D warnings`, `fmt --check`, and both no_std checks
(`pixelflow-ir` and `pixelflow-search`, `--no-default-features`) all
green locally.
Refs #1111, #1101, #1109, #1114.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: JP Pittman <jppittman@jpptech.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
jppittman pushed a commit that referenced this pull request Sep 2, 2026
Pulls the calls scattered through the document into one table: merge #1054
(after a mutants rerun) and #1072; rework #1109 rather than merging it as
written, since its change is unbounded on a clock-free main — 353 seconds for a
279-node kernel — and should be paired with Budget::Applications and
re-measured, with its stop-re-arming half landable separately; review #1113 and
#1114 normally; decide #994 either way; hold the seven gated on the class-cap
question.
Records the superseded set for the record: #1050 and #1044, both closed and
neither salvageable. Notes that #1054 looked like a third case and was not — it
was twice-invalidated but its tests were re-targetable, and it is now green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV5ZT2mxTnBC66unvBE7FF
Sign up for freeto 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.

2 participants

@jppittman@claude