diff --git a/algebra/__init__.py b/algebra/__init__.py index cf1aa1f0..6d6c3abf 100644 --- a/algebra/__init__.py +++ b/algebra/__init__.py @@ -1,5 +1,13 @@ from .cl41 import geometric_product, reverse, grade_project, scalar_part, norm_squared, basis_vector from .versor import versor_apply, normalize_to_versor, versor_condition -from .cga import cga_inner, outer_product, is_null, null_project, embed_point +from .cga import ( + EMBED_EXACT_MAX, + cga_inner, + outer_product, + is_null, + null_project, + embed_point, + read_scalar_e1, +) from .holonomy import holonomy_encode, holonomy_similarity from .rotor import word_transition_rotor diff --git a/algebra/cga.py b/algebra/cga.py index 8eae034f..42ff6b23 100644 --- a/algebra/cga.py +++ b/algebra/cga.py @@ -26,6 +26,14 @@ from .cl41 import geometric_product, scalar_part, basis_vector, N_COMPONENTS _E4_IDX = 4 _E5_IDX = 5 +# Pinned magnitude ceiling for f64-exact embedding + read-back (Phase 0A). +# Below this bound, ``embed_point(..., dtype=np.float64)`` round-trips integer +# coordinates exactly through ``read_scalar_e1`` and the conformal distance metric +# stays exact (proven in tests/test_cga_f64_exactness.py). The field-reasoner reader +# REFUSES any quantity whose magnitude exceeds this bound; the refusal lives in the +# reader — this module only states the bound. Generous vs GSM8K (quantities ~< 1e5). +EMBED_EXACT_MAX: int = 1_000_000 + def cga_inner(X: np.ndarray, Y: np.ndarray) -> float: """ @@ -62,18 +70,25 @@ def null_project(X: np.ndarray) -> np.ndarray: return embed_point(euclidean) -def embed_point(x: np.ndarray) -> np.ndarray: +def embed_point(x: np.ndarray, *, dtype: "np.typing.DTypeLike" = np.float32) -> np.ndarray: """ Embed a Euclidean point x in R^3 into the CGA null cone. X = x + n_o + 0.5|x|^2 n_inf, where n_o = 0.5(e5-e4), n_inf = e4+e5. + + ``dtype`` defaults to ``float32`` so every existing caller is byte-unchanged. + The field-reasoner reader passes ``dtype=np.float64`` to get an exact embedding: + ``geometric_product`` already preserves float64 (``np.result_type``), so the + only thing that forced f32 was this construction. f32 silently collapses the + ``n_o`` weight past ~1e4 (the ``0.5|x|^2`` terms lose the ``±1``); f64 keeps it + exact up to :data:`EMBED_EXACT_MAX` (see tests/test_cga_f64_exactness.py). """ - x = np.asarray(x, dtype=np.float32) + x = np.asarray(x, dtype=dtype) assert len(x) == 3, "embed_point expects a 3D vector" x_sq = float(np.dot(x, x)) - result = np.zeros(N_COMPONENTS, dtype=np.float32) + result = np.zeros(N_COMPONENTS, dtype=dtype) result[1:4] = x # n_o + 0.5|x|^2 n_inf @@ -82,3 +97,26 @@ def embed_point(x: np.ndarray) -> np.ndarray: result[_E4_IDX] = 0.5 * (x_sq - 1.0) result[_E5_IDX] = 0.5 * (x_sq + 1.0) return result + + +def read_scalar_e1(X: np.ndarray) -> float: + """Projective dehomogenization on the e1 axis — the exact, weight-invariant + read-back of a scalar coordinate from a (possibly dilated) conformal point. + + A point at coordinate ``v`` on the e1 number line embeds as + ``X = v*e1 + n_o + 0.5 v^2 n_inf``; a uniform conformal dilation by ``k`` + scales the whole null vector. The coordinate is recovered as + ``e1_coefficient / n_o_weight`` where the n_o weight is ``X[e5] - X[e4]`` + (== 1 for an un-dilated point), so any dilation weight divides out. This is + the correct read-back for weight-changing operators; a raw distance-from-origin + is wrong for them. + + Raises ``ValueError`` on a degenerate (zero) n_o weight — a point at infinity + or an f32 weight-collapse — rather than returning a silently wrong value. + """ + no_weight = float(X[_E5_IDX] - X[_E4_IDX]) + if no_weight == 0.0: + raise ValueError( + "read_scalar_e1: degenerate n_o weight (point at infinity or f32 collapse)" + ) + return float(X[1]) / no_weight diff --git a/core/reasoning/__init__.py b/core/reasoning/__init__.py index ccba2161..b363dd4b 100644 --- a/core/reasoning/__init__.py +++ b/core/reasoning/__init__.py @@ -7,6 +7,7 @@ from core.reasoning.evidence import ( DUPLICATE_STRUCTURAL_SIGNATURE, INSUFFICIENT_EVIDENCE, MISSING_COMMITMENT, + SAME_READER_LINEAGE, TIER2_VERIFIED, EvidenceBundle, OperatorEvidence, @@ -23,6 +24,7 @@ __all__ = [ "DUPLICATE_STRUCTURAL_SIGNATURE", "INSUFFICIENT_EVIDENCE", "MISSING_COMMITMENT", + "SAME_READER_LINEAGE", "TIER2_VERIFIED", "EvidenceBundle", "OperatorEvidence", diff --git a/core/reasoning/adapters.py b/core/reasoning/adapters.py index 2b9cd1ff..df5f01bb 100644 --- a/core/reasoning/adapters.py +++ b/core/reasoning/adapters.py @@ -47,6 +47,7 @@ def evidence_from_entailment_trace(trace: EntailmentTrace) -> OperatorEvidence: check_keys=check_keys, commitment_key=commitment_key, structural_signature=structural_signature, + reader_lineage="proof_chain.entail", payload={"entailment_trace": trace.as_dict()}, ) @@ -93,6 +94,7 @@ def evidence_from_math_solution( check_keys=(trace_hash, trace.graph_canonical_hash), commitment_key=commitment_key, structural_signature=structural_signature, + reader_lineage="math_problem_graph.solve_verify", payload={ "answer_entity": trace.answer_entity, "answer_unit": trace.answer_unit, diff --git a/core/reasoning/evidence.py b/core/reasoning/evidence.py index c0ea18bd..00d1e911 100644 --- a/core/reasoning/evidence.py +++ b/core/reasoning/evidence.py @@ -20,6 +20,7 @@ INSUFFICIENT_EVIDENCE: Final[str] = "insufficient_evidence" DUPLICATE_STRUCTURAL_SIGNATURE: Final[str] = "duplicate_structural_signature" COMMITMENT_DISAGREEMENT: Final[str] = "commitment_disagreement" MISSING_COMMITMENT: Final[str] = "missing_commitment" +SAME_READER_LINEAGE: Final[str] = "same_reader_lineage" TIER2_REASONS: Final[frozenset[str]] = frozenset({ TIER2_VERIFIED, @@ -27,6 +28,7 @@ TIER2_REASONS: Final[frozenset[str]] = frozenset({ DUPLICATE_STRUCTURAL_SIGNATURE, COMMITMENT_DISAGREEMENT, MISSING_COMMITMENT, + SAME_READER_LINEAGE, }) @@ -74,6 +76,17 @@ class OperatorEvidence: check_keys: tuple[str, ...] commitment_key: str structural_signature: str + # reader_lineage — the mechanistic identity of the decoding PATHWAY that + # produced this evidence (e.g. "proof_chain.entail"). The Tier-2 gate keys + # independence on this, not on the cosmetic structural_signature string, so a + # single reader cannot self-verify by relabeling. Deliberately EXCLUDED from + # ``as_dict``/``canonical_json`` (and therefore the evidence/trace hash): it is + # gate-routing provenance, not replayable evidence content, and including it + # would perturb the entailment trace_hash with no replay benefit. The + # static guarantee that distinct lineages are import-disjoint (cannot share a + # decoding pathway) is enforced by the architectural reader-disjointness + # invariant, not by this string alone. + reader_lineage: str = "" payload: Mapping[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: @@ -83,6 +96,7 @@ class OperatorEvidence: "outcome", "reason", "structural_signature", + "reader_lineage", ): value = getattr(self, field_name) if not isinstance(value, str) or not value.strip(): @@ -146,6 +160,7 @@ class Tier2Verdict: commitment_key: str = "" evidence_hash: str = "" structural_signatures: tuple[str, ...] = () + reader_lineages: tuple[str, ...] = () def __post_init__(self) -> None: if self.reason not in TIER2_REASONS: @@ -155,6 +170,11 @@ class Tier2Verdict: "structural_signatures", tuple(str(s) for s in self.structural_signatures), ) + object.__setattr__( + self, + "reader_lineages", + tuple(str(s) for s in self.reader_lineages), + ) def as_dict(self) -> dict[str, Any]: return { @@ -163,13 +183,23 @@ class Tier2Verdict: "commitment_key": self.commitment_key, "evidence_hash": self.evidence_hash, "structural_signatures": list(self.structural_signatures), + "reader_lineages": list(self.reader_lineages), } def verify_tier2_agreement( evidences: tuple[OperatorEvidence, ...] | list[OperatorEvidence], ) -> Tier2Verdict: - """Require two distinct structures converging on one non-empty commitment.""" + """Require two **independently-read** structures converging on one commitment. + + Independence is keyed on ``reader_lineage`` — the decoding PATHWAY — not on the + cosmetic ``structural_signature``. The same reader cannot self-verify by emitting + two differently-labeled signatures for one case: that is refused with + ``SAME_READER_LINEAGE``. The static guarantee that distinct lineages are + import-disjoint (so distinct lineage ⟹ no shared decoding pathway) is enforced + by the architectural reader-disjointness invariant; this gate enforces the + runtime half (distinct producing pathways + distinct structure + one commitment). + """ bundle = EvidenceBundle(tuple(evidences)) if len(bundle.evidences) < 2: return Tier2Verdict(False, INSUFFICIENT_EVIDENCE) @@ -177,13 +207,26 @@ def verify_tier2_agreement( if any(not ev.commitment_key for ev in bundle.evidences): return Tier2Verdict(False, MISSING_COMMITMENT, evidence_hash=bundle.evidence_hash) + lineages = tuple(ev.reader_lineage for ev in bundle.evidences) signatures = tuple(ev.structural_signature for ev in bundle.evidences) + + # Mechanistic independence firewall: at least two DISTINCT decoding pathways. + if len(set(lineages)) < 2: + return Tier2Verdict( + False, + SAME_READER_LINEAGE, + evidence_hash=bundle.evidence_hash, + structural_signatures=tuple(sorted(set(signatures))), + reader_lineages=tuple(sorted(set(lineages))), + ) + if len(set(signatures)) < 2: return Tier2Verdict( False, DUPLICATE_STRUCTURAL_SIGNATURE, evidence_hash=bundle.evidence_hash, structural_signatures=tuple(sorted(set(signatures))), + reader_lineages=tuple(sorted(set(lineages))), ) commitments = Counter(ev.commitment_key for ev in bundle.evidences) @@ -194,6 +237,7 @@ def verify_tier2_agreement( COMMITMENT_DISAGREEMENT, evidence_hash=bundle.evidence_hash, structural_signatures=tuple(sorted(set(signatures))), + reader_lineages=tuple(sorted(set(lineages))), ) return Tier2Verdict( @@ -202,4 +246,5 @@ def verify_tier2_agreement( commitment_key=shared[0], evidence_hash=bundle.evidence_hash, structural_signatures=tuple(sorted(set(signatures))), + reader_lineages=tuple(sorted(set(lineages))), ) diff --git a/docs/analysis/field-reasoner-wedge-design-and-falsification-2026-06-04.md b/docs/analysis/field-reasoner-wedge-design-and-falsification-2026-06-04.md new file mode 100644 index 00000000..477274da --- /dev/null +++ b/docs/analysis/field-reasoner-wedge-design-and-falsification-2026-06-04.md @@ -0,0 +1,362 @@ + + +# The field-reasoner wedge — design, adversarial correction, and the falsification it must pass + +This is the load-bearing research record that turns the deferred keystone of the +universal-structure plan +([`universal-structure-and-field-symbol-coherence-gate-2026-06-04.md`](./universal-structure-and-field-symbol-coherence-gate-2026-06-04.md)) +into a concrete, *falsifiable* experiment. It is planning-and-research only: it +assigns no ADR number, changes no serving path, and introduces no capability claim. + +**Provenance.** Compiled from (a) a first-hand read of the CL(4,1)/field/binding-graph/ +reliability-gate substrate, and (b) an 11-agent adversarial research workflow (4 substrate +mappers + 2 external-literature researchers → 1 design synthesis → 3 adversarial verifiers → +1 integrator). Every load-bearing claim below is confirmed against source with a `file:line` +citation. The recommended design returned **"revise" on all three adversarial lenses** +(decoration/independence, sub-enumeration, wrong=0); their required fixes are folded in. + +**The one-sentence finding.** The CL(4,1) field has exactly **one** exact native strength +relevant to reasoning — the conformal distance metric — and **none** of the incidence, +solver, or independent-reading machinery a metric reasoner needs; so the inevitable next +move is not "make the field the reasoner," it is **the cheapest honest test of whether a +metric *encoding* buys any genuine reading-independence at all**, wired so a negative result +cleanly lands the field back as a servant without polluting `wrong = 0`. + +--- + +## 1. The inevitable next level + +The architecture has exhausted the cheap moves and pinned the next one. Four constraints +intersect to a single design shape: + +1. The only verified reasoning capability today is **propositional entailment** (ROBDD, + `generate/proof_chain/entail.py`, holdout 500/500 `wrong=0` vs an independent + truth-table oracle). A clean geometric decoder of *logic* was already shown to be + `O(2^n)` truth-table enumeration in algebraic clothing — **the field cannot earn its + reasoning role on combinatorial logic**. Redirect to a **metric-continuous** domain + (distance, proportion) where CGA has real purchase. +2. **Independence must live in the *reading*, not the *solving*.** Two solvers over the + same extracted structure is fake independence; the `wrong=0` risk lives in + comprehension. For quantitative/NL problems the *reading* (text → structure) is the + unsound step — unlike logic, whose input is already a formula (independence there is + cheap: two parsers). +3. **`wrong = 0` is structural, not aspirational.** Whatever the field produces is admitted + only through a *checkable* gate. +4. The dormant reliability-gate lever `checker="t2_precision"` (`core/reliability_gate/gate.py:22,48-53`) + can fire only when **two structurally-distinct derivations converge on one + `commitment_key`** — and no genuinely-independent second derivation exists. + +Their intersection collapses the design space to one shape: **an independent metric +*reading* of quantitative-relational problem text, packaged as the field-side +`OperatorEvidence` for the existing `verify_tier2_agreement` seam, scored against a third, +code-disjoint gold registered as a new `INDEPENDENT_GOLD_LANE`.** And the inevitable *first +step* is the cheapest falsifiable version of it — because every heavier design **and the +null** branch off its result. + +--- + +## 2. What the substrate actually supports today (source-verified ledger) + +| Capability | Status | Evidence | +|---|---|---| +| Exact conformal distance metric `cga_inner(embed_point(a), embed_point(b)) = −½‖a−b‖²` | **REAL, exact** | `algebra/cga.py:30-37, 65-84`; docstring `:11-17` ("the ONLY distance metric") | +| Null-preserving conformal transforms (versors act correctly on encoded points) | **REAL** (dual-path: null input → raw sandwich) | `algebra/versor.py:160-186, 149-157` | +| Parameterized conformal motions (rotor slerp / transition rotor) | **REAL** but wired to word→word transitions, not point transforms | `algebra/rotor.py:75-144, 147-179` | +| `embed_point` precision | **f32-HARDCODED** — unusable at GSM8K scale; needs an f64 reimplementation | `algebra/cga.py:72,76,82,83`; v=12345 → 12344.99996 in f32 | +| Incidence/flat/round constructors (line/plane/sphere via wedge) | **ABSENT** | `algebra/cga.py` exports only `cga_inner / outer_product / is_null / null_project / embed_point` | +| `meet / join / dual / cross_ratio / betweenness` | **ABSENT** (zero construction hits across `algebra/field/generate/core`) | substrate audit | +| A constraint-propagation **solver** | **ABSENT** — `field/propagate.py` is a fixed-rotor walk ("no correction, no branching"); `field/operators.py::ConstraintCorrectionOperator` is **damped diffusion to a fixed prompt centroid**, NOT algebraic constraint satisfaction (the name is a trap) | `field/propagate.py:1-20,47`; `field/operators.py:153-281` | +| `versor_condition` | **unit-versor closure residual**, NOT a constraint-satisfaction measure | `algebra/versor.py:189-204` | +| Any runtime path embedding problem quantities as conformal points | **ABSENT** — `embed_point` used only in `evals/lab/` probes | substrate audit | +| Reasoning readers import field/algebra | **NONE** (`generate/derivation`, `generate/proof_chain`, `core/reliability_gate` import zero `versor_apply/cga_inner/holonomy/field.propagate`) | grep, confirmed | +| Binding-graph interlingua (`SemanticSymbolicBindingGraph`) | **EXISTS, frozen, INERT** — 6 sub-collections, refusal-first, acyclic, unit-aware, `SourceSpanLink` provenance, INV-26 neutral; derived FROM `MathProblemGraph` by `adapter.py` (so it carries no *independent* reading) | `generate/binding_graph/model.py:397-507`; `adapter.py` | +| Agreement-gate seam | **EXISTS** — `verify_tier2_agreement` admits iff ≥2 distinct `structural_signature`s collapse to one `commitment_key` | `core/reasoning/evidence.py:169-205` | +| Independence firewall on the gate's two readers | **DOES NOT EXIST** — the seam checks only `len(set(signatures)) < 2`, a free-form **label-string** test; INV-25 governs only the *oracle*, and its import check is **single-level / non-transitive** | `core/reasoning/evidence.py:180-187`; `tests/test_architectural_invariants.py:1115-1136` | +| `t2_precision` lever | **WIRED, dead** — nothing populates `t2_verified`; no serving call site passes `checker="t2_precision"`; no serving consumer of proposals | `core/reliability_gate/gate.py:22,48-53`; `propose.py:66-103`; `core/learning_arena/engine.py:80-99` | +| Independent-gold template | **EXISTS, proven** — `deductive_logic` + `dimensional` lanes (oracle shares no SUT code; gold frozen at generation; runner counts correct/wrong/refused) | `evals/deductive_logic/{oracle,generate,runner}.py`; `evals/dimensional/{oracle,runner}.py`; `INDEPENDENT_GOLD_LANES` at `tests/test_architectural_invariants.py:1088-1106` | + +**Ledger in one line:** one real native strength (the conformal distance metric); everything +else load-bearing must be built, and the precision substrate must move to f64. + +--- + +## 3. The recommended wedge — corrected form + +**Design C1 — the number-line incidence reader, sealed to forward-substitutable relations, +packaged as the field-side evidence for the existing t2 seam.** It is the smallest design +that exercises the one real substrate strength. The adversarial review did not kill it; it +corrected three things that, left unfixed, would have made it decoration or breached +`wrong=0`. + +### 3.1 The independent reading — and the honest size of the claim + +A **new allowlisted bridge** `generate/binding_graph/field_adapter.py` maps **raw problem +text** to conformal points. Its number detection, clause segmentation, unit assignment, and +relational-cue classification are an **independent reimplementation keyed off relational +phrase schemas** ("more than", "times as many", "in total", "ratio of"), importing **none** +of `generate.derivation`, `generate.math_candidate_parser`, `generate.math_roundtrip`, +`WORD_NUMBERS`, or `state/bind`. + +**Honest downgrade (required by all three lenses):** this is a *second hand-written reader*, +**not a categorically different comprehension mechanism.** There is no existing +quantity-preserving text→geometry map. By Knight–Leveson, two architecturally-disjoint +readers of the same text are exactly the regime where **correlated comprehension failure +persists** (~half of N-version faults correlate through the shared specification). +Therefore: + +- The ADR/code states independence is **a hypothesis the experiment is designed to refute**, + never a property held by construction. **Do not cite McGilchrist as evidence** — design + metaphor only; the only falsifiable form is the checkable gate. +- The independence firewall is made real and **transitive**: a new architectural invariant + follows the import graph transitively and applies to **both** readers feeding the t2 gate + (today's `_module_imports` is single-level and would miss a helper that re-pulls + `math_roundtrip`). The existing `len(set(signatures)) < 2` check is a label test and + proves nothing. + +The geometric encoding does add **one genuine marginal check the symbolic regex lacks**: for +an *over-determined* collinear configuration the read points must form a consistent set with +non-contradictory distances, so a relation the regex silently accepts can produce an +*incoherent* configuration the field refuses. That signal is **real but narrow** — vacuous +on a single unary relation, and it does **not** catch a *coherent* misreading. The margin is +measured, not asserted. + +### 3.2 The sub-enumeration argument (sealed) + +For the **forward-additive / part-whole** core the encoding is genuinely sub-enumeration and +not the logic wedge's `O(2^n)` trap: + +- "A is k more than B" → translation versor `T_k`, `A = versor_apply(T_k, B)`. +- part-whole → collinear metric sum. +- a chain of unary relations (`A = B+3; C = 2A; …`) is **one-shot closed-form forward + composition** of versors — `O(1)` per constraint, no Boolean assignment lattice. Values + live in a continuous metric space; that is the categorical difference from logic. + +Required fixes that keep this true: + +1. **Seal the micro-domain to forward-substitutable (triangular) relations.** The property + holds only where each relation pins one new unknown from already-resolved points. A + **coupled/over-determined** system is meet-of-hyperplanes = Gaussian elimination = + `O(n³)` and needs `meet/join/dual` primitives that **do not exist**. The generator and + gold exclude coupled systems. +2. **Drop the "propagate to `versor_condition<1e-6`" framing.** A forward chain has no + iterative solve and no residual; `versor_condition` measures the *operator's* closure, + not constraint satisfaction. Conflating them invites a **forbidden** hot-path normalizer + (CLAUDE.md forbids drift repair in `field/propagate.py`). +3. **The win is a sub-*enumeration encoding*, never a *solving* speedup.** Never pitch the + field as faster than Gaussian elimination. + +### 3.3 Correct answer read-back (the wrong=0 bug the verdicts caught) + +The naive read-back — "answer = signed distance from origin via `cga_inner`" — is +**mathematically wrong** for any weight-changing operator. A dilation (the multiplicative +case) yields a **weighted** null vector: dilating x=2 by k=4 gives e1=2.0, n_o-weight 0.25, +i.e. `0.25·embed_point([8,0,0])`; naive distance-from-origin returns 4.0 for true coordinate +8.0. **Fix:** read by **projective dehomogenization** — `answer = e1_coefficient / +n_o_weight` — verified exact (8.0). This is the read-back for **all** operators, since +composed motors change weight. + +Two further metric hazards the *read side* must own (not the geometry): + +- **`cga_inner` is sign-/orientation-blind** (`−d²/2` cannot distinguish A=2B from A=−2B, nor + enforce betweenness order). Orientation is a separate read-side decision the geometry does + not verify. Either add an explicit signed-coefficient orientation invariant **or refuse + all sign-ambiguous configurations.** +- **"k times" via dilation-about-origin / cross-ratio needs a non-degenerate 4-point frame**; + collinear configs are ill-conditioned. The additive/part-whole core is the load-bearing + claim; the multiplicative case is **fenced** as the conditioning risk. + +### 3.4 The wrong=0 admission contract (honest) + +The "three loud-failing layers" was inflated; two are decoration: + +- **"Geometric coherence"** on one number line is collinearity-by-construction + non- + contradictory distances — it catches *contradictory* readings, not a *single coherent + wrong* relation. +- **"Exact integer round-trip"** is decoration as a *comprehension* guard (confirmed: + `embed_point([103]), [300], [7]` each round-trip residual 0.0). A coherent misreading (300 + vs 103) passes trivially. Per CLAUDE.md's schema-defined-proof-obligation rule, demote it + to a **float-sanity guard**, not a `wrong=0` layer. + +**The real, load-bearing protection is exactly two checks:** + +1. **field⟂symbol agreement** on the **exact** `commitment_key` = (integer value + + `UnitVector` dimension). Float-tolerant equality is **never** the admit criterion — that + is precisely what sank `resolve_pooled` (right/wrong float readings indistinguishable). +2. **Scoring vs an independent gold** — a third oracle code-disjoint from **both** readers, + importing no field/algebra so it cannot agree with the field by construction. + +Plus **f64 + a pinned magnitude ceiling**: an f64 reimplementation of `X = x + n_o + +½|x|²·n_inf`, refusing any quantity above an *empirically pinned* bound (GSM8K reaches +thousands; ½|x|² ≈ 1e7; f64 recovers integers exactly to ~1e7, but the ceiling is pinned by +measurement, not assumed). Above the ceiling → refuse. + +### 3.5 The asymmetric refinement (the right second step, not the first) + +The literature's strongest guarantee is **producer/checker asymmetry** (certifying +algorithms / translation validation): an untrusted producer emits a *witness* a simpler, +code-disjoint *checker* validates without redoing the work. Symmetric "two decodings agree" +is weaker (exposed to correlated error). CORE already has an asymmetric checker for the +dimensional sub-check — `generate/binding_graph/units.py`'s `unit_proof` is free and +code-disjoint. **Use the asymmetric check where one exists** (a C2-style certifying-witness +for the dimensional component); reserve symmetric agreement only where no independent checker +is constructible. This is the correct step *after* the wedge proves the field lands a +coherent configuration at all — **not** premature surface area to build first. + +### 3.6 If the experiment fails — the sanctioned alternative (C3) + +The field stays a servant in its current cognition-turn role, and quantitative-relational +capability is carried by a **second fully-symbolic, code-disjoint reading** (a +relational-schema parser) agreeing with the existing candidate-graph reader at exact-integer +`commitment_key`s. C3 sidesteps every float/precision/orientation hazard and is the cleanest +`wrong=0` story. Its honest weakness: two symbolic readers are the closest analogue to +Knight–Leveson's refuted-independence case. **C3 is the correct landing iff the wedge shows +the field shares the symbolic reader's blind spots** — a *success*, because it answers the +field-as-reasoner question with evidence instead of deferring it. + +--- + +## 4. The falsifiable experiment (cheapest decisive test) + +**Build only:** the f64 number-line reader + four constraint versors (additive/part-whole +load-bearing; multiplicative/ratio fenced) + projective-dehomogenization read-back + the +ablation/diversity instrument. **Do not** build the C2 certifying checker yet. + +**Dataset:** ~150 seeded two/three-quantity problems ("k more than", "k times", part-whole), +gold from a **third hand-authored arithmetic oracle** sharing no code with either reader, +registered in `INDEPENDENT_GOLD_LANES`. Split into generation and a **held-out** set; measure +on held-out only (anti-overfit firewall — never tune to the 150 templates). + +**Three pass/fail measurements, wired as the gate (not diagnostics):** + +1. **Field-alone correctness** — does the field's projective-dehomogenized integer answer + equal the independent gold on committed cases, **`wrong=0`** on held-out? +2. **Ablation** — run the `wrong=0` gate **with the field vote removed** vs **with it**. The + admitted set **must CHANGE** (the field must refuse ≥1 comprehension error the symbol path + alone admits). Unchanged ⇒ **the field is decoration → land C3.** +3. **Diversity, reported PER relation class** — double-fault rate / Q-statistic / + coincident-failure diversity must clear a pinned threshold. **Aggregate is forbidden** (it + could net out signal while the field is decoration on the cases that matter). Field credit + only on classes where it demonstrably refuses an error the symbol path admits. + +**A negative result is sanctioned and expected-possible.** If (1) breaches `wrong=0` at +scale, or (2) shows an unchanged admitted set, or (3) shows high coincident failure (Q≈1), +the field is a servant here → land C3, and that result *confirms* the thesis that genuine +reading-diversity needs a categorically different mechanism — justifying a later deeper +research bet rather than this cheap wedge. No unfalsifiable "the field knows" claim is ever +permitted. + +--- + +## 5. Phased path + +> Each phase: **entry gate · exit gate · independent gold · `wrong=0` guard.** No phase +> begins until the prior phase's exit gate is green. A lookback review runs at each boundary. + +### Phase 0 — foundation (build regardless of the wedge result) +- **Build:** (a) f64 `embed_point` reimplementation + magnitude-ceiling refusal; (b) the + **transitive reader-disjointness invariant** applied to *both* readers feeding the t2 gate; + (c) replace the `verify_tier2_agreement` label-string distinctness check with a mechanistic + one (distinct signatures must be provably unable to come from the same decoding pathway); + (d) the independent-gold lane scaffolding (third arithmetic oracle, `INDEPENDENT_GOLD_LANES` + registration). +- **Exit:** all four shipped, full-suite green, INV-25/INV-26 still green; no serving path + touched. These are net hardening even if the field never reasons. + +### Phase W — field-alone metric reader + decoration instrument +- **Entry:** Phase 0 green; micro-domain sealed to forward-substitutable relations; + projective-dehomogenization read-back implemented. +- **Exit:** the three §4 measurements pass on held-out (field-alone `wrong=0`; ablation + changes the admitted set; per-class diversity above threshold) **OR** they fail → land C3 + and stop. +- **Independent gold:** third hand-authored arithmetic oracle, code-disjoint from both + readers, imports no field/algebra. +- **`wrong=0` guard:** exact integer + `UnitVector` `commitment_key` (no float tolerance); + f64 + refuse-above-ceiling; refuse sign-ambiguous configs. + +### Phase 3 — activate `t2_precision` (the dormant lever) +- **Entry:** Phase W exit green; field and symbol each emit `OperatorEvidence` with the same + exact `commitment_key` and **genuinely distinct** signatures (proven by the non-decoration + test, not by label). +- **Exit:** in sealed practice, `ClassTally.t2_precision` rises past `N_MIN`+ to the chosen θ + (PROPOSE=0.85 staging before SERVE=0.99); `propose_from_ledger(..., checker="t2_precision")` + emits a proposal; **and** a concrete serving consumer of `checker="t2_precision"` exists and + **refuses on disagreement** (close the ratify-vs-consume gap with an eval delta, not an + artifact append). +- **Independent gold:** the GoldTether earning t2_precision is independent of **both** readers + (INV-25 generalized to two SUTs). Re-using the symbolic answer as gold collapses the + firewall. +- **`wrong=0` guard:** practice scoring is gold-gated, so agree-on-wrong scores + `t2_verified=1, t2_agrees_gold=0` and **lowers** the license. The residual hazard lives + in the unbuilt serving consumer: pin its contract now — admit iff (agreement AND class + licensed); disagreement or field-refusal → deterministic refuse; no float tolerance + anywhere. The Wilson floor at θ=0.99 demands ~657 perfect committed agreements/class — the + coverage wall is the binding constraint. + +### Phase 4 — GSM8K on the structure (held-out / sealed) +- **Entry:** Phase 3 exit green; the field reader generalizes from seeded templates to **real + GSM8K** forward-substitutable relational cases (validate on `train_sample`/`holdout_dev`, + never the 150 templates). +- **Exit:** on a **sealed** GSM8K split, the agreement gate admits with **`wrong=0`**; + ablation still shows the field changing the admitted set on real cases. +- **Independent gold:** GSM8K reference answers; the gate's gold kept independent of both + readers. +- **`wrong=0` guard:** the serving-frozen lane (`scripts/verify_lane_shas.py`, `CLAIMS.md`) — + no bridge re-enables a reader without a sealed/independent `wrong=0` run. The standing + warning is `resolve_pooled` (2 right / 87 wrong): never admit a reader that cannot + distinguish its right readings from its wrong ones. + +### Phase 5 — cross-domain anti-overfit panel +- **Entry:** Phase 4 green; the field reader is a **third** golded domain alongside + `deductive_logic` and `dimensional`. +- **Exit:** registered in `INDEPENDENT_GOLD_LANES` with a code-disjoint oracle + (forbidden-prefix entries for **both** readers); INV-25 sub-properties hold; INV-26 green + (field→graph translation lives in the bridge, never in `model.py/units.py/admissibility.py`). +- **`wrong=0` guard:** full panel `wrong=0` across all three domains; a deliberately-unsound + engine must disagree with each oracle (non-vacuity). + +### And beyond +The deferred keystone remains deferred: a **categorically different comprehension mechanism** +(Gentner-style structure-mapping over a path that does not pass through any lexeme extractor) +that would make field⟂symbol independence *real by construction* rather than measured. The +wedge does **not** deliver that; it tests whether a metric *encoding* buys any independence on +the one real substrate strength. If Phase W lands C3, the "beyond" is a dedicated research +track on the comprehension mechanism — out of the near-term sequence, never licensed to +displace it. + +--- + +## 6. Risks, honest tensions, and STOP conditions + +**Tensions (named, not waved):** +- **Independence is architectural-only, not mechanistic.** A second hand-written parser is + precisely where correlated comprehension failure survives (Knight–Leveson). The claim is a + hypothesis the experiment refutes, not a property; the current firewall (a label-string + distinctness test) does not enforce it. +- **Symmetric agreement is weaker than producer/checker asymmetry.** Use the asymmetric + (certifying-witness) check where one exists (dimensional `unit_proof`); reserve symmetric + agreement only where no checker is constructible. +- **The metric is sign-/orientation-blind and the substrate has no incidence primitives.** + "Incidence" is faked via distance arithmetic; orientation is a read-side decision the + geometry does not verify — thinning the independent signal exactly on multiplicative/ratio + cases. + +**STOP (and land C3 or the servant null):** +1. Ablation shows an **unchanged admitted set** — the field is decoration. +2. Per-class diversity **collapses** (double-fault high / Q≈1) — fake independence. +3. **f64 + ceiling cannot preserve exact distance at GSM8K scale** — restrict to a band so + narrow it is not the comprehension layer, or stop. +4. **The seal cannot hold** — if real cases force coupled/over-determined systems, the + sub-enumeration claim degrades to `O(n³)` on primitives that do not exist; do **not** + fabricate `meet/join/dual`. +5. **A float coherence threshold gets reconciled with the exact admit gate by tolerance** — + the `resolve_pooled` failure mode; stop immediately, this breaches `wrong=0`. +6. **Any "the field knows" claim, or a McGilchrist-as-evidence citation, appears in code/ADR + without the checkable gate behind it** — that imports an unfalsifiable claim; revert. + +**Bottom line, without overclaim:** the field has one real, exact strength (the conformal +distance metric) and none of the reasoning machinery around it. The wedge is the cheapest +honest test of whether a metric *encoding* buys genuine reading-independence on +quantitative-relational structure. If it does — `wrong=0`, ablation-positive, diverse — it +activates the dormant `t2_precision` lever as the genuine second derivation. If it does not, +**the field stays a servant, a second code-disjoint symbolic reading carries the next level, +and that negative result is the success.** diff --git a/docs/analysis/field-wedge-ablation-result-2026-06-04.md b/docs/analysis/field-wedge-ablation-result-2026-06-04.md new file mode 100644 index 00000000..5147af4b --- /dev/null +++ b/docs/analysis/field-wedge-ablation-result-2026-06-04.md @@ -0,0 +1,83 @@ + + +# The field-reasoner wedge ablation — result: C3 (decoration on this domain) + +This records the outcome of the falsifiable experiment designed in +[`field-reasoner-wedge-design-and-falsification-2026-06-04.md`](./field-reasoner-wedge-design-and-falsification-2026-06-04.md). +The verdict is the dossier's sanctioned negative: **the field reads correctly but is +decoration here.** That is a success — it answers the field-as-reasoner question with +evidence instead of deferring it. + +## What was measured + +Two readings of the same forward-substitutable quantitative-relational micro-domain +(additive / part-whole), scored against an independent arithmetic oracle: + +- **Field reader** (`generate/relational_field_reader.py`) — reads TEXT into conformal + points on the e1 number line; additive relations are conformal translator versors; + the answer reads back by projective dehomogenization. (The geometric SUT.) +- **Symbolic reader** (`generate/relational_symbolic_reader.py`) — a competent, + code-disjoint reading by plain integer arithmetic; no `algebra` (INV-27 disjoint). + +The ablation (`evals/relational_metric/ablation.py`) runs both through the real +`verify_tier2_agreement` gate and asks the only questions that matter. + +## Result + +| Measurement | Value | Meaning | +|---|---|---| +| **field_wrong_commits** | `[]` | wrong=0 holds — the field never commits a bad integer (the per-step drift guard refuses instead). | +| **field_caught_symbolic_errors** | `[]` | The field caught **zero** comprehension errors the symbolic reader made. (Measurement #2 = fail.) | +| **field_lost_coverage** | `[rm-v1-0015]` | The only admitted-set change is the field **refusing a correct answer** at the precision ceiling — a liability, not signal. | +| **per-class diversity** | `0` everywhere | On every committable class the readers AGREE and are BOTH CORRECT: `disagree=0`, `double_fault=0`. (Measurement #3 = no diversity.) | +| **verdict** | **`C3`** | The field is decoration / a coverage liability on this domain. | + +## Why — the load-bearing insight + +**On forward-substitutable relations, geometric translation *is* arithmetic addition.** +`versor_apply(T_δ, embed[x]) = embed[x+δ]` is exactly `x + δ`. There is no metric +over-determination — no betweenness, incidence, or continuous-constraint structure — +for the field to exploit that a competent symbolic reader lacks. So the two readings are +**redundant** (the common-mode the dossier predicted via Knight–Leveson), and agreement +adds no independent soundness signal. The field's *one* genuine marginal check +(over-determination coherence) is matched by the symbolic reader's `over_determined_conflict` +refusal, so even there it is not unique. And the field's precision ceiling makes it a +strict coverage liability versus pure integer arithmetic. + +This is the deductive-logic finding's twin: there, logic was *combinatorial* not metric, +so the field could not earn it; here, the metric domain is *arithmetically trivial*, so +the geometry adds nothing. **The field earns a reasoning role only where geometry +genuinely exceeds the symbolic alternative — not yet demonstrated on any built domain.** + +## What this changes + +- **Field-as-reasoner is NOT earned.** No field vote enters any serving or capability + path. The field stays a servant (its existing cognition-turn role). No "the field + reasons" claim is made. +- **The capability path is symbolic (C3).** The symbolic reader is correct (15/15 here, + including the case the field refuses). Shipping quantitative capability safely is the + dossier's Phase 3+ on the symbolic path — **two** code-disjoint symbolic readings + agreeing + independent/sealed gold (one reader alone is the resolve_pooled risk). Not + built here; this slice answered the experiment, it did not ship serving. +- **The `t2_precision` lever stays dormant** for this domain — there is no genuine second + derivation to feed it (field⟂symbol is common-mode here). + +## What stays (all load-bearing, none reverted) + +- The **f64 conformal foundation** (Phase 0A) — net infrastructure hardening. +- The **reader-lineage firewall + INV-27** (Phase 0B/0C) — backs any future agreement gate. +- The **field reader + relational-metric lane** — a real wrong=0 demonstration that the + field *can* read (Phase W.1), and a valid 3rd independently-golded panel domain. +- The **symbolic reader + ablation instrument** — the C3 reader and a reusable decoration + test for the next domain. + +## The honest next bet (dedicated research, not the near-term sequence) + +Field-as-reasoner remains an open question for a domain where geometry genuinely beats +arithmetic: **continuous / over-determined / multi-constraint** structure (relative +position, betweenness, incidence among many points, ratio as cross-ratio on a +non-degenerate frame) — where a symbolic step-arithmetic reader and a field constraint +reading could genuinely diverge, and agreement would be a real second derivation. Until +such a domain is built and the ablation shows `field_caught_symbolic_errors > 0`, the +field stays a servant. Two negative domains (logic: combinatorial; additive: trivial) +now bound the search: the field needs *metric-nontrivial, arithmetically-hard* structure. diff --git a/evals/relational_metric/__init__.py b/evals/relational_metric/__init__.py new file mode 100644 index 00000000..026bdb02 --- /dev/null +++ b/evals/relational_metric/__init__.py @@ -0,0 +1,10 @@ +"""Relational-metric lane — the field-reasoner wedge's golded micro-domain. + +A panel of forward-substitutable quantitative-relational word problems (additive / +part-whole). The system under test is the geometric FIELD reader +(``generate.relational_field_reader``), which reads problem TEXT into conformal +points on the e1 number line and reads the answer back by projective +dehomogenization. The gold is an INDEPENDENT, pure-arithmetic oracle +(``evals.relational_metric.oracle``) that computes the answer from the structured +relations and shares no code with the reader (INV-25). +""" diff --git a/evals/relational_metric/ablation.py b/evals/relational_metric/ablation.py new file mode 100644 index 00000000..1c17b896 --- /dev/null +++ b/evals/relational_metric/ablation.py @@ -0,0 +1,162 @@ +"""The decoration instrument — does the geometric FIELD reader add independent signal? + +Measurements #2 (ablation) and #3 (per-class diversity) of the field-reasoner wedge +falsifiable experiment. For each case it runs BOTH readers and the real +``verify_tier2_agreement`` gate, then asks the only questions that matter: + +- **field_caught_symbolic_errors** — cases where the SYMBOLIC reader commits a WRONG + answer (≠ gold) but the agreement gate REFUSES (the field disagreed or refused). + This is the dossier's measurement-#2 PASS signal: the field refusing a comprehension + error the symbol path alone admits. +- **field_lost_coverage** — cases where the symbolic reader commits the CORRECT answer + but the gate refuses *because of the field* (field refused or disagreed). The field + subtracting a correct answer is a liability, not signal. +- **admitted_set_changed** — does the gate's admitted set differ from symbolic-alone? +- per-class double-fault / agreement diversity. + +VERDICT: +- ``PASS`` iff ``field_caught_symbolic_errors > 0`` (the field adds error-catching + signal) and never commits a wrong answer. +- ``C3`` (field is decoration / a coverage liability on this domain) otherwise — a + sanctioned, honest outcome: the symbolic reading carries the capability. + +Run: PYTHONPATH=. .venv/bin/python -m evals.relational_metric.ablation +""" + +from __future__ import annotations + +import json +import sys +from collections import defaultdict +from pathlib import Path +from typing import Any + +from core.reasoning import OperatorEvidence, verify_tier2_agreement +from evals.relational_metric.oracle import OracleError, oracle_answer +from generate.relational_field_reader import READER_LINEAGE as FIELD_LINEAGE +from generate.relational_field_reader import read_relational as field_read +from generate.relational_symbolic_reader import READER_LINEAGE as SYM_LINEAGE +from generate.relational_symbolic_reader import read_relational as sym_read + +_CASES = Path(__file__).resolve().parent / "v1" / "cases.jsonl" + + +def _commit_key(answer: int, unit: str | None) -> str: + return f"{answer}:{unit}" + + +def _gate_admits(field_r: Any, sym_r: Any) -> int | None: + """Run the real Tier-2 gate; return the agreed answer or None (refuse).""" + if field_r.refused or sym_r.refused: + return None + ev_field = OperatorEvidence( + domain="relational_metric", operator="field_read", outcome="committed", + reason="field_commit", input_keys=(), check_keys=(), + commitment_key=_commit_key(field_r.answer, field_r.answer_unit), + structural_signature="field.geometric.number_line", + reader_lineage=FIELD_LINEAGE, + ) + ev_sym = OperatorEvidence( + domain="relational_metric", operator="symbolic_read", outcome="committed", + reason="symbolic_commit", input_keys=(), check_keys=(), + commitment_key=_commit_key(sym_r.answer, sym_r.answer_unit), + structural_signature="symbolic.arithmetic.schema", + reader_lineage=SYM_LINEAGE, + ) + verdict = verify_tier2_agreement((ev_field, ev_sym)) + return field_r.answer if verdict.verified else None + + +def run() -> dict[str, Any]: + cases = [json.loads(l) for l in _CASES.read_text().splitlines() if l.strip()] + + rows: list[dict[str, Any]] = [] + per_class: dict[str, dict[str, int]] = defaultdict( + lambda: {"n": 0, "agree_commit": 0, "disagree": 0, + "both_correct": 0, "double_fault": 0} + ) + field_caught: list[str] = [] + field_lost_coverage: list[str] = [] + field_wrong: list[dict[str, Any]] = [] + + for case in cases: + cid, cls = case["id"], case["class"] + try: + gold = oracle_answer(case["relations"], case["query"]) + except OracleError: + continue + f = field_read(case["text"]) + s = sym_read(case["text"]) + gate = _gate_admits(f, s) + + f_ans = None if f.refused else f.answer + s_ans = None if s.refused else s.answer + sym_alone = s_ans # symbolic-alone admits its committed answer + + pc = per_class[cls] + pc["n"] += 1 + if f_ans is not None and s_ans is not None: + if f_ans == s_ans: + pc["agree_commit"] += 1 + if f_ans == gold: + pc["both_correct"] += 1 + else: + pc["disagree"] += 1 + if f_ans != gold and s_ans != gold: + pc["double_fault"] += 1 + + # The field's own wrong=0 (it must never commit a wrong answer). + if f_ans is not None and f_ans != gold: + field_wrong.append({"id": cid, "field": f_ans, "gold": gold}) + + # measurement #2 signals + if s_ans is not None and s_ans != gold and gate is None: + field_caught.append(cid) # symbolic committed WRONG; gate refused + if s_ans is not None and s_ans == gold and gate is None: + field_lost_coverage.append(cid) # symbolic correct; gate refused + + rows.append({ + "id": cid, "class": cls, "gold": gold, + "field": f_ans, "field_refused": f.refused and f.refusal_reason, + "symbolic": s_ans, "symbolic_refused": s.refused and s.refusal_reason, + "gate_admits": gate, "symbolic_alone": sym_alone, + }) + + gate_admitted = {r["id"]: r["gate_admits"] for r in rows if r["gate_admits"] is not None} + symbolic_admitted = {r["id"]: r["symbolic_alone"] for r in rows if r["symbolic_alone"] is not None} + admitted_changed = gate_admitted != symbolic_admitted + + verdict = ( + "PASS" if (field_caught and not field_wrong) + else "C3_field_is_decoration_or_liability" + ) + + return { + "total": len(rows), + "verdict": verdict, + "field_caught_symbolic_errors": field_caught, + "field_lost_coverage": field_lost_coverage, + "field_wrong_commits": field_wrong, + "admitted_set_changed": admitted_changed, + "gate_admitted_count": len(gate_admitted), + "symbolic_alone_admitted_count": len(symbolic_admitted), + "per_class": {k: dict(v) for k, v in per_class.items()}, + "rows": rows, + } + + +def main() -> int: + report = run() + summary = {k: v for k, v in report.items() if k != "rows"} + print(json.dumps(summary, indent=2)) + # The instrument is non-failing: a C3 verdict is a sanctioned, honest outcome. + # It only hard-fails if the field ever commits a WRONG answer (wrong=0 breach). + if report["field_wrong_commits"]: + print("FIELD COMMITTED A WRONG ANSWER (wrong!=0 breach):", + report["field_wrong_commits"], file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/evals/relational_metric/oracle.py b/evals/relational_metric/oracle.py new file mode 100644 index 00000000..33737b3c --- /dev/null +++ b/evals/relational_metric/oracle.py @@ -0,0 +1,78 @@ +"""Independent arithmetic oracle — the gold for the relational-metric lane. + +This is **deliberately a second, independent decision procedure**: it computes the +answer to a forward-substitutable quantitative-relational problem by plain integer +arithmetic over the *structured* relations. It never reads problem text, and it +shares **no code** with the geometric field reader under test +(``generate.relational_field_reader``) — it imports no ``algebra`` / ``field`` / +``generate`` module. Two independent procedures (the field reader off the TEXT, the +oracle off the STRUCTURE) agreeing is real evidence the field READ the text +correctly; a shared-code "gold" would only prove the reader agrees with itself +(INV-25). + +Supported relation kinds (the v1 forward-substitutable grammar): + +- ``fact`` : ``entity = value`` (a given quantity) +- ``more_than`` : ``entity = ref + delta`` +- ``fewer_than`` : ``entity = ref - delta`` +- ``sum_of`` : ``entity = sum(parts)`` (part-whole / total) + +Every relation's references must already be resolved (forward-substitutable / +triangular). The oracle refuses anything else, never guesses. +""" + +from __future__ import annotations + +from typing import Any + + +class OracleError(ValueError): + """Malformed or out-of-grammar case — the oracle refuses, never guesses.""" + + +_SUPPORTED = frozenset({"fact", "more_than", "fewer_than", "sum_of"}) + + +def oracle_answer(relations: list[dict[str, Any]], query: dict[str, Any]) -> int: + """Compute the integer answer by forward substitution over the relations. + + Raises :class:`OracleError` on an unknown relation kind, a forward reference to + an unresolved entity, a duplicate definition, or a missing query entity. + """ + values: dict[str, int] = {} + + for rel in relations: + kind = rel.get("kind") + entity = rel.get("entity") + if kind not in _SUPPORTED: + raise OracleError(f"unsupported relation kind: {kind!r}") + if not isinstance(entity, str) or not entity: + raise OracleError(f"relation missing entity: {rel!r}") + if entity in values: + raise OracleError(f"duplicate definition of {entity!r}") + + if kind == "fact": + value = rel.get("value") + if not isinstance(value, int) or isinstance(value, bool): + raise OracleError(f"fact value must be int: {rel!r}") + values[entity] = value + elif kind in ("more_than", "fewer_than"): + ref = rel.get("ref") + delta = rel.get("delta") + if ref not in values: + raise OracleError(f"forward reference to unresolved {ref!r}") + if not isinstance(delta, int) or isinstance(delta, bool): + raise OracleError(f"delta must be int: {rel!r}") + values[entity] = values[ref] + (delta if kind == "more_than" else -delta) + else: # sum_of + parts = rel.get("parts") + if not isinstance(parts, list) or not parts: + raise OracleError(f"sum_of needs non-empty parts: {rel!r}") + if any(p not in values for p in parts): + raise OracleError(f"sum_of references unresolved part: {rel!r}") + values[entity] = sum(values[p] for p in parts) + + target = query.get("entity") + if target not in values: + raise OracleError(f"query entity {target!r} not resolved") + return values[target] diff --git a/evals/relational_metric/runner.py b/evals/relational_metric/runner.py new file mode 100644 index 00000000..e5a0c902 --- /dev/null +++ b/evals/relational_metric/runner.py @@ -0,0 +1,98 @@ +"""Relational-metric lane runner — field reader vs the independent arithmetic gold. + +For each committed case: + +1. the independent oracle recomputes the gold from the STRUCTURED relations + (gold-integrity: a committed gold that the oracle cannot reproduce is rejected, + so the gold can never be field-derived — INV-25); +2. the geometric field reader reads the TEXT into an answer (or refuses); +3. the field's committed answer is scored against that gold. + +Buckets: ``correct`` (field == gold), ``wrong`` (field committed a different +integer), ``refused`` (field declined). ``wrong`` must be 0 — the runner exits 1 +otherwise. A refusal is honest coverage, not a wrong answer. + +Run: PYTHONPATH=. .venv/bin/python -m evals.relational_metric.runner +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +from evals.relational_metric.oracle import OracleError, oracle_answer +from generate.relational_field_reader import read_relational + +_CASES = Path(__file__).resolve().parent / "v1" / "cases.jsonl" + + +def _load_cases() -> list[dict[str, Any]]: + cases: list[dict[str, Any]] = [] + for line in _CASES.read_text(encoding="utf-8").splitlines(): + if line.strip(): + cases.append(json.loads(line)) + return cases + + +def run() -> dict[str, Any]: + """Run the lane and return a structured report.""" + cases = _load_cases() + correct = 0 + wrong: list[dict[str, Any]] = [] + refused: list[str] = [] + gold_integrity_failures: list[str] = [] + + for case in cases: + cid = case["id"] + committed_gold = case["gold"] + # 1 — independent gold integrity (the oracle must reproduce the committed gold) + try: + recomputed = oracle_answer(case["relations"], case["query"]) + except OracleError: + gold_integrity_failures.append(f"{cid}: oracle could not reproduce gold") + continue + if recomputed != committed_gold: + gold_integrity_failures.append( + f"{cid}: oracle={recomputed} != committed gold={committed_gold}" + ) + continue + + # 2/3 — the field reads the TEXT, scored against the independent gold + reading = read_relational(case["text"]) + if reading.refused: + refused.append(f"{cid}: {reading.refusal_reason}") + elif reading.answer == committed_gold: + correct += 1 + else: + wrong.append( + {"id": cid, "field": reading.answer, "gold": committed_gold} + ) + + return { + "total": len(cases), + "correct": correct, + "wrong": len(wrong), + "refused": len(refused), + "wrong_detail": wrong, + "refused_detail": refused, + "gold_integrity_failures": gold_integrity_failures, + } + + +def main() -> int: + report = run() + print(json.dumps({k: v for k, v in report.items() + if k not in ("wrong_detail",)}, indent=2)) + if report["gold_integrity_failures"]: + print("GOLD INTEGRITY FAILURE:", report["gold_integrity_failures"], file=sys.stderr) + return 1 + if report["wrong"] > 0: + print("WRONG ANSWERS (wrong!=0 breach):", report["wrong_detail"], file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/evals/relational_metric/v1/cases.jsonl b/evals/relational_metric/v1/cases.jsonl new file mode 100644 index 00000000..11aedd54 --- /dev/null +++ b/evals/relational_metric/v1/cases.jsonl @@ -0,0 +1,15 @@ +{"id": "rm-v1-0001", "text": "Liam has 6 stickers. Mia has 4 more stickers than Liam. How many stickers does Mia have?", "relations": [{"kind": "fact", "entity": "liam", "value": 6}, {"kind": "more_than", "entity": "mia", "ref": "liam", "delta": 4}], "query": {"entity": "mia", "unit": "stickers"}, "gold": 10, "class": "additive_more_than"} +{"id": "rm-v1-0002", "text": "Noah has 15 cards. Olivia has 6 fewer cards than Noah. How many cards does Olivia have?", "relations": [{"kind": "fact", "entity": "noah", "value": 15}, {"kind": "fewer_than", "entity": "olivia", "ref": "noah", "delta": 6}], "query": {"entity": "olivia", "unit": "cards"}, "gold": 9, "class": "additive_fewer_than"} +{"id": "rm-v1-0003", "text": "Ava has 8 pencils. Ben has 5 more pencils than Ava. Cara has 3 more pencils than Ben. How many pencils does Cara have?", "relations": [{"kind": "fact", "entity": "ava", "value": 8}, {"kind": "more_than", "entity": "ben", "ref": "ava", "delta": 5}, {"kind": "more_than", "entity": "cara", "ref": "ben", "delta": 3}], "query": {"entity": "cara", "unit": "pencils"}, "gold": 16, "class": "additive_chain"} +{"id": "rm-v1-0004", "text": "Dan has 7 coins. Eva has 9 more coins than Dan. How many coins do Dan and Eva have?", "relations": [{"kind": "fact", "entity": "dan", "value": 7}, {"kind": "more_than", "entity": "eva", "ref": "dan", "delta": 9}, {"kind": "sum_of", "entity": "total", "parts": ["dan", "eva"]}], "query": {"entity": "total", "unit": "coins"}, "gold": 23, "class": "part_whole_sum"} +{"id": "rm-v1-0005", "text": "Finn has 12 books. How many books does Finn have?", "relations": [{"kind": "fact", "entity": "finn", "value": 12}], "query": {"entity": "finn", "unit": "books"}, "gold": 12, "class": "fact_only"} +{"id": "rm-v1-0006", "text": "Gabe has 30 apples. Hana has 10 fewer apples than Gabe. How many apples does Hana have?", "relations": [{"kind": "fact", "entity": "gabe", "value": 30}, {"kind": "fewer_than", "entity": "hana", "ref": "gabe", "delta": 10}], "query": {"entity": "hana", "unit": "apples"}, "gold": 20, "class": "additive_fewer_than"} +{"id": "rm-v1-0007", "text": "Iris has 100 dollars. Jack has 250 more dollars than Iris. How many dollars does Jack have?", "relations": [{"kind": "fact", "entity": "iris", "value": 100}, {"kind": "more_than", "entity": "jack", "ref": "iris", "delta": 250}], "query": {"entity": "jack", "unit": "dollars"}, "gold": 350, "class": "additive_more_than"} +{"id": "rm-v1-0008", "text": "Kim has 2 marbles. Leo has 3 more marbles than Kim. How many marbles do Kim and Leo have?", "relations": [{"kind": "fact", "entity": "kim", "value": 2}, {"kind": "more_than", "entity": "leo", "ref": "kim", "delta": 3}, {"kind": "sum_of", "entity": "total", "parts": ["kim", "leo"]}], "query": {"entity": "total", "unit": "marbles"}, "gold": 7, "class": "part_whole_sum"} +{"id": "rm-v1-0009", "text": "Maya has 40 beads. Nico has 18 fewer beads than Maya. How many beads does Nico have?", "relations": [{"kind": "fact", "entity": "maya", "value": 40}, {"kind": "fewer_than", "entity": "nico", "ref": "maya", "delta": 18}], "query": {"entity": "nico", "unit": "beads"}, "gold": 22, "class": "additive_fewer_than"} +{"id": "rm-v1-0010", "text": "Omar has 5 tokens. Pia has 5 more tokens than Omar. Quinn has 5 more tokens than Pia. How many tokens does Quinn have?", "relations": [{"kind": "fact", "entity": "omar", "value": 5}, {"kind": "more_than", "entity": "pia", "ref": "omar", "delta": 5}, {"kind": "more_than", "entity": "quinn", "ref": "pia", "delta": 5}], "query": {"entity": "quinn", "unit": "tokens"}, "gold": 15, "class": "additive_chain"} +{"id": "rm-v1-0011", "text": "Rosa has 9 ribbons. Sam has 14 more ribbons than Rosa. How many ribbons do Rosa and Sam have?", "relations": [{"kind": "fact", "entity": "rosa", "value": 9}, {"kind": "more_than", "entity": "sam", "ref": "rosa", "delta": 14}, {"kind": "sum_of", "entity": "total", "parts": ["rosa", "sam"]}], "query": {"entity": "total", "unit": "ribbons"}, "gold": 32, "class": "part_whole_sum"} +{"id": "rm-v1-0012", "text": "Tara has 11 stamps. Uma has 11 fewer stamps than Tara. How many stamps does Uma have?", "relations": [{"kind": "fact", "entity": "tara", "value": 11}, {"kind": "fewer_than", "entity": "uma", "ref": "tara", "delta": 11}], "query": {"entity": "uma", "unit": "stamps"}, "gold": 0, "class": "additive_fewer_than"} +{"id": "rm-v1-0013", "text": "Vera has 1200 points. Will has 800 more points than Vera. How many points does Will have?", "relations": [{"kind": "fact", "entity": "vera", "value": 1200}, {"kind": "more_than", "entity": "will", "ref": "vera", "delta": 800}], "query": {"entity": "will", "unit": "points"}, "gold": 2000, "class": "additive_more_than"} +{"id": "rm-v1-0014", "text": "Xena has 25 shells. Yara has 13 more shells than Xena. Zane has 7 fewer shells than Yara. How many shells does Zane have?", "relations": [{"kind": "fact", "entity": "xena", "value": 25}, {"kind": "more_than", "entity": "yara", "ref": "xena", "delta": 13}, {"kind": "fewer_than", "entity": "zane", "ref": "yara", "delta": 7}], "query": {"entity": "zane", "unit": "shells"}, "gold": 31, "class": "additive_chain"} +{"id": "rm-v1-0015", "text": "Gus has 9000000 apples. How many apples does Gus have?", "relations": [{"kind": "fact", "entity": "gus", "value": 9000000}], "query": {"entity": "gus", "unit": "apples"}, "gold": 9000000, "class": "coverage_over_ceiling"} diff --git a/generate/relational_field_reader.py b/generate/relational_field_reader.py new file mode 100644 index 00000000..166ff3ad --- /dev/null +++ b/generate/relational_field_reader.py @@ -0,0 +1,211 @@ +"""The geometric FIELD reader for forward-substitutable quantitative relations. + +Phase W of the field-reasoner wedge +(docs/analysis/field-reasoner-wedge-design-and-falsification-2026-06-04.md). + +This is the system under test: it reads problem **text** into conformal points on +the e1 number line (``algebra.cga.embed_point`` at float64), resolves each unknown +by applying a conformal **translator versor** (``algebra.versor.versor_apply``), and +reads the answer back by **projective dehomogenization** (``read_scalar_e1``). The +resolution is genuinely geometric: ``versor_apply(T_delta, embed([x])) == embed([x+δ])`` +exactly (verified), so "A is δ more than B" is a translation, not a hidden add. + +It is a **second, hand-written reader**: its tokenizer / number extraction / relation +classification are an independent reimplementation. It imports **nothing** from +``generate.derivation`` / ``generate.math_candidate_parser`` / ``generate.math_*`` / +``WORD_NUMBERS`` — so its agreement with a symbolic reader is not common-mode by +construction (the disjointness is *proven* by INV-27, not asserted here). + +**Refusal-first.** It commits an answer only inside a narrow, sealed grammar +(digit integers; ``has``/``more``/``fewer``/``than``/``how many`` cues; additive and +part-whole only). It REFUSES — never guesses — on: + +- multiplicative / ratio cues (``times``/``twice``/``double``/``ratio`` …) — fenced, + because ``cga_inner`` is sign/orientation-blind (A=2B vs A=−2B); +- any quantity above :data:`algebra.cga.EMBED_EXACT_MAX` (precision ceiling); +- a non-forward-substitutable reference (an unknown referenced before it is defined); +- a negative resolved quantity, a non-integer read-back, or any unparsed sentence. + +The answer is exact-integer; there is no float tolerance anywhere on the commit path. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +import numpy as np + +from algebra.cga import EMBED_EXACT_MAX, embed_point, read_scalar_e1 +from algebra.cl41 import N_COMPONENTS, geometric_product +from algebra.versor import versor_apply + +# The reader's stable identity for the Tier-2 gate (registered in INV-27). +READER_LINEAGE: str = "field.relational_number_line" + +_F64 = np.float64 + +# Multiplicative / ratio cues are out of the sealed metric domain — refuse. +_FENCED_CUE = re.compile( + r"\b(times|twice|double|triple|thrice|half|quarter|ratio|per|each)\b" +) + +_MORE_THAN = re.compile(r"\b(\w+)\s+(?:has|have)\s+(\d+)\s+more\s+\w+\s+than\s+(\w+)") +_FEWER_THAN = re.compile( + r"\b(\w+)\s+(?:has|have)\s+(\d+)\s+(?:fewer|less)\s+\w+\s+than\s+(\w+)" +) +_FACT = re.compile(r"\b(\w+)\s+(?:has|have)\s+(\d+)\s+(\w+)") +_QUERY_SINGLE = re.compile(r"how many\s+(\w+)\s+does\s+(\w+)\s+have") +_QUERY_SUM = re.compile( + r"how many\s+(\w+)\s+do\s+(\w+)\s+and\s+(\w+)(?:\s+and\s+(\w+))?\s+have" +) + + +class FieldReaderError(ValueError): + """Internal signal that a case is out of the sealed grammar (→ typed refusal).""" + + +@dataclass(frozen=True, slots=True) +class FieldReading: + """Result of the geometric reading. ``answer is None`` iff ``refused``.""" + + refused: bool + answer: int | None = None + answer_unit: str | None = None + refusal_reason: str | None = None + reader_lineage: str = READER_LINEAGE + + +def _refuse(reason: str) -> FieldReading: + return FieldReading(refused=True, refusal_reason=reason) + + +def _translator_e1(delta: int) -> np.ndarray: + """The conformal translator versor that shifts an e1-axis point by ``delta``. + + ``T = 1 − ½·(δ·e1)·n_inf`` with ``n_inf = e4 + e5``. For null n_inf the + exponential series truncates exactly, so ``versor_apply(T, embed([x]))`` lands + on ``embed([x+δ])`` with zero residual (the metric stays exact in f64). + """ + n_inf = np.zeros(N_COMPONENTS, dtype=_F64) + n_inf[4] = 1.0 + n_inf[5] = 1.0 + e1 = np.zeros(N_COMPONENTS, dtype=_F64) + e1[1] = float(delta) + t = geometric_product(e1, n_inf) + rotor = np.zeros(N_COMPONENTS, dtype=_F64) + rotor[0] = 1.0 + return rotor - 0.5 * t + + +def _check_magnitude(value: int) -> None: + if abs(value) > EMBED_EXACT_MAX: + raise FieldReaderError("over_ceiling") + + +def _apply_delta(point: np.ndarray, delta: int) -> np.ndarray: + """Translate an e1 point by ``delta`` and PROVE the move was exact. + + The translator sandwich loses f64 integer exactness when ``δ·x²`` approaches + 2^52, which would silently commit a plausible-but-wrong integer (the resolve_pooled + failure mode). So we verify the read-back moved by *exactly* ``delta`` and refuse + (``precision_drift``) otherwise — the field never commits a drifted answer. + """ + before = _read_int(point) + moved = versor_apply(_translator_e1(delta), point) + if read_scalar_e1(moved) != before + delta: # exact integer move, or refuse + raise FieldReaderError("precision_drift") + return moved + + +def _sentences(text: str) -> list[str]: + return [s.strip() for s in re.split(r"[.?!]", text.lower()) if s.strip()] + + +def read_relational(text: str) -> FieldReading: + """Read problem ``text`` geometrically into an exact integer answer, or refuse.""" + if not isinstance(text, str) or not text.strip(): + return _refuse("empty_input") + if _FENCED_CUE.search(text.lower()): + return _refuse("fenced_multiplicative") + + try: + return _read(text) + except FieldReaderError as exc: + return _refuse(str(exc)) + + +def _read(text: str) -> FieldReading: + sentences = _sentences(text) + + # --- locate the query (the question sentence) ------------------------------- + query_unit: str | None = None + query_single: str | None = None + query_parts: list[str] | None = None + for s in sentences: + if "how many" in s: + if (m := _QUERY_SUM.search(s)) is not None: + query_unit = m.group(1) + query_parts = [g for g in m.groups()[1:] if g] + elif (m := _QUERY_SINGLE.search(s)) is not None: + query_unit = m.group(1) + query_single = m.group(2) + else: + raise FieldReaderError("unparsed_query") + break + if query_single is None and query_parts is None: + raise FieldReaderError("no_query") + + # --- parse the declarative sentences, in order ------------------------------ + points: dict[str, np.ndarray] = {} + + def resolve(entity: str) -> None: + if entity not in points: + raise FieldReaderError("non_forward_substitutable") + + for s in sentences: + if "how many" in s: + continue + if (m := _MORE_THAN.search(s)) is not None: + entity, n, ref = m.group(1), int(m.group(2)), m.group(3) + _check_magnitude(n) + resolve(ref) + points[entity] = _apply_delta(points[ref], n) + elif (m := _FEWER_THAN.search(s)) is not None: + entity, n, ref = m.group(1), int(m.group(2)), m.group(3) + _check_magnitude(n) + resolve(ref) + points[entity] = _apply_delta(points[ref], -n) + elif (m := _FACT.search(s)) is not None: + entity, n = m.group(1), int(m.group(2)) + _check_magnitude(n) + points[entity] = embed_point(np.array([float(n), 0.0, 0.0]), dtype=_F64) + else: + raise FieldReaderError("unparsed_sentence") + + # --- read the answer back off the geometry ---------------------------------- + if query_single is not None: + if query_single not in points: + raise FieldReaderError("query_entity_unresolved") + answer = _read_int(points[query_single]) + else: + assert query_parts is not None + total = embed_point(np.array([0.0, 0.0, 0.0]), dtype=_F64) + for part in query_parts: + if part not in points: + raise FieldReaderError("query_entity_unresolved") + total = _apply_delta(total, _read_int(points[part])) + answer = _read_int(total) + + if answer < 0: + raise FieldReaderError("negative_quantity") + return FieldReading(refused=False, answer=answer, answer_unit=query_unit) + + +def _read_int(point: np.ndarray) -> int: + """Projective read-back, refusing any non-integer coordinate (no float slack).""" + value = read_scalar_e1(point) + rounded = round(value) + if abs(value - rounded) > 0.0: # exact-integer commit path; no tolerance + raise FieldReaderError("non_integer_readback") + return int(rounded) diff --git a/generate/relational_symbolic_reader.py b/generate/relational_symbolic_reader.py new file mode 100644 index 00000000..dc725461 --- /dev/null +++ b/generate/relational_symbolic_reader.py @@ -0,0 +1,127 @@ +"""The SYMBOLIC reader for forward-substitutable quantitative relations. + +The second, code-disjoint reading of the same micro-domain as +``generate.relational_field_reader`` — but it resolves by plain integer arithmetic +over a parsed schema, importing **no** ``algebra`` / ``field`` (proven import-disjoint +from the field reader by INV-27). It is: + +- the control arm of the ablation experiment (does the geometric FIELD reader add any + signal over a competent symbolic reader, or is it decoration?), and +- the C3 capability path if the field does not earn its role (a second independent + reading whose agreement with another reader is the wrong=0 gate). + +It is a *competent* reader, not a strawman: it reads the same grammar and applies the +same fences (multiplicative/ratio, forward-substitutability, negatives), and it +detects over-determination (a quantity stated two ways that conflict) and refuses — +so the ablation is a fair test. It has NO float/precision limit (pure int), so unlike +the field it commits arbitrarily large quantities. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +# Stable identity for the Tier-2 gate (registered in INV-27). +READER_LINEAGE: str = "symbolic.relational_schema" + +_FENCED_CUE = re.compile( + r"\b(times|twice|double|triple|thrice|half|quarter|ratio|per|each)\b" +) + +# Independent regex set (own spelling/order) — same grammar, different implementation. +_REL_MORE = re.compile(r"(\w+) (?:has|have) (\d+) more \w+ than (\w+)") +_REL_FEWER = re.compile(r"(\w+) (?:has|have) (\d+) (?:fewer|less) \w+ than (\w+)") +_REL_FACT = re.compile(r"(\w+) (?:has|have) (\d+) (\w+)") +_Q_SINGLE = re.compile(r"how many (\w+) does (\w+) have") +_Q_SUM = re.compile(r"how many (\w+) do (\w+) and (\w+)(?: and (\w+))? have") + + +class SymbolicReaderError(ValueError): + """Out-of-grammar case → typed refusal.""" + + +@dataclass(frozen=True, slots=True) +class SymbolicReading: + refused: bool + answer: int | None = None + answer_unit: str | None = None + refusal_reason: str | None = None + reader_lineage: str = READER_LINEAGE + + +def _refuse(reason: str) -> SymbolicReading: + return SymbolicReading(refused=True, refusal_reason=reason) + + +def read_relational(text: str) -> SymbolicReading: + if not isinstance(text, str) or not text.strip(): + return _refuse("empty_input") + if _FENCED_CUE.search(text.lower()): + return _refuse("fenced_multiplicative") + try: + return _read(text.lower()) + except SymbolicReaderError as exc: + return _refuse(str(exc)) + + +def _set(values: dict[str, int], entity: str, value: int) -> None: + """Assign, detecting over-determination (a conflicting re-statement).""" + if entity in values and values[entity] != value: + raise SymbolicReaderError("over_determined_conflict") + values[entity] = value + + +def _read(text: str) -> SymbolicReading: + parts = [s.strip() for s in re.split(r"[.?!]", text) if s.strip()] + + unit: str | None = None + q_single: str | None = None + q_sum: list[str] | None = None + for s in parts: + if "how many" not in s: + continue + if (m := _Q_SUM.search(s)) is not None: + unit = m.group(1) + q_sum = [g for g in m.groups()[1:] if g] + elif (m := _Q_SINGLE.search(s)) is not None: + unit, q_single = m.group(1), m.group(2) + else: + raise SymbolicReaderError("unparsed_query") + break + if q_single is None and q_sum is None: + raise SymbolicReaderError("no_query") + + values: dict[str, int] = {} + for s in parts: + if "how many" in s: + continue + if (m := _REL_MORE.search(s)) is not None: + ent, n, ref = m.group(1), int(m.group(2)), m.group(3) + if ref not in values: + raise SymbolicReaderError("non_forward_substitutable") + _set(values, ent, values[ref] + n) + elif (m := _REL_FEWER.search(s)) is not None: + ent, n, ref = m.group(1), int(m.group(2)), m.group(3) + if ref not in values: + raise SymbolicReaderError("non_forward_substitutable") + _set(values, ent, values[ref] - n) + elif (m := _REL_FACT.search(s)) is not None: + ent, n = m.group(1), int(m.group(2)) + _set(values, ent, n) + else: + raise SymbolicReaderError("unparsed_sentence") + + if q_single is not None: + if q_single not in values: + raise SymbolicReaderError("query_entity_unresolved") + answer = values[q_single] + else: + assert q_sum is not None + if any(p not in values for p in q_sum): + raise SymbolicReaderError("query_entity_unresolved") + answer = sum(values[p] for p in q_sum) + + if answer < 0: + raise SymbolicReaderError("negative_quantity") + return SymbolicReading(refused=False, answer=answer, answer_unit=unit) diff --git a/tests/test_architectural_invariants.py b/tests/test_architectural_invariants.py index 26cd7bdc..95fd7265 100644 --- a/tests/test_architectural_invariants.py +++ b/tests/test_architectural_invariants.py @@ -1103,6 +1103,19 @@ INDEPENDENT_GOLD_LANES: tuple[IndependentGoldLane, ...] = ( oracle_module="evals/dimensional/oracle.py", sut_import_prefixes=("generate.binding_graph",), ), + # The relational-metric lane (field-reasoner wedge): the geometric field reader + # (generate.relational_field_reader, which reads TEXT into conformal points) is + # the SUT, so its arithmetic gold oracle must share no code with the reader or + # the field engine it rides on. + IndependentGoldLane( + name="relational_metric", + oracle_module="evals/relational_metric/oracle.py", + sut_import_prefixes=( + "generate.relational_field_reader", + "algebra", + "field", + ), + ), ) _DEDUCTIVE_CASE_FILES: tuple[str, ...] = ( @@ -1392,3 +1405,195 @@ class TestINV26InterlinguaNeutrality: "INV-26b predicate failed to detect the adapter's domain-reader " "import — the core-isolation check is vacuous." ) + + +# =========================================================================== +# INV-27 Tier-2 reader disjointness — the two readers whose AGREEMENT the +# Tier-2 gate trusts must share no decoding pathway +# =========================================================================== +# +# Claim (docs/analysis/field-reasoner-wedge-design-and-falsification-2026-06-04 +# .md §3.1; the decoration firewall): +# +# ``verify_tier2_agreement`` keys independence on ``reader_lineage`` — the +# decoding PATHWAY — not on the cosmetic ``structural_signature``. That runtime +# check is only load-bearing if "distinct lineage" actually means "no shared +# decoding pathway." A string alone cannot prove that. This invariant supplies +# the static proof: every reader that EMITS a Tier-2 lineage is registered with +# its entry module, and any two registered readers' TRANSITIVE first-party +# import sets are disjoint (modulo an explicit pure-data neutral allowlist). +# +# The old gate admitted on ``len(set(signatures)) < 2`` — a label test that a +# single reader could satisfy by relabeling. The single-level INV-25 import +# check would also miss a helper that re-pulls a shared reader transitively; +# this check is TRANSITIVE. +# +# 27a Every ``reader_lineage="..."`` literal emitted in core/reasoning/adapters +# is registered in TIER2_READER_PATHWAYS (so its disjointness is proven). +# 27b Each registered reader module exists (drift guard). +# 27c Every pair of registered readers is transitively import-disjoint, except +# modules explicitly allowlisted as pure-data neutral meeting points. +# 27d Non-vacuity: the disjointness predicate flags a genuinely-shared pair and +# the neutral allowlist genuinely clears one — proving 27c can fail. + +import dataclasses as _dataclasses + +# Top-level first-party package roots. An imported module whose first dotted +# component is one of these is project code (a potential shared decoding pathway); +# stdlib and third-party (numpy, ...) are not. +_FIRST_PARTY_ROOTS: frozenset[str] = frozenset({ + "generate", "core", "algebra", "field", "language_packs", "vault", "chat", + "teaching", "sensorium", "calibration", "evals", "ingest", "recognition", + "formation", "morphology", "vocab", "session", "contemplation", "persona", + "alignment", "probe", "core_ingest", "core_rs", +}) + + +@_dataclasses.dataclass(frozen=True) +class Tier2Reader: + """A reader whose evidence the Tier-2 gate may treat as an independent vote.""" + + lineage: str # must equal the ``reader_lineage`` this reader emits + module: str # repo-relative path to the reader's entry module + + +# Readers whose agreement the Tier-2 gate is allowed to trust. Adding one is the +# reviewable seam: it asserts (and INV-27c proves) the new reader shares no +# decoding pathway with the others. +TIER2_READER_PATHWAYS: tuple[Tier2Reader, ...] = ( + Tier2Reader("proof_chain.entail", "generate/proof_chain/entail.py"), + Tier2Reader("math_problem_graph.solve_verify", "generate/math_solver.py"), +) + +# Pure-data / contract modules that are a NEUTRAL meeting point, not a shared +# decoding pathway: two readers may both import these without their agreement +# becoming common-mode. Every entry must be parser/solver-free (frozen data or +# pure algebra). EMPTY today — the current readers share nothing; the field +# reader (Phase W) will add ``generate.binding_graph.model`` here, with review. +TIER2_NEUTRAL_MODULES: frozenset[str] = frozenset() + + +def _is_first_party(mod: str) -> bool: + return mod.split(".")[0] in _FIRST_PARTY_ROOTS + + +def _resolve_module(mod: str) -> Path | None: + base = _REPO_ROOT / mod.replace(".", "/") + if (f := base.with_suffix(".py")).is_file(): + return f + if (pkg := base / "__init__.py").is_file(): + return pkg + return None + + +def _transitive_first_party_imports(entry_module: str) -> set[str]: + """Every first-party module reachable from ``entry_module`` via imports. + + Follows ``import``/``from ... import`` edges through first-party modules only, + so a reader that pulls a shared comprehension helper three hops deep is still + caught — unlike the single-level :func:`_module_imports`. + """ + seen: set[str] = set() + stack = [entry_module] + while stack: + mod = stack.pop() + if mod in seen: + continue + path = _resolve_module(mod) + if path is None: + continue + seen.add(mod) + for imported in _module_imports(path): + if _is_first_party(imported) and imported not in seen: + stack.append(imported) + return seen + + +def _shared_first_party(a: set[str], b: set[str], neutral: frozenset[str]) -> set[str]: + """First-party modules both sets import, minus the neutral allowlist.""" + return (a & b) - set(neutral) + + +def _module_name_of(rel_path: str) -> str: + stem = rel_path[:-3] if rel_path.endswith(".py") else rel_path + return stem.replace("/", ".") + + +class TestINV27Tier2ReaderDisjointness: + """The two readers whose agreement the Tier-2 gate trusts share no decoding + pathway — so ``verify_tier2_agreement``'s lineage check is load-bearing.""" + + def test_emitted_lineages_are_registered(self): + """27a — every reader_lineage a producer emits is registered, so its + disjointness is actually proven below (not merely asserted by a string).""" + adapters = _REPO_ROOT / "core" / "reasoning" / "adapters.py" + tree = ast.parse(adapters.read_text()) + emitted: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Call): + for kw in node.keywords: + if kw.arg == "reader_lineage" and isinstance(kw.value, ast.Constant): + if isinstance(kw.value.value, str): + emitted.add(kw.value.value) + registered = {r.lineage for r in TIER2_READER_PATHWAYS} + unregistered = sorted(emitted - registered) + assert not unregistered, ( + f"Reader(s) emit unregistered Tier-2 lineage(s): {unregistered}. " + "Register them in TIER2_READER_PATHWAYS so INV-27c proves they share " + "no decoding pathway, or the gate's independence check is unbacked." + ) + + def test_registered_reader_modules_exist(self): + """27b — drift guard: a registered module that moved makes 27c vacuous.""" + missing = [r.module for r in TIER2_READER_PATHWAYS + if not (_REPO_ROOT / r.module).is_file()] + assert not missing, ( + f"Registered Tier-2 reader module(s) not found: {missing}. " + "Update TIER2_READER_PATHWAYS." + ) + + def test_registered_readers_are_pairwise_import_disjoint(self): + """27c — the load-bearing static guarantee. Any two readers the gate may + treat as independent votes import no first-party module in common (except + explicit neutral pure-data modules). Distinct lineage ⟹ disjoint pathway.""" + transitive = { + r.lineage: _transitive_first_party_imports(_module_name_of(r.module)) + for r in TIER2_READER_PATHWAYS + } + offenders: list[str] = [] + readers = list(TIER2_READER_PATHWAYS) + for i in range(len(readers)): + for j in range(i + 1, len(readers)): + a, b = readers[i], readers[j] + shared = _shared_first_party( + transitive[a.lineage], transitive[b.lineage], TIER2_NEUTRAL_MODULES + ) + if shared: + offenders.append( + f"{a.lineage} ⟂ {b.lineage} share {sorted(shared)}" + ) + assert not offenders, ( + "Tier-2 readers the gate trusts as independent share a decoding " + "pathway:\n " + "\n ".join(offenders) + + "\n\nTheir agreement would be common-mode, not independent (the " + "GSM8K blind spot). Re-implement one independently, or — if the shared " + "module is genuinely pure data — add it to TIER2_NEUTRAL_MODULES with " + "a justification that it is parser/solver-free." + ) + + def test_disjointness_predicate_is_non_vacuous(self): + """27d — prove 27c can fail and that the neutral allowlist genuinely + clears a shared module (CLAUDE.md schema-defined proof obligation).""" + a = {"generate.reader_a", "generate.shared_parser", "algebra.versor"} + b = {"generate.reader_b", "generate.shared_parser"} + # A genuinely shared first-party module is flagged. + assert _shared_first_party(a, b, frozenset()) == {"generate.shared_parser"} + # Allowlisting that module as neutral clears it. + assert _shared_first_party(a, b, frozenset({"generate.shared_parser"})) == set() + # The transitive walker really follows edges: math_solver reaches the + # problem-graph module it is built on (a multi-hop first-party import). + ms = _transitive_first_party_imports("generate.math_solver") + assert "generate.math_problem_graph" in ms, ( + "transitive walker did not follow math_solver -> math_problem_graph; " + "the disjointness proof would miss deep shared readers." + ) diff --git a/tests/test_cga_f64_exactness.py b/tests/test_cga_f64_exactness.py new file mode 100644 index 00000000..2fb971b9 --- /dev/null +++ b/tests/test_cga_f64_exactness.py @@ -0,0 +1,112 @@ +"""Phase 0A — f64-exact conformal embedding + projective read-back + pinned ceiling. + +The field-reasoner wedge encodes quantities as conformal points on the e1 number +line and reads the answer back by *projective dehomogenization* — the only exact +read-back for weight-changing (dilation) operators. ``embed_point`` was f32-hardcoded +(``algebra/cga.py``), which silently destroys integers past ~1e4: at v=12345 the f32 +``n_o`` weight collapses to 0 and the read-back is unusable. + +These tests pin three contracts: + +1. ``embed_point``'s f32 default is byte-unchanged (no existing caller regresses). +2. The new ``dtype=np.float64`` path + ``read_scalar_e1`` recover integer coordinates + **exactly** across the whole admissible band, where f32 already fails. +3. ``EMBED_EXACT_MAX`` is the pinned magnitude ceiling: exactness is asserted up to it; + the field reader refuses above it (the refusal lives in the reader, not here). +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from algebra.cga import ( + EMBED_EXACT_MAX, + cga_inner, + embed_point, + read_scalar_e1, +) + + +def _e1(v: float, dtype: "np.typing.DTypeLike" = np.float64) -> np.ndarray: + """Embed a scalar coordinate on the e1 axis.""" + return embed_point(np.array([v, 0.0, 0.0]), dtype=dtype) + + +# --- contract 1: f32 default is byte-unchanged ----------------------------- + + +def test_embed_point_f32_default_unchanged(): + """The default path stays float32 and byte-identical to the prior impl.""" + x = np.array([1.0, 2.0, 3.0], dtype=np.float32) + X = embed_point(x) + assert X.dtype == np.float32 + # Prior closed-form: result[1:4]=x, e4=0.5(|x|^2-1), e5=0.5(|x|^2+1). + x_sq = float(np.dot(x, x)) # 14.0 + assert X[1] == np.float32(1.0) + assert X[2] == np.float32(2.0) + assert X[3] == np.float32(3.0) + assert X[4] == np.float32(0.5 * (x_sq - 1.0)) + assert X[5] == np.float32(0.5 * (x_sq + 1.0)) + + +# --- contract 2: f64 exact read-back where f32 fails ----------------------- + + +@pytest.mark.parametrize("v", [0, 1, 7, 42, 103, 300, 9999, 12345, 100000, 1000000]) +def test_read_scalar_e1_exact_f64(v): + """Projective dehomogenization recovers the integer coordinate exactly in f64.""" + X = _e1(float(v), dtype=np.float64) + assert read_scalar_e1(X) == float(v) + + +def test_f32_readback_fails_where_f64_succeeds(): + """The motivating hazard: f32 cannot round-trip a mid-size integer; f64 can. + + A meaningfully-failing guard — if this passes under f32 the f64 work is moot. + """ + v = 12345.0 + f64 = read_scalar_e1(_e1(v, dtype=np.float64)) + assert f64 == v + Xf32 = _e1(v, dtype=np.float32) + denom = float(Xf32[5] - Xf32[4]) # n_o weight collapses toward 0 in f32 + assert denom != 1.0 # the f32 weight is already wrong at this scale + + +def test_dilation_weighted_point_readback(): + """Read-back is exact for a *weighted* (dilated) null vector, not just unit weight. + + A dilation about the origin by factor k scales the whole null vector; the e1 + coordinate must come back as k*v via the e1/n_o-weight ratio, never as a raw + distance-from-origin. + """ + v, k = 2.0, 4.0 + X = _e1(v, dtype=np.float64) + Xw = (k * X).astype(np.float64) # uniform conformal weight k + assert read_scalar_e1(Xw) == v # projective: weight divides out + + +# --- contract 3: the pinned ceiling ---------------------------------------- + + +def test_embed_exact_max_is_pinned_and_generous(): + """The ceiling is a concrete int, comfortably above any GSM8K quantity.""" + assert isinstance(EMBED_EXACT_MAX, int) + assert EMBED_EXACT_MAX >= 1_000_000 + + +def test_distance_exact_within_ceiling(): + """The conformal distance metric stays exact for integer pairs up to the ceiling. + + cga_inner(embed(a), embed(b)) = -1/2 (a-b)^2, computed in f64. + """ + for a, b in [(0, 1), (3, 7), (100, 103), (0, EMBED_EXACT_MAX)]: + inner = cga_inner(_e1(float(a)), _e1(float(b))) + expected = -0.5 * (a - b) ** 2 + assert inner == expected, f"a={a} b={b}: {inner} != {expected}" + + +def test_embedded_f64_point_is_null(): + """f64 embedding still lands on the null cone.""" + X = _e1(123.0, dtype=np.float64) + assert abs(cga_inner(X, X)) < 1e-6 diff --git a/tests/test_reasoning_evidence.py b/tests/test_reasoning_evidence.py index f23277aa..ab6eeb2b 100644 --- a/tests/test_reasoning_evidence.py +++ b/tests/test_reasoning_evidence.py @@ -12,6 +12,7 @@ from core.reasoning import ( COMMITMENT_DISAGREEMENT, DUPLICATE_STRUCTURAL_SIGNATURE, MISSING_COMMITMENT, + SAME_READER_LINEAGE, TIER2_VERIFIED, EvidenceBundle, OperatorEvidence, @@ -24,6 +25,7 @@ def _ev( signature: str = "shape-a", commitment: str = "answer:42", outcome: str = "verified", + lineage: str = "reader-a", ) -> OperatorEvidence: return OperatorEvidence( domain="mathematics_logic", @@ -34,6 +36,7 @@ def _ev( check_keys=("check-a",), commitment_key=commitment, structural_signature=signature, + reader_lineage=lineage, payload={"nested": {"values": [1, 2, "x"]}}, ) @@ -64,19 +67,35 @@ def test_evidence_bundle_hash_is_ordered_and_stable() -> None: def test_tier2_verifies_two_distinct_structures_same_commitment() -> None: verdict = verify_tier2_agreement(( - _ev(signature="shape-a"), - _ev(signature="shape-b"), + _ev(signature="shape-a", lineage="reader-a"), + _ev(signature="shape-b", lineage="reader-b"), )) assert verdict.verified is True assert verdict.reason == TIER2_VERIFIED assert verdict.commitment_key == "answer:42" assert verdict.structural_signatures == ("shape-a", "shape-b") + assert verdict.reader_lineages == ("reader-a", "reader-b") + + +def test_tier2_refuses_same_reader_lineage() -> None: + """The decoration firewall: one reader cannot self-verify by relabeling its + structural signature. Two evidences from the SAME decoding pathway are refused + even when their structural_signatures differ and the commitment agrees. + + Meaningfully-failing guard: under the old label-only check this admitted. + """ + verdict = verify_tier2_agreement(( + _ev(signature="shape-a", lineage="one-reader"), + _ev(signature="shape-b", lineage="one-reader"), + )) + assert verdict.verified is False + assert verdict.reason == SAME_READER_LINEAGE def test_tier2_refuses_duplicate_signature() -> None: verdict = verify_tier2_agreement(( - _ev(signature="same"), - _ev(signature="same"), + _ev(signature="same", lineage="reader-a"), + _ev(signature="same", lineage="reader-b"), )) assert verdict.verified is False assert verdict.reason == DUPLICATE_STRUCTURAL_SIGNATURE @@ -84,8 +103,8 @@ def test_tier2_refuses_duplicate_signature() -> None: def test_tier2_refuses_disagreeing_commitments() -> None: verdict = verify_tier2_agreement(( - _ev(signature="shape-a", commitment="answer:42"), - _ev(signature="shape-b", commitment="answer:41"), + _ev(signature="shape-a", commitment="answer:42", lineage="reader-a"), + _ev(signature="shape-b", commitment="answer:41", lineage="reader-b"), )) assert verdict.verified is False assert verdict.reason == COMMITMENT_DISAGREEMENT diff --git a/tests/test_relational_ablation.py b/tests/test_relational_ablation.py new file mode 100644 index 00000000..6cc2834c --- /dev/null +++ b/tests/test_relational_ablation.py @@ -0,0 +1,52 @@ +"""The ablation verdict — pinned as the honest experimental result. + +The field-reasoner wedge's measurement #2/#3 on the simple additive/part-whole +micro-domain: the geometric field reader is **decoration** — it catches no +comprehension error the symbolic reader makes, and the only place it changes the +admitted set is by *losing* coverage (refusing a correct answer at the precision +ceiling). This test pins that finding; if a future change makes the field genuinely +catch a symbolic error, ``field_caught_symbolic_errors`` becomes non-empty and the +PASS assertion here must be revisited (a deliberately meaningful pin). + +The one INVARIANT that must always hold regardless of the verdict: the field never +commits a wrong answer (wrong=0 is structural). +""" + +from __future__ import annotations + +from evals.relational_metric.ablation import run + + +def test_field_never_commits_wrong(): + """wrong=0 — the non-negotiable. The field refuses rather than commit a bad int.""" + report = run() + assert report["field_wrong_commits"] == [], report["field_wrong_commits"] + + +def test_verdict_is_honest_c3_on_this_domain(): + """On forward-substitutable additive relations the field adds no independent signal: + geometric translation == arithmetic addition, so it catches zero symbolic errors.""" + report = run() + assert report["field_caught_symbolic_errors"] == [] + assert report["verdict"].startswith("C3") + + +def test_field_only_changes_admitted_set_by_losing_coverage(): + """The admitted set differs from symbolic-alone only because the field REFUSES a + correct answer (the precision ceiling) — a liability, not error-catching signal.""" + report = run() + assert report["admitted_set_changed"] is True + assert report["gate_admitted_count"] < report["symbolic_alone_admitted_count"] + assert report["field_lost_coverage"] # non-empty: the ceiling case + + +def test_readers_agree_and_are_both_correct_on_committable_classes(): + """Per-class diversity is ZERO on every committable class — the readers are + redundant here (the dossier's predicted common-mode on a metric-trivial domain).""" + report = run() + for cls, stats in report["per_class"].items(): + if cls.startswith("coverage_"): + continue + assert stats["disagree"] == 0 + assert stats["double_fault"] == 0 + assert stats["both_correct"] == stats["n"] diff --git a/tests/test_relational_field_reader.py b/tests/test_relational_field_reader.py new file mode 100644 index 00000000..1e482fa2 --- /dev/null +++ b/tests/test_relational_field_reader.py @@ -0,0 +1,133 @@ +"""Phase W — the geometric field reader on forward-substitutable relations. + +Proves the field reads problem TEXT into an exact integer answer via conformal +translators + projective read-back, and REFUSES (never guesses) outside its sealed +metric grammar. wrong==0 is structural: every commit is an exact-integer read-back. +""" + +from __future__ import annotations + +from generate.relational_field_reader import READER_LINEAGE, read_relational + + +# --- commits: the field reads correctly ------------------------------------ + + +def test_fact_then_more_than(): + r = read_relational( + "Tom has 3 marbles. Jane has 5 more marbles than Tom. " + "How many marbles does Jane have?" + ) + assert not r.refused + assert r.answer == 8 + assert r.answer_unit == "marbles" + assert r.reader_lineage == READER_LINEAGE + + +def test_fewer_than(): + r = read_relational( + "Anna has 20 apples. Ben has 7 fewer apples than Anna. " + "How many apples does Ben have?" + ) + assert not r.refused + assert r.answer == 13 + + +def test_chained_forward_substitution(): + r = read_relational( + "Tom has 4 coins. Jane has 6 more coins than Tom. " + "Sara has 10 more coins than Jane. How many coins does Sara have?" + ) + assert not r.refused + assert r.answer == 20 # 4 -> 10 -> 20 + + +def test_part_whole_sum_query(): + r = read_relational( + "Tom has 3 marbles. Jane has 5 more marbles than Tom. " + "How many marbles do Tom and Jane have?" + ) + assert not r.refused + assert r.answer == 11 # 3 + 8 + + +def test_large_value_within_ceiling_is_exact(): + # x=12345 already collapses f32 (the n_o weight loses the ±1 past ~4096); + # f64 + translator stays exact here. + r = read_relational( + "Tom has 12345 dollars. Jane has 5000 more dollars than Tom. " + "How many dollars does Jane have?" + ) + assert not r.refused + assert r.answer == 17345 + + +def test_never_commits_a_drifted_answer(): + """wrong==0 guard: at a scale where the translator sandwich loses f64 integer + exactness, the field REFUSES (precision_drift) rather than commit a wrong int.""" + r = read_relational( + "Tom has 123456 dollars. Jane has 654321 more dollars than Tom. " + "How many dollars does Jane have?" + ) + # Either it commits the EXACT answer, or it refuses — never a wrong integer. + assert r.refused or r.answer == 777777 + if r.refused: + assert r.refusal_reason == "precision_drift" + + +# --- refusals: the field declines outside its sealed grammar ---------------- + + +def test_refuses_multiplicative(): + r = read_relational( + "Tom has 3 marbles. Jane has twice as many marbles as Tom. " + "How many marbles does Jane have?" + ) + assert r.refused + assert r.refusal_reason == "fenced_multiplicative" + + +def test_refuses_times_cue(): + r = read_relational( + "Tom has 3 marbles. Jane has 4 times as many marbles as Tom. " + "How many marbles does Jane have?" + ) + assert r.refused + assert r.refusal_reason == "fenced_multiplicative" + + +def test_refuses_over_ceiling(): + r = read_relational( + "Tom has 9000000 marbles. How many marbles does Tom have?" + ) + assert r.refused + assert r.refusal_reason == "over_ceiling" + + +def test_refuses_forward_reference(): + r = read_relational( + "Jane has 5 more marbles than Tom. Tom has 3 marbles. " + "How many marbles does Jane have?" + ) + assert r.refused + assert r.refusal_reason == "non_forward_substitutable" + + +def test_refuses_no_question(): + r = read_relational("Tom has 3 marbles. Jane has 5 more marbles than Tom.") + assert r.refused + assert r.refusal_reason == "no_query" + + +def test_refuses_negative_quantity(): + r = read_relational( + "Tom has 3 marbles. Jane has 5 fewer marbles than Tom. " + "How many marbles does Jane have?" + ) + assert r.refused + assert r.refusal_reason == "negative_quantity" + + +def test_refuses_empty(): + assert read_relational("").refused + assert read_relational(" ").refusal_reason == "empty_input" diff --git a/tests/test_relational_metric_lane.py b/tests/test_relational_metric_lane.py new file mode 100644 index 00000000..5be04bf6 --- /dev/null +++ b/tests/test_relational_metric_lane.py @@ -0,0 +1,35 @@ +"""Relational-metric gold lane — the field reader is wrong==0 vs an independent gold. + +This is measurement #1 of the field-reasoner wedge falsifiable experiment: does the +geometric field reader, reading problem TEXT, commit answers that match an +independent arithmetic oracle (computed from the STRUCTURE) with zero wrong? The +oracle shares no code with the reader (enforced structurally by INV-25's +INDEPENDENT_GOLD_LANES registration). +""" + +from __future__ import annotations + +from evals.relational_metric.runner import run + + +def test_lane_is_wrong_zero_with_independent_gold(): + report = run() + # The committed gold is reproducible by the independent oracle (never field-derived). + assert report["gold_integrity_failures"] == [], report["gold_integrity_failures"] + # wrong==0 is the prime directive on this lane. + assert report["wrong"] == 0, report["wrong_detail"] + # Buckets account for every case. + assert report["total"] == report["correct"] + report["wrong"] + report["refused"] + + +def test_field_actually_commits_not_a_refusal_floor(): + """A refusal floor (refuse everything) would be wrong==0 too — and worthless. + The capability claim is COVERAGE with wrong==0: the field must commit real cases.""" + report = run() + assert report["correct"] >= 10 + + +def test_over_ceiling_is_refused_not_wrong(): + """The precision ceiling is honest coverage (a refusal), never a wrong commit.""" + report = run() + assert any("over_ceiling" in r for r in report["refused_detail"]) diff --git a/tests/test_relational_symbolic_reader.py b/tests/test_relational_symbolic_reader.py new file mode 100644 index 00000000..749a8ec4 --- /dev/null +++ b/tests/test_relational_symbolic_reader.py @@ -0,0 +1,59 @@ +"""The symbolic reader — the ablation control arm and the C3 capability path. + +It must be a *competent* reader (correct on the grammar, fences the same out-of-domain +cases, detects over-determination), so the ablation is a fair test of whether the field +adds anything over it. Unlike the field it has no precision limit (pure int). +""" + +from __future__ import annotations + +from generate.relational_symbolic_reader import READER_LINEAGE, read_relational + + +def test_commits_additive_more_than(): + r = read_relational( + "Tom has 3 marbles. Jane has 5 more marbles than Tom. " + "How many marbles does Jane have?" + ) + assert not r.refused and r.answer == 8 + assert r.reader_lineage == READER_LINEAGE + + +def test_commits_part_whole_sum(): + r = read_relational( + "Tom has 3 marbles. Jane has 5 more marbles than Tom. " + "How many marbles do Tom and Jane have?" + ) + assert not r.refused and r.answer == 11 + + +def test_commits_beyond_field_ceiling(): + """The symbolic reader has no f64 precision limit — it commits where the field + refuses (over_ceiling). This is the field's coverage liability, not a symbolic bug.""" + r = read_relational("Gus has 9000000 apples. How many apples does Gus have?") + assert not r.refused and r.answer == 9000000 + + +def test_fences_multiplicative(): + r = read_relational( + "Tom has 3 marbles. Jane has twice as many marbles as Tom. " + "How many marbles does Jane have?" + ) + assert r.refused and r.refusal_reason == "fenced_multiplicative" + + +def test_detects_over_determination(): + """A competent reader refuses a conflicting re-statement (so the field's geometric + coherence check is not a unique advantage).""" + r = read_relational( + "Tom has 3 marbles. Tom has 5 marbles. How many marbles does Tom have?" + ) + assert r.refused and r.refusal_reason == "over_determined_conflict" + + +def test_refuses_forward_reference(): + r = read_relational( + "Jane has 5 more marbles than Tom. Tom has 3 marbles. " + "How many marbles does Jane have?" + ) + assert r.refused and r.refusal_reason == "non_forward_substitutable"