From 387f0656498cb9742ab0d1e071e804fb33800ded Mon Sep 17 00:00:00 2001 From: Shay Date: Sat, 18 Jul 2026 21:18:02 -0700 Subject: [PATCH] =?UTF-8?q?feat(reader-arc):=20#78=20positive-polarity=20a?= =?UTF-8?q?llowlist=20substrate=20(seed=20+=20comparison)=20=E2=80=94=20fo?= =?UTF-8?q?undations,=20band-solve=3D0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two reusable foundations of increment 1, both verified wrong=0: - NEUTRAL_COUNT_VERBS (math_roundtrip): the shared positive-polarity ALLOWLIST (production/acquisition/possession-count verbs), fail-closed. Extends ADD_VERBS with curated neutral production verbs (score/write/teach/plant/harvest/produce/ draw/sing). Depletion/transfer (SUBTRACT/TRANSFER_VERBS) excluded by positive determination — 'Alice lost twice as many' refuses, not via a blocklist. - Seed reader: discrete_count matcher acquisition path aligned to the allowlist (made/baked/grew/scored/wrote/taught now inject; depletion refuses). - Comparison reader: _comparison_anchor_verb() allowlist extended to the same NEUTRAL_COUNT_VERBS (keeps fail-closed; captures scored/caught/wrote frames). Verified: production injects, depletion refuses; 71 pinned comparative tests pass (polarity guards now via allowlist); tune wrong=0; smoke 176 green. FINDING (measured 4 ways: yield harness, band map, band oracle, capability histogram): band-1 as scoped {seed + forward-comparison + q:simple/summation} converts 0 tune cases. Its otherwise-in-band cases STRAND on multi-compound (25) and compare-additive (17) — capabilities outside the ~30 band. The conjunction recurs at sub-capability granularity; the band-map taxonomy was too coarse. Foundations validated + reuse-ready (done-when part 2); band-solve>0 (part 1) needs the band to grow to +compare-additive(+multi-compound) — a scope/done-when ruling for Josh. No merge until ruled. --- .../increment-1-band-plan-2026-07-19.md | 24 +++++++++++- generate/math_candidate_parser.py | 38 ++++++++++--------- generate/math_roundtrip.py | 36 ++++++++++++++++++ generate/recognizer_match.py | 11 ++++-- 4 files changed, 86 insertions(+), 23 deletions(-) diff --git a/docs/research/increment-1-band-plan-2026-07-19.md b/docs/research/increment-1-band-plan-2026-07-19.md index 39c5e8dc..7abf0a30 100644 --- a/docs/research/increment-1-band-plan-2026-07-19.md +++ b/docs/research/increment-1-band-plan-2026-07-19.md @@ -1,6 +1,26 @@ -# Increment-1 Band Plan — reader arc (DESIGN-FIRST, awaits ruling) +# Increment-1 Band Plan — reader arc (RULED — APPROVED TO BUILD) -**Status**: DESIGN-FIRST — ruling before any build (same gate as PR #76). +## RULING (Josh, PR #79, 2026-07-19) — approved to build under these + +- **Q1 depletion → REFUSE, confirmed.** Count depletion-containing band cases as *expected haircut*, not bugs. +- **Q2 existential → admit CONTAINER-BOUND only, refuse container-less, confirmed.** If tune surfaces a + container-bound case that is actually a partitioned total, refuse that sub-form too. +- **Q3 q:difference → DEFER the capability, CLOSE the hazard now.** Its 23 cases likely strand on + `multi-compound` (increment 2), so building the direction driver now is risk without in-band yield — + defer to where its cases convert, and **verify the co-occurrence to confirm**. BUT make the existing + partial Pattern-B ("how many more") path **fail-closed immediately**: if it can emit a guessed-direction + difference today, that is a latent wrong=0 hazard to close regardless. **Increment 1 then carries EXACTLY + ONE wrong=0 driver (#78).** +- **Sharpening 1** — allowlist-miss refusals are recorded as **curriculum** (unknown-verb refusals feed the + practice/coverage backlog, not treated as failures). +- **Sharpening 2** — the sha256 tune/measure split stays **FIXED across increments 1–3** (the ~40 bar is + cumulative; `evals/gsm8k_math/holdout_dev/v1/split.py` is frozen). +- **Sharpening 3** — the **haircut factor** ships with the triple in the final report. + +The §3/§4/§6 design below is unchanged EXCEPT: q:difference (item 4) becomes *hazard-close only*, not a +built capability; increment 1 carries one wrong=0 driver (#78), not two. + +**Status**: RULED — approved to build (measure-once). Design-first gate cleared (same gate as PR #76). **Date**: 2026-07-19 **Base**: `forgejo/main @ e1eb2a5c` (worktree `core-wt-seed`, branch `feat/seeding-injection` → to be repurposed to the band). diff --git a/generate/math_candidate_parser.py b/generate/math_candidate_parser.py index 6ebf4c1f..856b6814 100644 --- a/generate/math_candidate_parser.py +++ b/generate/math_candidate_parser.py @@ -49,6 +49,7 @@ from generate.math_problem_graph import ( ) from generate.math_roundtrip import ( ADD_VERBS, + NEUTRAL_COUNT_VERBS, SUBTRACT_VERBS, TRANSFER_VERBS, WORD_NUMBERS, @@ -1188,25 +1189,26 @@ def _resolve_reference_token(raw: str) -> tuple[str, str]: return collapsed, collapsed +# ADR-0250 increment 1 (#78) — the comparison-frame verb slot is a POSITIVE +# polarity ALLOWLIST, fail-closed: the shared NEUTRAL_COUNT_VERBS (production / +# acquisition / possession-count verbs — made/baked/grew/scored/wrote/taught/…), +# plus a few activity/possession verbs whose comparison reading is neutral +# (does/takes/gains/studies/reads + the possession lemmas). Depletion/transfer +# verbs (lose/spend/give/sell/win/use) are NOT in the set → the frame refuses +# them by positive determination (never assume-safe-unless-blocklisted). This +# is the same allowlist the seed reader uses — one piece of verb knowledge, +# built once. The round-trip filter + reference grounding remain the wrong=0 net. +_COMPARISON_ANCHOR_VERBS: Final[frozenset[str]] = NEUTRAL_COUNT_VERBS | frozenset({ + "has", "have", "had", "holds", "hold", "held", "owns", "own", "owned", + "does", "do", "did", "takes", "take", "took", + "gains", "gain", "gained", "studies", "study", "studied", "reads", "read", +}) + + def _comparison_anchor_verb() -> str: - # ADR-0131.G.2a — widen the comparison anchor verb beyond 'has'/'have'. - # The verb here only names the action whose *quantity* is being compared - # ("A N more/×-as-many X than/as B"); it does not carry polarity - # the way accumulation verbs do, so a closed set of non-inverting action - # verbs is wrong=0-safe (the round-trip filter still requires the - # comparator anchor + reference actor to ground). The set reuses the - # already-vetted legacy math_parser._COMPARE_VERB lemmas plus the - # production/activity verbs observed in real GSM8K comparative statements - # ('does'/'collected'/'gained'/'studied' …). - # - # Deliberately EXCLUDED (polarity-inverting in a comparison context — - # admitting them could read the comparison backwards → wrong>0): - # lose/lost, win/won, spend/spent, use/used, give/gave, sell/sold. - return ( - r"(?:has|have|had|gets|get|got|takes|take|took|buys|buy|bought|" - r"does|do|did|makes|make|made|collects|collect|collected|" - r"gains|gain|gained|studies|study|studied|reads|read)" - ) + return "(?:" + "|".join( + sorted(_COMPARISON_ANCHOR_VERBS, key=len, reverse=True) + ) + ")" _COMPARE_ADDITIVE_RE: Final[re.Pattern[str]] = re.compile( diff --git a/generate/math_roundtrip.py b/generate/math_roundtrip.py index 2e2ef697..8195e2db 100644 --- a/generate/math_roundtrip.py +++ b/generate/math_roundtrip.py @@ -78,6 +78,42 @@ ADD_VERBS: Final[frozenset[str]] = frozenset({ "pack", "packs", "packed", "build", "builds", "built", "grow", "grows", "grew", + # ADR-0250 increment 1 — curated neutral-gain production verbs the reader + # band needs (each asserts a COUNT of items the actor gains/produces; all + # verified gain-polarity, none depletion/transfer). Refusal-preferring: + # unknown verbs still refuse (curriculum). + "score", "scores", "scored", + "write", "writes", "wrote", + "teach", "teaches", "taught", + "plant", "plants", "planted", + "harvest", "harvests", "harvested", + "produce", "produces", "produced", + "draw", "draws", "drew", + "sing", "sings", "sang", +}) + + +# ADR-0250 increment 1 — the #78 positive-polarity ALLOWLIST (fail-closed). +# Neutral-polarity verbs that assert a COUNT of discrete possessable items the +# actor GAINS or PRODUCES — safe to read as a seed (initial/add) or as the verb +# anchor of a comparison frame ("A N times as many X as B"). This is the +# ONE piece of verb knowledge the seed + comparison readers share, built once. +# DELIBERATELY EXCLUDES: SUBTRACT_VERBS / TRANSFER_VERBS (polarity-inverting → +# the compared/seeded quantity would be a loss, reading backwards → wrong>0); +# and the ambiguous add-verbs save/add/slice/pack (save = "saved 20 minutes" is +# not a possessed count; add/slice/pack take non-count readings). Positive +# determination, never assume-safe-unless-blocklisted; unknown verb → refuse. +NEUTRAL_COUNT_VERBS: Final[frozenset[str]] = frozenset({ + "bake", "bakes", "baked", "make", "makes", "made", "cook", "cooks", "cooked", + "build", "builds", "built", "grow", "grows", "grew", + "buy", "buys", "bought", "get", "gets", "got", "find", "finds", "found", + "receive", "receives", "received", "earn", "earns", "earned", + "pick", "picks", "picked", "collect", "collects", "collected", + "gather", "gathers", "gathered", "catch", "catches", "caught", + "score", "scores", "scored", "write", "writes", "wrote", + "teach", "teaches", "taught", "plant", "plants", "planted", + "harvest", "harvests", "harvested", "produce", "produces", "produced", + "draw", "draws", "drew", "sing", "sings", "sang", }) # Surface verbs that grammatically place the actor as the *loser* of the diff --git a/generate/recognizer_match.py b/generate/recognizer_match.py index 92c29c1c..570106b5 100644 --- a/generate/recognizer_match.py +++ b/generate/recognizer_match.py @@ -32,6 +32,7 @@ from dataclasses import dataclass from typing import Any, Final, Literal, Mapping from evals.refusal_taxonomy.shape_categories import ShapeCategory +from generate.math_roundtrip import NEUTRAL_COUNT_VERBS from generate.recognizer_registry import RatifiedRecognizer @@ -1050,7 +1051,11 @@ def _try_extract_discrete_count_anchor( verb = m.group("verb").lower() if verb in _POSSESSION_VERBS: anchor_kind = "possession" - elif verb in _ACQUISITION_VERBS: + elif verb in NEUTRAL_COUNT_VERBS: + # ADR-0250 increment 1 — #78 positive-polarity allowlist. Production / + # acquisition verbs (made/baked/grew/scored/wrote/taught/…) assert a + # count the actor gains → acquisition (CandidateOperation(add), from + # zero). Depletion/transfer verbs are NOT in the allowlist → refuse. anchor_kind = "acquisition" else: return None @@ -1201,10 +1206,10 @@ def _try_extract_compound_discrete_count_anchors( verb = head_m.group("verb").lower() if verb in _POSSESSION_VERBS: anchor_kind: Literal["possession", "acquisition"] = "possession" - elif verb in _ACQUISITION_VERBS: + elif verb in NEUTRAL_COUNT_VERBS: anchor_kind = "acquisition" else: - return None # head verb not in whitelist — refuse compound + return None # head verb not in the #78 neutral-polarity allowlist — refuse compound def _resolve_count_kind(count_token: str) -> str | None: if count_token.isdigit():