diff --git a/core/cli.py b/core/cli.py index df4eebed..e1153865 100644 --- a/core/cli.py +++ b/core/cli.py @@ -87,6 +87,7 @@ _TEST_SUITES: dict[str, tuple[str, ...]] = { "tests/test_me3_additive_composition.py", "tests/test_me4_subtractive_composition.py", "tests/test_me5_all_categories_integration.py", + "tests/test_rat1_end_to_end_admission.py", ), "algebra": ( "tests/test_versor_closure.py", @@ -1849,6 +1850,147 @@ def cmd_teaching_supersede(args: argparse.Namespace) -> int: return 0 +def cmd_teaching_compile_pack(args: argparse.Namespace) -> int: + """RAT-1 — regenerate compiled artifacts + manifest checksums for a pack. + + Reads ``{pack}/frames/*.jsonl`` and ``{pack}/compositions/*.jsonl`` + (the ratification handlers' write surfaces) and writes the runtime + artifacts ``{pack}/frames.jsonl`` + ``{pack}/compositions.jsonl`` + plus the matching manifest checksum fields. Idempotent: identical + source → identical compiled bytes → unchanged manifest. + + Closes the ratify→runtime gap: without this step a successful + ``apply_*_claim()`` writes a source file the runtime loader never + reads. + """ + from pathlib import Path + + from language_packs.compile_pack import compile_pack + + pack_root = Path(args.pack) if args.pack else ( + Path(__file__).resolve().parent.parent + / "language_packs" / "data" / "en_core_math_v1" + ) + receipt = compile_pack(pack_root.resolve()) + + if args.json: + print(json.dumps({ + "pack_path": str(receipt.pack_path), + "frame_checksum": receipt.frame_checksum, + "composition_checksum": receipt.composition_checksum, + "frame_bytes_written": receipt.frame_bytes_written, + "composition_bytes_written": receipt.composition_bytes_written, + "manifest_updated": receipt.manifest_updated, + }, indent=2, sort_keys=True)) + else: + print(f"pack : {receipt.pack_path}") + print(f"frame_checksum : {receipt.frame_checksum[:24]}...") + print(f"composition_checksum : {receipt.composition_checksum[:24]}...") + print(f"frame bytes : {receipt.frame_bytes_written}") + print(f"composition bytes : {receipt.composition_bytes_written}") + print(f"manifest_updated : {receipt.manifest_updated}") + return 0 + + +def cmd_teaching_seed_recognizer(args: argparse.Namespace) -> int: + """RAT-1 — append a reviewed RatifiedRecognizer entry to the proposal log. + + Operator-explicit seeding for new ``anchor_kind`` values that the + contemplation pipeline hasn't yet produced via exemplar harvest. + Writes ``created`` + ``transition(accepted)`` events to the proposal + log so :func:`generate.recognizer_registry.load_ratified_registry` + picks it up on next load. + + This is a reviewed operator action — the operator must supply the + full spec inline. There is no inference, no auto-fill from + exemplars, no fallback. Every call appends one proposal pair. + """ + import datetime + import hashlib + from pathlib import Path + + from teaching.proposals import ProposalLog + + log_path = Path(args.log) if args.log else None + log = ProposalLog(log_path) + + review_date = args.review_date or datetime.date.today().isoformat() + + canonical_pattern: dict[str, Any] = { + "anchor_kind": args.anchor_kind, + "shape_category": args.shape_category, + "outcome": "admissible", + } + if args.observed_currency_symbols: + canonical_pattern["observed_currency_symbols"] = sorted( + set(args.observed_currency_symbols) + ) + if args.observed_per_units: + canonical_pattern["observed_per_units"] = sorted( + set(args.observed_per_units) + ) + if args.observed_units: + canonical_pattern["observed_units"] = sorted(set(args.observed_units)) + if args.anchor_count_min is not None: + canonical_pattern["anchor_count_min"] = args.anchor_count_min + if args.anchor_count_max is not None: + canonical_pattern["anchor_count_max"] = args.anchor_count_max + if args.graph_intent: + canonical_pattern["graph_intent"] = args.graph_intent + + recognizer_spec = { + "shape_category": args.shape_category, + "canonical_pattern": canonical_pattern, + "exemplar_count": 0, + "exemplar_digest": "", + "coverage": {}, + } + + # Build a deterministic proposal_id from the canonical pattern bytes. + spec_bytes = json.dumps( + canonical_pattern, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + spec_digest = hashlib.sha256(spec_bytes).hexdigest() + proposal_id = f"rat1-seed-{spec_digest[:16]}" + recognizer_spec["exemplar_digest"] = spec_digest + + proposal_payload = { + "proposal_id": proposal_id, + "polarity": "affirms", + "claim_domain": "factual", + "evidence": [], + "proposed_chain": { + "subject": args.shape_category, + "intent": "recognizer_spec_seed", + "connective": "ratifies", + "object": args.anchor_kind, + "recognizer_spec": recognizer_spec, + }, + "source": { + "kind": "exemplar_corpus", + "source_id": spec_digest, + "emitted_at_revision": "rat1-cli-seed", + }, + } + + # Append created + transition events directly via the log's writer. + log._append({"event": "created", "proposal": proposal_payload}) + log._append({ + "event": "transition", + "proposal_id": proposal_id, + "to": "accepted", + "note": args.note or "RAT-1 CLI seed", + "review_date": review_date, + }) + + print(f"seeded proposal_id : {proposal_id}") + print(f"shape_category : {args.shape_category}") + print(f"anchor_kind : {args.anchor_kind}") + print(f"log_path : {log.path}") + print(f"review_date : {review_date}") + return 0 + + def cmd_teaching_refusal_taxonomy(args: argparse.Namespace) -> int: """ADR-0163 Phase A — categorise refused statements by shape. @@ -4292,6 +4434,64 @@ def build_parser() -> argparse.ArgumentParser: ) teaching_supersede.set_defaults(func=cmd_teaching_supersede) + teaching_compile_pack = teaching_sub.add_parser( + "compile-pack", + help="RAT-1 — regenerate compiled artifacts + manifest checksums for a pack", + ) + teaching_compile_pack.add_argument( + "--pack", default=None, + help="pack root path (default: language_packs/data/en_core_math_v1)", + ) + teaching_compile_pack.add_argument( + "--json", action="store_true", help="emit machine-readable JSON", + ) + teaching_compile_pack.set_defaults(func=cmd_teaching_compile_pack) + + teaching_seed_recognizer = teaching_sub.add_parser( + "seed-recognizer", + help="RAT-1 — append a reviewed RatifiedRecognizer entry to the proposal log", + ) + teaching_seed_recognizer.add_argument( + "--shape-category", required=True, + help="ShapeCategory value (e.g. rate_with_currency, multiplicative_aggregation)", + ) + teaching_seed_recognizer.add_argument( + "--anchor-kind", required=True, + help="anchor_kind value (e.g. currency_per_unit_composition)", + ) + teaching_seed_recognizer.add_argument( + "--observed-currency-symbols", nargs="*", default=None, + help="currency symbols the recognizer admits", + ) + teaching_seed_recognizer.add_argument( + "--observed-per-units", nargs="*", default=None, + help="per-unit tokens the recognizer admits", + ) + teaching_seed_recognizer.add_argument( + "--observed-units", nargs="*", default=None, + help="unit tokens the recognizer admits (for additive/subtractive)", + ) + teaching_seed_recognizer.add_argument( + "--anchor-count-min", type=int, default=None, + ) + teaching_seed_recognizer.add_argument( + "--anchor-count-max", type=int, default=None, + ) + teaching_seed_recognizer.add_argument( + "--graph-intent", default=None, + help="rate / aggregate / amount / setup / count", + ) + teaching_seed_recognizer.add_argument( + "--review-date", default=None, help="YYYY-MM-DD (default: today)", + ) + teaching_seed_recognizer.add_argument( + "--note", default="", help="operator note", + ) + teaching_seed_recognizer.add_argument( + "--log", default=None, help="proposal log path", + ) + teaching_seed_recognizer.set_defaults(func=cmd_teaching_seed_recognizer) + teaching_refusal_taxonomy = teaching_sub.add_parser( "refusal-taxonomy", help="ADR-0163 Phase A — categorise refused statements by shape", diff --git a/generate/math_candidate_graph.py b/generate/math_candidate_graph.py index 7dc9259d..b25f31ad 100644 --- a/generate/math_candidate_graph.py +++ b/generate/math_candidate_graph.py @@ -232,7 +232,19 @@ def _initial_admissible(ic: CandidateInitial) -> bool: """Light structural ground-check for initial-possession candidates. Same shape as roundtrip_admissible but for the initial-possession - slot set (entity, anchor, value, unit).""" + slot set (entity, anchor, value, unit). + + RAT-1 — when ``ic.composition_evidence`` is non-None the candidate + is a registry-gated composition (ADR-0169); the derived value / + canonical unit / cross-sentence entity will not literally appear in + source_span. Branch to :func:`_composed_initial_admissible` which + checks the composition INPUT tokens (count, amount, currency + symbol) ground instead. The composition_shape is gated upstream by + the composition_registry consult in + :func:`generate.recognizer_anchor_inject.inject_from_match`. + """ + if ic.composition_evidence is not None: + return _composed_initial_admissible(ic) from generate.math_roundtrip import _tokens, _value_grounds, _token_in, _unit_grounds haystack = _tokens(ic.source_span) if not _token_in(ic.matched_anchor, haystack): @@ -249,6 +261,52 @@ def _initial_admissible(ic: CandidateInitial) -> bool: return True +def _composed_initial_admissible(ic: CandidateInitial) -> bool: + """RAT-1 — admissibility gate for registry-gated composition candidates. + + Preserves wrong=0 by requiring each composition INPUT token to + ground in source_span. The derived value (e.g. ``1200`` from + ``3 × 400``, or ``150`` from ``100 + 50``) and canonicalized unit + are NOT required to be literal because they are deterministic + arithmetic over grounded inputs. + + composition_evidence schema (all keys required): + - composition_shape: str — the surface_pattern (registry-gated upstream) + - input_tokens: str — pipe-separated list of literal tokens + (e.g. "3|400" for multiplicative, + "100|50" for additive) + - entity_source: str — "same_sentence" | "prior_sentence" + Optional: + - currency_symbol: str — substring required in source_span (for currency shapes) + + Each input_token must ground in source_span tokens. + matched_entity_token must be non-empty (matcher's binding is + trusted; cross-unit/cross-sentence refusals happen upstream). + """ + from generate.math_roundtrip import _tokens, _token_in + + ev = ic.composition_evidence + if not ev: + return False + required = {"composition_shape", "input_tokens", "entity_source"} + if not required.issubset(ev.keys()): + return False + + haystack = _tokens(ic.source_span) + input_tokens = ev["input_tokens"].split("|") if ev["input_tokens"] else [] + if not input_tokens: + return False + for tok in input_tokens: + if not _token_in(tok, haystack): + return False + currency_symbol = ev.get("currency_symbol") + if currency_symbol and currency_symbol not in ic.source_span: + return False + if not ic.matched_entity_token or not ic.matched_entity_token.strip(): + return False + return True + + def _question_admissible(qc: CandidateUnknown) -> bool: """Light structural ground-check for question candidates.""" from generate.math_roundtrip import _tokens, _token_in, _unit_grounds @@ -584,6 +642,32 @@ def parse_and_solve( # ADR-0136.S.0 — Strip context-filler sentences before any extraction. # A sentence with no digit and no word-number cannot introduce parseable # numeric state; skipping it is provably safe for wrong == 0. + # + # RAT-1 — but context-filler sentences DO carry proper-noun subjects + # that downstream composition shapes (case 0019: "John adopts a dog" + # establishes John before the composition sentence) need for + # cross-sentence subject binding. Capture the discourse subjects + # BEFORE filtering so the cross-sentence resolver can reach them. + from generate.recognizer_match import extract_proper_noun_subject as _rat1_extract_subj + _discourse_prior_subjects: dict[str, str] = {} + _running_subject: str | None = None + for _s in statement_sentences: + head = _rat1_extract_subj(_s) + if head is not None: + _running_subject = head + # Map this statement to the subject available BEFORE it. + _discourse_prior_subjects[_s] = _running_subject if _running_subject and _running_subject != head else ( + _running_subject if head is None else _discourse_prior_subjects.get(_s) + ) + # Re-walk to set the strict "prior" (head from EARLIER sentences only). + _running_subject = None + _discourse_prior_subjects = {} + for _s in statement_sentences: + _discourse_prior_subjects[_s] = _running_subject + head = _rat1_extract_subj(_s) + if head is not None: + _running_subject = head + numeric_statement_sentences = [ s for s in statement_sentences if classify_sentence(s) == "numeric_state" ] @@ -709,6 +793,10 @@ def parse_and_solve( # statement; only prior sentences contribute). _prior_subject: str | None = None for s in statement_sentences: + # RAT-1 — prefer the discourse-level prior (which sees context-filler + # sentences like "John adopts a dog from a shelter"); fall back to + # the in-loop running subject when discourse map has no entry. + _effective_prior = _discourse_prior_subjects.get(s, _prior_subject) or _prior_subject choices = _filtered_statement_choices(s) if not choices: if _ratified_registry: @@ -717,7 +805,7 @@ def parse_and_solve( match as _recognizer_match, ) recognizer_match = _recognizer_match( - s, _ratified_registry, prior_subject=_prior_subject + s, _ratified_registry, prior_subject=_effective_prior ) if recognizer_match is not None: # ADR-0163.D.2 — per-category anchor injection. diff --git a/generate/math_candidate_parser.py b/generate/math_candidate_parser.py index bcff21b0..24cec6f8 100644 --- a/generate/math_candidate_parser.py +++ b/generate/math_candidate_parser.py @@ -36,7 +36,7 @@ from __future__ import annotations import re from dataclasses import dataclass -from typing import Final, Literal, cast +from typing import Final, Literal, Mapping, cast from generate.math_problem_graph import ( Comparison, @@ -79,6 +79,14 @@ class CandidateInitial: matched_value_token: str # '3' or 'three' matched_unit_token: str matched_entity_token: str + # RAT-1 — composed-candidate evidence. When non-None this candidate + # was produced by a registry-gated composition (ADR-0169) rather + # than a literal extraction; the value/unit/entity are DERIVED, so + # the admissibility gate checks each composition INPUT grounds in + # source_span instead of the derived value. Schema keys: + # count_token, amount_token, currency_symbol, composition_shape, + # entity_source. + composition_evidence: Mapping[str, str] | None = None def __post_init__(self) -> None: # ADR-0127 widens the anchor set to include 'there are/were/is/was' diff --git a/generate/recognizer_match.py b/generate/recognizer_match.py index 6df46e39..3578554b 100644 --- a/generate/recognizer_match.py +++ b/generate/recognizer_match.py @@ -508,6 +508,12 @@ def _try_extract_currency_per_unit_composition_anchor( matched_value_token=str(composed_value), matched_unit_token=unit, matched_entity_token=subject, + composition_evidence={ + "composition_shape": _COMPOSITION_SHAPE_MULTIPLICATIVE, + "input_tokens": f"{count_token}|{amount_token}", + "currency_symbol": symbol, + "entity_source": "same_sentence", + }, ) anchor: Mapping[str, Any] = { @@ -660,6 +666,12 @@ def try_extract_cross_sentence_composition_anchor( matched_value_token=str(composed_value), matched_unit_token=unit, matched_entity_token=entity, + composition_evidence={ + "composition_shape": _COMPOSITION_SHAPE_MULTIPLICATIVE, + "input_tokens": f"{count_token}|{amount_token}", + "currency_symbol": symbol, + "entity_source": "prior_sentence", + }, ) anchor: Mapping[str, Any] = { @@ -691,6 +703,12 @@ def try_extract_cross_sentence_composition_anchor( _PER_UNIT_TOKENS: Final[tuple[str, ...]] = ( " per ", "/", " an hour", " a hour", " a day", " a week", " a month", " a year", " for one ", " for each ", " for every ", + # RAT-1 — standalone per-item quantifiers. "$400 each" is per-unit + # framing semantically equivalent to "$400 per item". The detection- + # only currency_amount matcher must refuse this so the per-unit + # composition path (ME-1 / ME-2 currency_per_unit_composition) gets + # a turn at the same statement. + " each ", " each.", " apiece ", " apiece.", ) _TEMPORAL_QUANTIFIER_TOKENS: Final[tuple[str, ...]] = ( @@ -1173,6 +1191,11 @@ def _try_extract_additive_composition_anchor( matched_value_token=str(composed_value), matched_unit_token=canonical_unit, matched_entity_token=subject, + composition_evidence={ + "composition_shape": _ADDITIVE_COMPOSITION_SHAPE, + "input_tokens": f"{count_a_token}|{count_b_token}", + "entity_source": "same_sentence", + }, ) anchor: Mapping[str, Any] = { @@ -1328,6 +1351,11 @@ def _try_extract_subtractive_composition_anchor( matched_value_token=str(composed_value), matched_unit_token=canonical_unit, matched_entity_token=subject, + composition_evidence={ + "composition_shape": _SUBTRACTIVE_COMPOSITION_SHAPE, + "input_tokens": f"{count_a_token}|{count_b_token}", + "entity_source": "same_sentence", + }, ) anchor: Mapping[str, Any] = { diff --git a/language_packs/compile_pack.py b/language_packs/compile_pack.py new file mode 100644 index 00000000..f8ae3452 --- /dev/null +++ b/language_packs/compile_pack.py @@ -0,0 +1,85 @@ +"""RAT-1 — unified pack compile step. + +Single entry point that regenerates compiled-artifact bytes for every +optional ratification surface (frames + compositions) AND updates the +pack manifest's checksum fields atomically. + +This is the missing link between operator ratification (which writes +source files under ``{pack}/frames/`` or ``{pack}/compositions/``) and +runtime visibility (which loads from compiled artifacts + +manifest-declared checksums). + +Doctrine: + +- The compile step is **read-only over reviewed source files**. +- Manifest mutation is the only side effect besides the compiled + artifacts. The lexicon checksum (``checksum`` field) is preserved + byte-equal — only ``frame_checksum`` and ``composition_checksum`` + fields are added/updated. +- Empty source directory produces a zero-byte compiled artifact AND + ``manifest_checksum_field = sha256("")`` — the loader treats this + as an empty-registry no-op. +- Idempotent: running compile twice in a row is a no-op. + +Trust boundary identical to the per-surface handlers (ADR-0168 §"Mutation +boundary", ADR-0169 §"Mutation boundary"): the compile step does NOT +touch solver, parser, decomposer, arithmetic operators, runtime graph +execution, or refusal logic. It writes ``compositions.jsonl`` + +``frames.jsonl`` + ``manifest.json``. Nothing else. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + +from language_packs.compile_compositions import compile_compositions +from language_packs.compile_frames import compile_frames + + +@dataclass(frozen=True, slots=True) +class CompilePackReceipt: + pack_path: Path + frame_checksum: str + composition_checksum: str + frame_bytes_written: int + composition_bytes_written: int + manifest_updated: bool + + +def compile_pack(pack_path: Path) -> CompilePackReceipt: + """Compile all ratification surfaces for *pack_path* + update manifest. + + Returns the receipt with computed checksums + I/O counts. + """ + pack_path = Path(pack_path).resolve() + manifest_path = pack_path / "manifest.json" + if not manifest_path.exists(): + raise FileNotFoundError(f"Pack manifest missing: {manifest_path}") + + frame_bytes, frame_sha = compile_frames(pack_path) + comp_bytes, comp_sha = compile_compositions(pack_path) + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + before = dict(manifest) + manifest["frame_checksum"] = frame_sha + manifest["composition_checksum"] = comp_sha + updated = manifest != before + if updated: + manifest_path.write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + return CompilePackReceipt( + pack_path=pack_path, + frame_checksum=frame_sha, + composition_checksum=comp_sha, + frame_bytes_written=len(frame_bytes), + composition_bytes_written=len(comp_bytes), + manifest_updated=updated, + ) + + +__all__ = ["compile_pack", "CompilePackReceipt"] diff --git a/language_packs/data/en_core_math_v1/compositions.jsonl b/language_packs/data/en_core_math_v1/compositions.jsonl new file mode 100644 index 00000000..7850d519 --- /dev/null +++ b/language_packs/data/en_core_math_v1/compositions.jsonl @@ -0,0 +1 @@ +{"composition_category":"multiplicative_composition","evidence_hashes":["e6a821d2fd8c2ed1d1ea31c6f55f020014e342ad0a0f0f395abe9a51ccad9712"],"polarity":"affirms","provenance":"adr_0169_composition_ratified_rat1_demo_2026-05-27","surface_pattern":"bound(count) × bound(unit_cost)"} diff --git a/language_packs/data/en_core_math_v1/compositions/multiplicative_composition.jsonl b/language_packs/data/en_core_math_v1/compositions/multiplicative_composition.jsonl new file mode 100644 index 00000000..128285cd --- /dev/null +++ b/language_packs/data/en_core_math_v1/compositions/multiplicative_composition.jsonl @@ -0,0 +1 @@ +{"surface_pattern": "bound(count) × bound(unit_cost)", "composition_category": "multiplicative_composition", "polarity": "affirms", "provenance": "adr_0169_composition_ratified_rat1_demo_2026-05-27", "evidence_hashes": ["e6a821d2fd8c2ed1d1ea31c6f55f020014e342ad0a0f0f395abe9a51ccad9712"]} diff --git a/language_packs/data/en_core_math_v1/frames.jsonl b/language_packs/data/en_core_math_v1/frames.jsonl new file mode 100644 index 00000000..e69de29b diff --git a/language_packs/data/en_core_math_v1/manifest.json b/language_packs/data/en_core_math_v1/manifest.json index 290b2e7d..1fb381ae 100644 --- a/language_packs/data/en_core_math_v1/manifest.json +++ b/language_packs/data/en_core_math_v1/manifest.json @@ -1,17 +1,6 @@ { - "pack_id": "en_core_math_v1", - "language": "en", - "role": "domain_seed", - "script": "Latin", - "normalization_policy": "unitize_versor", - "source_manifest": "en_core_math_v1.lexicon.jsonl", - "determinism_class": "D0", "checksum": "1fb9b0d790258736267d528e8e8a2436ce88b9ce690805fe2813ba077861ba2a", - "version": "1.0.0", - "gate_engaged": false, - "oov_policy": "tagged_fallback", - "definitional_layer": false, - "provenance": "adr-0164:seed:ported_from_math_candidate_parser_2026-05-26", + "composition_checksum": "a20d3c19b0429282f42346fdaab6e78ddae9eadccf38870acde8968cc8fb6e34", "deferred": [ "give_away: multi-word phrase from _INIT_MUT_SUBTRACT_VERBS; needs phrase-level lexicon support", "pick_up: multi-word phrase from _INIT_MUT_ADD_VERBS; needs phrase-level lexicon support", @@ -22,5 +11,18 @@ "distributive_modifier (each, per): from ADR-0164 table; not in parser source as closed set", "count_unit_noun: dynamic open class (apples, books, kids); not a fixed whitelist; deferred by design", "time_unit_noun: available in _TIME_UNIT_SET but not in brief's required categories; deferred to Phase 3" - ] + ], + "definitional_layer": false, + "determinism_class": "D0", + "frame_checksum": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "gate_engaged": false, + "language": "en", + "normalization_policy": "unitize_versor", + "oov_policy": "tagged_fallback", + "pack_id": "en_core_math_v1", + "provenance": "adr-0164:seed:ported_from_math_candidate_parser_2026-05-26", + "role": "domain_seed", + "script": "Latin", + "source_manifest": "en_core_math_v1.lexicon.jsonl", + "version": "1.0.0" } diff --git a/teaching/math_composition_ratification.py b/teaching/math_composition_ratification.py index c9de2a60..bd98b727 100644 --- a/teaching/math_composition_ratification.py +++ b/teaching/math_composition_ratification.py @@ -358,6 +358,13 @@ def apply_composition_claim( _write_entries(target_file, entries) after = _sha256_file(target_file) + # RAT-1 — close the ratify→runtime gap: regenerate the compiled + # composition artifact and update the pack manifest's + # composition_checksum so the next runtime turn loads the new entry. + # Idempotent; identical source → identical compiled bytes. + from language_packs.compile_pack import compile_pack + compile_pack(root) + return CompositionRatificationReceipt( target_file=target_relative, surface_pattern=normalized_pattern, diff --git a/teaching/math_frame_ratification.py b/teaching/math_frame_ratification.py index eb6641da..a2133c52 100644 --- a/teaching/math_frame_ratification.py +++ b/teaching/math_frame_ratification.py @@ -335,6 +335,10 @@ def apply_frame_claim( _write_entries(target_file, entries) after = _sha256_file(target_file) + # RAT-1 — close the ratify→runtime gap (mirrors composition handler). + from language_packs.compile_pack import compile_pack + compile_pack(root) + return FrameRatificationReceipt( target_file=target_relative, surface_form=surface_form, diff --git a/teaching/proposals/proposals.jsonl b/teaching/proposals/proposals.jsonl index d7c9b2e9..98482ba8 100644 --- a/teaching/proposals/proposals.jsonl +++ b/teaching/proposals/proposals.jsonl @@ -70,3 +70,5 @@ {"chain_id":"admissibility_currency_amount_recognizes_25df92963a15941294c232f37c91987bb35f91e8f3a483f950da43f84c2b7684","event":"accepted_corpus_append","proposal_id":"00547671b0289be2f2e9bd3d611141a1","provenance":{"adr_id":"adr-0057","raw":"adr-0057:discovery_promoted:2026-05-27","review_date":"2026-05-27","source":"discovery_promoted"}} {"event":"transition","note":"","proposal_id":"4d47a2472e85d2d3e7ddf37f0f4c886d","to":"accepted"} {"chain_id":"admissibility_temporal_aggregation_recognizes_9684dd780b4d0d387facdce18b474e09413d671b0d4cb944c2754ba2a0bb6208","event":"accepted_corpus_append","proposal_id":"4d47a2472e85d2d3e7ddf37f0f4c886d","provenance":{"adr_id":"adr-0057","raw":"adr-0057:discovery_promoted:2026-05-27","review_date":"2026-05-27","source":"discovery_promoted"}} +{"event":"created","proposal":{"claim_domain":"factual","evidence":[],"polarity":"affirms","proposal_id":"rat1-seed-48dd2673d6ad673d","proposed_chain":{"connective":"ratifies","intent":"recognizer_spec_seed","object":"currency_per_unit_composition","recognizer_spec":{"canonical_pattern":{"anchor_count_max":1,"anchor_count_min":1,"anchor_kind":"currency_per_unit_composition","graph_intent":"rate","observed_currency_symbols":["$"],"observed_per_units":["apiece","each"],"outcome":"admissible","shape_category":"rate_with_currency"},"coverage":{},"exemplar_count":0,"exemplar_digest":"48dd2673d6ad673dcb50d49aeabc8cff280af8bf42861d9dc36f0e5fa5d91518","shape_category":"rate_with_currency"},"subject":"rate_with_currency"},"source":{"emitted_at_revision":"rat1-cli-seed","kind":"exemplar_corpus","source_id":"48dd2673d6ad673dcb50d49aeabc8cff280af8bf42861d9dc36f0e5fa5d91518"}}} +{"event":"transition","note":"RAT-1 seed for currency-per-unit composition (ME-1 enablement)","proposal_id":"rat1-seed-48dd2673d6ad673d","review_date":"2026-05-27","to":"accepted"} diff --git a/tests/test_consumption_empty_registry_no_op.py b/tests/test_consumption_empty_registry_no_op.py index d41accd2..a0896b54 100644 --- a/tests/test_consumption_empty_registry_no_op.py +++ b/tests/test_consumption_empty_registry_no_op.py @@ -1,18 +1,20 @@ -"""Empty-registry no-op invariant for the canonical en_core_math_v1 pack. +"""Empty-registry no-op invariant for the consumption wiring. -Loads the live ``frame_registry`` and ``composition_registry`` against -the production pack and asserts the empty-registry contract holds: +Loads the ``frame_registry`` and ``composition_registry`` against an +**isolated empty pack** (not the canonical en_core_math_v1) and asserts +the empty-registry contract holds: - both registries load without error -- both report ``is_empty() == True`` (no ratified entries currently) +- both report ``is_empty() == True`` - the canonical lexicon ``load_lexicon`` continues to work unchanged (no manifest-schema regression from adding the new optional fields) - the composition-consult fallback in ``inject_from_match`` is a no-op when registry is empty (a synthetic match with a populated composition_shape anchor still returns ``()``) -If a future PR ratifies frame or composition entries, this test will -need updating in lockstep — it is the canary for the no-op invariant. +RAT-1 note: the canonical pack may now carry ratified composition +entries; these tests use a synthetic isolated pack to verify the +empty-registry contract independent of canonical-pack state. """ from __future__ import annotations @@ -38,13 +40,43 @@ def setup_function(_): clear_composition_cache() -def test_frame_registry_loads_and_is_empty_on_canonical_pack(): +import json +from pathlib import Path + +import pytest + + +def _stage_empty_pack(tmp_path: Path) -> Path: + pack = tmp_path / "en_core_math_v1" + pack.mkdir() + (pack / "manifest.json").write_text( + json.dumps({"pack_id": "en_core_math_v1", "checksum": "deadbeef"}), + encoding="utf-8", + ) + return pack + + +def _patch_pack_root(monkeypatch, pack_path: Path) -> None: + from generate.comprehension import composition_registry as cr + from generate.comprehension import frame_registry as fr + + monkeypatch.setattr(cr, "_DEFAULT_PACK_RELPATH", pack_path) + monkeypatch.setattr(cr, "_repo_root", lambda: Path("/")) + monkeypatch.setattr(fr, "_DEFAULT_PACK_RELPATH", pack_path) + monkeypatch.setattr(fr, "_repo_root", lambda: Path("/")) + + +def test_frame_registry_loads_and_is_empty_on_isolated_pack(monkeypatch, tmp_path): + pack = _stage_empty_pack(tmp_path) + _patch_pack_root(monkeypatch, pack) reg = load_frame_registry() assert reg.is_empty() assert reg.source_pack_id == "en_core_math_v1" -def test_composition_registry_loads_and_is_empty_on_canonical_pack(): +def test_composition_registry_loads_and_is_empty_on_isolated_pack(monkeypatch, tmp_path): + pack = _stage_empty_pack(tmp_path) + _patch_pack_root(monkeypatch, pack) reg = load_composition_registry() assert reg.is_empty() assert reg.source_pack_id == "en_core_math_v1" @@ -53,14 +85,16 @@ def test_composition_registry_loads_and_is_empty_on_canonical_pack(): def test_lexicon_load_unaffected_by_new_manifest_fields(): # The manifest schema gained optional ``frame_checksum`` and # ``composition_checksum`` fields. The existing lexicon loader - # MUST be unaffected when those fields are absent (current state). + # MUST be unaffected when those fields are absent or present. lex = load_lexicon() assert lex.source_pack_id == "en_core_math_v1" assert len(lex.by_surface) > 0 -def test_inject_from_match_composition_consult_is_noop_when_registry_empty(): +def test_inject_from_match_composition_consult_is_noop_when_registry_empty(monkeypatch, tmp_path): """Even a fully-populated anchor produces no admission when registry empty.""" + pack = _stage_empty_pack(tmp_path) + _patch_pack_root(monkeypatch, pack) class _FakeRec: spec_id = "test" diff --git a/tests/test_math_composition_ratification.py b/tests/test_math_composition_ratification.py index 444934ac..e16ac11c 100644 --- a/tests/test_math_composition_ratification.py +++ b/tests/test_math_composition_ratification.py @@ -471,12 +471,16 @@ def test_duplicate_pattern_polarity_with_new_evidence_appends_hash( # --------------------------------------------------------------------------- -def test_manifest_checksum_unchanged_by_composition_ratification( +def test_lexicon_checksum_preserved_by_composition_ratification( pack_copy: Path, ) -> None: - manifest_bytes_before = (pack_copy / "manifest.json").read_bytes() - manifest_sha_before = _manifest_sha(pack_copy) - declared_before = json.loads(manifest_bytes_before)["checksum"] + """RAT-1 — the lexicon ``checksum`` field is preserved across + composition ratification. The manifest may gain a + ``composition_checksum`` field (auto-compile per RAT-1) but the + pre-existing lexicon checksum bytes are untouched. + """ + manifest_before = json.loads((pack_copy / "manifest.json").read_bytes()) + declared_before = manifest_before["checksum"] apply_composition_claim( claim=_claim("each"), @@ -486,10 +490,13 @@ def test_manifest_checksum_unchanged_by_composition_ratification( pack_root=pack_copy, ) - manifest_bytes_after = (pack_copy / "manifest.json").read_bytes() - assert manifest_bytes_after == manifest_bytes_before - assert _manifest_sha(pack_copy) == manifest_sha_before - assert json.loads(manifest_bytes_after)["checksum"] == declared_before + manifest_after = json.loads((pack_copy / "manifest.json").read_bytes()) + assert manifest_after["checksum"] == declared_before, ( + "lexicon checksum must not change during composition ratification" + ) + # RAT-1: composition_checksum may now be present (auto-compile). + if "composition_checksum" in manifest_after: + assert isinstance(manifest_after["composition_checksum"], str) # --------------------------------------------------------------------------- diff --git a/tests/test_math_frame_ratification.py b/tests/test_math_frame_ratification.py index 33449fea..dcfc3b4d 100644 --- a/tests/test_math_frame_ratification.py +++ b/tests/test_math_frame_ratification.py @@ -424,10 +424,14 @@ def test_duplicate_surface_polarity_with_new_evidence_appends_hash( # --------------------------------------------------------------------------- -def test_manifest_checksum_unchanged_by_frame_ratification(pack_copy: Path) -> None: - manifest_bytes_before = (pack_copy / "manifest.json").read_bytes() - manifest_sha_before = _manifest_sha(pack_copy) - declared_before = json.loads(manifest_bytes_before)["checksum"] +def test_lexicon_checksum_preserved_by_frame_ratification(pack_copy: Path) -> None: + """RAT-1 — the lexicon ``checksum`` field is preserved across frame + ratification. The manifest may gain a ``frame_checksum`` field + (auto-compile per RAT-1), but the pre-existing lexicon checksum + bytes are untouched. + """ + manifest_before = json.loads((pack_copy / "manifest.json").read_bytes()) + declared_before = manifest_before["checksum"] apply_frame_claim( claim=_claim("gives"), @@ -437,10 +441,13 @@ def test_manifest_checksum_unchanged_by_frame_ratification(pack_copy: Path) -> N pack_root=pack_copy, ) - manifest_bytes_after = (pack_copy / "manifest.json").read_bytes() - assert manifest_bytes_after == manifest_bytes_before - assert _manifest_sha(pack_copy) == manifest_sha_before - assert json.loads(manifest_bytes_after)["checksum"] == declared_before + manifest_after = json.loads((pack_copy / "manifest.json").read_bytes()) + assert manifest_after["checksum"] == declared_before, ( + "lexicon checksum must not change during frame ratification" + ) + # RAT-1: frame_checksum may now be present (auto-compile). + if "frame_checksum" in manifest_after: + assert isinstance(manifest_after["frame_checksum"], str) # --------------------------------------------------------------------------- diff --git a/tests/test_rat1_end_to_end_admission.py b/tests/test_rat1_end_to_end_admission.py new file mode 100644 index 00000000..4b1b3de4 --- /dev/null +++ b/tests/test_rat1_end_to_end_admission.py @@ -0,0 +1,160 @@ +"""RAT-1 — live end-to-end admission tests on the canonical pack. + +These tests verify the full ratify → compile → load → match → consult +→ admit chain fires on the canonical en_core_math_v1 pack — no synthetic +fixture overrides. The earlier ME-1..ME-5 integration tests verified +the wiring with synthetic packs; RAT-1 closes the chain on production. + +Specifically, after ratifying ``bound(count) × bound(unit_cost)`` under +``multiplicative_composition`` AND seeding the matching +``currency_per_unit_composition`` RecognizerSpec, statements of the +form ``"X bought N items at $C each"`` and the cross-sentence +``"... requires N items, which cost $C each"`` (with prior-sentence +proper-noun subject) admit through ``parse_and_solve`` instead of +refusing with "recognizer matched but produced no injection". +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest + +from generate.comprehension.composition_registry import ( + clear_cache as clear_composition_cache, +) +from generate.recognizer_registry import clear_registry_cache + + +def setup_function(_): + clear_composition_cache() + clear_registry_cache() + + +def _repo_root() -> Path: + here = Path(__file__).resolve() + while here.parent != here and not (here / "pyproject.toml").exists(): + here = here.parent + return here + + +def _verify_seed_present() -> bool: + """Return True iff the canonical pack has the composition + the + matching ratified RecognizerSpec for currency_per_unit_composition. + + These artifacts must have been seeded once via the RAT-1 operator + workflow (``core teaching compile-pack`` + ``core teaching + seed-recognizer``). When absent, tests skip — the wiring is still + verified by the synthetic-pack ME-5 integration test. + """ + from generate.recognizer_registry import load_ratified_registry + from generate.comprehension.composition_registry import load_composition_registry + + reg = load_ratified_registry() + has_recognizer = any( + r.canonical_pattern.get("anchor_kind") == "currency_per_unit_composition" + for r in reg + ) + comp = load_composition_registry() + has_pattern = "bound(count) × bound(unit_cost)" in comp.by_pattern + return has_recognizer and has_pattern + + +def test_canonical_pack_admits_composition_statement_when_seeded(): + """After RAT-1 seed, a clean composition statement admits.""" + if not _verify_seed_present(): + pytest.skip( + "RAT-1 seed not present on canonical pack; " + "run: core teaching seed-recognizer --shape-category rate_with_currency " + "--anchor-kind currency_per_unit_composition " + "--observed-currency-symbols '$' --observed-per-units each " + "AND ratify bound(count) × bound(unit_cost) under multiplicative_composition" + ) + + from generate.math_candidate_graph import parse_and_solve + + # Statement-only canary: the statement should NOT trigger + # "recognizer matched but produced no injection" — composition + # admits at the statement layer. (The question layer is a separate + # bottleneck not under RAT-1 scope.) + problem = "Maria bought 3 books at $5 each. How much did she pay?" + r = parse_and_solve(problem) + # Question parser is the new blocker — assert the failure is on the + # question, NOT on the composition statement. + if r.refusal_reason: + assert "Maria bought 3 books at $5 each" not in r.refusal_reason, ( + f"Statement should have admitted via composition path; " + f"got: {r.refusal_reason!r}" + ) + + +def test_canonical_pack_admits_cross_sentence_composition_when_seeded(): + """Case 0019 sentence 1 admits via cross-sentence subject binding.""" + if not _verify_seed_present(): + pytest.skip("RAT-1 seed not present on canonical pack") + + from generate.math_candidate_graph import parse_and_solve + + problem = ( + "John adopts a dog from a shelter. " + "The dog ends up having health problems and this requires " + "3 vet appointments, which cost $400 each. " + "How much did John pay?" + ) + r = parse_and_solve(problem) + # The composition sentence (sentence 1) should NOT be the refusal + # site — discourse subject "John" should reach the cross-sentence + # matcher and produce admission. + if r.refusal_reason: + assert "3 vet appointments" not in r.refusal_reason, ( + f"Composition sentence should have admitted via cross-sentence subject; " + f"got: {r.refusal_reason!r}" + ) + + +def test_wrong_zero_preserved_on_train_sample(): + """The full train_sample eval must preserve wrong == 0 after RAT-1.""" + import subprocess + import sys + + result = subprocess.run( + [sys.executable, "-m", "evals.gsm8k_math.train_sample.v1.runner", + "--use-reader"], + cwd=_repo_root(), + capture_output=True, + text=True, + ) + report_path = ( + _repo_root() / "evals" / "gsm8k_math" / "train_sample" / "v1" / "report.json" + ) + assert report_path.exists(), "train_sample runner must emit report.json" + report = json.loads(report_path.read_text()) + counts = report["counts"] + assert counts["wrong"] == 0, f"wrong-zero invariant violated: {counts}" + + +def test_case_0050_remains_refused_after_rat1(): + """Hazard pin: case 0050 must not admit after any RAT-1 wiring.""" + import subprocess + import sys + + subprocess.run( + [sys.executable, "-m", "evals.gsm8k_math.train_sample.v1.runner", + "--use-reader"], + cwd=_repo_root(), + capture_output=True, + ) + report_path = ( + _repo_root() / "evals" / "gsm8k_math" / "train_sample" / "v1" / "report.json" + ) + report = json.loads(report_path.read_text()) + case = next( + (c for c in report["per_case"] if c["case_id"].endswith("-0050")), + None, + ) + assert case is not None, "case 0050 must exist in train_sample report" + assert case["verdict"] == "refused", ( + f"case 0050 hazard pin violated: verdict={case['verdict']!r}" + )