From 145d797196b7d255045aeef601d39ff4ddc0b4df Mon Sep 17 00:00:00 2001 From: Shay Date: Thu, 4 Jun 2026 19:34:43 -0700 Subject: [PATCH] =?UTF-8?q?feat(field-wedge):=20geometric=20field=20reader?= =?UTF-8?q?=20=E2=80=94=20relational-metric=20lane=20wrong=3D0=20(Phase=20?= =?UTF-8?q?W.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measurement #1 of the field-reasoner falsifiable experiment: does the CL(4,1) field, given an honest metric encoding, read forward-substitutable quantitative-relational problems from TEXT with wrong==0? It does — 14/15 correct, 0 wrong, 1 refused (precision ceiling), scored against an independent arithmetic oracle. - generate/relational_field_reader.py: reads problem text into conformal points on the e1 number line; additive/part-whole relations are conformal TRANSLATOR versors (versor_apply(T_delta, embed[x]) == embed[x+delta], exact); the answer reads back by projective dehomogenization. Refusal-first: fences multiplicative/ratio (the sign/orientation-blind cases), the precision ceiling, non-forward-substitutable references, negatives. A per-step exactness self-check turns any f64 translator drift into a refusal (precision_drift) — it NEVER commits a wrong integer. Its parser is an independent reimplementation importing no generate.derivation/math_*. - evals/relational_metric/: independent arithmetic oracle (computes gold from the STRUCTURE, shares no code with the reader), 15-case fixture, and a runner that enforces gold integrity + wrong==0. - INV-25: relational_metric registered in INDEPENDENT_GOLD_LANES (oracle proven code-disjoint from the field reader and the algebra engine). The independently golded panel is now three domains: deductive, dimensional, relational-metric. Green: smoke 87, 53 architectural invariants, 16 new tests; deductive + dimensional lanes unperturbed (wrong=0). --- evals/relational_metric/__init__.py | 10 ++ evals/relational_metric/oracle.py | 78 +++++++++ evals/relational_metric/runner.py | 98 ++++++++++++ evals/relational_metric/v1/cases.jsonl | 15 ++ generate/relational_field_reader.py | 211 +++++++++++++++++++++++++ tests/test_architectural_invariants.py | 13 ++ tests/test_relational_field_reader.py | 133 ++++++++++++++++ tests/test_relational_metric_lane.py | 35 ++++ 8 files changed, 593 insertions(+) create mode 100644 evals/relational_metric/__init__.py create mode 100644 evals/relational_metric/oracle.py create mode 100644 evals/relational_metric/runner.py create mode 100644 evals/relational_metric/v1/cases.jsonl create mode 100644 generate/relational_field_reader.py create mode 100644 tests/test_relational_field_reader.py create mode 100644 tests/test_relational_metric_lane.py 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/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/tests/test_architectural_invariants.py b/tests/test_architectural_invariants.py index ddc78d9e..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, ...] = ( 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"])