feat(reader-arc): compare_multiplicative increment 1 — compiler tier + frame-anchored reader (measured delta=0, reroutes to shared-layer) #77

Merged
core-labs merged 8 commits from feat/compare-mult-increment into main 2026-07-19 03:07:12 +00:00
Owner

Increment 1 of the design-first reader arc (ADR-0250), both halves, measured.

Per the ruling (PR #76): per-family increments, reader-grammar + compiler-tier in lockstep, develop-on-tune / measure-once, X=15 ratified. Honest headline: X=15 MISSED, measured holdout delta = 0 — and that zero is the deliverable. It precisely locates the real bottleneck: the compare_multiplicative reader is not the GSM8K wall.

Compiler half — real capability (commit 9136c181, 260e56ca)

compare_multiplicative (actor = factor × reference) as a cross-register dilation: read the reference register's field state, dilate by -ln(factor), write the actor. New bits: reads-one-writes-another; actor may be defined by the compare (no seed) yet still summed (amendment 1, registers-driven summation with unit propagation); record binds the reference entity + its state digest for records-alone re-verification (amendment 2, conditional source_entity so pre-compare digests stay byte-identical); no conservation pin (compare creates quantity). Fail-closed guards: inverse-mis-direction (compare_redefines_register), chained-reference-before-definition, compare_additive out-of-scope, self-reference. 27/27 (tests/test_adr_0250_compare.py).

Reader half — frame-anchored recognizer (commit c3aed13b)

Per the construction constraint (anchor on the comparative FRAME, not verb-whitelist growth): the two multiplicative-frame regexes now gate on the closed-class frame <factor> as many/much <unit> as <REF>; the verb slot is a verb-free bounded connector; as much joins as many. _comparison_anchor_verb() stays for the additive/nested frames (out of scope). Both regexes remain $-anchored, so multi-clause/nested surfaces stay refused by construction.

Polarity refutation + sanctioned fallback. Pure verb-free is not wrong=0-safe: a polarity-inverting/depletion verb in the frame ("Alice lost twice as many apples as Bob") makes the compared quantity a loss, so actor = factor × reference reads backwards → wrong>0 (pinned by test_adr_0131_G2a…::test_polarity_inverting_verb_not_admitted, …spend…, test_recognizer_comparative_inject…[Alice lost…]). Fix = frame-anchored plus a closed-class polarity blocklist (_COMPARE_POLARITY_BLOCK: lose/spend/use/give/sell/win/donate/pay/eat/drop/lend) the connector may not span. The frame is the gate for every other verb; the blocklist is the minimal closed-class piece of verb knowledge wrong=0 requires — not a whitelist.

Measured footprint — provable, tiny, safe (develop-on-tune / measure-once held)

New-vs-old frame-fire diff over all 500 official holdout_dev/v1 cases:

Split cases divergent frame-fires wrong new-correct
tune 261 0 (byte-identical refusal histogram) 0 0
measure 239 10441 "Zig wrote four times as many books as Flo." 0 0

0441 is the exact verb-blocked archetype. Its frame now fires correctly — and it still refuses, one layer downstream: "no admissible candidate for question: 'how many books did they write altogether?'". It advanced frame-refusal → question-refusal (the pre-registered sanctioned delta), proving the frame reads but the binding wall is the shared summation-question layer (+ inverse-compare: 0441 has the known quantity as the actor).

X=15 missed → shared-layer, not family-exhaustion (per the pre-committed funnel rule)

The 38 tune compare-family cases, classified by actual blocker: 16 multiclause/nested, 10 frame-no-match (multi-word refs / thanas / decimals / unit-ellipsis / subject-is-comparison), 5 "times the number/amount of" (self-ref/possessive), 5 "as ADJ as" (attribute register), 2 "double what". End-to-end traces (0228/0274/0491/0165/0299) show every one dies at a non-compare layer: summation-question, seeding-sentence injection (135/261 "no injection" wall), additive-compare, attribute registers, simultaneous equations. Even the cleanest compare-family extension (multi-word reference) converts 0 end-to-end and adds an actor-misbinding hazard → reverted. Ruling: next increment = shared-layer (seeding-sentence injection, the dominant wall). X re-applied after. Full detail + triple report: docs/research/compare-increment-funnel-2026-07-18.md.

Files

  • evals/multi_register_program.py — compare compiler tier + registers-driven summation
  • generate/math_candidate_parser.py — frame-anchored regexes + polarity blocklist
  • evals/gsm8k_math/holdout_dev/v1/split.py — deterministic tune/measure split (pinned before grammar)
  • tests/test_adr_0250_compare.py — 27 tests
  • docs/research/compare-increment-funnel-2026-07-18.md — funnel + measured outcome

[Verification]: local worktree uv run core test --suite smoke -q176 passed @ c3aed13b; targeted comparative suites 97 passed, 2 skipped; compare tier 27/27. wrong=0 held on tune (261), measure (239), smoke (176).

🤖 Generated with Claude Code

## Increment 1 of the design-first reader arc (ADR-0250), both halves, measured. Per the ruling (PR #76): per-family increments, reader-grammar + compiler-tier in lockstep, develop-on-tune / measure-once, X=15 ratified. **Honest headline: X=15 MISSED, measured holdout delta = 0 — and that zero is the deliverable.** It precisely locates the real bottleneck: the compare_multiplicative reader is *not* the GSM8K wall. ### Compiler half — real capability (commit 9136c181, 260e56ca) `compare_multiplicative` (`actor = factor × reference`) as a cross-register dilation: read the reference register's field state, dilate by `-ln(factor)`, write the actor. New bits: reads-one-writes-another; actor may be *defined* by the compare (no seed) yet still summed (amendment 1, registers-driven summation with unit propagation); record binds the reference entity + its state digest for records-alone re-verification (amendment 2, conditional `source_entity` so pre-compare digests stay byte-identical); no conservation pin (compare *creates* quantity). Fail-closed guards: inverse-mis-direction (`compare_redefines_register`), chained-reference-before-definition, `compare_additive` out-of-scope, self-reference. **27/27** (`tests/test_adr_0250_compare.py`). ### Reader half — frame-anchored recognizer (commit c3aed13b) Per the construction constraint (anchor on the comparative FRAME, not verb-whitelist growth): the two multiplicative-frame regexes now gate on the closed-class frame `<factor> as many/much <unit> as <REF>`; the verb slot is a verb-free bounded connector; `as much` joins `as many`. `_comparison_anchor_verb()` stays for the additive/nested frames (out of scope). Both regexes remain `$`-anchored, so multi-clause/nested surfaces stay refused by construction. **Polarity refutation + sanctioned fallback.** Pure verb-free is *not* wrong=0-safe: a polarity-inverting/depletion verb in the frame ("Alice **lost** twice as many apples as Bob") makes the compared quantity a loss, so `actor = factor × reference` reads backwards → wrong>0 (pinned by `test_adr_0131_G2a…::test_polarity_inverting_verb_not_admitted`, `…spend…`, `test_recognizer_comparative_inject…[Alice lost…]`). Fix = frame-anchored **plus a closed-class polarity blocklist** (`_COMPARE_POLARITY_BLOCK`: lose/spend/use/give/sell/win/donate/pay/eat/drop/lend) the connector may not span. The frame is the gate for every other verb; the blocklist is the minimal closed-class piece of verb knowledge wrong=0 requires — not a whitelist. ### Measured footprint — provable, tiny, safe (develop-on-tune / measure-once held) New-vs-old frame-fire diff over all 500 official `holdout_dev/v1` cases: | Split | cases | divergent frame-fires | wrong | new-correct | | :--- | ---: | ---: | ---: | ---: | | tune | 261 | **0** (byte-identical refusal histogram) | 0 | 0 | | measure | 239 | **1** — `0441` "Zig **wrote** four times as many books as Flo." | 0 | **0** | `0441` is the exact verb-blocked archetype. Its frame now fires correctly — and it still refuses, one layer downstream: *"no admissible candidate for question: 'how many books did they write altogether?'"*. It advanced frame-refusal → **question-refusal** (the pre-registered sanctioned delta), proving the frame reads but the binding wall is the shared summation-question layer (+ inverse-compare: `0441` has the known quantity as the *actor*). ### X=15 missed → shared-layer, not family-exhaustion (per the pre-committed funnel rule) The 38 tune compare-family cases, classified by *actual* blocker: 16 multiclause/nested, 10 frame-no-match (multi-word refs / `than`≠`as` / decimals / unit-ellipsis / subject-is-comparison), 5 "times the number/amount of" (self-ref/possessive), 5 "as ADJ as" (attribute register), 2 "double what". End-to-end traces (0228/0274/0491/0165/0299) show **every one dies at a non-compare layer**: summation-question, seeding-sentence injection (135/261 "no injection" wall), additive-compare, attribute registers, simultaneous equations. Even the cleanest compare-family extension (multi-word reference) converts 0 end-to-end and adds an actor-misbinding hazard → reverted. **Ruling: next increment = shared-layer (seeding-sentence injection, the dominant wall). X re-applied after.** Full detail + triple report: `docs/research/compare-increment-funnel-2026-07-18.md`. ### Files - `evals/multi_register_program.py` — compare compiler tier + registers-driven summation - `generate/math_candidate_parser.py` — frame-anchored regexes + polarity blocklist - `evals/gsm8k_math/holdout_dev/v1/split.py` — deterministic tune/measure split (pinned before grammar) - `tests/test_adr_0250_compare.py` — 27 tests - `docs/research/compare-increment-funnel-2026-07-18.md` — funnel + measured outcome [Verification]: local worktree `uv run core test --suite smoke -q` → **176 passed** @ c3aed13b; targeted comparative suites **97 passed, 2 skipped**; compare tier 27/27. wrong=0 held on tune (261), measure (239), smoke (176). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
core-labs added 7 commits 2026-07-19 02:43:01 +00:00
Cross-register affine definition: actor := factor x reference. Reads the
reference register's field STATE, dilates by -ln(factor), writes the actor.
Reuses quantity_kernel + the multi-register framework + the 2b summation.

Amendments (PR #76):
1. Summation registers-driven: MultiRegisterProgram.register_order (seed order
   then compare-defined order); execute sums ALL registers in that order, so a
   compare-DEFINED register (no seed) is included in totals. compile validates
   the answer target AFTER ops (a concrete unknown may be compare-defined) and
   propagates the defined register's unit from the reference.
2. Compare record: RegisterTurn.reference; MultiRegisterRecord.source_entity
   (the reference) + operand_source_digest (reference state digest), both
   CONDITIONAL in the payload so affine/transfer/summation record digests are
   byte-identical to before compare. Re-verification reconstructs the source
   register from records alone.

Compare CREATES a quantity (not a transfer) -> exempt from the conservation pin.
fraction direction folded in (factor < 1). compare_additive is a later increment
(refused). Real official data: the 5 compare parses the reader emits today now
solve end-to-end wrong=0 -> corridor real-reach 0/500 -> 5/500 (loop-works proof).

25/25 (9 new + 16 existing regression, digests byte-identical).

[Verification]: uv run python -m pytest tests/test_adr_0250_compare.py tests/test_adr_0250_multi_register.py tests/test_adr_0250_summation.py -q
AMENDMENT 3 (PR #76): the reader grammar family is developed against the TUNE
split only; increment deltas are measured on the disjoint MEASURE split only.
Split = pure function of case id (measure iff sha256(id) even, else tune), so
disjointness is enforced by construction and cannot drift. Fixed 2026-07-18,
never re-seeded. Sizes: tune=261, measure=239. This commit lands BEFORE any
grammar-tuning commit — the git history is the proof the split was pinned in
advance (criterion 3). wrong=0 is verified across the full 500 regardless of
split; only the coverage delta is attributed to the measure split.
Two shapes (PR ruling) pinned compiler-side with tests — reader direction errors
surface as refusals, never wrongs:
- Chained / ordering: a compare whose reference is neither seeded nor
  already-defined at that point refuses (compare_reference_not_a_register).
  Pinned explicitly with a test (story order != dependency order), not left to
  fall out of existing checks.
- Inverse mis-direction: a compare that would DEFINE an already-known (seeded or
  earlier-defined) register refuses (compare_redefines_register) — the reader
  must bind the unknown side as the target with a fraction factor; the compiler
  never overwrites a known quantity.

The 5 real compare cases still solve wrong=0. 27/27 (2 new guards + 25).

[Verification]: uv run python -m pytest tests/test_adr_0250_compare.py tests/test_adr_0250_multi_register.py tests/test_adr_0250_summation.py -q
Instrument-first (PR #76 ruling): before touching the parser, measure where the
61 compare-marked TUNE cases die. Funnel: 57/61 die pre-enumeration; of those,
**45 at the injection layer** ('recognizer matched but produced no injection'),
8 'no admissible candidate for statement', 3 'expected exactly one question
sentence'; 3 solve today.

Finding: the leverage is INJECTION (45/61), not the candidate regexes (which
fire on clean clauses but the real multi-clause sentences never reach them) nor
segmentation writ large. Injection is an EMITTER, so the sanctioned fix is
making it inject compare candidates the recognizer already matches — never
loosening the round-trip gate. Fix order by measured mass: injection (45) ->
statement-admissibility (8) -> question-sentence (3). Tune-only development;
single measure run at the end with refusal-by-reason + delta.
Code archaeology before treating 'no injection' as a defect: it is a DELIBERATE
narrowing, not broken code. recognizer_anchor_inject.py's discrete-count injector
refuses comparative surfaces with the explicit comment 'Refuse here until a real
compare_multiplicative operation can be emitted' (~L324) and 'does not create any
new admitting path' (~L460). The lockstep thesis of the arc in the code's own
history: the reader refused compare shapes to protect wrong=0 + ADR-0191
completeness because nothing downstream could compile them. The compiler tier
this increment shipped IS that operation -> the fix is LIFTING a now-obsolete
guard whose reason has shipped, not patching injection.
Pre-registration before the shared-layer edit (PR ruling), all in the record:

1. Refusal-histogram baseline (tune, n=261): 'no injection' = 196/261 (75%) —
   a SHARED bottleneck across every family, not a compare defect; 48 statement,
   9 question, 3 PARSED (compare). Pinned; assert stable-or-explained after —
   only sanctioned delta = compare 'no injection' -> PARSED, disclosed.
2. Funnel-conditioned escalation rule (pre-committed): X=15 miss read via the
   funnel — compare-specific mass -> practice ruling; shared-layer mass ->
   shared-layer design increment (NOT practice). Load-bearing: practice cannot
   learn past a deterministic wall (better candidates still cross the same
   pipeline), so shared bottlenecks are design work regardless of X.
3. Reframe recorded: compare is the first slice of a larger reader project
   (reader parses ~1% of real GSM8K; 'no injection' 75% is a shared wall).
   Not scope creep — the arc seeing its true size.
Increment 1 reader half. Re-anchor the two multiplicative-comparison frame
regexes on the closed-class frame ('<factor> as many/much <unit> as <REF>')
instead of the _comparison_anchor_verb() whitelist: the verb slot becomes a
verb-free bounded connector, 'as much' joins 'as many'. Admission is now
open-class for safe verbs (scored/wrote/caught/taught/…), gated by the frame.

One closed class is NOT frame-neutral — polarity-inverting/depletion verbs
(lose/spend/give/sell/win/donate/pay/eat/drop/lend): in a compare frame they
make the compared quantity a loss, so actor=factor×reference reads backwards
→ wrong>0. _COMPARE_POLARITY_BLOCK excludes exactly that set (the sanctioned
fallback; the frame is the gate for every other verb). Pins the wrong=0
guards in test_adr_0131_G2a + test_recognizer_comparative_inject.

Develop-on-tune / measure-once held. Footprint over 500 official cases: tune
byte-identical (0 divergences, wrong=0, PARSED=3); measure 1 divergence
(0441 'Zig wrote four times as many books as Flo') which advances
frame-refusal -> question-refusal (sanctioned delta) but does not solve — the
binding wall is the shared summation-question layer. X=15 measured delta=0.
Ruling per the funnel-conditioned escalation rule: mass is in shared layers,
so the next increment is shared-layer (summation-question reader), not a
practice escalation and not more compare frames. wrong=0 held on tune (261),
measure (239), smoke (176).
Author
Owner

Review — compiler tier + finding: approved. Reader half: hold it out of this PR.

Verified clean and mergeable:

  • Compiler tier (multi_register_program.py): four amendments correct. Registers-driven summation includes compare-defined registers (register_order, seed-then-defined) so a defined register is never dropped from a total; source_entity/operand_source_digest are conditional in the payload, so pre-compare record digests are byte-identical (criterion 4 ✓, pinned by test_summation_record_has_no_source_entity); unit propagation from the reference; answer-target validation moved after the ops loop so a compare-defined unknown resolves. Fail-closed guards comprehensive — redefine-known-register (mis-directed inverse), chained-reference-before-definition, additive-out-of-scope, self-reference, non-finite/non-positive factor. 27/27.
  • Split (split.py): deterministic id-only (sha256(id) parity) — disjointness enforced by construction, not by discipline; wrong=0 across the full 500, delta attributed to measure only. Criteria 1–3 ✓.
  • Finding: delta=0 → shared-layer reroute per the pre-committed funnel rule is exactly right and load-bearing for increment 2.

The reader half (math_candidate_parser.py, c3aed13b) should not land here. Three facts compound:

  1. It converts 0 cases across the full 500 (measured) — zero current benefit.
  2. Its consumer is rate-frame admission (a later increment). The next increment is seeding-sentence injection — a shared layer that doesn't touch it. Nothing on the critical path needs frame-anchoring now.
  3. It points a fail-open surface at the sealed test (the run-once final arbiter) for that zero benefit. The round-trip net guards parse consistency, not gold-correctness, so an unenumerated depletion verb inside a complete frame yields a coherent-but-inverted graph — precisely the wrong=0 violation the arc's fail-closed doctrine exists to prevent, and the blocklist's closedness can't be proven.

The polarity refutation is the real lesson: pure verb-free frame-anchoring is not wrong=0-safe — the verb carries a polarity bit that must be positively determined, not assumed-safe-unless-blocklisted. Resolving that properly (frame + a real polarity determination) is design work that belongs in the rate-frame increment where it's exercised end-to-end and measured, not a blocklist bolted on to reach "frame-anchored" for an increment whose delta is 0. Revert c3aed13b, confirm the compare-tier tests stay green under the whitelist reader (the 5 solved cases are non-divergent, so test_real_compare_cases_solved_wrong_zero should hold), merge the tier + finding, and record frame-anchoring as an open design task owned by the rate-frame increment.

**Review — compiler tier + finding: approved. Reader half: hold it out of this PR.** Verified clean and mergeable: - **Compiler tier** (`multi_register_program.py`): four amendments correct. Registers-driven summation includes compare-defined registers (`register_order`, seed-then-defined) so a defined register is never dropped from a total; `source_entity`/`operand_source_digest` are conditional in the payload, so pre-compare record digests are byte-identical (criterion 4 ✓, pinned by `test_summation_record_has_no_source_entity`); unit propagation from the reference; answer-target validation moved after the ops loop so a compare-defined unknown resolves. Fail-closed guards comprehensive — redefine-known-register (mis-directed inverse), chained-reference-before-definition, additive-out-of-scope, self-reference, non-finite/non-positive factor. 27/27. - **Split** (`split.py`): deterministic id-only (`sha256(id)` parity) — disjointness enforced by construction, not by discipline; wrong=0 across the full 500, delta attributed to measure only. Criteria 1–3 ✓. - **Finding**: delta=0 → shared-layer reroute per the pre-committed funnel rule is exactly right and load-bearing for increment 2. **The reader half (`math_candidate_parser.py`, c3aed13b) should not land here.** Three facts compound: 1. It converts **0** cases across the full 500 (measured) — zero current benefit. 2. Its consumer is rate-frame admission (a *later* increment). The next increment is seeding-sentence injection — a shared layer that doesn't touch it. Nothing on the critical path needs frame-anchoring now. 3. It points a **fail-open** surface at the sealed test (the run-once final arbiter) for that zero benefit. The round-trip net guards parse consistency, not gold-correctness, so an unenumerated depletion verb inside a complete frame yields a coherent-but-inverted graph — precisely the wrong=0 violation the arc's fail-closed doctrine exists to prevent, and the blocklist's closedness can't be proven. The polarity refutation is the real lesson: pure verb-free frame-anchoring is *not* wrong=0-safe — the verb carries a polarity bit that must be **positively determined**, not assumed-safe-unless-blocklisted. Resolving that properly (frame + a real polarity determination) is design work that belongs in the rate-frame increment where it's exercised end-to-end and measured, not a blocklist bolted on to reach "frame-anchored" for an increment whose delta is 0. Revert c3aed13b, confirm the compare-tier tests stay green under the whitelist reader (the 5 solved cases are non-divergent, so `test_real_compare_cases_solved_wrong_zero` should hold), merge the tier + finding, and record frame-anchoring as an open design task owned by the rate-frame increment.
core-labs added 1 commit 2026-07-19 03:06:24 +00:00
Reverts the c3aed13b parser change (frame-anchored comparative regexes +
verb-free connector + _COMPARE_POLARITY_BLOCK + 'as much'); the reader stays
on the _comparison_anchor_verb() whitelist (fail-closed). Increment 1 ships
the compiler tier + the funnel finding only.

Three reasons on record: (1) frame-anchoring converts 0 of 500 official cases
— zero measured benefit today; (2) its consumer is rate-frame admission, a
later increment — the next increment (seeding-sentence injection) is a shared
layer that doesn't touch it; (3) the polarity blocklist is fail-OPEN — an
unenumerated depletion verb in a complete frame is a coherent-but-inverted
graph = a wrong on the run-once sealed arbiter (round-trip guards parse
consistency, not gold-correctness), a trade the fail-closed doctrine never
takes for zero gain.

Verified before revert-merge: with the whitelist reader, the 5 real compare
parses are non-divergent — test_real_compare_cases_solved_wrong_zero still
sees 5, solves 5, wrong 0 (corridor real-reach 0->5/500 preserved).

Frame-anchoring recorded as an OPEN DESIGN TASK owned by the rate-frame
increment: frame + POSITIVE polarity determination (neutral-polarity allowlist
or small polarity classifier), not assume-safe-unless-blocklisted. Detail in
docs/research/compare-increment-funnel-2026-07-18.md.
core-labs merged commit e1eb2a5ca1 into main 2026-07-19 03:07:12 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: core-labs/core#77
No description provided.