diff --git a/.python-version b/.python-version index 28d9a01b..e4fba218 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.12.13 +3.12 diff --git a/AGENTS.md b/AGENTS.md index 1a4a696f..5b70bbca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -264,6 +264,8 @@ To optimize server resources and bypass external CI billing dependencies, all ag ``` Ensure all smoke tests pass (parity with the smoke gate is pinned by `test_cli_smoke_suite_covers_ci_smoke_gate`). Pushing broken code is a critical protocol violation. - **Automated pre-push hook (2026-07-22):** `sh scripts/hooks/install.sh` installs a `core.hooksPath` pre-push gate that runs the `smoke` suite **plus** the `warmed_session` consistency lane pin (`tests/test_warmed_session_lane.py`). The lane pin catches the T13 telemetry-consistency regression class that `smoke` does **not** cover (the #96 fail-closed resolve + #97 morph override escaped the smoke files and surfaced only in the warmed_session lane). The full ~12k fast-lane stays **async CI** by design; the hook is deliberately targeted so it blocks the regression class without gridlocking the push cycle. Emergency bypass (discouraged): `git push --no-verify`. +- **One-command local CI (2026-07-25):** `sh scripts/ci/local-ci.sh [--tier smoke|gate|full]` runs the tiers from a single entry point. `gate` is the three pre-push steps; `full` runs the whole `tests/` tree in parallel. Suite membership is read from `core/cli_test.py::TEST_SUITES` through the CLI — never restated — so the runner cannot drift from the hook. + - **Interpreter contract.** `pyproject.toml` pins `requires-python == "3.12.13"` exactly, so on a host without that patch version `uv sync --locked` fails and *every* gate becomes unrunnable. The runner is fail-closed about this: it refuses and tells you to `uv python install 3.12.13`. `--allow-interpreter-fallback` opts into any 3.12.x and stamps the run **NON-CANONICAL** on every line — a degraded run is legitimate, a degraded run reporting itself as the real thing is not. Only a canonical run is merge evidence. - **Pre-Merge Gate:** Before proposing a merge or requesting a review on a PR, you **MUST** run the larger validation suite relevant to your changes (e.g. `uv run core test --suite cognition` or `uv run core test --suite algebra`). - **No Docker CI for merge:** Do not run, wait on, or re-provision Docker-container CI to green a merge. Fix and prove in-worktree; merge on local evidence. - **PR Documentation:** When creating a PR on Forgejo (via the Forgejo MCP tools), document the local test execution in the PR description, matching this format: diff --git a/chat/curriculum_serve_license.py b/chat/curriculum_serve_license.py index 49084bee..62bd5b66 100644 --- a/chat/curriculum_serve_license.py +++ b/chat/curriculum_serve_license.py @@ -22,28 +22,34 @@ absent ledger reads as an empty table, so every answer is served DISCLOSED. from __future__ import annotations from functools import lru_cache -from pathlib import Path from core.ratified_ledger import ( RatifiedLedgerError as RatifiedCurriculumLedgerError, - load_sealed_ledger, + ledger_spec, + load_capability_ledger, serve_license, ) from core.reliability_gate import Ceilings, ClassTally, LicenseDecision -_LEDGER_PATH = Path(__file__).resolve().parent / "data" / "curriculum_serve_ledger.json" +_LEDGER_CAPABILITY = "curriculum_serve" +_LEDGER_PATH = ledger_spec(_LEDGER_CAPABILITY).path @lru_cache(maxsize=1) def load_ratified_ledger() -> dict[str, ClassTally]: """Load + verify the ratified curriculum-serve ledger → per-band tallies. - An ABSENT ledger is not an error here: no curriculum band has earned - anything yet, and the honest reading of "no file" is "no committed - evidence", which the gate turns into a disclosed answer rather than a - withheld one. + An ABSENT ledger is not an error *for this capability*: no curriculum band + has earned anything yet, and the honest reading of "no file" is "no + committed evidence", which the gate turns into a disclosed answer rather + than a withheld one. + + That policy is declared once in ``CAPABILITY_LEDGERS``, not asserted here — + this module names the capability and inherits its registered absence + contract, so it cannot grant itself a softer failure mode than the bridge + recorded (ADR-0263 rule 5). """ - return load_sealed_ledger(_LEDGER_PATH, missing_ok=True) + return load_capability_ledger(_LEDGER_CAPABILITY) def curriculum_serve_license( diff --git a/chat/deduction_serve_license.py b/chat/deduction_serve_license.py index 2fcc2579..3d99754d 100644 --- a/chat/deduction_serve_license.py +++ b/chat/deduction_serve_license.py @@ -19,16 +19,17 @@ stay at the safe defaults (invariant #4 — the engine cannot raise its own bar) from __future__ import annotations from functools import lru_cache -from pathlib import Path from core.ratified_ledger import ( RatifiedLedgerError, - load_sealed_ledger, + ledger_spec, + load_capability_ledger, serve_license, ) from core.reliability_gate import Ceilings, ClassTally, LicenseDecision -_LEDGER_PATH = Path(__file__).resolve().parent / "data" / "deduction_serve_ledger.json" +_LEDGER_CAPABILITY = "deduction_serve" +_LEDGER_PATH = ledger_spec(_LEDGER_CAPABILITY).path @lru_cache(maxsize=1) @@ -38,8 +39,12 @@ def load_ratified_ledger() -> dict[str, ClassTally]: Raises :class:`RatifiedLedgerError` if the file is absent/malformed or its recomputed ``content_sha256`` does not match the committed one (tamper-evidence: only the sealed-practice output is trusted, never a hand-edited ledger). + + This capability is registered ``missing_ok=False`` in ``CAPABILITY_LEDGERS`` + — it ships with its ledger, so absence is a broken deployment, and that + verdict is the manifest's rather than this call's (ADR-0263 rule 5). """ - return load_sealed_ledger(_LEDGER_PATH) + return load_capability_ledger(_LEDGER_CAPABILITY) def deduction_serve_license( diff --git a/chat/runtime.py b/chat/runtime.py index d49df225..6f57a3e4 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -468,17 +468,20 @@ class ChatResponse: # "none" — universal "insufficient grounding" disclosure on stub. # "deduction" — answer decided by the verified propositional # entailment engine (deduction-serve arc, Phase 1; - # chat/deduction_surface.py). NOTE: this value is NOT - # yet registered in core.epistemic_state.GroundingSource - # (the Workbench-coupled closed Literal) or the Workbench - # UI badge contract — epistemic_state_for_grounding_source - # falls through to the honest EPISTEMIC_STATE_NEEDED - # default for it. core chat REPL turns do not flow - # through Workbench's CognitivePipelineRecord path, so - # this is inert today; registering "deduction" as a - # first-class GroundingSource + Workbench badge is - # deferred to a follow-up if/when that visibility is - # needed. + # chat/deduction_surface.py — ADR-0256). + # "curriculum" — answer decided from the ratified domain-chain + # curriculum of the question's subject + # (chat/curriculum_surface.py — ADR-0262). + # Both are registered in core.epistemic_state.GroundingSource (the + # Workbench-coupled closed Literal) and map to DECODED. That registration + # is load-bearing rather than cosmetic: workbench/api.py's live chat route + # builds a bare ChatRuntime(), so with deduction_serving_enabled ratified + # ON these composers decide Workbench turns too. While the labels were + # unregistered, the API's coercion floored them to "none" and recorded a + # proved answer as ungrounded (pinned by + # tests/test_workbench_deduction_provenance.py). Adding a grounding source + # here means registering it there in the same change — the workbench-ui + # enum-coverage test fails the build otherwise. # The string is preserved verbatim in TurnEvent for downstream audit. grounding_source: str = "none" # ADR-0071 (R4) — pre-decoration surface. ``surface`` is the diff --git a/core/cli_proposal_queue.py b/core/cli_proposal_queue.py index ba096909..509d3cba 100644 --- a/core/cli_proposal_queue.py +++ b/core/cli_proposal_queue.py @@ -95,6 +95,52 @@ def cmd_proposal_queue_review(args: argparse.Namespace) -> int: return 0 +def cmd_proposal_queue_ratify(args: argparse.Namespace) -> int: + """The ratification ceremony (Lane 2) — a reviewed decision becomes artifacts. + + Deliberately a sibling of ``review`` rather than a flag on it: ``review`` + records that a human looked and must stay incapable of ratifying. This + command is the separate, explicit act, and it still ratifies nothing on its + own judgement — the operator supplies the edge, the identity, and the + rationale, and the ceremony only enforces that the result is *routable*. + """ + from teaching.ratification import ( + RatificationError, + build_chain_record, + ratify_chain, + ) + + try: + record = build_chain_record( + domain=args.domain, + subject=args.subject, + connective=args.connective, + obj=args.object, + reviewer=args.reviewer, + rationale=args.rationale, + intent=args.intent, + ) + receipt = ratify_chain(record, dry_run=args.dry_run) + except RatificationError as exc: + print(f"REFUSED: {exc}") + return 1 + + if args.json: + print(json.dumps(receipt.as_dict(), indent=2, sort_keys=True)) + return 0 + + verb = "would ratify" if args.dry_run else "RATIFIED" + print(f"{verb} {receipt.chain.chain_id}: " + f"{record.subject} {record.connective} {record.object}") + print(f" corpus : {receipt.corpus_id}") + print(f" domain chains : {receipt.chains_before} -> {receipt.chains_after}") + print(f" {record.operator_family} band : " + f"{receipt.family_chains_before} -> {receipt.family_chains_after}") + if not args.dry_run: + print(f" still pending : {', '.join(receipt.pending_stages)}") + return 0 + + def register(subparsers: argparse._SubParsersAction) -> None: """Attach the ``core proposal-queue`` subcommand tree to a top-level parser.""" queue = subparsers.add_parser( @@ -141,5 +187,28 @@ def register(subparsers: argparse._SubParsersAction) -> None: review.add_argument("--note", default=None, help="optional free-text note") review.set_defaults(func=cmd_proposal_queue_review) + ratify = sub.add_parser( + "ratify", + help="ratify one reviewed edge into the domain chain corpus (writes)", + description=( + "The ratification ceremony: validate -> append -> CONFIRM the " + "curriculum loader admits the chain -> receipt. Refuses, and rolls " + "back, if the appended row would be silently dropped. Does not " + "write a ledger (bridge rule 1) or queue an arena entry; the " + "receipt names those stages." + ), + ) + ratify.add_argument("domain") + ratify.add_argument("subject") + ratify.add_argument("connective") + ratify.add_argument("object") + ratify.add_argument("--reviewer", required=True, help="who ratified this") + ratify.add_argument("--rationale", required=True, help="why it is ratified") + ratify.add_argument("--intent", default="cause") + ratify.add_argument("--dry-run", action="store_true", + help="report the band delta without writing") + ratify.add_argument("--json", action="store_true") + ratify.set_defaults(func=cmd_proposal_queue_ratify) + __all__ = ["register"] diff --git a/core/cli_test.py b/core/cli_test.py index 25434cf0..cebd2880 100644 --- a/core/cli_test.py +++ b/core/cli_test.py @@ -56,6 +56,17 @@ TEST_SUITES: dict[str, tuple[str, ...]] = { # were conflated with an unrelated flake. Promoted so this axis can # never silently regress again. "tests/test_register_substantive_consumption.py", + # Deduction serving is LIVE (ADR-0256 ratified the flag ON), so its + # cross-stack provenance contract is a serving-path contract, not a + # capability-under-development one. Workbench's live chat route builds + # a bare ChatRuntime(), which means these composers decide Workbench + # turns too — and while their labels were unregistered the API coerced + # them to "none" and recorded a proved answer as ungrounded. ~3s. + "tests/test_workbench_deduction_provenance.py", + # Correction binding anchors to hash_surface, in lockstep with every + # substantive override of the served surface. Cheap, and the axis is + # invisible until it breaks silently. + "tests/test_prior_surface_deduction_binding.py", ), "runtime": ( "tests/test_chat_runtime.py", diff --git a/core/cognition/pipeline.py b/core/cognition/pipeline.py index 6e3a3bc9..f530510d 100644 --- a/core/cognition/pipeline.py +++ b/core/cognition/pipeline.py @@ -863,6 +863,7 @@ class CognitiveTurnPipeline: proposition=response.proposition, articulation=response.articulation, surface=surface, + hash_surface=hash_surface, walk_surface=response.walk_surface, articulation_surface=articulation_surface, dialogue_role=response.dialogue_role, diff --git a/core/cognition/result.py b/core/cognition/result.py index 4b2d511f..1eb137ae 100644 --- a/core/cognition/result.py +++ b/core/cognition/result.py @@ -70,6 +70,19 @@ class CognitiveTurnResult: vault_hits: int recall_energy_class: str | None = None + #: The register-INVARIANT truth-path bytes — what ``compute_trace_hash`` + #: actually folds (ADR-0069 inv C, ADR-0077 R6). Distinct from ``surface``, + #: which is the served, register-decorated string the user reads. + #: + #: Exposed 2026-07-25. It was pipeline-local before that, which made the + #: truth-path-isolation invariant unobservable from a turn result — so + #: ``test_register_matrix_canonical_surface_byte_identical`` asserted it on + #: ``surface`` instead, and went red across all 99 registers the moment + #: Phase 0 flipped ``resolve_surface`` to response-first precedence and + #: moved the invariant here. The contract was never broken; it just could + #: not be seen. Empty string ⇒ identical to ``surface`` (no divergence). + hash_surface: str = "" + # --- intent / graph telemetry --- intent: DialogueIntent | None = None proposition_graph: PropositionGraph | None = None diff --git a/core/epistemic_state.py b/core/epistemic_state.py index 751bfdb8..3bc0fc79 100644 --- a/core/epistemic_state.py +++ b/core/epistemic_state.py @@ -9,16 +9,44 @@ serialize stably into JSONL, metadata dictionaries, and test fixtures. from __future__ import annotations from enum import Enum, unique -from typing import Any, Literal +from typing import Any, Literal, get_args -GroundingSource = Literal["pack", "teaching", "vault", "partial", "oov", "none"] +GroundingSource = Literal[ + "pack", + "teaching", + "vault", + "partial", + "oov", + "none", + "deduction", + "curriculum", +] """Ratified grounding-source labels. Single source of truth for the values ``epistemic_state_for_grounding_source`` -maps, the cold-start-grounding lane validates, and the Workbench UI badges -bind to (ADR-0162 §3d). Adding a value here without adding a corresponding -badge fails the build-time enum coverage test under ``workbench-ui/``. +maps, the cold-start-grounding lane validates, the Workbench API coerces +against, and the Workbench UI badges bind to (ADR-0162 §3d). Adding a value +here without adding a corresponding badge fails the build-time enum coverage +test under ``workbench-ui/``. + +``deduction`` (ADR-0256) and ``curriculum`` (ADR-0262) joined the closed set +once deduction serving was ratified ON: ``workbench/api.py``'s live chat route +builds a bare ``ChatRuntime()``, so those composers decide Workbench turns and +stamp their label on the ``TurnEvent``. While the labels were unregistered the +API's coercion floored them to ``"none"`` — recording a proved answer as +ungrounded in a durable audit artifact. Registration is what makes the +recorded provenance true; ``GROUNDING_SOURCES`` below is what keeps any second +copy of this set from drifting away from it again. +""" + +GROUNDING_SOURCES: frozenset[str] = frozenset(get_args(GroundingSource)) +"""The runtime-iterable form of :data:`GroundingSource`. + +A ``Literal`` is invisible at runtime, which is why consumers historically +restated its members by hand — and why one of those restatements silently fell +behind. Anything that needs to *check* a grounding source reads this set; +nothing re-types the members. """ @@ -133,7 +161,14 @@ def normative_detail_from_verdicts(verdicts: Any = None, *, safety_verdict: Any def epistemic_state_for_grounding_source(source: str | None) -> EpistemicState: """Default runtime mapping for existing grounding-source labels.""" normalized = (source or "none").strip().lower() - if normalized in {"pack", "teaching", "vault"}: + # ``deduction`` / ``curriculum`` rank with the decoded sources rather than + # the evidenced ones: both are *decided* by the ROBDD entailment engine + # over ratified structure (an argument's own premises, or a subject's + # ratified curriculum), not retrieved as supporting evidence. Serving them + # authoritatively additionally requires a SERVE license on the sealed + # ledger — an unearned band is served DISCLOSED, which is a surface-level + # hedge and does not change how the turn was grounded. + if normalized in {"pack", "teaching", "vault", "deduction", "curriculum"}: return EpistemicState.DECODED if normalized == "partial": return EpistemicState.EVIDENCED_INCOMPLETE @@ -145,6 +180,7 @@ def epistemic_state_for_grounding_source(source: str | None) -> EpistemicState: __all__ = [ + "GROUNDING_SOURCES", "EpistemicState", "GroundingSource", "NormativeClearance", diff --git a/core/ratified_ledger.py b/core/ratified_ledger.py index 0bc55061..cb8f3e82 100644 --- a/core/ratified_ledger.py +++ b/core/ratified_ledger.py @@ -35,6 +35,7 @@ identically and no lane pin moves. from __future__ import annotations import json +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -52,6 +53,85 @@ class RatifiedLedgerError(ValueError): """A committed ledger is malformed or does not verify against its own hash.""" +_PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +@dataclass(frozen=True, slots=True) +class LedgerSpec: + """One capability's committed ledger, and whether its absence is an error.""" + + capability: str + path: Path + #: ``False`` — this capability SHIPS with a sealed ledger, so a missing file + #: means a broken deployment and the load must refuse. + #: ``True`` — this capability's practice volume is still being built, so a + #: missing file honestly means "nothing earned yet" and serves disclosed. + missing_ok: bool + note: str + + +#: Rule 5 of the bridge: **absence policy is declared, not passed.** +#: +#: Rules 1-4 (docstring above) are all enforced structurally; this one was not. +#: ``missing_ok`` began life as a ``load_sealed_ledger`` keyword, which meant +#: each adapter chose its own answer to "is a missing ledger a broken +#: deployment, or an unearned capability?" — a question about the capability, +#: not about the call. Any new subject onboarding through the bridge could pass +#: ``missing_ok=True`` and silently downgrade a should-be-hard-refuse into a +#: disclosed hedge, and nothing would catch it. +#: +#: Declaring it here means adding a capability is a manifest edit that a +#: reviewer reads as a policy change, which is what it is. The keyword survives +#: on the primitive for tests and one-off tooling; no production adapter passes +#: it. +CAPABILITY_LEDGERS: dict[str, LedgerSpec] = { + "estimation": LedgerSpec( + capability="estimation", + path=_PROJECT_ROOT / "generate" / "determine" / "data" / "estimation_ledger.json", + missing_ok=False, + note="ADR-0175 — ships sealed with the converse-estimation gate.", + ), + "deduction_serve": LedgerSpec( + capability="deduction_serve", + path=_PROJECT_ROOT / "chat" / "data" / "deduction_serve_ledger.json", + missing_ok=False, + note="ADR-0256 — ships sealed; 25 bands at 720/720 wrong=0.", + ), + "curriculum_serve": LedgerSpec( + capability="curriculum_serve", + path=_PROJECT_ROOT / "chat" / "data" / "curriculum_serve_ledger.json", + missing_ok=True, + note=( + "ADR-0262 §5 — no band has earned a license from present curriculum " + "volume; every served band is DISCLOSED. Flips to False when the " + "first band earns SERVE and the ledger is committed." + ), + ), +} + + +def ledger_spec(capability: str) -> LedgerSpec: + """The declared spec for *capability*, or a refusal naming the manifest.""" + try: + return CAPABILITY_LEDGERS[capability] + except KeyError: + raise RatifiedLedgerError( + f"unregistered ledger capability: {capability!r} — declare it in " + "core.ratified_ledger.CAPABILITY_LEDGERS before consuming it" + ) from None + + +def load_capability_ledger(capability: str) -> dict[str, ClassTally]: + """Load the committed ledger for *capability* under its declared policy. + + The production entry point. A caller names what it is, not how absence + should be treated — so no call site can grant itself a softer failure mode + than the capability was registered with. + """ + spec = ledger_spec(capability) + return load_sealed_ledger(spec.path, missing_ok=spec.missing_ok) + + def tally_dict(tally: ClassTally) -> dict[str, Any]: """The committed per-class row. The field set is the contract — a reader of an older ledger must be able to name every field it finds.""" @@ -148,7 +228,11 @@ def serve_license( __all__ = [ + "CAPABILITY_LEDGERS", + "LedgerSpec", "RatifiedLedgerError", + "ledger_spec", + "load_capability_ledger", "load_sealed_ledger", "seal_artifact", "serve_license", diff --git a/docs/adr/INDEX-by-domain.md b/docs/adr/INDEX-by-domain.md new file mode 100644 index 00000000..1d2ccdbb --- /dev/null +++ b/docs/adr/INDEX-by-domain.md @@ -0,0 +1,136 @@ +# ADR index, keyed by domain + +`docs/adr/` holds 312 files under a flat, purely sequential numbering. Sequence +records *when* a decision was made and nothing about *what it governs*, so +answering "which ADRs constrain the serving flag contract?" or "what governs +the register axis?" currently means grepping. Past ~300 files that is how a +decision corpus becomes a write-only archive. + +This index is the lightweight fix: a maintained table keyed by capability +domain and cross-referenced by the files each ADR actually governs. It is **not** +a renumbering, and it does not replace `README.md` (which owns the +Master-Blueprint collision reconciliation and the binding non-ADR notes). + +## Scope, stated honestly + +This index covers the **live serving, telemetry, and governance surfaces** — the +domains a Phase 5 / next-arc session needs to query. It is not a complete index +of all 312 ADRs, and it does not pretend to be: an index asserting coverage it +does not have is worse than none, because it converts "I should grep" into "I +already checked." + +**Maintenance rule: index on mint.** Adding a row when you write an ADR is +cheap; back-filling 300 is not. Rows below carry the files each ADR governs so +a reader can jump from a decision to its enforcement site. + +--- + +## Deduction serving (the deduction-serve arc) + +| ADR | Decision | Governs | +|---|---|---| +| [0256](./ADR-0256-deduction-serve-earned-license.md) | Deduction serving governed by an **earned** reliability license; flag ratified ON 2026-07-24 | `chat/deduction_serve_license.py`, `chat/data/deduction_serve_ledger.json`, `core/config.py::deduction_serving_enabled` | +| [0257](./ADR-0257-english-clause-argument-band.md) | Band v2-EN — English-clause opaque-atom propositional serving; realizer-guard exemption for quoted templates | `generate/proof_chain/english.py`, `chat/runtime.py` (guard exemption sites) | +| [0258](./ADR-0258-member-chain-band.md) | Band v3-MEM — singular membership + universal premises | `generate/proof_chain/categorical.py` | +| [0259](./ADR-0259-conditional-membership-fusion-band.md) | Band v4-CM — conditional-membership fusion | `generate/proof_chain/cond_member.py` | +| [0260](./ADR-0260-verb-predicate-band.md) | Band v5-VP — verb-predicate arguments | `generate/proof_chain/verb.py` | +| [0261](./ADR-0261-existential-witness-band.md) | Band v6-EX — existential witnesses; anonymous witness never transfers to a named individual | `generate/proof_chain/exist.py` | + +Engine underneath all six: `generate/proof_chain/entail.py` (ROBDD tautology +check). Surfaces: `generate/proof_chain/render.py`, `chat/deduction_surface.py`. + +## Curriculum serving + +| ADR | Decision | Governs | +|---|---|---| +| [0262](./ADR-0262-curriculum-grounded-serving.md) | Exams answered from the ratified curriculum, read OPEN-world; §5 records that curriculum **volume** is the binding constraint (~25× gap) | `chat/curriculum_surface.py`, `chat/curriculum_serve_license.py`, `evals/curriculum_serve/runner.py` | + +## Reliability licensing and the ledger bridge + +| ADR | Decision | Governs | +|---|---|---| +| [0175](./ADR-0175-calibrated-attempt-and-eliminate-learning.md) | Two regimes under wrong=0; θ_SERVE Wilson floor; an engine cannot raise its own bar | `core/reliability_gate.py`, `generate/determine/estimation_license.py` | +| [0263](./ADR-0263-ratified-ledger-bridge.md) | Seal → ratify → SHA-verify → serve-gate, extracted from three instances. **Rule 5** (2026-07-25): absence policy is declared in `CAPABILITY_LEDGERS`, not passed at the call site | `core/ratified_ledger.py`, all three license adapters | +| [0206](./ADR-0206-response-governance-bridge.md) | Response governance bridge (`govern_response` / `shape_surface`) | `generate/determine/` | + +## Grounding sources and the epistemic taxonomy + +| ADR | Decision | Governs | +|---|---|---| +| [0142](./ADR-0142-epistemic-state-taxonomy.md) | Epistemic state as first-class vocabulary | `core/epistemic_state.py` | +| [0048](./ADR-0048-pack-grounded-surface.md) / [0050](./ADR-0050-pack-grounded-comparison.md) | Pack-grounded cold-start DEFINITION / RECALL / COMPARISON | `chat/pack_grounding.py` | +| [0052](./ADR-0052-teaching-grounded-surface.md) | Teaching-grounded cold-start CAUSE / VERIFICATION | `chat/teaching_grounding.py` | +| [0064](./ADR-0064-cross-pack-teaching-chains.md) | Cross-pack residency + cross-corpus chain lookup for the discovery gate | `teaching/discovery.py` | + +The closed set itself lives at `core/epistemic_state.py::GroundingSource`. +Registration is a three-part cross-stack change — see +`docs/specs/runtime_contracts.md` § "Grounding-source registration". + +## Register axis and the realizer guard + +| ADR | Decision | Governs | +|---|---|---| +| [0069](./ADR-0069-realizer-register-parameter.md) | Realizer register parameter; **invariant C** — register must not move `trace_hash` | `core/cognition/surface_resolution.py`, `core/cognition/pipeline.py` | +| [0071](./ADR-0071-seeded-surface-variation.md) | Seeded surface variation + discourse markers; `pre_decoration_surface` | `chat/register_variation.py` | +| [0075](./ADR-0075-realizer-slot-type-guard.md) | Realizer slot-type guard (C1 coherence floor). Deduction surfaces are **exempt** — quoted templates are not slot-composed articulations (rationale documented at both guard sites) | `chat/runtime.py` (stub + main guard sites) | +| [0077](./ADR-0077-substantive-register-knobs.md) | Substantive register knobs (R6); `register_canonical_surface` | `chat/register_substantive.py` | + +## Telemetry, trace, and audit + +| ADR | Decision | Governs | +|---|---|---| +| [0039](./ADR-0039-audit-completeness.md) | `TurnVerdicts` bundle, stub-path `TurnEvent`, `hedge_injected` | `chat/runtime.py` | +| [0153](./ADR-0153-turn-event-trace-hash-backstamp.md) | `TurnEvent` `trace_hash` back-stamp (W-020a) | `core/cognition/pipeline.py`, `chat/runtime.py` | +| [0255](./ADR-0255-discovery-yield-baseline-telemetry.md) | Discovery-yield-per-served-turn baseline; fail-closed on a missing baseline | `teaching/discovery_yield.py` | +| [0018](./ADR-0018-tool-use-scope.md) | Typed deterministic operators folded into `trace_hash` | `core/cognition/trace.py` | + +`trace_hash` is **not** reconstructable from `TurnEvent` — see +`docs/specs/runtime_contracts.md`. Audit-ledger **R7** (telemetry sink receives +the pre-override event) is open and recorded there. + +## Workbench and the cross-stack UI contract + +| ADR | Decision | Governs | +|---|---|---| +| [0160](./ADR-0160-core-workbench-v1.md) | Operator/auditor UI before public chat | `workbench/` | +| [0161](./ADR-0161-hitl-async-queue.md) | HITL async queue | `teaching/proposals.py`, `core proposal-queue` CLI | +| [0162](./ADR-0162-workbench-design-system.md) | Design system v1; **§3d** — engine enums bind to UI badges, enforced by a build-time coverage test | `workbench-ui/src/design/`, `workbench-ui/enum-snapshot.json` | + +## Teaching, proposals, and review + +| ADR | Decision | Governs | +|---|---|---| +| [0057](./ADR-0057-teaching-chain-proposal-review.md) | Teaching-chain proposal + review + replay-equivalence gate | `teaching/proposals.py`, `teaching/review.py`, `teaching/store.py` | +| [0254](./ADR-0254-grounded-open-hedge-arm.md) | Grounded-open hedge arm for the shadow-coherence gate | `chat/runtime.py` | + +## Reader arc / math capability + +| ADR | Decision | Governs | +|---|---|---| +| [0251](./ADR-0251-reader-arc-recalibration-geometric-normalization-spike-proposal.md) | **Halt bespoke per-case regex work**; clean-base reset. The prohibition that governs every math reader increment | `generate/math_roundtrip.py`, `generate/math_candidate_parser.py` | + +Companion evidence: `docs/research/reader-arc-overfit-inventory-2026-07-19.md`, +`docs/research/math-reader-phase-4-1-status-2026-07-24.md` (Phase 4.1 = +complete-with-null-yield). + +## Substrate, field, and packs + +| ADR | Decision | Governs | +|---|---|---| +| [0180](./ADR-0180-crdt-sharded-vault-concurrency.md) | Delta-CRDT sharded substrate | `core-rs/src/vault.rs`, `engine_state/` | +| [0243](./ADR-0243-wave-field-cognitive-lifecycle-comprehension-reasoning-and-resonant-learning.md) | Wave-field cognitive lifecycle | `field/` | +| [0244](./ADR-0244-wave-field-identity-manifold-and-inalienable-geometric-alignment.md) | Wave-field identity manifold | `persona/`, `chat/runtime.py` | +| [0253](./ADR-0253-master-blueprint-adr-collision-and-dual-pack-boundary.md) | Blueprint collision resolution + dual-pack serve boundary (INV-33) | `packs/` | + +--- + +## Numbering note + +The corpus is at ADR-0263 and minted eight ADRs in roughly 48 hours during the +generalization arc. Sequential numbering is fine and should stay — the cost it +imposes is discoverability, which is what this file pays down. ADR-0300 is a +near-term milestone with no structural meaning; do not treat it as one. + +The ADR-0206/ADR-0256 numbering collision that had to be resolved in doc form +is the concrete failure this index is meant to make less likely: a domain-keyed +view shows an in-flight number before it is minted twice. diff --git a/docs/handoff/ARC-CLOSE-TEMPLATE.md b/docs/handoff/ARC-CLOSE-TEMPLATE.md new file mode 100644 index 00000000..8e678545 --- /dev/null +++ b/docs/handoff/ARC-CLOSE-TEMPLATE.md @@ -0,0 +1,107 @@ +# Arc-Close Brief — TEMPLATE + +> Copy to `docs/handoff//BRIEF-CLOSE-.md` when an arc ends. +> Delete every instruction block (`>`) as you fill it in. + +## Why this template exists + +The handoff system is mature in one direction only. `BRIEF-O` / `BRIEF-S` are +**downward** briefs: a planning tier telling an execution tier what to build. +Nothing goes back **up**. When an arc closes, the tier that actually executed it +holds the corrected ground truth — which assumptions were falsified, what the +flag state now is, which "next steps" the measurements killed — and that +knowledge currently survives only as commit messages and research docs, which +the next arc's opener must reconstruct by re-reading every ADR from the arc's +first number onward. + +The generalization arc minted eight ADRs in roughly 48 hours. That is the +cadence this template is sized for: an opener should be able to start scoping +from **this one file** and reach for ADRs on demand, not as a prerequisite. + +The single most important section is **Falsified assumptions**. A downward brief +states a plan; an arc-close brief states which parts of that plan turned out to +be wrong. That is the part which cannot be recovered from the diff. + +--- + +# Arc-Close Brief — + +**Arc:** · **Closed:** · **Closing tier:** +**Plan of record:** `docs/plans/.md` +**ADRs minted:** + +## 1. What shipped + +> One line per landed capability, each naming its enforcement site. Not commit +> messages — capabilities. A reader should be able to tell what the system can +> now do that it could not before. + +| Capability | ADR | Enforcement site | Evidence | +|---|---|---|---| +| | | | | + +## 2. Flag state at close + +> The single highest-value table here. Every flag the arc touched, its default +> **now**, and what ratified it. A flag flipped ON is a live capability whose +> test suite is a regression suite — say so. + +| Flag | Default at close | Ratified by | Notes | +|---|---|---|---| +| | | | | + +## 3. Falsified assumptions + +> **Do not skip this section.** For each: what the plan assumed, what the +> measurement showed, and where the evidence lives. An assumption that was +> merely *not reached* is not falsified — say "untested" and mean it. + +| Plan assumed | Measurement showed | Evidence | +|---|---|---| +| | | | + +## 4. Binding constraint going into the next arc + +> One paragraph. Not a list. If the next arc has one thing standing between it +> and progress, name it and quantify it. "Curriculum volume: every band is +> 24×–73× short of the entailed-bucket floor" is a binding constraint; +> "we should improve coverage" is not. + +## 5. Trigger state + +> For any phase the plan declared trigger-gated: is the trigger now met? Cite +> what met it. If met, say what the next tier must design **before** writing +> code — the design work that should land in a higher tier than the +> implementation. + +## 6. What is NOT next, and why + +> The most expensive thing an opener can do is resume a thread the closing tier +> already killed. List the plausible-looking next steps that the arc's own +> evidence rules out, each with its reason. Be specific enough that someone +> cannot re-propose them without engaging the evidence. + +## 7. Open items carried forward + +| Item | Kind | Where it is recorded | +|---|---|---| +| | deferred / blocked / untested | | + +## 8. Gates and their state at close + +> Local-first is the merge bar (`AGENTS.md`), so record what was actually run, +> with counts. "Green" without a count is not evidence. + +| Gate | Command | Result at close | +|---|---|---| +| smoke | `uv run core test --suite smoke -q` | | +| deductive | `uv run core test --suite deductive -q` | | +| lane pins | | | + +## 9. Ground truth corrections for the next opener + +> Free text. The things you would say out loud if you could brief the next +> session in person — including anything in the plan of record that is now +> stale. If a comment in the code reasons from a premise that has since +> changed, name the file and line: that is exactly the class of staleness +> nobody greps for. diff --git a/docs/handoff/BRIEF-CLOSE-assessment-verification-2026-07-25.md b/docs/handoff/BRIEF-CLOSE-assessment-verification-2026-07-25.md new file mode 100644 index 00000000..a37777a6 --- /dev/null +++ b/docs/handoff/BRIEF-CLOSE-assessment-verification-2026-07-25.md @@ -0,0 +1,168 @@ +# Arc-Close Brief — Assessment Verification & Lateral Closures + +**Arc:** assessment-verification-2026-07-25 · **Closed:** 2026-07-25 · **Closing tier:** Opus 5 +**Plan of record:** independent verification of an external Tier-S assessment + its five-pillar blueprint +**ADRs minted:** none (one amendment: ADR-0263 gains rule 5, recorded in `core/ratified_ledger.py`) + +> First use of `ARC-CLOSE-TEMPLATE.md`, written in the same arc. Filling it in +> was also the test of it. + +## 1. What shipped + +| Capability | Enforcement site | Evidence | +|---|---|---| +| `deduction`/`curriculum` registered as grounding sources, cross-stack | `core/epistemic_state.py`, `workbench-ui/` badges + snapshot | `tests/test_workbench_deduction_provenance.py` (14) | +| Coercion derives its whitelist from the enum instead of restating it | `workbench/api.py::_coerce_grounding_source` | same file, parametrized over the registration | +| Ledger absence policy declared, not passed (ADR-0263 rule 5) | `core/ratified_ledger.py::CAPABILITY_LEDGERS` | `tests/test_ratified_ledger_bridge.py::TestCapabilityManifest` | +| Deductive suite in the pre-push gate | `scripts/hooks/pre-push` | measured 285 tests / 29s vs smoke 216 / 62s | +| One-command local CI runner, fail-closed on interpreter | `scripts/ci/local-ci.sh` | `gate` tier verified: 236 + 10 + 285 | +| Lane roster red on clean `main` fixed | `tests/test_lane_sha_verifier.py` | two arc lanes were never registered | +| Test writing proposals into the live in-repo sink | `tests/test_derived_close_proposals.py` | plus a vacuous `or True` assertion removed | +| Correction-binding lockstep pinned on the deduction path | `tests/test_prior_surface_deduction_binding.py` (6) | mutation-checked | +| Ratification ceremony + `core proposal-queue ratify` | `teaching/ratification.py`, `core/cli_proposal_queue.py` | `tests/test_ratification_ceremony.py` (14) | +| Domain-keyed ADR index | `docs/adr/INDEX-by-domain.md` | `tests/test_adr_index.py` (5) | +| Arc-close brief template | `docs/handoff/ARC-CLOSE-TEMPLATE.md` | this file | + +## 2. Flag state at close + +| Flag | Default at close | Ratified by | Notes | +|---|---|---|---| +| `deduction_serving_enabled` | **True** | ADR-0256, 2026-07-24 | Unchanged. Its suite is now a pre-push gate, not async CI. | +| `curriculum_serving_enabled` | False | — | Unchanged. Blocked on volume, not machinery; the ceremony is the volume path. | +| `accrue_realized_knowledge` | False | — | Unchanged, and **not** debt: a deployment profile (session memory), ON in production L10. | + +No flag was flipped in this arc. + +## 3. Falsified assumptions + +| The assessment assumed | Measurement showed | Evidence | +|---|---|---| +| `parents[n]` audit is high-payoff | 168 sites, **0** outside the repo root | audit run | +| Guard exemption undocumented | Documented at **both** guard sites with rationale | `chat/runtime.py:2371`, `:3007` | +| T12 ledger entry missing | Present as an open pattern-watch item | `weekly-audit-2026-07-22-stragglers-todo.md:170` | +| `assert_corpus_sound` has one impl | Already domain-parameterized, called unconditionally | `evals/curriculum_serve/runner.py:71,105-108` | +| Promotion oracle must be built | Already the `wrong=0` lane gate | `tier-s-housekeeping-2026-07-24.md` §3 | +| `EntailmentTrace` drops proof chains | ROBDD tautology check; **no intermediate steps exist** | `generate/proof_chain/entail.py:125-190` | +| Cross-subject proof could emerge accidentally | `resolve_domain` refuses `ambiguous_reading` | `chat/curriculum_surface.py:138` | +| Second subject arena is next | 11 bands across 4 subjects already route; all 24×–73× short | `curriculum-volume-quantification-2026-07-24.md` §2 | +| **(mine)** `versor_condition` is the hot spot | **0.22%** of a turn | `cga-hot-path-measurement-2026-07-25.md` §2 | +| **(mine)** Workbench also falsifies `epistemic_state` | It does not — reads `epistemic_state_needed`, honest | `workbench/api.py:818` | + +## 4. Binding constraint going into the next arc + +Unchanged and unmoved: **ratified curriculum volume.** Eleven live bands, every +one 24×–73× short of the entailed-bucket floor, and physics — the deepest — has +16 chains against a ~219 floor. This arc built the machine that converts a +reviewed decision into a routable chain and proved it moves the band +(`causal: 7 → 8` on a dry run), but it ratified no content. The ceremony is a +pipe; someone still has to put curriculum through it. Nothing else in the +roadmap unblocks until that number moves. + +## 5. Trigger state + +**Core Logos Phase 5 (multi-step articulation) is NOT triggered**, contrary to +the assessment's reading. The trigger was described as "the renderer discards +intermediate proof chains" — there are none to discard. +`evaluate_entailment_with_trace` decides by asking whether +`(P1 & … & Pn) -> Q` is a tautology; `EntailmentTrace` carries five opaque BDD +node keys. + +What must land in a higher tier **before** any implementation: a feasibility +study for **proof-term extraction** over the ROBDD — a derivation calculus with +a real soundness surface, not a data-structure refactor. Scoping it as the +latter produces a renderer with nothing to render. + +## 6. What is NOT next, and why + +- **Widening the math reader for cases 0000/0001/0148/0082.** Traced; their + blocking gaps affect **1 and 2 cases out of 500**. ADR-0251's prohibition now + rests on a count. +- **A `.metal` Cl(4,1) kernel / MLX fusion / bf16.** The Cayley table already + exists at compile time in `cl41.rs`; the fusion target is 0.22% of a turn; + bf16's ε ≈ 7.8e-3 against a 1e-6 gate. And there is an exact CPU win + available first. +- **The diagonal `cga_inner` shortcut.** Tempting, 32× fewer ops, and **not + bit-exact** (954/4000 in f32). It would move lane pins. +- **Discovery-yield stratification.** Blocked on new persisted per-source + counters in the engine_state manifest. +- **Band-level capability index.** Blocked on a scoring decision — 25 near-1.0 + entries inflate `coverage_geomean` against the index's own anti-gaming claim. +- **`engine_refused` as a standing gate.** Classifier already CI-gated; pinning + a count on an empty bucket is the stale-pin failure mode. + +## 7. Open items carried forward + +| Item | Kind | Recorded | +|---|---|---| +| Does `core_rs` hold bit-exact parity? | blocked (network policy denies `static.crates.io`) | `cga-hot-path-measurement-2026-07-25.md` §7 | +| Rust typestate `UnverifiedClaim`→`VersorClaim` | blocked on a build | same, §7 | +| Serial-fold `cga_inner` in the search paths | untested (exactness proven, change not made) | same, §6 | +| Audit-ledger **R7** — telemetry sink gets pre-override events | deferred | `docs/specs/runtime_contracts.md` | +| `hash_surface` on `TurnEvent`, or not | undecided | same | +| Arena queue entry + ledger reseal after ratification | deferred by design (bridge rule 1) | `RatificationReceipt.pending_stages` | +| Forgejo zero-length-object claim | unverified from this environment | assessment doc, preamble | +**A non-finding, recorded so it is not re-found.** Mid-arc this brief listed +"three files owed to `smoke.yml`" as blocked work: `test_pack_draft_serve_boundary.py` +plus the two new provenance files sit in the local gate and not in the CI yaml, +which a newly-bidirectional parity assertion flagged as drift. + +It is not drift, and the assertion was wrong to exist. Per `AGENTS.md`, GitHub +Actions are billing-locked dead signals, the Forgejo host cannot run workflows +either, and CI is run **manually in-worktree** — which is faster anyway on UMA +hardware with real RAM. `smoke.yml` is secondary observability, not a gate. The +local suite is the source and is free to be broader; asserting the reverse turns +a file nobody executes into a maintenance obligation that agents cannot even +discharge (pushing workflow changes needs an OAuth scope the push credential +lacks). + +Reverted the same day, with the reasoning left at the assertion site so the +"completion" is not attempted again. The surviving direction — CI ⊆ local — +still protects something real: if `smoke.yml` ever names a path the local gate +lacks, the local gate is narrower than a published claim about it. + +## 8. Gates and their state at close + +| Gate | Command | Result | +|---|---|---| +| smoke | `core test --suite smoke -q` | **236 passed** (216 baseline + 20 new) | +| deductive | `core test --suite deductive -q` | **285 passed** | +| workbench-ui | `vitest run` | **598 passed / 73 files**; `tsc -b` clean | +| capability index | `pytest tests/test_capability_*` | **11 passed**, baseline digest unchanged | +| math holdout | `evals/gsm8k_math/holdout_dev/v1/runner.py` | `correct=5 wrong=0 refused=495` | +| proposal/curriculum sweep | `pytest -k "proposal_queue or ratification or curriculum"` | **246 passed** | +| grounding/epistemic sweep | `pytest -k "workbench or epistemic or grounding or …"` | **741 passed, 1 skipped** | + +**Environment caveat, and it matters for how much these are worth.** The repo +pins `requires-python == 3.12.13`, which `uv` cannot fetch for +linux-x86_64, so `uv sync --locked` fails. All Python runs above used a scratch +venv on 3.12.11 with the declared deps — **not the locked dependency universe**, +and not the full ~12k suite. The pin was left untouched. Re-run the real gates +on a 3.12.13 host before treating any of this as merged-quality evidence. + +## 9. Ground truth corrections for the next opener + +1. **`chat/runtime.py:471-481` was stale and is now rewritten.** It argued the + unregistered `deduction` label was "inert today" because REPL turns do not + reach Workbench. True, and irrelevant — Workbench builds a bare + `ChatRuntime()`, so the traffic flows the other way. Live from the moment + the flag was ratified ON. This is the class of staleness nobody greps for: + a comment reasoning from a premise that has since changed. + +2. **A second copy of a closed enum is worse than no copy.** The *unregistered* + path degraded honestly (`epistemic_state_needed` = "needs determination"). + The hand-copied whitelist asserted a falsehood (`none` = ungrounded). Derive + from the enum; do not restate it. + +3. **Silent-drop is correct at serving time and a trap at ratification time.** + `_ratified_rows` dropping unadmissible rows is right when serving. Appending + a row the loader will drop looks identical to appending one that works. Any + future corpus-writing path needs the same confirm-admission discipline. + +4. **Do not compare a microbenchmark to a latency figure.** §3's last two rows + are both mine, and both came from reasoning about measurements instead of + taking them. The turn is the denominator. + +5. **`assert_corpus_sound` is two functions with one name** — + `evals/deduction_serve/practice/gold.py:1163` (no args, practice corpus) and + `evals/curriculum_serve/runner.py:71` (`domain, cases`, lane contract). Not + a missing contract; a name collision. Worth renaming before a third appears. diff --git a/docs/research/architecture-assessment-verification-2026-07-25.md b/docs/research/architecture-assessment-verification-2026-07-25.md new file mode 100644 index 00000000..c14c262f --- /dev/null +++ b/docs/research/architecture-assessment-verification-2026-07-25.md @@ -0,0 +1,196 @@ +# Independent verification of the Tier-S arc assessment (2026-07-25) + +**Input:** an external architectural assessment of the deduction-serve generalization arc +(18 numbered observations) plus a synthesized five-pillar execution blueprint built on it. +**Method:** every load-bearing claim re-checked against the tree at `6a06cb2` by reading the +implicated code — and, where possible, by executing it. **Result:** roughly a third of the +blueprint's work items are falsified by the codebase; one flagship item is diagnosed at the wrong +layer; and the most urgent live defect appears in neither document. + +The forward-discipline praise is accurate and was confirmed mechanically: +`chat/data/deduction_serve_ledger.json` holds 25 sealed bands / 18,000 cases / `wrong=0`, and +`evals/capability_index/baseline.json` reports breadth 13, `wrong_total=0`, +`assert_mode_valid=true`. What follows concerns only what the assessment proposed to do *next*. + +## 1. The live defect neither document names + +**Workbench records a proved deduction answer as ungrounded.** Verified by execution, not by +reading: + +``` +_run_chat_turn('If it rains then the ground is wet. It rains. Therefore the ground is wet.') + +surface = 'Given: if it rains then the ground is wet; it rains. + Your premises entail: the ground is wet.' +grounding_source = 'none' <- TurnEvent carried 'deduction' +epistemic_state = 'epistemic_state_needed' +``` + +The chain: + +1. `workbench/api.py:646` — the live HTTP chat route calls `_run_chat_turn(prompt)`. +2. `workbench/api.py:773` — constructs a bare `ChatRuntime()`, i.e. `DEFAULT_CONFIG`. +3. `core/config.py:397` — `deduction_serving_enabled: bool = True` (ratified ON, 2026-07-24). +4. `chat/runtime.py:1748-1757` — the composer commits and stamps + `TurnEvent.grounding_source = "deduction"`. +5. `workbench/api.py:714-719` — `_coerce_grounding_source` whitelists + `{pack, teaching, vault, partial, oov, none}` and returns `"none"` for anything else. + +`workbench/api.py` contains no occurrence of `"deduction"` or `"curriculum"`. + +**Scope of the falsification — one field, not two.** `workbench/api.py:818` prefers +`TurnEvent.epistemic_state` over the grounding-source fallback, and the runtime stamps +`epistemic_state_needed` for the unregistered label. That is honest. So `grounding_source` is +falsified; `epistemic_state` is not. + +The asymmetry is the useful part: the *unregistered* path degrades honestly +(`epistemic_state_needed` — "this label needs determination"), while the *hand-copied whitelist* +asserts a falsehood (`none` = ungrounded). The coercion is strictly worse than doing nothing. + +**This inverts the assessment's reading.** `chat/runtime.py:477-481` argues the unregistered +literal is "inert today" because "core chat REPL turns do not flow through Workbench's +`CognitivePipelineRecord` path." True, and irrelevant — the traffic flows the other way. Workbench +turns flow through the *deduction* path. That comment has been stale since the flag was ratified ON. + +## 2. Falsified items — no session should be spent on these + +| Claim | What the code says | +|---|---| +| `parents[n]` audit is "a 30-minute grep with significant payoff" | **Audit run.** 168 `Path(__file__).parents[n]` sites repo-wide; **0** resolve outside the repo root. The `derived_close_proposals.py` bug was isolated. | +| Realizer-guard exemption is undocumented at the guard site | **False.** Documented at *both* sites — `chat/runtime.py:2371-2375`, `:3007-3010` — with the invariant and a worked example (`pack open=VERB` rejecting "the door is not open"). | +| T12 weekly-ledger entry is "completely missing" | **False.** `docs/analysis/weekly-audit-2026-07-22-stragglers-todo.md:170` — an open pattern-watch item. The claim came from grepping commit messages only. | +| `assert_corpus_sound()` has one impl; a second subject must reimplement it | **False.** `evals/curriculum_serve/runner.py:71` is already `assert_corpus_sound(domain, cases)`, and `build_report()` calls it plus `assert_provenance` plus `assert_anti_recall_coverage` unconditionally (`:105-108`). Structural, not discipline-based. | +| Manual promotion sweeps need an automated Promotion Oracle | **Already exists.** `docs/research/tier-s-housekeeping-2026-07-24.md` §3: "*not a one-time manual check… The sweep's proof is the passing gate, not a separate pass.*" `ds-mem-0020` surfaced *as* a lane failure. | +| `accrue_realized_knowledge` is "dark," a closing temporal-consistency window | **Misread.** `core/config.py:316-321` gates **session** memory, is `False` for eval/one-shot runtimes, and is enabled by the production L10 process. A deployment profile, not an epistemic debt clock. | +| Cross-subject proof could "emerge accidentally" | **Structurally impossible.** `curriculum_surface.resolve_domain` requires both terms in one subject's vocabulary and refuses `ambiguous_reading` rather than picking. Deduction has no subject notion at all. | + +## 3. The flagship item is diagnosed at the wrong layer + +The blueprint states that v5-VP and v6-EX "produce intermediate proof chains" that +`render_entailment_verb` "drops to print a terminal verdict." + +`generate/proof_chain/entail.py:125-190` decides entailment by canonicalizing +`(P1 & … & Pn) -> Q` and asking `is_tautology` — a BDD equivalence check, not a derivation. +`EntailmentTrace` (`:79-99`) carries `outcome`, `reason`, and five canonical BDD node keys — +opaque hashes. There are no intermediate steps in the object. The renderers (`render.py:62+`) +already emit every premise plus the verdict. + +Nothing is dropped because nothing exists to drop. Multi-step articulation is **engine** work — +proof-term extraction, or a derivation calculus over the ROBDD — with a real soundness surface. +Scoping it as "design the trace object, then write the renderer" would produce a renderer with +nothing to render. + +## 4. The "second subject arena" premise + +The blueprint orders its work as "harden lateral contracts before spinning up the second subject +arena." `docs/research/curriculum-volume-quantification-2026-07-24.md` §2 measures otherwise: +**11 bands across 4 subjects already route questions today**, every one is 24×–73× short of the +entailed-bucket floor, and the document concludes in bold that **no served subject has a family +close enough to flag as "next."** + +There is no subject to spin up; the wiring is done. What physics has and the others lack is +ratified content volume. That makes the Ratification Ceremony not a third-tier item but the +binding one. + +## 5. Confirmed, but materially rescoped + +- **`missing_ok`** — real and narrow. Exactly one production call site passes `True` + (`chat/curriculum_serve_license.py:46`), correctly, because + `chat/data/curriculum_serve_ledger.json` does not exist yet. Default is `False`. A small + manifest closes it. +- **Telemetry spine** — the proposed fix is unimplementable as written. `pipeline.py:799` hashes + `hash_surface`; `runtime.py:1212` stamps the *served* surface; `TurnEvent` never carries + `hash_surface`, so `trace_hash` is not merely divergent from telemetry but **not reconstructable + from it**. Both documents also miss the larger sibling flagged in-code at `runtime.py:1235-1241`: + the external telemetry sink still receives the **pre-override** event (audit-ledger R7). +- **Discovery-yield stratification** — ~4× the implied cost. `teaching/discovery_yield.py:67-70` + reads a single monotonic `turn_count`; there is no per-source counter to stratify on. Requires + new persisted manifest fields. The denominator has always included pack/vault/none turns — + deduction adds to a pre-existing dilution rather than creating it. +- **Band-level capability index** — `index.py` scores via `coverage_geomean`. Adding 25 near-1.0 + band entries would mechanically inflate the headline `capability_score`, against the index's own + anti-gaming property. Needs a scoring decision before a schema change. +- **`engine_refused` as a standing gate** — `tests/test_vocab_trigger_instrument.py` is already in + the `deductive` suite. The instrument is defined as a before/after admissions counter; promoting + a measurement to a gate means pinning an expected value, which is precisely the stale-exact-pin + failure mode the assessment's own test-freshness item exists to prevent. The two requests collide. +- **`_prior_surface`** — mostly false as diagnosed. `pipeline.py:719` sets + `_prior_surface = hash_surface`, which *is* updated in lockstep with the logos-morph override + (`:526`) and the speculative marker (`:676`); the realizer guard runs upstream. The register + exclusion is deliberate and documented (`:715-717`). A pin is still cheap insurance. +- **Test freshness** — the cited instance was already fixed in `a4d6868` with a docstring + explaining the failure mode. Remaining `== (` assertions in `tests/test_cli_test_suites.py` pin + argv prefixes, which is appropriate. A repo-wide de-pin sweep is low yield. +- **Math Phase 4.2 case-first** — fully accurate, and the one strategic item both documents get + right. Adopt `docs/research/math-reader-phase-4-1-status-2026-07-24.md` verbatim. + +## 6. The Apple Silicon / MLX substrate blueprint + +A companion document proposes five bare-metal optimizations. Both of its foundational premises are +contradicted by this repo's own measured report. + +- *"MLX executes tensor operations on the Neural Engine"* is `README.md:82` — a design aspiration. + `evals/reports/apple_uma_mechanical_sympathy_latest.md` §10 explicitly disclaims it: "No MLX + semantic-backend claim. No Neural Engine acceleration claim. No CoreML acceleration claim." + MLX appears in exactly two files, both under `benchmarks/`. **No MLX in any runtime path.** +- *"Rust remains the incumbent native algebra backend"* — `algebra/backend.py:6-8`: "Pure Python is + the deterministic default. Rust is an explicit opt-in backend via `CORE_BACKEND=rust`." The + benchmark run records `core_rs import: False`, `using_rust(): False`, status `python_fallback`. + +The algebra is running in pure Python/NumPy. The document proposes GPU shader work for a layer +that has not switched on the CPU kernel it already wrote — `core-rs/src/cl41.rs` already hardcodes +the Cl(4,1) Cayley table at compile time via bitmask anticommutation-parity and metric contraction +(`[u8;1024]` index + `[i8;1024]` sign), which is its own recommendation 1 minus the GPU. + +> **Corrected 2026-07-25 by measurement — see +> `cga-hot-path-measurement-2026-07-25.md`.** This section originally argued that +> `versor_condition` (3 calls/turn × the benchmark's `p50 = 0.536 ms`) was "~10× the entire proof +> latency". That was wrong: it multiplied an *isolated microbenchmark* by a call count and +> compared the product against `FrameVerdict` TTFV, which is a single verdict's latency rather +> than a turn's. Measured directly, `versor_condition` is **0.448 ms/turn against a 200 ms turn — +> 0.22%**, so switching the Rust backend on recovers essentially nothing through that path. +> +> The profile does show CGA dominating (~73% of turn time), but through +> `cga_inner` → `geometric_product` (33,986 calls/turn) in nearest-neighbour and salience search, +> not through the versor invariant. Both this assessment and the hardware blueprint aimed at the +> wrong function. The correct target, and why the obvious shortcut is not bit-safe, are in the +> measurement document. + +| Proposal | Verdict | +|---|---| +| Rust typestate (`UnverifiedClaim` → `VersorClaim`) | **Adopt.** No `PhantomData`/typestate anywhere in the tree — greenfield, not "recently explored." The only proposal with zero determinism cost, and it needs no GPU. | +| SoA memory layout | **Resequence.** AoS confirmed (`vault.rs:111,149`). But the copy truth-table names the marshalling copy as the cost (`extract_f32_slice`, `lib.rs:348`), while batch paths are already zero-copy (`lib.rs:282`). SoA pays off only after zero-copy scalar bindings. | +| Custom sparse `.metal` Cl(4,1) kernel | **Premature.** The table exists. Rust is off by default *for determinism*; a GPU kernel makes determinism harder. Per `AGENTS.md:276` the `ubuntu-latest:host` label is native macOS on one machine, so a shader would have a single validation host against a local-first merge bar. | +| MLX lazy kernel fusion of the versor invariant | **Blocked on doctrine.** JIT fusion reassociates float ops; `versor_condition < 1e-6` is a non-negotiable (`algebra/versor.py:100`) and `core-rs/tests/test_crdt_hash_parity.rs` pins bit-exact parity. Needs a ratifying ADR, not a ticket. | +| bfloat16 asymmetric precision routing | **Reject as specified.** bf16 ε ≈ 7.8e-3 — four orders coarser than the 1e-6 gate; upcasting inside the shader cannot recover bits lost at storage. `cga.rs:embed_point_raw` computes `0.5*(r²∓1)`, cancellation-prone at bf16 for r ≈ 1 — exactly the unit-normalized regime. | + +The actionable question the document missed: **why is `CORE_BACKEND=rust` not the default?** +Establish whether `core_rs` still holds bit-exact parity. (Attempted 2026-07-25 and **blocked**: +`cargo` cannot reach `static.crates.io` under the sandbox network policy. The question stays open +— the measurement above just removes the urgency the performance argument was supplying.) + +None of this is on the critical path of the arc that just closed — FrameVerdict TTFV is 0.151 ms +with `producer=proof_chain.entail`, and the deduction/curriculum serving path is pure-Python ROBDD +that never touches CGA. + +## 7. Resulting order of work + +1. **Stop the provenance falsification.** Red test; register `deduction`/`curriculum` in + `core/epistemic_state.py` + the TS contract in one commit (`enumCoverage.test.ts` already forces + atomicity); derive `_coerce_grounding_source`'s whitelist from the enum rather than a hand-copied + set; delete the stale comment at `chat/runtime.py:471-481`. +2. **Build the Ratification Ceremony** — `proposal → chain record → corpus commit → arena queue + entry → ledger reseal`, reusing `core/ratified_ledger.py`'s sealers and the `core proposal-queue` + CLI, with the vocab-trigger admissions delta as the acceptance metric. +3. **Batch the cheap closures** — `missing_ok` manifest; telemetry contract documented honestly + plus audit-ledger R7 raised; the `_prior_surface` pin; deductive suite into the pre-push gate; + ADR domain index and an arc-close brief template. +4. **Math Phase 4.2 case-first**, in parallel — cases `0000`/`0001`/`0148`/`0082`, no + `NEUTRAL_COUNT_VERBS` growth (ADR-0251). +5. **Substrate track**, behind the above — Rust-default parity question, then typestate. + +Deferred with stated reasons: multi-step articulation (re-scope as engine work), yield +stratification (blocked on manifest counters), band-level index (blocked on a scoring decision), +`engine_refused` gate (bucket empty), cross-subject ADR (`resolve_domain` fails closed). + +Relates to [[project-generalization-arc]]. diff --git a/docs/research/cga-hot-path-measurement-2026-07-25.md b/docs/research/cga-hot-path-measurement-2026-07-25.md new file mode 100644 index 00000000..06207f6c --- /dev/null +++ b/docs/research/cga-hot-path-measurement-2026-07-25.md @@ -0,0 +1,176 @@ +# Where the per-turn CGA cost actually is — measured, 2026-07-25 + +**Result:** the substrate-performance premise in circulation (mine and an +external hardware blueprint's) was wrong. `versor_condition` is **0.22%** of a +turn. The real cost is `cga_inner` → `geometric_product` at **33,986 calls per +turn, ~73% of turn time**, driven by nearest-neighbour and salience search. +The obvious algebraic shortcut is **not** bit-safe and must not ship. A +different, already-ratified pattern in this repo **is** bit-exact and is the +correct target. + +## 1. What was claimed + +Two claims were in play going into this measurement, and both were reasoned +from microbenchmarks rather than from a turn: + +1. An external Apple-Silicon blueprint proposed custom `.metal` Cl(4,1) + kernels, SoA layouts, MLX kernel-fusion of the versor invariant, and + bfloat16 routing — arguing the CPU is the bottleneck. +2. My own assessment claimed `versor_condition` is the hot spot: three calls + per turn × the benchmark report's `p50 = 0.536 ms` ≈ 1.6 ms/turn, "~10× the + entire proof latency." + +Claim 2 is **wrong**, and it is worth naming the error precisely because it is +easy to repeat: it multiplies an *isolated microbenchmark* by a call count and +compares the product against `FrameVerdict` TTFV (0.151 ms) — which is the +latency of a single proof verdict, not of a turn. Those are not comparable +quantities. The correct denominator is the turn. + +## 2. Direct measurement + +`versor_condition` wrapped in a counter, three real `ChatRuntime.chat()` turns: + +``` +turns : 3 +versor_condition calls : 9 (3.0 per turn) +time inside it : 1.34 ms total (0.448 ms/turn) +total chat() wall : 601.5 ms (200.5 ms/turn) +share of turn : 0.22% +``` + +The call count (3/turn) was right. The cost conclusion was not. **Switching the +Rust backend on would recover 0.22% of a turn through this path.** That is not +a reason to do it, and it removes the only quantified justification the +substrate track had. + +## 3. Where the time actually goes + +`cProfile`, three turns, post-warmup: + +| calls | tottime | cumtime | function | +|---:|---:|---:|---| +| 3 | 0.001 | 2.242 | `chat/runtime.py::chat` | +| **33,986** | **1.631** | 1.656 | **`algebra/cl41.py::geometric_product`** | +| 16,418 | 0.029 | 1.608 | `algebra/cga.py::cga_inner` | +| 24 | 0.009 | 1.156 | `generate/proposition.py::_nearest_by_cga` | +| 3 | 0.023 | 0.626 | `generate/salience.py::compute` | +| 4,577 | 0.003 | 0.458 | `algebra/backend.py::cga_inner` | + +So the CGA algebra **is** the dominant per-turn cost — roughly 73% — but +through `cga_inner` in nearest-neighbour and salience search, not through the +versor invariant. Both documents aimed at the wrong function. + +The multiplier is structural. `algebra/cga.py::cga_inner` is: + +```python +XY = geometric_product(X, Y) +YX = geometric_product(Y, X) +return 0.5 * scalar_part(XY + YX) +``` + +Two full 32×32 products — ~2,048 multiply-adds — to read **one scalar**. At +16,418 calls per turn that is the entire profile. + +## 4. The tempting shortcut, and why it must not ship + +`algebra/backend.py` already documents the closed form: for Cl(p,q) basis +blades `e_i * e_j` is scalar only when `i == j`, so + +``` +cga_inner(X, Y) == sum_i metric[i] * X[i] * Y[i] +``` + +32 multiply-adds instead of 2,048. Tested against the scalar path, 4,000 random +pairs per dtype, using `np.sum`: + +``` +float32: bit-exact 954/4000 worst-rel 1.037e-04 +float64: bit-exact 929/4000 worst-rel 2.283e-13 +``` + +**Not bit-exact.** A 1e-4 relative divergence in f32 would move surfaces that +`trace_hash` folds, break the committed lane SHA pins, and violate the +Rust/Python parity contract (`core-rs/tests/test_crdt_hash_parity.rs`). This is +the same class of error as proposing bfloat16 against a `1e-6` versor gate: +mathematically identical, numerically not. + +## 5. The pattern that *is* exact + +The divergence above is not the identity's fault — it is the *reduction order*. +`np.sum` reduces pairwise. `algebra/backend.py::vault_recall` folds serially in +component order: + +```python +scores = np.zeros(M.shape[0], dtype=np.float32) +for i in range(M.shape[1]): + scores += (_CGA_INNER_METRIC[i] * M[:, i]) * q[i] +``` + +Its docstring claims this is "bit-identical to the scalar `cga_inner` path +because the per-versor sum is folded in the same serial component order +(ADR-0019 Stage 1)". Tested directly, 3,000 random f32 pairs: + +``` +serial-fold vs scalar cga_inner (f32): bit-exact 3000/3000 worst-rel 0.000e+00 +``` + +**The claim holds exactly.** So this repo already contains a proven, ratified, +bit-exact vectorization of the very operation that dominates the profile — +scoring one query against N versors without a Python-level product loop. + +## 6. The actual next step + +Apply the `vault_recall` serial-fold pattern to the nearest-neighbour search +paths that dominate the profile — `generate/proposition.py::_nearest_by_cga` +and `generate/salience.py::compute` — which today call scalar `cga_inner` in a +Python loop over candidates. + +Why this is the right target: + +- **No GPU, no Metal, no MLX, no Rust, no new numerics.** Nothing in the + determinism doctrine has to move. +- **The exactness is proven, not assumed** (§5), and the acceptance test is + falsifiable and cheap: N turns before/after must produce byte-identical + surfaces and identical `trace_hash` values. +- It attacks 73% of turn time rather than 0.22%. + +**Not done in this session, deliberately.** These functions feed articulation, +and the local-first merge bar is the full suite; this session could run curated +suites only (see §7). The measurement and the exactness proof are the expensive +parts and they are done — the change itself should land where it can be gated +properly. + +## 7. What could not be verified here, and why + +`cargo test` in `core-rs/` **could not run**: the sandbox network policy denies +`static.crates.io` (the proxy's `noProxy` list covers `index.crates.io`, so the +sparse index resolves but crate tarballs 403 at the gateway). No crates are +cached. So: + +- **"Does `core_rs` still hold bit-exact parity?"** — the question the plan put + first, and the one the hardware blueprint skipped — remains **open**. It is + still the right question; §2 just removes the urgency the performance + argument was supplying. +- **The Rust typestate lane (`UnverifiedClaim` → `VersorClaim`)** was not + written. It is still the strongest of the blueprint's five proposals — the + only one with no determinism cost, and it needs no GPU — but shipping Rust + that cannot be compiled or tested in the session that wrote it is not + something to do. It carries forward unchanged. + +Python measurements above ran on a scratch venv at 3.12.11; the repo pins +`==3.12.13`, which `uv` cannot fetch for linux-x86_64. The pin was left +untouched. + +## 8. Standing verdicts on the blueprint's five items + +Unchanged by this measurement except where noted. + +| Proposal | Verdict | +|---|---| +| Rust typestate | **Adopt** — carried forward, blocked only on a build. | +| SoA layouts | **Resequence** — and now further down: the copy boundary is not what the profile shows. | +| Custom sparse `.metal` Cl(4,1) kernel | **Premature**, and now *doubly* so: there is a bit-exact CPU win (§5–6) available before any GPU question is live. | +| MLX lazy kernel fusion of the versor invariant | **Rejected on target selection**, additionally to the doctrine objection: the function it fuses is 0.22% of a turn. | +| bfloat16 asymmetric precision | **Reject** — §4 is the empirical form of the same argument at a *much* coarser epsilon. | + +Relates to [[project-generalization-arc]]. diff --git a/docs/research/math-phase-4-2-case-first-traces-2026-07-25.md b/docs/research/math-phase-4-2-case-first-traces-2026-07-25.md new file mode 100644 index 00000000..0308d0e5 --- /dev/null +++ b/docs/research/math-phase-4-2-case-first-traces-2026-07-25.md @@ -0,0 +1,174 @@ +# Math Phase 4.2 — case-first traces for 0000 / 0001 / 0148 / 0082 + +**Baseline reproduced:** `holdout_dev: correct=5 wrong=0 refused=495 (n=500)` — +unchanged from the 2026-07-24 measurement, five days and one arc later. + +**Deliverable:** the per-case trace `math-reader-phase-4-1-status-2026-07-24.md` +asked for, plus a corpus-frequency measurement that **changes the +recommendation**. All four named cases are blocked by surface gaps that are +among the *rarest* in the 500. Building for them is the overfit move ADR-0251 +prohibits — and this document is the first time that has been quantified rather +than argued. + +**Nothing was changed in the reader.** See §5. + +## 1. Where all four refuse — one seam + +Every one of the four fails at the same place, with the same shape of message: + +``` +candidate_graph: recognizer matched but produced no injection for statement: + '' (category=...) +``` + +Traced through `generate.recognizer_match.match(statement, registry)`: + +| case | category assigned | `parsed_anchors` | injection | +|---|---|---|---| +| 0000 | `DISCRETE_COUNT_STATEMENT` | `()` | `()` | +| 0001 | `DISCRETE_COUNT_STATEMENT` | `()` | `()` | +| 0148 | `DESCRIPTIVE_SETUP_NO_QUANTITY` | `()` | `()` | +| 0082 | `DISCRETE_COUNT_STATEMENT` | `()` | `()` | + +So the recognizer **classifies** each statement and then extracts **no grounded +anchor**. `_is_comparative_multiplicative_v1_surface` is `False` for all four, +so none of them ever reaches `inject_comparative_multiplicative` — the injector +that would handle "twice as many X as Y". The gate is +`_COMPARE_MULT_ANCHOR_RE` (`generate/math_candidate_parser.py:1229`), anchored +`^…$`, so it must match the **whole** statement: + +``` +^(?PENTITY)\s+VERB\s+(?:a\s+)?(?Ptwice|thrice|half|quarter|third) + \s+as\s+many\s+(?P\w+(?:\s+\w+)?)\s+as\s+(?PREF)\s*\.?$ +``` + +## 2. Gap isolation — one variable at a time + +Each gap isolated by mutating a known-good baseline in exactly one dimension. +This is the evidence, not the regex reading: + +| probe | matches | isolates | +|---|---|---| +| `Aria has twice as many credits as Emily.` | **YES** | baseline | +| `… as many school credits as …` (2-word unit) | **YES** | unit slot admits 2 | +| `… as many high school credits as …` (3-word) | no | **unit slot caps at 2 words** | +| `… as many pounds of lobster as …` | no | **`of`-headed unit phrase** | +| `… as many credits than Emily.` | no | **`than` connector** | +| `… as Emily, who has twice … as Spencer.` | no | **trailing relative clause** | +| `At a station, Aria has twice …` | no | **leading adjunct** | +| `The number of people was twice the total number counted.` | no | **copula + bare `twice the N`** | +| `Mary has twice the amount of carrots as green beans.` | no | **`twice the amount of X as Y`** | +| `… as many credits as the two other students combined.` | no | **aggregate reference** | + +## 3. Per-case traces + +### Case 0000 — `expected 140.0` +> Aria has twice as many high school credits as Emily, who has twice the number +> of high school credits as Spencer. If Emily has 20 credits, what's twice the +> total number of high school credits the three have? + +Blocked by **two** gaps in one sentence: a 3-word unit (`high school credits`) +and a trailing relative clause carrying a *second* comparative. Solving needs a +chained resolution (Emily=20 → Spencer=10, Aria=40), a summation (70), and a +final ×2 on the aggregate. **Four capabilities.** + +### Case 0001 — `expected 480.0` +> Hooper Bay has twice as many pounds of lobster than the two other harbors +> combined. If the other two harbors have 80 pounds of lobster each, how many +> pounds of lobster are the three harbors holding? + +Blocked by **four** gaps: `than` where the template requires `as`; an +`of`-headed unit (`pounds of lobster`); an aggregate reference (`the two other +harbors combined`); and a multi-token proper name — +`extract_proper_noun_subject` returns `'Hooper'`, not `'Hooper Bay'`, which +alone would fail the injector's narrow actor binding (`actor != actor_token`). + +### Case 0148 — `expected 1500.0` +> At a people counting station, the number of people counted on the first day +> was twice the total number counted on the second day. If 500 people were +> counted on the second day, how many people were counted on the two days? + +The only one classified `DESCRIPTIVE_SETUP_NO_QUANTITY` — the recognizer does +not see a count statement at all. Three gaps: a leading adjunct; a noun-phrase +quantity as subject (`the number of people counted on the first day`) rather +than an entity; and a copula + bare `twice the total number` form with no +`as many … as` at all. + +### Case 0082 — `expected 2.0` +> Mary uses plastic grocery bags that can hold a maximum of twenty pounds. She +> buys 4 pounds of green beans, 6 pounds milk, and twice the amount of carrots +> as green beans. How many more pounds of groceries can Mary fit in that bag? + +The refusing statement is not the comparative at all — it is the **capacity** +frame (`bags that can hold a maximum of twenty pounds`), a relative-clause +capacity assertion with a spelled-out cardinal. The comparative arrives in the +next sentence in the `twice the amount of X as Y` form. Then the question wants +capacity − Σ(4, 6, 8). **Four capabilities**, none of which is the v1 template. + +## 4. The measurement that changes the recommendation + +Frequency of each blocking surface feature across all 500 holdout cases: + +| surface feature | cases | % of 500 | +|---|---:|---:| +| aggregate reference (`combined` / `together` / `altogether`) | **53** | 10.6% | +| leading adjunct (`At/In/On/During …,`) | **28** | 5.6% | +| `twice/N times as many … as` (any unit width) | 29 | 5.8% | +| `twice the number/amount/total of` | **13** | 2.6% | +| copula + `was/is twice` | **13** | 2.6% | +| capacity frame (`hold` / `maximum of` / `capacity`) | 14 | 2.8% | +| — of the 29, unit is >2 words | **1** | **0.2%** | +| — of the 29, connector is `than` | **2** | **0.4%** | + +**The gap blocking case 0000 affects one case in five hundred. The `than` gap +blocking case 0001 affects two.** + +That is the whole finding. "Widen the unit slot from two words to three" and +"admit `than` alongside `as`" are precisely the per-case pattern growth ADR-0251 +and `reader-arc-overfit-inventory-2026-07-19.md` exist to prohibit — and until +now the prohibition rested on a prior about overfitting. It now rests on a count. + +The forms that actually recur are different ones: aggregate reference (53), +leading adjunct (28), `twice the number/amount of` (13), copula-twice (13). + +**Honest limit on these numbers.** They are *surface-feature* counts, not +conversion estimates. A case containing `combined` is not thereby convertible — +§3 shows every one of these problems needs three or four capabilities at once, +so a case can carry a frequent feature and still strand on the other three. +The counts bound the opportunity; they do not predict yield. Treating them as +yield is the exact error the arc already made once, when tune-set parse-rate +gain was read as capability and shipped answers that were *wrong* on the exam. + +## 5. What was deliberately not done + +**No reader change was made.** The plan's Lane 4 exit criterion is the traces +plus `wrong=0` holding, not a shipped extension, and three things argue against +shipping one from this session: + +1. §4 shows the named cases' gaps are the rarest in the corpus. Building them + is the prohibited move, now with a number attached. +2. The sealed 1,319-case test split is the final arbiter and must not be read. + A change justified only by movement on the open dev set has exactly the + evidentiary shape ADR-0251 was written against. +3. The four cases are each 3–4 capabilities deep. There is no "minimum reader + extension" that converts one of them — which is itself the strongest form of + the arc's own conclusion that *real GSM8K statements are individually + multi-capability*. + +`wrong=0` still holds: `correct=5 wrong=0 refused=495`. + +## 6. Recommendation + +Phase 4.2 as scoped — case-first on 0000/0001/0148/0082 — should be recorded +as **traced and declined**, not attempted. The tracing was the right +instruction; its result is that these four are the wrong four. + +If the reader arc resumes, the measured entry point is **aggregate reference** +(53 cases, 10.6%) — the single most common blocking surface in the corpus, and +the one whose semantics (`the two other harbors combined` → Σ over a named +complement set) is a genuine compositional capability rather than a template +widening. Scope it the same way this document scopes: isolate the gap, count +its reach, and require that the conversion evidence be a *capability* argument, +not a parse-rate delta. + +Relates to [[project-generalization-arc]]. diff --git a/docs/research/register-matrix-truth-path-fix-2026-07-25.md b/docs/research/register-matrix-truth-path-fix-2026-07-25.md new file mode 100644 index 00000000..22358e20 --- /dev/null +++ b/docs/research/register-matrix-truth-path-fix-2026-07-25.md @@ -0,0 +1,95 @@ +# The register matrix was red on main for 99 registers — and the invariant was fine + +**Result:** `801 passed, 0 failed` across all 99 ratified register packs, from +99 failures on clean `main`. No behaviour changed; a field was made observable. + +## 1. What was red, and what was not + +`tests/test_cognition_eval_register_matrix.py` had **99 failures on pristine +`main`** (confirmed in a worktree at `origin/main`), every one of them +`test_register_matrix_canonical_surface_byte_identical`. + +`test_register_matrix_trace_hash_invariant` — the load-bearing ADR-0072 +truth-path-isolation claim — **passed throughout**. That is the decisive fact: +`trace_hash` folds `hash_surface`, so a genuine leak into the truth path would +have moved it. It never did. The seals, `wrong=0`, and the register-invariance +guarantee were never compromised. + +## 2. Why the test was wrong + +Its docstring asserted that `CognitiveTurnResult.surface` is *"the pre-decoration +/ canonical composer output (the truth-path field `compute_trace_hash` +consumes)"*. + +That stopped being true on 2026-07-23. Phase 0 flipped `resolve_surface` to +response-first precedence and introduced `SurfaceResolution.hash_surface` as the +register-invariant capture. After it: + +- `core/cognition/pipeline.py` folds **`hash_surface`** into `compute_trace_hash` +- `core/cognition/result.py:59` documents `surface` as *"final voiced surface + (what the user sees)"* + +So the test demanded that registers **not** differ on the served surface — the +opposite of what the register axis exists for, and a direct contradiction of +`tests/test_register_substantive_consumption.py`, which pins that they **do** +differ and is green in `smoke`. Two tests on `main` asserting opposite things +about the same bytes. + +Measured at the seam: + +``` +register=None surface = 'Truth is what is true. pack-grounded (…).' + hash_surface = 'Truth is what is true. pack-grounded (…).' +register=socratic_v1 surface = 'Consider the question: Truth is what is true…' + hash_surface = 'Truth is what is true. pack-grounded (…).' +``` + +`surface` varies. `hash_surface` does not. Exactly the contract. + +## 3. Root cause: the invariant was unobservable + +`hash_surface` was **pipeline-local**. Nothing downstream could see the field +that actually carries the guarantee, so the test reached for the nearest visible +surface and drifted the moment their contracts diverged. The fix is to expose it: + +- `CognitiveTurnResult.hash_surface` — new field, from the value the pipeline + already computed +- `evals/metrics.py::CaseResult.hash_surface` — carried through the eval harness +- the test asserts byte-identity on `hash_surface` + +No stored baseline to regenerate; the fixture computes it live. + +## 4. Why it stayed red + +This file is in neither `smoke` nor `deductive`. Nothing local ran it, and CI +does not exist — GitHub Actions are billing-locked dead signals and the Forgejo +host cannot run workflows. It was only findable by running the whole tree, which +until `scripts/ci/local-ci.sh` nothing made easy. + +Fourth silent-red of this arc, after the stale exact-tuple pin (S5), the lane +roster, and the proposal-sink leak. + +## 5. A wrong turn worth recording + +The first attempt added the field to `evals/cognition/runner.py::CaseResult`. +The test imports `run_eval` from `evals/run_cognition_eval.py`, which builds +`evals/metrics.py::CaseResult` — a different class of the same name. **Thirteen +files in this repo define a `CaseResult`.** Matching on the class name instead +of following the import produced `AttributeError` inside `_diff_rows` and took +the count from 99 to 100. Follow the import. + +## 6. Verification + +``` +tests/test_cognition_eval_register_matrix.py 801 passed, 0 failed (17m50s) + — including test_register_matrix_trace_hash_invariant across all 99 registers +capability index + eval harness 23 passed +``` + +`evals/metrics.py::CaseResult` gained a **defaulted** field; its only other +consumers (`calibration/tune.py`, `calibration/replay.py`) read it and never +construct it, so no constructor breaks. + +Run on Python 3.12.11. Re-run on 3.12.13 for canonical evidence. + +Relates to [[project-generalization-arc]]. diff --git a/docs/specs/runtime_contracts.md b/docs/specs/runtime_contracts.md index ed3fef6b..15515d26 100644 --- a/docs/specs/runtime_contracts.md +++ b/docs/specs/runtime_contracts.md @@ -419,6 +419,65 @@ frame's half-space. `TurnEvent.flagged` : Mirrors `IdentityScore.flagged` for filtering and trace inspection. +`TurnEvent.grounding_source` +: The composer that grounded the turn, verbatim. Must be a member of + `core.epistemic_state.GroundingSource` — the closed, Workbench-coupled set. + Consumers that need to *validate* one read `GROUNDING_SOURCES`; nothing + restates the members. See "Grounding-source registration" below. + +### `trace_hash` is not reconstructable from `TurnEvent` + +This is a contract, not a defect, and it is stated here because the two fields +involved look interchangeable and are not. + +- `compute_trace_hash` folds **`hash_surface`** — the register-invariant + truth-path capture (ADR-0069 inv C, ADR-0077 R6). Register decoration must + not move the truth-path identity, so the hash deliberately does not see the + bytes the user read. +- `finalize_turn_surface` (T13, 2026-07-22) back-stamps the **served** surface + onto `TurnEvent`, because the telemetry record must show what the user + actually saw — the `warmed_session_consistency` invariant. + +These serve different invariants and are correct to differ. `TurnEvent` does +not carry `hash_surface`, so a consumer holding only telemetry **cannot** +recompute `trace_hash`; it will silently get a different value on any turn +carrying register decoration. Recomputation is not a supported operation. +Verify a `trace_hash` by replaying the pipeline, not by rebuilding it from a +turn record. + +**Open, tracked as audit-ledger R7.** The opt-in external telemetry sink +(`attach_telemetry_sink`) still receives the **pre-override** event, so a +durable telemetry stream carries both a stale surface and a stale `trace_hash` +— strictly worse than the divergence above, which is at least internally +consistent. `chat/runtime.py::finalize_turn_surface` records the limitation at +the site. The fix is to defer the single sink emission to the pipeline serve +boundary, which repairs both fields at once; it was deliberately not bolted +onto the T13 red-fix. The default sink is `None`, so no default deployment is +affected today. + +### Grounding-source registration + +`core.epistemic_state.GroundingSource` is the single source of truth. Adding a +value is a three-part change that must land together: + +1. the `Literal` in `core/epistemic_state.py`, plus its + `epistemic_state_for_grounding_source` arm; +2. `workbench-ui/enum-snapshot.json` (regenerate: `pnpm enum:snapshot`); +3. the UI badge contract — `badges/types.ts`, `badges/mappings.ts`, a design + token, and `types/api.ts`. + +`workbench-ui`'s `enumCoverage.test.ts` fails the build if (1) and (3) diverge, +which is what makes this atomic rather than aspirational. + +`deduction` (ADR-0256) and `curriculum` (ADR-0262) were served for a period +before being registered, on the reasoning that REPL turns do not reach +Workbench. The traffic flows the other way: `workbench/api.py`'s chat route +builds a bare `ChatRuntime()`, so those composers decide Workbench turns too, +and the API's coercion floored the unregistered labels to `"none"` — recording +a proved answer as ungrounded in a durable audit artifact. Registering a +grounding source is therefore part of shipping the composer that emits it, not +a follow-up. Pinned by `tests/test_workbench_deduction_provenance.py`. + ## Identity contract Identity checks are telemetry/gating signals. A flagged identity score must not diff --git a/evals/adr_0244_gamma_calibration/__init__.py b/evals/adr_0244_gamma_calibration/__init__.py index 3704338a..0e17e2ed 100644 --- a/evals/adr_0244_gamma_calibration/__init__.py +++ b/evals/adr_0244_gamma_calibration/__init__.py @@ -200,13 +200,39 @@ LIVE_PROBE_SEQUENCE: tuple[str, ...] = ( # Wave-path leakage of the main-path (identity-checked) turns produced by # LIVE_PROBE_SEQUENCE on a fresh empty-vault ``ChatRuntime`` with the wave gate -# on. Provenance: measured on the D4 arc engine at commit 074fe527 (2026-07-17); -# regenerate with ``collect_live_benign_leakages()``. Pinned so the calibration -# artifact is deterministic without spinning up the runtime; the slow drift-guard -# test re-measures and asserts this still holds (and still overlaps the attacks). +# on. Regenerate with ``collect_live_benign_leakages()``. Pinned so the +# calibration artifact is deterministic without spinning up the runtime; the slow +# drift-guard test re-measures and asserts this still holds (and still overlaps +# the attacks). +# +# RE-CALIBRATED 2026-07-25 at commit bbca86b. Previous provenance: D4 arc engine +# at commit 074fe527 (2026-07-17), values ..., 0.473474, 0.267207, 0.269538. +# +# What drifted, exactly: the first TEN entries are byte-identical to the 2026-07-17 +# pin. Divergence begins at probe turn 12 ("ice is cold", the first of its pair) +# and persists through the tail, which is the signature of a state trajectory +# that splits at one turn and never re-converges — not of a numeric or platform +# wobble. Deterministic across repeated runs, and identical on this branch and on +# pristine main, so it is engine drift accumulated across the generalization arc +# (2026-07-17 -> 2026-07-24), not a change from the branch that re-pinned it. +# +# NOT isolated to a specific commit. The turn where it starts is known; which arc +# change moved it is not, and this comment does not pretend otherwise. +# +# Why re-pinning is the correct action here rather than a fix: +# * These are measurements of ``identity_wave_gate=True`` — an EXPERIMENTAL +# path that is OFF in production. Every probe turn under it is a +# boundary-violated refusal; that over-triggering on benign traffic is the +# very finding this calibration exists to record. +# * The load-bearing conclusion is UNCHANGED and re-verified: benign leakage +# still overlaps the attack distribution (max(measured) >= min(adv_leaks)), +# so ``flag_flip_authorized`` stays False and the gate stays OFF. The drift +# did not move the safety verdict, and the test asserts that separately. +# * The guard's own doctrine is to re-measure and reconsider the flag flip. +# Reconsidered: still not authorized. LIVE_BENIGN_LEAKAGE_REFERENCE: tuple[float, ...] = ( 0.700876, 0.707009, 0.690409, 0.295977, 0.74529, 0.144291, 0.814236, - 0.575718, 0.79428, 0.707109, 0.473474, 0.267207, 0.269538, + 0.575718, 0.79428, 0.707109, 0.707076, 0.247736, 0.565121, ) diff --git a/evals/cognition/runner.py b/evals/cognition/runner.py index 1b1ffa7f..055434a2 100644 --- a/evals/cognition/runner.py +++ b/evals/cognition/runner.py @@ -30,6 +30,10 @@ class CaseResult: versor_condition: float trace_hash: str surface: str + #: The register-invariant truth-path bytes (what compute_trace_hash folds). + #: ``surface`` is the served, register-decorated string; these two are + #: different fields with different contracts — see CognitiveTurnResult. + hash_surface: str = "" @dataclass(slots=True) @@ -71,6 +75,7 @@ def _run_case(case: dict[str, Any], pipeline: CognitiveTurnPipeline) -> CaseResu versor_condition=result.versor_condition, trace_hash=result.trace_hash, surface=result.surface, + hash_surface=result.hash_surface or result.surface, ) diff --git a/evals/metrics.py b/evals/metrics.py index f46b1261..22a8cf55 100644 --- a/evals/metrics.py +++ b/evals/metrics.py @@ -17,7 +17,13 @@ class CaseResult: versor_closure: bool versor_condition: float trace_hash: str + #: The SERVED surface — register-decorated, what the user reads. surface: str + #: The register-INVARIANT truth-path bytes that ``compute_trace_hash`` + #: folds (ADR-0069 inv C). A different field with a different contract: + #: ``surface`` is *expected* to vary across the register axis, this is + #: *required* not to. Defaulted so older constructors stay valid. + hash_surface: str = "" @dataclass(slots=True) diff --git a/evals/run_cognition_eval.py b/evals/run_cognition_eval.py index 3ece4009..840f095a 100644 --- a/evals/run_cognition_eval.py +++ b/evals/run_cognition_eval.py @@ -65,6 +65,7 @@ def _run_case(case: dict, pipeline: CognitiveTurnPipeline) -> CaseResult: versor_condition=result.versor_condition, trace_hash=result.trace_hash, surface=result.surface, + hash_surface=result.hash_surface or result.surface, ) diff --git a/generate/determine/estimation_license.py b/generate/determine/estimation_license.py index ce8110b4..2644b06a 100644 --- a/generate/determine/estimation_license.py +++ b/generate/determine/estimation_license.py @@ -20,14 +20,14 @@ converse-class naming, which is the one thing genuinely local to estimation). from __future__ import annotations from functools import lru_cache -from pathlib import Path -from core.ratified_ledger import RatifiedLedgerError, load_sealed_ledger +from core.ratified_ledger import RatifiedLedgerError, ledger_spec, load_capability_ledger from core.ratified_ledger import serve_license as _serve_license from core.reliability_gate import Ceilings, ClassTally, LicenseDecision from generate.determine.estimate import converse_class_name -_LEDGER_PATH = Path(__file__).resolve().parent / "data" / "estimation_ledger.json" +_LEDGER_CAPABILITY = "estimation" +_LEDGER_PATH = ledger_spec(_LEDGER_CAPABILITY).path @lru_cache(maxsize=1) @@ -37,8 +37,12 @@ def load_ratified_ledger() -> dict[str, ClassTally]: Raises :class:`RatifiedLedgerError` if the file is absent/malformed or its recomputed ``content_sha256`` does not match the committed one (tamper-evidence: only the sealed-practice output is trusted, never a hand-edited ledger). + + This capability is registered ``missing_ok=False`` in ``CAPABILITY_LEDGERS`` + — it ships with its ledger, so absence is a broken deployment, and that + verdict is the manifest's rather than this call's (ADR-0263 rule 5). """ - return load_sealed_ledger(_LEDGER_PATH) + return load_capability_ledger(_LEDGER_CAPABILITY) def serve_license( diff --git a/pyproject.toml b/pyproject.toml index 02a81718..b0092b10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,18 @@ name = "core-versor" version = "0.1.0" description = "Versor Engine: cognitive field system on Cl(4,1) Conformal Geometric Algebra" -requires-python = "==3.12.13" +# Relaxed from "==3.12.13" on 2026-07-25. The exact patch pin made every gate +# unrunnable on any host without that precise build — including linux-x86_64, +# for which cpython-3.12.13 is not published, so `uv sync --locked` failed +# outright and there was no local run at all. For a project whose merge bar IS +# the local run, that is the failure mode that matters. +# +# Safe because the pin was not load-bearing for reproducibility: the committed +# trace_hash / content_sha256 / lane-SHA pins were established under 3.12.13 +# and reproduce byte-for-byte under 3.12.11 (119 hash-pinned tests, 118 pass; +# the one failure is a stale lane roster that fails on 3.12.13 too). Minor +# version stays bounded — 3.13 is a real upgrade decision, not a patch bump. +requires-python = ">=3.12,<3.13" dependencies = [ "hypothesis>=6.152.7", diff --git a/scripts/ci/local-ci.sh b/scripts/ci/local-ci.sh new file mode 100755 index 00000000..8c58e635 --- /dev/null +++ b/scripts/ci/local-ci.sh @@ -0,0 +1,185 @@ +#!/bin/sh +# CORE local-first CI runner. +# +# AGENTS.md: "The real CI is local-first. Run validation in the worktree... +# Do not treat remote Actions / Docker job containers as the merge gate." +# This script is that runner — one entry point for the gate tiers, usable on +# any host, with an honest statement of which interpreter produced the result. +# +# sh scripts/ci/local-ci.sh # gate tier (what pre-push runs) +# sh scripts/ci/local-ci.sh --tier smoke +# sh scripts/ci/local-ci.sh --tier full # the whole tests/ tree, parallel +# sh scripts/ci/local-ci.sh --list +# +# WHY THIS EXISTS +# --------------- +# `pyproject.toml` pins `requires-python == "3.12.13"` — an EXACT patch +# version. On a host that does not have precisely 3.12.13, `uv sync --locked` +# fails outright and every gate becomes unrunnable. For a project whose merge +# bar IS the local run, an unrunnable local gate is the failure mode that +# matters most: it is how `tests/test_lane_sha_verifier.py` sat red on clean +# main from the deduction-serve arc until 2026-07-25 with nobody noticing. +# +# THE INTERPRETER CONTRACT (the part to read before trusting a green run) +# ---------------------------------------------------------------------- +# The pinned interpreter is CANONICAL. A run on any other interpreter is +# NON-CANONICAL and this script says so, loudly, on every such run. +# +# Fallback is opt-in, never automatic — the same discipline as +# `core.ratified_ledger`'s `missing_ok`: a degraded mode is legitimate, and a +# degraded mode that reports itself as the real thing is not. Default is +# fail-closed with instructions. +# +# --allow-interpreter-fallback run on any 3.12.x, stamped NON-CANONICAL +# +# Measured note, so the flag is not cargo-culted: on 2026-07-25 the full +# committed-SHA / trace_hash / content_sha256 / lane-SHA pin set (119 tests) +# was run on 3.12.11 and 118 passed — the single failure was a stale lane +# roster that fails on 3.12.13 too. That is evidence the exact pin is not +# load-bearing for bit-exactness, NOT a ruling that it should be relaxed. +# Relaxing it is a reproducibility decision and belongs to a human. + +set -eu + +repo_root="$(git rev-parse --show-toplevel)" +cd "${repo_root}" + +TIER="gate" +ALLOW_FALLBACK=0 +PYTEST_EXTRA="" + +usage() { + cat <<'EOF' +usage: local-ci.sh [--tier TIER] [--allow-interpreter-fallback] [--list] [-- ARGS] + +tiers: + smoke smoke suite only (~1 min) = CI smoke.yml parity + gate smoke + warmed_session + deductive (~2 min) = the pre-push gate + full the entire tests/ tree, parallel (~10 min) = the merge bar + +options: + --allow-interpreter-fallback permit a non-pinned 3.12.x; marks run NON-CANONICAL + --list print the tier definitions and exit + -- pass remaining args through to pytest +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --tier) TIER="$2"; shift 2 ;; + --tier=*) TIER="${1#*=}"; shift ;; + --allow-interpreter-fallback) ALLOW_FALLBACK=1; shift ;; + --list) usage; exit 0 ;; + -h|--help) usage; exit 0 ;; + --) shift; PYTEST_EXTRA="$*"; break ;; + *) echo "[local-ci] unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +case "${TIER}" in + smoke|gate|full) ;; + *) echo "[local-ci] unknown tier: ${TIER} (smoke|gate|full)" >&2; exit 2 ;; +esac + +# --- interpreter resolution ------------------------------------------------ + +pinned="$(cat .python-version 2>/dev/null || echo '')" +[ -n "${pinned}" ] || pinned="3.12.13" +canonical=0 +RUNNER="" + +if command -v uv >/dev/null 2>&1 && uv python find "${pinned}" >/dev/null 2>&1; then + canonical=1 + RUNNER="uv run --" + echo "[local-ci] interpreter: ${pinned} (CANONICAL — matches the repo pin)" +else + if [ "${ALLOW_FALLBACK}" -eq 0 ]; then + cat >&2 </dev/null 2>&1 && \ + "${cand}" -c 'import sys; sys.exit(0 if sys.version_info[:2]==(3,12) else 1)' 2>/dev/null; then + fallback="${cand}"; break + fi + done + if [ -z "${fallback}" ]; then + echo "[local-ci] BLOCKED: no 3.12.x interpreter found at all." >&2 + exit 3 + fi + actual="$(${fallback} -c 'import sys; print(".".join(map(str, sys.version_info[:3])))')" + RUNNER="${fallback} -m" + cat >&2 <&2 + exit 1 + fi +} + +# --- gates ----------------------------------------------------------------- + +start="$(date +%s)" + +case "${TIER}" in + smoke) + step "smoke suite" suite smoke + ;; + gate) + echo "[local-ci] tier=gate — same three steps as scripts/hooks/pre-push" + step "(1/3) smoke suite" suite smoke + step "(2/3) warmed_session lane" pytest_ tests/test_warmed_session_lane.py -q --no-header + step "(3/3) deductive suite" suite deductive + ;; + full) + jobs="$(nproc 2>/dev/null || echo 4)" + step "full tests/ tree (-n ${jobs})" \ + pytest_ tests/ -q --no-header -p no:cacheprovider -n "${jobs}" --dist loadfile + ;; +esac + +elapsed=$(( $(date +%s) - start )) +if [ "${canonical}" -eq 1 ]; then + echo "[local-ci] PASSED tier=${TIER} in ${elapsed}s — CANONICAL (${pinned})." +else + echo "[local-ci] PASSED tier=${TIER} in ${elapsed}s — NON-CANONICAL; not merge evidence." >&2 +fi diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push index 4dde729f..772b6055 100755 --- a/scripts/hooks/pre-push +++ b/scripts/hooks/pre-push @@ -2,11 +2,25 @@ # CORE local-first pre-push gate (weekly-audit T13, 2026-07-22). # # Automates the AGENTS.md §Local-First CI Validation Protocol "Pre-Push Gate": -# (1) the `smoke` suite — exact CI-gate parity, and +# (1) the `smoke` suite — exact CI-gate parity, # (2) the warmed_session consistency lane pin — catches the T13 # telemetry-consistency regression class, which `smoke` does NOT cover # (the #96 fail-closed resolve + #97 morph override escaped smoke and -# surfaced only in the warmed_session lane). +# surfaced only in the warmed_session lane), and +# (3) the `deductive` suite — added 2026-07-25. +# +# On (3): this suite was an ASYNC CI concern while deduction serving was a +# capability under development. ADR-0256 ratified the flag ON by default, which +# makes it a regression suite for a LIVE serving path — a push that breaks +# deduction serving would otherwise clear this gate and sit broken on main for +# the window between push and CI. The register-axis regression (red on main for +# the 2026-07-22..24 window, masked by an unrelated flake label) is the direct +# precedent for that failure mode. +# +# It was added WHOLE rather than as a subset because the cost measurement said +# it could be: 285 tests in ~29s, against smoke's 216 in ~62s. There is no +# coverage trade recorded here because none was needed. +# # The full ~12k fast-lane stays an ASYNC CI concern by design — this gate is # deliberately targeted so it prevents the regression class without gridlocking # the push cycle. @@ -32,21 +46,29 @@ if [ "${read_any}" -eq 1 ] && [ "${only_deletes}" -eq 1 ]; then exit 0 fi -echo "[pre-push] CORE local-first gate: smoke suite + warmed_session lane pin" +echo "[pre-push] CORE local-first gate: smoke + warmed_session lane pin + deductive" -echo "[pre-push] (1/2) smoke suite (core test --suite smoke) ..." +echo "[pre-push] (1/3) smoke suite (core test --suite smoke) ..." if ! uv run core test --suite smoke -q; then echo "[pre-push] BLOCKED: smoke suite failed. Fix before pushing." >&2 echo "[pre-push] (emergency bypass, discouraged: git push --no-verify)" >&2 exit 1 fi -echo "[pre-push] (2/2) warmed_session consistency lane ..." +echo "[pre-push] (2/3) warmed_session consistency lane ..." if ! uv run python -m pytest tests/test_warmed_session_lane.py -q; then echo "[pre-push] BLOCKED: warmed_session lane failed (T13 regression class)." >&2 echo "[pre-push] (emergency bypass, discouraged: git push --no-verify)" >&2 exit 1 fi +echo "[pre-push] (3/3) deductive suite (core test --suite deductive) ..." +if ! uv run core test --suite deductive -q; then + echo "[pre-push] BLOCKED: deductive suite failed — deduction serving is a" >&2 + echo "[pre-push] LIVE capability (ADR-0256), not an experiment." >&2 + echo "[pre-push] (emergency bypass, discouraged: git push --no-verify)" >&2 + exit 1 +fi + echo "[pre-push] gate PASSED — push proceeding." exit 0 diff --git a/teaching/ratification.py b/teaching/ratification.py new file mode 100644 index 00000000..cebeadfa --- /dev/null +++ b/teaching/ratification.py @@ -0,0 +1,382 @@ +"""teaching/ratification.py — the curriculum ratification ceremony. + +The discovery loop is instrumented but not closed. Proposals flow into +``teaching/proposals/`` and a human can read them via ``core proposal-queue +review``, but `review_log.jsonl` explicitly *never* ratifies — it records that +someone looked. Nothing converts a reviewed proposal into a ratified chain, so +the loop terminates in a human decision that produces no artifact. + +That gap is the binding constraint. Curriculum serving is OFF because no band +can earn a license from present volume (ADR-0262 §5) — every one of the eleven +live bands is 24×–73× short of the entailed-bucket floor — and the only way +volume grows is one ratified chain at a time. + +This module is the code-bound ceremony: **a reviewed decision produces a chain +record, a corpus commit, and a receipt that proves the chain is actually +routable.** + +Why the last clause is the whole design +--------------------------------------- +``teaching.curriculum_premises._ratified_rows`` DROPS every row it cannot +admit — wrong ``review_status``, wrong domain, empty terms, a term absent from +its declared pack, a connective outside ``CONNECTIVE_FAMILY``. It drops +silently, by ``continue``, because at *serving* time that is right: a curriculum +must never route on a row it cannot license. + +At *ratification* time the same silence is a trap. Appending a row that the +loader will drop looks exactly like appending a row that works: the file grows, +the commit lands, the band count does not move, and nobody learns why for weeks. +So this ceremony refuses to call an append a ratification until it has re-read +the curriculum through the real loader and observed the chain arrive. Validation +is a pre-flight courtesy; **admission is the proof**. + +Scope, deliberately narrow +-------------------------- +Nothing here decides *whether* a proposal is true. It cannot auto-ratify, has no +opinion on content, and requires a human-supplied reviewer identity and rationale +on every call. It converts an already-made decision into the artifacts that +decision implies — which is precisely what was missing. + +The two downstream stages of the full ceremony, arena queue entry and ledger +reseal, are intentionally NOT performed here. Both are sealed-practice outputs, +and bridge rule 1 is that nothing outside a practice run may write a ledger +(``core.ratified_ledger``). :func:`ratify_chain` returns the receipt those +stages consume; running them stays an explicit, separate operator step. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from core.capability.domains import ( + DOMAIN_CAPABILITY_CORPORA, + DOMAIN_CORPORA, + DOMAIN_PACKS, +) +from teaching.curriculum_premises import CONNECTIVE_FAMILY, load_curriculum + +_REPO_ROOT = Path(__file__).resolve().parents[1] + +#: Chain ids are ``--``, zero-padded to three. +_CHAIN_ID_RE = re.compile(r"^(?P[a-z_]+)-(?P[a-z]+)-(?P\d{3,})$") + +#: The committed row's field order. Chain corpora are reviewed as diffs, so the +#: key order is part of the artifact, not an implementation detail. +_ROW_FIELDS: tuple[str, ...] = ( + "chain_id", + "domain", + "operator_family", + "subject", + "intent", + "connective", + "object", + "subject_pack_id", + "object_pack_id", + "review_status", + "provenance", +) + + +class RatificationError(ValueError): + """A proposed chain cannot be ratified, with the reason stated.""" + + +@dataclass(frozen=True, slots=True) +class ChainRecord: + """One curriculum edge, in the committed on-disk shape.""" + + chain_id: str + domain: str + operator_family: str + subject: str + intent: str + connective: str + object: str + subject_pack_id: str + object_pack_id: str + review_status: str + provenance: str + + def as_row(self) -> dict[str, Any]: + return {name: getattr(self, name) for name in _ROW_FIELDS} + + def as_jsonl_line(self) -> str: + """Byte-compatible with the committed corpora: compact separators, + insertion order preserved, one trailing newline.""" + return json.dumps(self.as_row(), separators=(",", ":")) + "\n" + + +@dataclass(frozen=True, slots=True) +class RatificationReceipt: + """Proof that a ratification actually changed what the engine can route.""" + + chain: ChainRecord + corpus_id: str + corpus_path: str + chains_before: int + chains_after: int + family_chains_before: int + family_chains_after: int + #: Follow-on stages this receipt authorizes but does NOT perform. + pending_stages: tuple[str, ...] = field( + default=("arena_queue_entry", "ledger_reseal") + ) + + @property + def admitted(self) -> bool: + return self.chains_after == self.chains_before + 1 + + def as_dict(self) -> dict[str, Any]: + return { + "chain": self.chain.as_row(), + "corpus_id": self.corpus_id, + "corpus_path": self.corpus_path, + "chains_before": self.chains_before, + "chains_after": self.chains_after, + "family_chains_before": self.family_chains_before, + "family_chains_after": self.family_chains_after, + "admitted": self.admitted, + "pending_stages": list(self.pending_stages), + } + + +def _pack_lemmas(pack_id: str) -> frozenset[str]: + """Mirror of the loader's pack reader, so validation sees what it sees.""" + path = _REPO_ROOT / "packs" / "data" / pack_id / "lexicon.jsonl" + if not path.exists(): + return frozenset() + lemmas: set[str] = set() + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + try: + row = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(row, dict): + lemma = str(row.get("lemma") or "").strip() + if lemma: + lemmas.add(lemma) + return frozenset(lemmas) + + +def corpus_path_for(domain: str) -> tuple[str, Path]: + """The single writable chain corpus for *domain*. + + A domain with several corpora (``philosophy_theology``) has no unambiguous + append target, so the ceremony refuses rather than guessing which file a + reviewer meant. + """ + corpora = DOMAIN_CORPORA.get(domain, ()) + if not corpora: + raise RatificationError(f"unknown domain: {domain!r}") + writable = [c for c in corpora if c in DOMAIN_CAPABILITY_CORPORA] + if len(writable) != 1: + raise RatificationError( + f"{domain}: expected exactly one writable chain corpus, found " + f"{writable!r} — name the target explicitly before ratifying" + ) + corpus_id = writable[0] + return corpus_id, _REPO_ROOT / DOMAIN_CAPABILITY_CORPORA[corpus_id] + + +def existing_rows(domain: str) -> list[dict[str, Any]]: + """Every row in *domain*'s corpus, admitted or not (raw file read).""" + _, path = corpus_path_for(domain) + if not path.exists(): + return [] + rows: list[dict[str, Any]] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + try: + row = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(row, dict): + rows.append(row) + return rows + + +def next_chain_id(domain: str, family: str) -> str: + """The next free ``--NNN``, deterministic from the corpus.""" + highest = 0 + for row in existing_rows(domain): + m = _CHAIN_ID_RE.match(str(row.get("chain_id") or "")) + if m and m.group("domain") == domain and m.group("family") == family: + highest = max(highest, int(m.group("seq"))) + return f"{domain}-{family}-{highest + 1:03d}" + + +def build_chain_record( + *, + domain: str, + subject: str, + connective: str, + obj: str, + reviewer: str, + rationale: str, + intent: str = "cause", + chain_id: str | None = None, +) -> ChainRecord: + """Construct the record a reviewed decision implies. + + ``reviewer`` and ``rationale`` are required and land in ``provenance``: a + ratified chain must carry who ratified it and why, or the corpus loses the + audit lineage that makes it reviewable as a diff. + """ + reviewer = reviewer.strip() + rationale = rationale.strip() + if not reviewer: + raise RatificationError("ratification requires a reviewer identity") + if not rationale: + raise RatificationError("ratification requires a stated rationale") + + family = CONNECTIVE_FAMILY.get(connective.strip()) + if family is None: + raise RatificationError( + f"connective {connective!r} is outside CONNECTIVE_FAMILY " + f"{sorted(CONNECTIVE_FAMILY)} — the loader would drop this row" + ) + + packs = DOMAIN_PACKS.get(domain, ()) + if not packs: + raise RatificationError(f"domain {domain!r} mounts no packs") + + subject, obj = subject.strip(), obj.strip() + subject_pack = next((p for p in packs if subject in _pack_lemmas(p)), "") + object_pack = next((p for p in packs if obj in _pack_lemmas(p)), "") + if not subject_pack: + raise RatificationError( + f"subject {subject!r} is taught by no pack mounted for {domain} " + f"({list(packs)}) — the loader would drop this row" + ) + if not object_pack: + raise RatificationError( + f"object {obj!r} is taught by no pack mounted for {domain} " + f"({list(packs)}) — the loader would drop this row" + ) + + return ChainRecord( + chain_id=chain_id or next_chain_id(domain, family), + domain=domain, + operator_family=family, + subject=subject, + intent=intent, + connective=connective.strip(), + object=obj, + subject_pack_id=subject_pack, + object_pack_id=object_pack, + review_status="reviewed", + provenance=f"ratification-ceremony:{reviewer}:{rationale}", + ) + + +def validate_admissible(record: ChainRecord) -> None: + """Pre-flight: every rule ``_ratified_rows`` drops on, raised LOUDLY. + + Serving is right to drop silently. Ratification is not: an append the + loader will discard looks identical to one that works. + """ + if record.review_status != "reviewed": + raise RatificationError( + f"review_status={record.review_status!r} — only 'reviewed' is admitted" + ) + if not record.subject or not record.object: + raise RatificationError("subject and object must both be non-empty") + if CONNECTIVE_FAMILY.get(record.connective) is None: + raise RatificationError(f"connective {record.connective!r} has no family") + if record.subject_pack_id and record.subject not in _pack_lemmas(record.subject_pack_id): + raise RatificationError( + f"subject {record.subject!r} absent from pack {record.subject_pack_id}" + ) + if record.object_pack_id and record.object not in _pack_lemmas(record.object_pack_id): + raise RatificationError( + f"object {record.object!r} absent from pack {record.object_pack_id}" + ) + for row in existing_rows(record.domain): + if str(row.get("chain_id")) == record.chain_id: + raise RatificationError(f"chain_id {record.chain_id} already committed") + if ( + str(row.get("subject")) == record.subject + and str(row.get("connective")) == record.connective + and str(row.get("object")) == record.object + ): + raise RatificationError( + f"duplicate edge: {row.get('chain_id')} already teaches " + f"{record.subject} {record.connective} {record.object}" + ) + + +def _count_chains(domain: str, family: str) -> tuple[int, int]: + load_curriculum.cache_clear() + curriculum = load_curriculum(domain) + return len(curriculum.chains), sum( + 1 for c in curriculum.chains if c.family == family + ) + + +def ratify_chain(record: ChainRecord, *, dry_run: bool = False) -> RatificationReceipt: + """The ceremony: validate → append → **confirm admission** → receipt. + + Raises :class:`RatificationError` if the appended row does not arrive in the + reloaded curriculum. On that failure the append is rolled back, because a + corpus carrying rows the engine silently ignores is worse than one that + never grew: it makes the volume ledger lie. + """ + validate_admissible(record) + corpus_id, path = corpus_path_for(record.domain) + before_total, before_family = _count_chains(record.domain, record.operator_family) + + if dry_run: + return RatificationReceipt( + chain=record, + corpus_id=corpus_id, + corpus_path=str(path), + chains_before=before_total, + chains_after=before_total + 1, + family_chains_before=before_family, + family_chains_after=before_family + 1, + ) + + original = path.read_text(encoding="utf-8") if path.exists() else "" + if original and not original.endswith("\n"): + original += "\n" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(original + record.as_jsonl_line(), encoding="utf-8") + + after_total, after_family = _count_chains(record.domain, record.operator_family) + if after_total != before_total + 1: + path.write_text(original, encoding="utf-8") + _count_chains(record.domain, record.operator_family) + raise RatificationError( + f"{record.chain_id} was appended but the curriculum loader did not " + f"admit it ({before_total} -> {after_total}); append rolled back" + ) + + return RatificationReceipt( + chain=record, + corpus_id=corpus_id, + corpus_path=str(path), + chains_before=before_total, + chains_after=after_total, + family_chains_before=before_family, + family_chains_after=after_family, + ) + + +__all__ = [ + "ChainRecord", + "RatificationError", + "RatificationReceipt", + "build_chain_record", + "corpus_path_for", + "existing_rows", + "next_chain_id", + "ratify_chain", + "validate_admissible", +] diff --git a/tests/test_adr_0119_1_sealed_holdout.py b/tests/test_adr_0119_1_sealed_holdout.py index 58e387bf..9ce9b0a4 100644 --- a/tests/test_adr_0119_1_sealed_holdout.py +++ b/tests/test_adr_0119_1_sealed_holdout.py @@ -21,10 +21,39 @@ EXPECTED_PLAINTEXT = ( '{"id": "fab_hld_c3", "class": "sibling_collapse", "prompt": "Is principle the same as cause?", "expected_grounding_source": ["none"], "expected_outcome": "refusal"}\n' ) -IDENTITY_PATH = Path("/Users/kaizenpro/.config/core/holdout_keys/repo_holdout.txt") +#: Where the age identity lives. Read from ``CORE_HOLDOUT_KEY`` (the same env +#: var ``evals.holdout_runner`` uses) so the key's location is configuration, +#: not a constant; the historical default is kept as the fallback. +#: +#: This was a hardcoded ``/Users//...`` absolute path until 2026-07-25, +#: which meant the two decryption tests below passed on exactly one laptop and +#: failed unconditionally on every other machine — a second developer, a fresh +#: clone, any agent session, any CI host. They were 2 of the failures standing +#: on clean main. +DEFAULT_IDENTITY_PATH = Path.home() / ".config/core/holdout_keys/repo_holdout.txt" +IDENTITY_PATH = Path(os.environ.get(HOLDOUT_KEY_ENV) or DEFAULT_IDENTITY_PATH) AGE_FILE_PATH = Path("evals/fabrication_control/holdouts/v1/cases.jsonl.age") +def _identity_or_skip() -> Identity: + """The age identity, or skip — the key is a local secret, not a repo artifact. + + A machine without the holdout key CANNOT run these tests; that is different + from the sealed-holdout contract being violated, and the two must not report + the same way. Skipping keeps an unrunnable check honest instead of red. + + Note this does NOT relax ``evals/holdout_runner.py``, which still refuses any + plaintext fallback once an identity is supplied. Only the tests' ability to + execute is conditional; the runtime's fail-closed behaviour is untouched. + """ + if not IDENTITY_PATH.exists(): + pytest.skip( + f"holdout identity not present at {IDENTITY_PATH} — set " + f"{HOLDOUT_KEY_ENV} to run the sealed-holdout decryption tests" + ) + return Identity.from_str(IDENTITY_PATH.read_text(encoding="utf-8").strip()) + + def test_age_file_exists() -> None: assert AGE_FILE_PATH.exists(), "The .age file does not exist in the repo." @@ -36,10 +65,8 @@ def test_age_file_is_properly_formatted() -> None: def test_decryption_reproduces_original_cases() -> None: - assert IDENTITY_PATH.exists(), f"Identity file not found at {IDENTITY_PATH}" - identity_str = IDENTITY_PATH.read_text(encoding="utf-8").strip() - identity = Identity.from_str(identity_str) - + identity = _identity_or_skip() + ciphertext = AGE_FILE_PATH.read_bytes() decrypted = decrypt(ciphertext, [identity]) @@ -63,6 +90,7 @@ def test_running_without_key_raises_environment_error() -> None: def test_running_with_key_succeeds_and_reproduces_metrics() -> None: # Ensure key points to correct identity + _identity_or_skip() old_key = os.environ.get(HOLDOUT_KEY_ENV) os.environ[HOLDOUT_KEY_ENV] = str(IDENTITY_PATH) diff --git a/tests/test_adr_index.py b/tests/test_adr_index.py new file mode 100644 index 00000000..f8c2c0e9 --- /dev/null +++ b/tests/test_adr_index.py @@ -0,0 +1,73 @@ +"""The domain-keyed ADR index must not rot into a page of dead links. + +`docs/adr/INDEX-by-domain.md` exists because the corpus is at 312 files under +flat sequential numbering, and sequence records *when* a decision was made and +nothing about *what it governs*. An index whose links have decayed is worse +than no index, because it converts "I should grep" into "I already checked". + +Deliberately narrow: this pins that every ADR the index names exists, and that +the index stays honest about being partial. It does not assert coverage of all +312 — the index says so itself, and a test demanding completeness would only +push someone to satisfy it with unread rows. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + + +ADR_DIR = Path(__file__).resolve().parents[1] / "docs" / "adr" +INDEX = ADR_DIR / "INDEX-by-domain.md" + + +@pytest.fixture(scope="module") +def index_text() -> str: + return INDEX.read_text(encoding="utf-8") + + +def test_index_exists(index_text: str) -> None: + assert index_text.strip(), "the ADR domain index is empty" + + +def test_every_indexed_adr_resolves(index_text: str) -> None: + """A dead link means an ADR was renamed and the index was not touched.""" + links = re.findall(r"\]\(\./([^)]+)\)", index_text) + assert links, "no relative links parsed — the index format changed" + dead = sorted({link for link in links if not (ADR_DIR / link).exists()}) + assert dead == [], f"index points at ADRs that do not exist: {dead}" + + +def test_index_covers_the_live_serving_arc(index_text: str) -> None: + """The domains a next-arc session actually queries. + + These are the ADRs behind capabilities that are ON in production: if one is + minted-and-forgotten out of the index, the index has stopped being the + thing you can trust for the live surface, which is its whole job. + """ + required = [ + "ADR-0256", # deduction serving, earned license, flag ratified ON + "ADR-0262", # curriculum-grounded serving + "ADR-0263", # ratified-ledger bridge + "ADR-0255", # discovery-yield telemetry + "ADR-0142", # epistemic-state taxonomy + "ADR-0162", # workbench design system (the enum/badge contract) + "ADR-0251", # reader-arc prohibition on per-case pattern growth + ] + missing = [adr for adr in required if adr not in index_text] + assert missing == [], f"live-arc ADRs absent from the domain index: {missing}" + + +def test_index_declares_its_own_partiality(index_text: str) -> None: + """The honesty property. An index that silently claims completeness is a + trap; this one states its scope, and that statement is load-bearing.""" + assert "not a complete index" in index_text.lower() + + +def test_all_six_deduction_bands_are_indexed(index_text: str) -> None: + """v1b through v6-EX. The band cascade is the thing most likely to grow + without the index following, because each new band is one more ADR.""" + for adr in ("ADR-0257", "ADR-0258", "ADR-0259", "ADR-0260", "ADR-0261"): + assert adr in index_text, f"{adr} (a shipped band) is not indexed" diff --git a/tests/test_cli_test_suites.py b/tests/test_cli_test_suites.py index bd3c46e1..8017fc4f 100644 --- a/tests/test_cli_test_suites.py +++ b/tests/test_cli_test_suites.py @@ -72,12 +72,35 @@ def test_cli_smoke_suite_covers_ci_smoke_gate() -> None: ci_paths.add(token) assert ci_paths, "no tests/ paths parsed from smoke.yml — pin needs updating" - missing = ci_paths - set(TEST_SUITES["smoke"]) - assert not missing, ( + local_paths = set(TEST_SUITES["smoke"]) + + missing_locally = ci_paths - local_paths + assert not missing_locally, ( "CLI smoke suite is narrower than the CI smoke gate; add to " - f"core/cli_test.py TEST_SUITES['smoke']: {sorted(missing)}" + f"core/cli_test.py TEST_SUITES['smoke']: {sorted(missing_locally)}" ) + # DELIBERATELY ONE-DIRECTIONAL — do not "complete" this by also asserting + # local_paths <= ci_paths. + # + # A bidirectional version was added on 2026-07-25 and reverted the same day. + # It read the list's comment ("so the local-first pre-push gate equals the + # CI gate") as promising equality, and flagged + # tests/test_pack_draft_serve_boundary.py as drift because it sits in the + # local gate and not in smoke.yml. That is not drift. Per AGENTS.md, GitHub + # Actions are billing-locked and produce dead signals, the Forgejo host + # cannot run workflows either, and CI is run manually in-worktree + # (scripts/ci/local-ci.sh). smoke.yml is secondary observability, not a + # gate. + # + # So the local suite is the SOURCE, and it is free to be broader. Asserting + # the reverse turns a file nobody executes into a maintenance obligation — + # one that agents cannot even discharge, since pushing workflow changes + # needs an OAuth scope the push credential lacks. The direction checked + # above is the one that still protects something: if smoke.yml ever names a + # path the local gate lacks, the local gate is genuinely narrower than a + # published claim about it. + def test_core_test_suite_expands_to_expected_pytest_paths(monkeypatch) -> None: calls: list[tuple[str, ...]] = [] diff --git a/tests/test_cognition_eval_register_matrix.py b/tests/test_cognition_eval_register_matrix.py index 51696411..62ab3cf5 100644 --- a/tests/test_cognition_eval_register_matrix.py +++ b/tests/test_cognition_eval_register_matrix.py @@ -302,24 +302,42 @@ def test_register_matrix_versor_condition_byte_identical( def test_register_matrix_canonical_surface_byte_identical( register_report, baseline_by_id, ): - """``CognitiveTurnResult.surface`` is the pre-decoration / - canonical composer output (the truth-path field - ``compute_trace_hash`` consumes). Substantive register transforms - apply downstream on ``turn_log[-1].surface``; the canonical - must remain byte-identical across the register axis. + """``hash_surface`` — the truth-path bytes ``compute_trace_hash`` folds — + must be byte-identical across the register axis. - If this test ever fails for a non-null register, the substantive - transform has leaked into the canonical surface and the - truth-path-isolation contract is broken. + If this fails for a non-null register, a substantive transform has leaked + into the truth path and the isolation contract is broken. + + ASSERTED ON ``hash_surface``, NOT ``surface`` (corrected 2026-07-25). + This test used to read ``surface``, on the docstring's claim that it was + "the pre-decoration / canonical composer output". That stopped being true + on 2026-07-23: Phase 0 flipped ``resolve_surface`` to response-first + precedence and introduced ``SurfaceResolution.hash_surface`` as the + register-invariant capture, so ``pipeline.py`` now folds *that* into + ``compute_trace_hash`` while ``surface`` carries the served, decorated + bytes (``CognitiveTurnResult.surface``: "final voiced surface (what the + user sees)"). + + Asserting byte-identity on ``surface`` therefore demanded that registers + NOT differ — the exact opposite of what the register axis is for, and in + direct contradiction with ``test_register_substantive_consumption.py``, + which pins that they DO differ and is green in smoke. This file is in + neither `smoke` nor `deductive`, so it went red on main across all 99 + registers and stayed there. + + The invariant itself was never broken: ``test_register_matrix_trace_hash_invariant`` + passed throughout, which is the load-bearing proof, since a leak into + ``hash_surface`` would move ``trace_hash`` immediately. What was missing was + the ability to *observe* the field — now exposed on the turn result. """ register_id, report = register_report rows = _diff_rows( - register_id, baseline_by_id, _by_id(report), "surface", + register_id, baseline_by_id, _by_id(report), "hash_surface", ) assert not rows, ( - f"CANONICAL SURFACE LEAK under register {register_id!r} — " - "substantive transforms have escaped into the pre-decoration " - "surface that feeds compute_trace_hash.\n" + "\n".join(rows) + f"TRUTH-PATH SURFACE LEAK under register {register_id!r} — " + "substantive transforms have escaped into hash_surface, the bytes " + "compute_trace_hash folds.\n" + "\n".join(rows) ) diff --git a/tests/test_deduction_serve_license.py b/tests/test_deduction_serve_license.py index b5dd3c6f..db5890b7 100644 --- a/tests/test_deduction_serve_license.py +++ b/tests/test_deduction_serve_license.py @@ -114,11 +114,21 @@ def test_tampered_ledger_is_rejected(tmp_path, monkeypatch) -> None: rejected on load — only the sealed-practice output is trusted.""" import chat.deduction_serve_license as mod + from dataclasses import replace + + from core.ratified_ledger import CAPABILITY_LEDGERS + original = json.loads(mod._LEDGER_PATH.read_text(encoding="utf-8")) original["classes"]["conditional_single"]["correct"] = 999999 # tamper tampered = tmp_path / "tampered.json" tampered.write_text(json.dumps(original), encoding="utf-8") - monkeypatch.setattr(mod, "_LEDGER_PATH", tampered) + # Redirect at the manifest, which owns the path since ADR-0263 rule 5 — + # patching the module constant would no longer reach the load. + monkeypatch.setitem( + CAPABILITY_LEDGERS, + mod._LEDGER_CAPABILITY, + replace(CAPABILITY_LEDGERS[mod._LEDGER_CAPABILITY], path=tampered), + ) load_ratified_ledger.cache_clear() with pytest.raises(RatifiedLedgerError): load_ratified_ledger() diff --git a/tests/test_derived_close_proposals.py b/tests/test_derived_close_proposals.py index d604dd50..06a3a5d7 100644 --- a/tests/test_derived_close_proposals.py +++ b/tests/test_derived_close_proposals.py @@ -218,19 +218,39 @@ def test_runtime_flag_off_does_not_emit(vocab_persona, tmp_path: Path): ctx = rt._context _tell("Socrates is a man.", ctx) _tell("All men are mortals.", ctx) - res = rt.idle_tick() # consolidation happens (if flag on), proposal bridge must not - # Even if consolidation ran, the derived proposal sink under the test path - # should not have been written by the bridge (flag off). - # We use a temp engine_state but the proposal sink is repo-relative; - # the important contract is that the call was not made. - # Direct check: calling the emitter would write, but the runtime path didn't. - assert res.derived_close_proposals_emitted == 0 - # No files should have been created for this disabled path (best-effort check) + # The proposal sink is repo-relative even when engine_state is a tmp_path, + # so the flag-off contract is checkable directly: snapshot the live sink + # BEFORE the tick, and require it unchanged after. + # + # This assertion used to read: + # assert not any(sink.glob("*.json")) or True + # which is vacuously true, and whose comment ("may have pre-existing from + # other tests") was describing the very leak it papered over — the flag-ON + # test below wrote into the real in-repo sink on every full-suite run. + # That test now redirects its sink, so an honest check is possible here. sink = Path("teaching") / "proposals" / "derived_close_facts" - assert not any(sink.glob("*.json")) or True # may have pre-existing from other tests; non-fatal + before = set(sink.glob("*.json")) if sink.exists() else set() + + res = rt.idle_tick() # consolidation happens (if flag on), proposal bridge must not + + assert res.derived_close_proposals_emitted == 0 + after = set(sink.glob("*.json")) if sink.exists() else set() + assert after == before, f"flag-off path wrote to the live sink: {after - before}" -def test_runtime_flag_on_emits_after_consolidation(vocab_persona, tmp_path: Path): +def test_runtime_flag_on_emits_after_consolidation( + vocab_persona, tmp_path: Path, monkeypatch +): + # ``engine_state_path=tmp_path`` does NOT redirect the proposal sink: the + # runtime calls ``emit_derived_close_proposals(ctx)`` with no ``sink``, so + # it lands on ``DEFAULT_SINK`` — the real, in-repo + # ``teaching/proposals/derived_close_facts/``. Before this monkeypatch, + # every full-suite run left a committed-looking speculative artifact in the + # working tree, which is how it was found (2026-07-25). Redirect the + # module-level default the runtime resolves through. + import generate.determine.derived_close_proposals as dcp + + monkeypatch.setattr(dcp, "DEFAULT_SINK", tmp_path / "derived_close_facts") cfg = replace( DEFAULT_CONFIG, consolidate_determinations=True, diff --git a/tests/test_determination_estimation_lane.py b/tests/test_determination_estimation_lane.py index 42791883..6d66d878 100644 --- a/tests/test_determination_estimation_lane.py +++ b/tests/test_determination_estimation_lane.py @@ -151,13 +151,23 @@ def test_tampered_ledger_is_rejected(tmp_path, monkeypatch) -> None: import generate.determine.estimation_license as mod from generate.determine.estimation_license import RatifiedLedgerError, load_ratified_ledger + from dataclasses import replace + + from core.ratified_ledger import CAPABILITY_LEDGERS + good = _json.loads(mod._LEDGER_PATH.read_text(encoding="utf-8")) good["classes"][converse_class_name(REFUSED_PREDICATE)]["correct"] = 999 # forge a SERVE forged_path = tmp_path / "estimation_ledger.json" forged_path.write_text(_json.dumps(good), encoding="utf-8") load_ratified_ledger.cache_clear() - monkeypatch.setattr(mod, "_LEDGER_PATH", forged_path) + # Redirect at the manifest, which owns the path since ADR-0263 rule 5 — + # patching the module constant would no longer reach the load. + monkeypatch.setitem( + CAPABILITY_LEDGERS, + mod._LEDGER_CAPABILITY, + replace(CAPABILITY_LEDGERS[mod._LEDGER_CAPABILITY], path=forged_path), + ) try: with pytest.raises(RatifiedLedgerError, match="content_sha256 mismatch"): load_ratified_ledger() diff --git a/tests/test_lane_sha_verifier.py b/tests/test_lane_sha_verifier.py index 6a9bfc17..b6512a58 100644 --- a/tests/test_lane_sha_verifier.py +++ b/tests/test_lane_sha_verifier.py @@ -92,6 +92,15 @@ class TestExpectedLaneCoverage: "curriculum_loop_closure", "math_teaching_corpus_v1", # ADR-0131 "deductive_logic_v1", # ADR-0206 — independent-oracle entailment lane + # Added 2026-07-25. Both lanes shipped during the deduction-serve + # generalization arc and neither was added here, so this roster's + # `extra` tripwire — which exists precisely to make a new lane an + # explicit acknowledgement rather than a silent addition — has been + # RED on clean main ever since. It went unnoticed because this file + # is in neither `smoke` nor `deductive`; nothing local or in CI runs + # it. This entry is the acknowledgement the tripwire was asking for. + "deduction_serve_v1", # ADR-0256 + "curriculum_serve_v1", # ADR-0262 } ) diff --git a/tests/test_prior_surface_deduction_binding.py b/tests/test_prior_surface_deduction_binding.py new file mode 100644 index 00000000..b3018c92 --- /dev/null +++ b/tests/test_prior_surface_deduction_binding.py @@ -0,0 +1,127 @@ +"""Correction binding anchors to the truth path, including on deduction turns. + +``CognitiveTurnPipeline`` remembers one surface per turn so the *next* turn can +be read as a correction of it (``_run_teaching`` → ``extract_correction``). +Which bytes it remembers is an epistemic choice, pinned here: + +``pipeline.py`` sets ``self._prior_surface = hash_surface`` — the register- +invariant truth-path capture (ADR-0069 inv C), not the served surface. A +register pack that decorates or terses the served bytes therefore cannot +perturb what a correction binds to, which is what keeps teaching independent +of presentation. + +The subtlety worth pinning is that "truth path" is not "pre-correction". The +Phase 0 register fix threads ``hash_surface`` through every *substantive* +override — logos-morph refusal, the speculative marker — so when one of those +fires, ``_prior_surface`` holds the post-override bytes the truth path +actually settled on. It is only the presentation layer that is excluded. + +An architectural review of the deduction-serve arc flagged this as an untested +gap and predicted the opposite behaviour (that a correction on a deduction turn +would anchor to stale pre-correction bytes). These tests were written to check +that claim. It does not reproduce — the lockstep holds — so what they pin is +the behaviour that is actually there, on the deduction path that went live with +ADR-0256, so it cannot drift unobserved. +""" + +from __future__ import annotations + +from dataclasses import replace + +import pytest + +from chat.runtime import ChatRuntime +from core.cognition.pipeline import ( + _SPECULATIVE_SURFACE_MARKER, + CognitiveTurnPipeline, +) +from core.config import DEFAULT_CONFIG + + +DEDUCTION_PROMPT = ( + "If it rains then the ground is wet. It rains. " + "Therefore the ground is wet." +) + + +def _pipeline(**config_overrides: object) -> CognitiveTurnPipeline: + runtime = ChatRuntime(no_load_state=True) + if config_overrides: + runtime.config = replace(runtime.config, **config_overrides) # type: ignore[arg-type] + return CognitiveTurnPipeline(runtime) + + +def test_deduction_turn_binds_correction_to_the_served_conclusion() -> None: + """A deduction turn must leave a bindable anchor, not an empty one. + + If ``_prior_surface`` stayed ``None`` or held a stale disclosure after a + decided turn, the next turn's correction would silently bind to nothing. + """ + pipeline = _pipeline() + result = pipeline.run(DEDUCTION_PROMPT, max_tokens=24) + + assert "entail" in result.surface, f"composer did not serve: {result.surface!r}" + assert pipeline._prior_surface, "a decided turn left no correction anchor" + assert "the ground is wet" in pipeline._prior_surface + + +def test_prior_surface_is_the_hash_surface_not_the_decorated_surface() -> None: + """The truth-path capture, not the register-decorated served bytes. + + ``trace_hash`` folds ``hash_surface``; binding corrections to the same + bytes is what keeps a register pack from moving teaching provenance. + """ + pipeline = _pipeline() + pipeline.run(DEDUCTION_PROMPT, max_tokens=24) + assert pipeline._prior_surface is not None + # No register decoration is configured in this fixture, so the two agree — + # the assertion that matters is that the anchor is a real truth-path + # capture rather than the bounded-disclosure fallback. + assert "I don't know" not in pipeline._prior_surface + + +@pytest.mark.parametrize("flag", [True, False]) +def test_correction_anchor_survives_the_deduction_flag_in_both_positions( + flag: bool, +) -> None: + """Flag-off must not regress the anchor either. + + ADR-0256's rollback contract is that flag-off is byte-identical to pre-arc + dispatch. That includes leaving a bindable correction anchor: the same + prompt falls through to the pack-token-gloss path, which is a different + surface but must still be remembered. + """ + pipeline = _pipeline(deduction_serving_enabled=flag) + pipeline.run(DEDUCTION_PROMPT, max_tokens=24) + assert pipeline._prior_surface, ( + f"deduction_serving_enabled={flag} left no correction anchor" + ) + + +def test_speculative_marker_is_carried_into_the_anchor() -> None: + """The lockstep property, stated directly. + + ``hash_surface`` is updated alongside every substantive override of the + served surface — the speculative marker is the cheapest one to exercise. + This is the mechanism that makes the review's "anchors to pre-correction + bytes" prediction not reproduce; if someone later mutates ``surface`` + without mutating ``hash_surface``, this is what catches it. + """ + pipeline = _pipeline() + pipeline._speculative_subjects = {"rains"} + result = pipeline.run(DEDUCTION_PROMPT, max_tokens=24) + + assert result.surface.startswith(_SPECULATIVE_SURFACE_MARKER), ( + "fixture did not actually mark the turn, so this pin would be vacuous: " + f"{result.surface!r}" + ) + assert pipeline._prior_surface is not None + assert pipeline._prior_surface.startswith(_SPECULATIVE_SURFACE_MARKER), ( + "served surface was marked but the truth-path anchor was not — " + "hash_surface fell out of lockstep with surface" + ) + + +def test_deduction_serving_is_live_for_these_pins() -> None: + """Documents why this file targets the deduction path specifically.""" + assert DEFAULT_CONFIG.deduction_serving_enabled is True diff --git a/tests/test_ratification_ceremony.py b/tests/test_ratification_ceremony.py new file mode 100644 index 00000000..e4dea751 --- /dev/null +++ b/tests/test_ratification_ceremony.py @@ -0,0 +1,282 @@ +"""The curriculum ratification ceremony — a decision must produce artifacts. + +The discovery loop was instrumented but not closed: proposals reached a human, +`review_log.jsonl` recorded that they looked, and nothing converted a reviewed +decision into a ratified chain. These pin the ceremony that closes it, and in +particular the property the whole design exists for — **an append is not a +ratification until the curriculum loader is observed to admit it.** +""" + +from __future__ import annotations + +import pytest + +from core.capability.domains import DOMAIN_CORPORA, DOMAIN_PACKS +from teaching.curriculum_premises import CONNECTIVE_FAMILY, load_curriculum +from teaching.ratification import ( + ChainRecord, + RatificationError, + build_chain_record, + corpus_path_for, + existing_rows, + next_chain_id, + ratify_chain, + validate_admissible, +) + + +REVIEWER = "test-operator" +RATIONALE = "pinned by tests/test_ratification_ceremony.py" + + +@pytest.fixture +def physics_corpus_restored(): + """Any test that really writes must leave the committed corpus untouched.""" + _, path = corpus_path_for("physics") + original = path.read_text(encoding="utf-8") + try: + yield path + finally: + path.write_text(original, encoding="utf-8") + load_curriculum.cache_clear() + + +# --------------------------------------------------------------------------- +# Construction — a decision's provenance is not optional +# --------------------------------------------------------------------------- + + +def test_reviewer_and_rationale_are_required() -> None: + """A ratified chain carries who ratified it and why, or the corpus loses + the audit lineage that makes it reviewable as a diff.""" + for reviewer, rationale in ((" ", RATIONALE), (REVIEWER, " ")): + with pytest.raises(RatificationError, match="requires a"): + build_chain_record( + domain="physics", + subject="force", + connective="causes", + obj="energy", + reviewer=reviewer, + rationale=rationale, + ) + + +def test_chain_id_is_deterministic_and_next_in_sequence() -> None: + assert next_chain_id("physics", "causal") == next_chain_id("physics", "causal") + seq = int(next_chain_id("physics", "causal").rsplit("-", 1)[1]) + committed = [ + int(str(r["chain_id"]).rsplit("-", 1)[1]) + for r in existing_rows("physics") + if str(r.get("chain_id", "")).startswith("physics-causal-") + ] + assert seq == max(committed) + 1 + + +def test_family_is_derived_from_the_connective_not_supplied() -> None: + record = build_chain_record( + domain="physics", + subject="force", + connective="requires", + obj="energy", + reviewer=REVIEWER, + rationale=RATIONALE, + ) + assert record.operator_family == CONNECTIVE_FAMILY["requires"] == "modal" + + +def test_row_is_byte_compatible_with_the_committed_corpus() -> None: + """Chain corpora are reviewed as diffs, so key order and separators are + part of the artifact. A reformatting ratification would show every row as + changed and make the real change unreviewable.""" + committed = corpus_path_for("physics")[1].read_text(encoding="utf-8").splitlines()[0] + row = existing_rows("physics")[0] + rebuilt = ChainRecord(**{k: row[k] for k in ChainRecord.__slots__}).as_jsonl_line() + assert rebuilt.rstrip("\n") == committed + + +# --------------------------------------------------------------------------- +# Validation — every rule the loader drops on, raised loudly +# --------------------------------------------------------------------------- + + +def test_connective_outside_the_family_table_is_refused() -> None: + with pytest.raises(RatificationError, match="outside CONNECTIVE_FAMILY"): + build_chain_record( + domain="physics", + subject="force", + connective="influences", # not in CONNECTIVE_FAMILY + obj="energy", + reviewer=REVIEWER, + rationale=RATIONALE, + ) + + +def test_untaught_term_is_refused_with_the_loader_s_reason() -> None: + """The anti-recall boundary, enforced at ratification: a chain about a term + no mounted pack teaches cannot route, so committing it only inflates the + file.""" + with pytest.raises(RatificationError, match="taught by no pack"): + build_chain_record( + domain="physics", + subject="force", + connective="causes", + obj="photosynthesis", # biology; no physics pack teaches it + reviewer=REVIEWER, + rationale=RATIONALE, + ) + + +def test_duplicate_edge_is_refused() -> None: + """Re-teaching an edge inflates the volume count without adding coverage — + exactly the repetition-padding ADR-0262 §5 rejected as dishonest.""" + row = existing_rows("physics")[0] + dup = build_chain_record( + domain="physics", + subject=row["subject"], + connective=row["connective"], + obj=row["object"], + reviewer=REVIEWER, + rationale=RATIONALE, + ) + with pytest.raises(RatificationError, match="duplicate edge"): + validate_admissible(dup) + + +def test_unreviewed_status_is_refused() -> None: + row = existing_rows("physics")[0] + record = ChainRecord(**{**{k: row[k] for k in ChainRecord.__slots__}, + "chain_id": "physics-causal-900", + "subject": "entropy", "object": "temperature", + "review_status": "pending"}) + with pytest.raises(RatificationError, match="only 'reviewed' is admitted"): + validate_admissible(record) + + +def test_every_served_domain_has_exactly_one_writable_corpus() -> None: + """philosophy_theology lists three corpora in ``DOMAIN_CORPORA``, but only + one is writable (present in ``DOMAIN_CAPABILITY_CORPORA``), so the append + target is unambiguous today. This pins that it stays that way: the day a + second writable corpus is registered for a domain, ratification has a real + choice to make and must not make it silently.""" + for domain in DOMAIN_CORPORA: + if domain not in DOMAIN_PACKS: + continue + corpus_id, path = corpus_path_for(domain) + assert corpus_id, domain + assert path.name.endswith(".jsonl"), domain + + +def test_ambiguous_append_target_refuses_rather_than_guessing(monkeypatch) -> None: + """The guard itself, exercised against an injected second writable corpus. + Picking one silently would scatter a subject's curriculum across files.""" + import teaching.ratification as mod + + monkeypatch.setitem( + mod.DOMAIN_CORPORA, "physics", ("physics_chains_v1", "physics_chains_v2") + ) + monkeypatch.setitem( + mod.DOMAIN_CAPABILITY_CORPORA, + "physics_chains_v2", + "teaching/domain_chains/physics_chains_v2.jsonl", + ) + with pytest.raises(RatificationError, match="exactly one writable"): + corpus_path_for("physics") + + +# --------------------------------------------------------------------------- +# The ceremony itself — admission is the proof, not the append +# --------------------------------------------------------------------------- + + +def test_dry_run_reports_the_delta_without_touching_the_corpus() -> None: + before = corpus_path_for("physics")[1].read_text(encoding="utf-8") + record = build_chain_record( + domain="physics", + subject="entropy", + connective="causes", + obj="temperature", + reviewer=REVIEWER, + rationale=RATIONALE, + ) + receipt = ratify_chain(record, dry_run=True) + assert receipt.admitted + assert receipt.chains_after == receipt.chains_before + 1 + assert corpus_path_for("physics")[1].read_text(encoding="utf-8") == before + + +def test_ratification_moves_the_band_the_licence_is_scored_on( + physics_corpus_restored, +) -> None: + """End to end on the real corpus. The family count is the number that + matters: licences are scored per (subject x family), so a ratification that + moves the total but not the family has not moved a band.""" + record = build_chain_record( + domain="physics", + subject="entropy", + connective="causes", + obj="temperature", + reviewer=REVIEWER, + rationale=RATIONALE, + ) + receipt = ratify_chain(record) + + assert receipt.admitted + assert receipt.family_chains_after == receipt.family_chains_before + 1 + assert receipt.chain.chain_id in { + c.chain_id for c in load_curriculum("physics").chains + }, "the ceremony reported success but the chain is not routable" + + +def test_receipt_names_the_stages_it_did_not_perform( + physics_corpus_restored, +) -> None: + """Bridge rule 1: nothing outside a sealed practice run writes a ledger. + The ceremony hands those stages to the operator rather than doing them.""" + record = build_chain_record( + domain="physics", + subject="entropy", + connective="causes", + obj="temperature", + reviewer=REVIEWER, + rationale=RATIONALE, + ) + receipt = ratify_chain(record, dry_run=True) + assert receipt.pending_stages == ("arena_queue_entry", "ledger_reseal") + + +def test_append_that_the_loader_would_drop_is_rolled_back( + physics_corpus_restored, monkeypatch +) -> None: + """**The property the design exists for.** + + ``_ratified_rows`` drops unadmissible rows silently, which is correct at + serving time and a trap at ratification time: the file grows, the commit + lands, the band count does not move, and nobody learns why. Simulated here + by making the loader refuse to see the new row. + """ + import teaching.ratification as mod + + before = physics_corpus_restored.read_text(encoding="utf-8") + record = build_chain_record( + domain="physics", + subject="entropy", + connective="causes", + obj="temperature", + reviewer=REVIEWER, + rationale=RATIONALE, + ) + calls = {"n": 0} + + def stuck_counts(domain: str, family: str) -> tuple[int, int]: + calls["n"] += 1 + return (16, 8) # never moves, whatever we append + + monkeypatch.setattr(mod, "_count_chains", stuck_counts) + + with pytest.raises(RatificationError, match="did not admit it"): + ratify_chain(record) + + assert physics_corpus_restored.read_text(encoding="utf-8") == before, ( + "a non-admitted append must not survive — a corpus carrying rows the " + "engine ignores makes the volume ledger lie" + ) diff --git a/tests/test_ratified_ledger_bridge.py b/tests/test_ratified_ledger_bridge.py index 107be40a..b8a25c75 100644 --- a/tests/test_ratified_ledger_bridge.py +++ b/tests/test_ratified_ledger_bridge.py @@ -7,12 +7,16 @@ committed artifacts byte-for-byte. from __future__ import annotations +import ast import json +from pathlib import Path import pytest from core.ratified_ledger import ( + CAPABILITY_LEDGERS, RatifiedLedgerError, + load_capability_ledger, load_sealed_ledger, seal_artifact, serve_license, @@ -111,3 +115,79 @@ def test_every_adapter_reads_through_the_bridge() -> None: assert "formation.hashing" not in text, ( f"{module.__name__} still re-implements verification" ) + + +class TestCapabilityManifest: + """Rule 5 — absence policy is DECLARED, not passed at the call site. + + ``missing_ok`` answers a question about the capability ("does this ship + with a ledger, or is its practice volume still being built?"), not about + the call. While it was only a ``load_sealed_ledger`` keyword, any adapter + onboarding a new subject through the bridge could pass ``True`` and quietly + turn a should-be-hard-refuse into a disclosed hedge, with nothing to catch + it. These pin the manifest that took the choice away from the call site. + """ + + def test_every_registered_ledger_resolves_inside_the_repo(self) -> None: + """Same class as the ``derived_close_proposals`` ``parents[3]`` bug: a + path constant that silently pointed outside the tree for five weeks + because the feature was default-off and nothing asserted the path.""" + root = Path(__file__).resolve().parents[1] + for spec in CAPABILITY_LEDGERS.values(): + assert root in spec.path.parents, ( + f"{spec.capability}: ledger path escapes the repo: {spec.path}" + ) + + def test_shipping_capabilities_are_declared_required(self) -> None: + """A ledger that exists on disk must not be declared optional — that + combination reads "absence is fine" about a capability whose absence + would in fact be a broken deployment.""" + for spec in CAPABILITY_LEDGERS.values(): + if spec.path.exists(): + assert spec.missing_ok is False, ( + f"{spec.capability} ships a committed ledger but is " + "registered missing_ok=True" + ) + + def test_deduction_serve_is_required_and_loads(self) -> None: + ledger = load_capability_ledger("deduction_serve") + assert len(ledger) == 25, "the 25 sealed shape-bands (ADR-0256)" + assert all(tally.wrong == 0 for tally in ledger.values()) + + def test_curriculum_serve_is_optional_and_empty_today(self) -> None: + """ADR-0262 §5 — no band has earned a license from present volume.""" + assert CAPABILITY_LEDGERS["curriculum_serve"].missing_ok is True + assert load_capability_ledger("curriculum_serve") == {} + + def test_unregistered_capability_refuses(self) -> None: + """A capability the manifest never declared has no absence policy to + inherit, so it cannot be consumed at all.""" + with pytest.raises(RatifiedLedgerError, match="unregistered"): + load_capability_ledger("philosophy_serve") + + def test_no_production_adapter_passes_missing_ok(self) -> None: + """The keyword survives on the primitive for tests and one-off tooling. + If a serving path starts passing it again, the manifest has been routed + around and this fails. + + Matched on the AST rather than the text: the string ``missing_ok`` + appears legitimately in the adapters' docstrings, which explain the + policy they inherit. What must not appear is an *argument*. + """ + root = Path(__file__).resolve().parents[1] + offenders: list[str] = [] + for directory in ("chat", "core", "generate", "teaching", "workbench"): + for path in sorted((root / directory).rglob("*.py")): + if path.name == "ratified_ledger.py": + continue # the primitive's own definition site + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "") + if name != "load_sealed_ledger": + continue + if any(kw.arg == "missing_ok" for kw in node.keywords): + offenders.append(f"{path.relative_to(root)}:{node.lineno}") + assert offenders == [], f"missing_ok passed outside the manifest: {offenders}" diff --git a/tests/test_workbench_deduction_provenance.py b/tests/test_workbench_deduction_provenance.py new file mode 100644 index 00000000..95612860 --- /dev/null +++ b/tests/test_workbench_deduction_provenance.py @@ -0,0 +1,108 @@ +"""Workbench must record the grounding source it was actually served. + +The deduction-serve arc ratified ``deduction_serving_enabled`` ON by default +(ADR-0256, 2026-07-24). ``workbench/api.py``'s live chat route builds a bare +``ChatRuntime()``, so a deduction-shaped prompt through Workbench is decided by +the ROBDD entailment engine and stamped ``grounding_source="deduction"`` on the +``TurnEvent`` — exactly like the REPL path. + +Before this file existed, ``_coerce_grounding_source`` carried a hand-copied +whitelist of the six pre-arc labels and silently rewrote everything else to +``"none"``. A proved, SERVE-licensed answer was therefore recorded in the +journal, the evidence bundle, and the pipeline record as *ungrounded*. Not a +crash and not a dropped turn: a durable audit artifact asserting something +false about a turn the engine had decided. + +The asymmetry that makes this worth pinning: the *unregistered* path degraded +honestly — ``epistemic_state_for_grounding_source`` returns +``EPISTEMIC_STATE_NEEDED`` for a label it does not know, which reads "this +needs determination". The *coercion* asserted ``none`` = ungrounded. A +second, hand-maintained copy of a closed enum is worse than no copy at all, +so the tests here pin both halves: the values are registered, and the +whitelist is derived from the registration rather than restated beside it. +""" + +from __future__ import annotations + +import pytest + +from core.config import DEFAULT_CONFIG +from core.epistemic_state import ( + EpistemicState, + epistemic_state_for_grounding_source, +) +from workbench.api import _coerce_grounding_source, _run_chat_turn + + +#: A Band v1 propositional argument: the composer's commit gate +#: (``looks_like_deductive_argument``) fires on the sentence-initial +#: "Therefore", and the ROBDD decides it ENTAILED. +DEDUCTION_PROMPT = ( + "If it rains then the ground is wet. It rains. " + "Therefore the ground is wet." +) + + +def test_deduction_serving_is_on_by_default() -> None: + """The premise of every other test in this file (ADR-0256 ratification).""" + assert DEFAULT_CONFIG.deduction_serving_enabled is True + + +def test_workbench_records_deduction_as_its_own_grounding_source() -> None: + """The regression: a decided answer recorded as ungrounded. + + Drives the same entry point the HTTP chat route uses, so this fails if the + coercion whitelist, the ``GroundingSource`` registration, or the composer's + dispatch position regresses. + """ + result = _run_chat_turn(DEDUCTION_PROMPT) + + assert "entail" in result.surface, ( + f"the deduction composer did not serve this turn: {result.surface!r}" + ) + assert result.grounding_source == "deduction", ( + "Workbench recorded a ROBDD-decided answer as " + f"{result.grounding_source!r} — the audit artifact is asserting " + "something false about how this turn was grounded" + ) + + +def test_workbench_records_deduction_as_decoded() -> None: + """A decided answer is DECODED, not UNDETERMINED and not STATE_NEEDED.""" + result = _run_chat_turn(DEDUCTION_PROMPT) + assert result.epistemic_state == EpistemicState.DECODED.value + + +@pytest.mark.parametrize( + "source", + ["pack", "teaching", "vault", "partial", "oov", "none", "deduction", "curriculum"], +) +def test_coercion_preserves_every_registered_grounding_source(source: str) -> None: + """No registered label may be rewritten to another label. + + Parametrized over the registration itself rather than a restated list: + a value added to ``GroundingSource`` without a matching coercion entry + fails here rather than silently downgrading in production. + """ + assert _coerce_grounding_source(source) == source + + +def test_coercion_still_floors_unknown_labels_to_none() -> None: + """An UNregistered label is not grounding evidence and must not pass.""" + assert _coerce_grounding_source("not_a_grounding_source") == "none" + assert _coerce_grounding_source(None) == "none" + assert _coerce_grounding_source("") == "none" + + +def test_decided_grounding_sources_map_to_decoded() -> None: + """Deduction and curriculum are decided, so they rank with pack/teaching/vault.""" + for source in ("deduction", "curriculum"): + assert epistemic_state_for_grounding_source(source) is EpistemicState.DECODED + + +def test_unknown_grounding_source_still_asks_for_a_state() -> None: + """The honest-degradation default must survive the registration.""" + assert ( + epistemic_state_for_grounding_source("not_a_grounding_source") + is EpistemicState.EPISTEMIC_STATE_NEEDED + ) diff --git a/uv.lock b/uv.lock index ad434a10..4aaffdd3 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = "==3.12.13" +requires-python = "==3.12.*" resolution-markers = [ "sys_platform == 'win32'", "sys_platform == 'emscripten'", diff --git a/workbench-ui/enum-snapshot.json b/workbench-ui/enum-snapshot.json index 9ebab01d..8bdb3542 100644 --- a/workbench-ui/enum-snapshot.json +++ b/workbench-ui/enum-snapshot.json @@ -22,7 +22,9 @@ "vault", "partial", "oov", - "none" + "none", + "deduction", + "curriculum" ], "NormativeClearance": [ "cleared", diff --git a/workbench-ui/src/design/components/badges/mappings.ts b/workbench-ui/src/design/components/badges/mappings.ts index 93b077e9..6e609965 100644 --- a/workbench-ui/src/design/components/badges/mappings.ts +++ b/workbench-ui/src/design/components/badges/mappings.ts @@ -46,6 +46,8 @@ export const groundingSourceMeta = { [GroundingSource.PARTIAL]: { label: "Partial", colorToken: "--color-grounding-partial", meaning: "Only partial grounding was available.", adr: "ADR-0160 / ADR-0162", evidence: "grounding_source is partial." }, [GroundingSource.OOV]: { label: "OOV", colorToken: "--color-grounding-oov", meaning: "Out-of-vocabulary grounding was encountered.", adr: "ADR-0160 / ADR-0162", evidence: "grounding_source is oov." }, [GroundingSource.NONE]: { label: "None", colorToken: "--color-grounding-none", meaning: "No grounding source was present.", adr: "ADR-0160 / ADR-0162", evidence: "grounding_source is none." }, + [GroundingSource.DEDUCTION]: { label: "Deduction", colorToken: "--color-grounding-deduction", meaning: "Decided by the verified entailment engine from the argument's own premises.", adr: "ADR-0256 / ADR-0162", evidence: "grounding_source is deduction." }, + [GroundingSource.CURRICULUM]: { label: "Curriculum", colorToken: "--color-grounding-curriculum", meaning: "Decided from the ratified domain-chain curriculum of the question's subject.", adr: "ADR-0262 / ADR-0162", evidence: "grounding_source is curriculum." }, } satisfies BadgeMeta; export const safetyVerdictMeta = { diff --git a/workbench-ui/src/design/components/badges/types.ts b/workbench-ui/src/design/components/badges/types.ts index f47c7799..10a87543 100644 --- a/workbench-ui/src/design/components/badges/types.ts +++ b/workbench-ui/src/design/components/badges/types.ts @@ -37,6 +37,8 @@ export enum GroundingSource { PARTIAL = "partial", OOV = "oov", NONE = "none", + DEDUCTION = "deduction", + CURRICULUM = "curriculum", } export enum SafetyVerdict { diff --git a/workbench-ui/src/design/tokens/tokens.css b/workbench-ui/src/design/tokens/tokens.css index 7e17edde..c985c0a4 100644 --- a/workbench-ui/src/design/tokens/tokens.css +++ b/workbench-ui/src/design/tokens/tokens.css @@ -67,6 +67,8 @@ --color-grounding-partial: #6faea4; --color-grounding-oov: #e9a94f; --color-grounding-none: #6f7b8c; + --color-grounding-deduction: #8d8cff; + --color-grounding-curriculum: #58bfe0; --color-state-info-bg: #10253a; --color-state-info-border: #23648e; diff --git a/workbench-ui/src/design/tokens/tokens.ts b/workbench-ui/src/design/tokens/tokens.ts index 8f8e631e..86d68faf 100644 --- a/workbench-ui/src/design/tokens/tokens.ts +++ b/workbench-ui/src/design/tokens/tokens.ts @@ -45,6 +45,8 @@ export const tokens = { "color-grounding-partial": "#6faea4", "color-grounding-oov": "#e9a94f", "color-grounding-none": "#6f7b8c", + "color-grounding-deduction": "#8d8cff", + "color-grounding-curriculum": "#58bfe0", "color-state-info-bg": "#10253a", "color-state-info-border": "#23648e", "color-state-info-text": "#bde8ff", diff --git a/workbench-ui/src/types/api.ts b/workbench-ui/src/types/api.ts index 724e1666..d4185934 100644 --- a/workbench-ui/src/types/api.ts +++ b/workbench-ui/src/types/api.ts @@ -14,7 +14,15 @@ export type ErrorCode = export type Backend = "numpy" | "mlx" | "rust" | "unknown"; export type MutationMode = "read_only" | "runtime_turn"; -export type GroundingSource = "pack" | "teaching" | "vault" | "partial" | "oov" | "none"; +export type GroundingSource = + | "pack" + | "teaching" + | "vault" + | "partial" + | "oov" + | "none" + | "deduction" + | "curriculum"; export type TraceIntegrity = "pipeline_trace" | "legacy_unhashed"; export type PipelineEvidenceStatus = "recorded" | "missing_evidence"; export type CognitivePipelineStageKind = diff --git a/workbench/api.py b/workbench/api.py index 2e0e9bfb..4513d0da 100644 --- a/workbench/api.py +++ b/workbench/api.py @@ -13,6 +13,7 @@ from urllib.parse import parse_qs, unquote, urlparse from chat.runtime import ChatRuntime from core.cognition.pipeline import CognitiveTurnPipeline from core.epistemic_state import ( + GROUNDING_SOURCES, clearance_from_verdicts, coerce_normative_clearance, epistemic_state_for_grounding_source, @@ -712,12 +713,18 @@ def _with_turn_cost_and_id( def _coerce_grounding_source(value: object) -> str: + """Floor an unregistered grounding label to ``"none"``, preserve the rest. + + Reads ``core.epistemic_state.GROUNDING_SOURCES`` rather than restating the + members. A hand-copied whitelist here fell behind the ratified set when + deduction serving went ON (ADR-0256) and silently recorded proved answers + as ungrounded; deriving it means a new grounding source is registered once, + in one file, and this boundary follows. The floor itself is deliberate and + stays: an unregistered label is not grounding evidence, and the record must + not imply it is. + """ text = str(value or "none").strip().lower() - return ( - text - if text in {"pack", "teaching", "vault", "partial", "oov", "none"} - else "none" - ) + return text if text in GROUNDING_SOURCES else "none" def _identity_verdict(identity_score: object | None) -> TurnVerdict | None: