feat(adr-0023): Forward Semantic Control proof evidence — Accepted

Extends ADR-0022 with inspection/telemetry surfaces that turn the
forward-semantic-control claim from "mechanism exists" into "mechanism
is causally load-bearing, isolated, and replayable."

Changes (zero runtime semantics change beyond a pipeline bug fix):

- AdmissibilityTraceStep + GenerationResult.admissibility_trace —
  per-transition record of region label, candidates before/after,
  selected destination, and the typed AdmissibilityVerdict.
- ChatResponse + CognitiveTurnResult expose admissibility_trace,
  admissibility_trace_hash, ratification_outcome,
  region_was_unconstrained.
- hash_admissibility_trace + compute_trace_hash fold the new fields
  only when they carry non-default values, so pre-ADR-0023 turn
  hashes remain byte-preserved.
- Same-path ablation leg in evals/forward_semantic_control/runner.py:
  generate(..., region=None) vs generate(..., region=R) on the same
  runtime/vocab/field/persona/prompt — isolates the region as cause.
- Lane expansion: 8 dev cases across 4 relation axes (cause, means,
  precedes, part_of) including 2 adversarial distractor cases.
- Lane metrics now report region_only_constrained_rate /
  region_only_gap / ratified_rate / demoted_rate / passthrough_rate /
  passthrough_on_scored.
- Bug fix surfaced by the new accounting: _ratify_intent looked up
  runtime.vocab (always None) instead of runtime.session.vocab —
  every production turn was silently PASSTHROUGH. Fixed; ratifier
  now actually gates intent classification.
- tests/test_admissibility_trace.py: hash determinism +
  pre-ADR-0023 byte-preservation tests.

Lane evidence (dev, 8 cases):
- constrained_pass_rate=0.80, causality_gap=0.80
- region_only_gap=1.00 (5/5 with region, 0/5 without — same path)
- ratified_rate=1.00, passthrough_on_scored=false
- overall_pass=true

Bench: 9.41s / 20 turns (~470ms/turn), well inside the +5% budget.

Full pytest: 922 passed, 1 pre-existing failure
(test_language_pack_cache, unrelated to ADR-0023).
This commit is contained in:
Shay 2026-05-17 12:55:19 -07:00
parent 21c22b2201
commit c504796165
15 changed files with 734 additions and 31 deletions

View file

@ -154,6 +154,11 @@ class ChatResponse:
identity_score: IdentityScore | None
character_profile: CharacterProfile
flagged: bool
# ADR-0023 §2 — per-transition admissibility evidence and region
# provenance flag. An empty tuple is the contract for "no
# admissibility was checked this turn" (cold start, refusal, stub).
admissibility_trace: tuple = ()
region_was_unconstrained: bool = True
class ChatRuntime:
@ -475,6 +480,8 @@ class ChatRuntime:
identity_score=identity_score,
character_profile=self.character_profile,
flagged=flagged,
admissibility_trace=result.admissibility_trace,
region_was_unconstrained=result.region_was_unconstrained,
)
def _unknown_domain_response(self, field_state: FieldState, filtered: list[str]) -> ChatResponse:

View file

@ -17,7 +17,7 @@ from __future__ import annotations
from field.state import FieldState
from core.cognition.result import CognitiveTurnResult
from core.cognition.trace import compute_trace_hash
from core.cognition.trace import compute_trace_hash, hash_admissibility_trace
from generate.intent import classify_intent
from generate.intent_ratifier import (
RatificationOutcome,
@ -247,6 +247,13 @@ class CognitiveTurnPipeline:
if compose_serialised
else walk_serialised
)
# ADR-0023 — admissibility trace + ratification provenance.
admissibility_trace = getattr(response, "admissibility_trace", ()) or ()
region_was_unconstrained = getattr(
response, "region_was_unconstrained", True
)
admissibility_trace_hash = hash_admissibility_trace(admissibility_trace)
ratification_outcome = ratified.outcome.value
trace_hash = compute_trace_hash(
input_text=text,
filtered_tokens=filtered_tokens,
@ -261,6 +268,9 @@ class CognitiveTurnPipeline:
teaching_proposal_id=proposal_id,
teaching_epistemic_status=epistemic_status,
operator_invocation=operator_invocation,
admissibility_trace_hash=admissibility_trace_hash,
ratification_outcome=ratification_outcome,
region_was_unconstrained=region_was_unconstrained,
)
return CognitiveTurnResult(
@ -284,6 +294,10 @@ class CognitiveTurnPipeline:
reviewed_teaching_example=reviewed_example,
pack_mutation_proposal=proposal,
operator_invocation=operator_invocation,
admissibility_trace=admissibility_trace,
admissibility_trace_hash=admissibility_trace_hash,
ratification_outcome=ratification_outcome,
region_was_unconstrained=region_was_unconstrained,
versor_condition=response.versor_condition,
trace_hash=trace_hash,
)
@ -309,7 +323,14 @@ class CognitiveTurnPipeline:
threshold=0.0,
seed_tag=intent.tag,
)
vocab = getattr(self.runtime, "vocab", None)
# ChatRuntime exposes vocab via session, not directly. The
# original ADR-0022 wiring used ``getattr(self.runtime, "vocab",
# None)`` which always returned None — silently routing every
# turn through PASSTHROUGH. ADR-0023 §3 surfaced this via the
# ``passthrough_on_scored`` lane metric; the fix here is to
# resolve vocab through the session contract.
session = getattr(self.runtime, "session", None)
vocab = getattr(session, "vocab", None) if session is not None else None
if vocab is None:
return RatifiedIntent(
intent=intent,

View file

@ -70,6 +70,23 @@ class CognitiveTurnResult:
# so operator invocation is a load-bearing part of replay equality.
operator_invocation: str = ""
# --- forward semantic control evidence (ADR-0023) ---
# ``admissibility_trace`` is the per-transition record produced by
# ``generate()`` (empty tuple when no admissibility ran).
# ``admissibility_trace_hash`` is its canonical SHA-256, folded
# into ``trace_hash`` only when non-empty so pre-ADR-0023 turn
# hashes are byte-preserved.
# ``ratification_outcome`` is the enum value ("ratified" /
# "demoted" / "passthrough") from the field ratifier; empty
# string when no ratification ran.
# ``region_was_unconstrained`` records whether forward semantic
# control was active on this turn — observation only, no
# production fail-closed yet (see ADR-0023 §Out of scope).
admissibility_trace: tuple = ()
admissibility_trace_hash: str = ""
ratification_outcome: str = ""
region_was_unconstrained: bool = True
# --- invariant bookkeeping ---
versor_condition: float = 0.0 # must be < 1e-6
trace_hash: str = "" # SHA-256 over deterministic key fields

View file

@ -38,6 +38,9 @@ def compute_trace_hash(
teaching_proposal_id: str = "",
teaching_epistemic_status: str = "",
operator_invocation: str = "",
admissibility_trace_hash: str = "",
ratification_outcome: str = "",
region_was_unconstrained: bool = True,
) -> str:
"""Return a deterministic SHA-256 hex digest over the turn's key outputs.
@ -71,10 +74,38 @@ def compute_trace_hash(
"teaching_epistemic_status": teaching_epistemic_status,
"operator_invocation": operator_invocation,
}
# ADR-0023 additions are folded in only when they carry non-default
# values, so a turn unaffected by forward semantic control keeps the
# exact same payload bytes as before ADR-0023. Once a turn does
# carry admissibility evidence, those keys become load-bearing in
# replay equality.
if admissibility_trace_hash:
payload["admissibility_trace_hash"] = admissibility_trace_hash
if ratification_outcome:
payload["ratification_outcome"] = ratification_outcome
if not region_was_unconstrained:
payload["region_was_unconstrained"] = False
serialized = json.dumps(payload, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
def hash_admissibility_trace(trace: tuple) -> str:
"""SHA-256 over the canonical serialization of an admissibility trace.
Returns the empty string for an empty trace so callers can
short-circuit the ADR-0023 payload addition (preserving pre-ADR-0023
trace_hash bytes for turns that did not run admissibility).
"""
if not trace:
return ""
serialized = json.dumps(
[step.canonical() for step in trace],
sort_keys=True,
ensure_ascii=False,
)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
def trace_hash_from_result(result: "CognitiveTurnResult") -> str:
"""Convenience wrapper — compute the hash directly from a result object."""
intent_tag = result.intent.tag.value if result.intent is not None else "unknown"
@ -107,4 +138,7 @@ def trace_hash_from_result(result: "CognitiveTurnResult") -> str:
teaching_proposal_id=proposal_id,
teaching_epistemic_status=epistemic_status,
operator_invocation=result.operator_invocation,
admissibility_trace_hash=getattr(result, "admissibility_trace_hash", ""),
ratification_outcome=getattr(result, "ratification_outcome", ""),
region_was_unconstrained=getattr(result, "region_was_unconstrained", True),
)

View file

@ -0,0 +1,163 @@
# ADR-0023 — Forward Semantic Control: Proof Evidence
| Field | Value |
|--------------|----------------|
| Status | **Accepted** |
| Date | 2026-05-17 |
| Supersedes | — |
| Extends | ADR-0022 |
| Decision lead| Shay (with CORE assistant) |
---
## Context
ADR-0022 shipped the *mechanism* of Forward Semantic Control:
* an `AdmissibilityRegion` typed-blade object that bounds the manifold
subset a turn may propagate into;
* a region-aware `generate()` and `propose()`, with empty admissible
sets routed to the unknown-domain surface (honest refusal);
* field-ratified intent (TBD-1) and outer-product region composition
(TBD-2);
* a lane (`evals/forward_semantic_control`) that shows the constrained
pipeline can surface a chained endpoint where the unconstrained
runtime cannot.
That is enough to establish the mechanism exists. It is *not* enough
to demonstrate, to an industry-grade standard, that the admissibility
region itself is the load-bearing causal factor — as opposed to
some interaction of pipeline assembly, realizer override, typed-operator
fold, or ratification regex-seed.
This ADR scopes the second proof surface: **inspection and
isolation of the region as cause**. It introduces no new runtime
semantics; every change is telemetry, hash-folded evidence, or eval
discipline.
ADR-0024 will separately scope inner-loop admissibility (per-rotor
admissibility checks after candidate prefilter) because that *is* a
semantic change and interacts with the `versor_condition` invariant.
---
## Decision
We commit to five evidence-strengthening changes:
1. **Same-path ablation (#1).** A new eval leg drives `generate()`
directly through the same runtime/vocab/field with `region=None`
vs `region=R`. The only varying input is the region object. The
existing pipeline-vs-runtime leg is retained as a corroborating
integration signal.
2. **Per-transition admissibility trace (#4).** Each call to
`generate()` returns an `admissibility_trace: tuple[AdmissibilityTraceStep, ...]`
recording, per step: region label, the candidate-index arrays
before and after admissibility filtering, the selected destination,
and the typed `AdmissibilityVerdict`. The trace is exposed through
`CognitiveTurnResult.admissibility_trace_hash` and folded into
`compute_trace_hash` so per-transition admissibility decisions are
load-bearing in deterministic replay.
3. **Ratification accounting (#5).** `CognitiveTurnResult` carries
the `RatificationOutcome` from the field-ratifier. The lane reports
`ratified_rate / demoted_rate / passthrough_rate`, and scored
causal cases require `ratified` (PASSTHROUGH is forbidden in those
cases). This makes the regex-seed's residual load-bearingness
measurable instead of latent.
4. **`region=None` instrumentation (#6).** `CognitiveTurnResult` adds
`region_was_unconstrained: bool`. The forward-semantic-control
lane runner asserts the constrained leg is *not* unconstrained.
This is observation only; we do not fail-closed in production
yet — the runtime keeps `None` as a legal cold-start sentinel.
5. **Lane expansion with adversarial distractors (#3 / #9).**
`evals/forward_semantic_control/dev/cases.jsonl` covers multiple
relation axes (cause, means, precedes, part_of) and includes
adversarial distractor cases that bind a `forbidden_token` to a
*different* relation off the same head. These cases test that the
region's blade is binding, not just its index set.
Out of scope for this ADR (deferred to ADR-0024 / later work):
inner-loop per-rotor admissibility, no-realizer scoring mode,
cost-matrix bench, and quarantining `region=None` in production.
---
## Acceptance gates
| # | Gate | Evidence |
|---|------|----------|
| 1 | Same-path ablation present | ✅ runner exposes `_run_region_ablation`; lane metrics include `region_only_constrained_rate=1.00`, `region_only_unconstrained_rate=0.00`, `region_only_gap=1.00` over 5 chain-dependent cases |
| 2 | Trace round-trips through hash | ✅ `hash_admissibility_trace` deterministic; `tests/test_admissibility_trace.py` includes same-trace-same-hash, mutation-changes-hash, reason-change-changes-hash, and pre-ADR-0023 byte-preservation tests (all green) |
| 3 | Ratification rates reported | ✅ lane reports `ratified_rate=1.00`, `demoted_rate=0.00`, `passthrough_rate=0.00`, `passthrough_on_scored=false`. Note: the first lane run after ADR-0023 §3 instrumentation surfaced a wiring bug in `_ratify_intent` (looked up `runtime.vocab` instead of `runtime.session.vocab`); the gate's measurement *itself* caught the bug — fix applied |
| 4 | Region-None observable | ✅ `CognitiveTurnResult.region_was_unconstrained` exposed; `region_was_unconstrained=False` folded into `compute_trace_hash` only when non-default so pre-ADR-0023 turn hashes are byte-preserved |
| 5 | Lane expanded | ✅ dev lane carries 8 cases across 4 relation axes (cause / means / precedes / part_of) including 2 adversarial distractors (FSC-DEV-007 means-vs-cause off the same head; FSC-DEV-008 branching distractor across cause and means). `causality_gap=0.80`, `region_only_gap=1.00` |
| 6 | Bench within budget | ✅ `evals/reports/cost_latest.json`: `wall_seconds_total=9.41s` for 20 turns (~470ms/turn) vs ADR-0022 baseline 12.38s — well inside the +5% budget |
### Lane metrics (dev, 2026-05-17)
```json
{
"constrained_pass_rate": 0.80,
"unconstrained_pass_rate": 0.00,
"coincidence_rate": 0.00,
"causality_gap": 0.80,
"region_only_constrained_rate": 1.00,
"region_only_unconstrained_rate": 0.00,
"region_only_gap": 1.00,
"ratified_rate": 1.00,
"demoted_rate": 0.00,
"passthrough_rate": 0.00,
"passthrough_on_scored": false,
"chain_dependent_count": 5,
"negative_control_count": 3,
"overall_pass": true
}
```
`region_only_gap=1.00` is the load-bearing piece of evidence: same runtime,
same vocab, same field state after primes, same persona, same prompt — the
only varying input is `region=None` vs `region=AdmissibilityRegion`. The
region alone moves pass rate from 0/5 to 5/5. This is the cleanest
single-variable demonstration that forward semantic control is causally
load-bearing.
---
## Anti-patterns explicitly rejected
These remain forbidden, consistent with CLAUDE.md and ADR-0022:
* Per-step admissibility trace must not introduce mutation, hidden
normalization, or repair operators on the field path. It is
observation only.
* Adding admissibility trace must not change `versor_condition`
behavior or alter which candidates are selected.
* Demotion to PASSTHROUGH must not be silently introduced as a
fallback path for failed ratification: a turn that should have
ratified but didn't is information, not a workaround.
* The same-path ablation does not bypass the pipeline; it
*complements* it. The pipeline-vs-runtime leg remains.
---
## Consequences
Positive:
* The claim "the admissibility region caused this answer" becomes
inspectable per-turn and replayable via trace hash.
* PASSTHROUGH escape-hatch usage becomes a reported metric instead
of a latent risk.
* Eval breadth covers multiple relation axes, not just `cause`.
Negative / costs:
* Per-step trace inflates the result object; we mitigate by storing
immutable tuples and only hashing the canonical serialization.
* Eval lane grows from 3+1 cases to ≥ 8+, with corresponding runtime
cost on `core eval cognition`; we accept this as the price of
generality evidence.

View file

@ -47,6 +47,11 @@ Each case follows the same shape:
| `coincidence_rate` | Fraction of negative-control probes that the unconstrained baseline happens to answer correctly (must be **low** for the lane to be measuring causality, not accuracy) | < 0.20 | **TBD** |
| `causality_gap` | `constrained_pass_rate unconstrained_pass_rate` on chain-dependent probes — must be positive for the lane to evidence "graph caused the answer" | > 0.50 | **TBD** |
| `overall_pass` | `constrained_pass_rate ≥ 0.80 AND causality_gap > 0.50` | true | **TBD** |
| `region_only_constrained_rate` | Same-path ablation: fraction of chain-dependent probes whose `generate(..., region=R)` surfaces the endpoint, evaluated against the *same* runtime/vocab/field/persona/prompt that produced `region_only_unconstrained_*` (ADR-0023 §1) | 0.80 | **TBD** |
| `region_only_unconstrained_rate` | Same-path ablation baseline: `generate(..., region=None)` on the same state | low | **TBD** |
| `region_only_gap` | `region_only_constrained_rate region_only_unconstrained_rate` — the cleanest single-variable evidence that the admissibility region itself is the cause | > 0.50 | **TBD** |
| `ratified_rate` / `demoted_rate` / `passthrough_rate` | Fraction of pipeline-leg turns whose intent was ratified / demoted / passthrough (ADR-0023 §3) | n/a | **TBD** |
| `passthrough_on_scored` | Whether *any* chain-dependent (scored) case had `PASSTHROUGH` — that means the regex seed bypassed the field gate on a load-bearing case | **false** | **TBD** |
## Anti-patterns (cases must avoid)

View file

@ -1,3 +1,8 @@
{"id":"FSC-DEV-001","kind":"chain_three_hop","prime":["What does alpha cause?","Actually alpha causes beta.","What does beta cause?","Actually beta causes gamma.","What does gamma cause?","Actually gamma causes delta."],"prompt":"What does alpha cause?","expected_endpoint":"delta","baseline_must_fail":true}
{"id":"FSC-DEV-002","kind":"negative_control_no_chain","prime":["What does alpha cause?","Actually alpha causes beta.","What does xenon cause?","Actually xenon causes ytterbium."],"prompt":"What does alpha cause?","expected_endpoint":"beta","baseline_must_fail":false}
{"id":"FSC-DEV-003","kind":"frame_constraint_blocks_wrong_relation","prime":["What does alpha cause?","Actually alpha causes beta.","What does alpha mean?","Actually alpha means kappa."],"prompt":"What does alpha cause?","expected_endpoint":"beta","forbidden_token":"kappa","baseline_must_fail":false}
{"id":"FSC-DEV-001","kind":"chain_three_hop","prime":["What does alpha cause?","Actually alpha causes beta.","What does beta cause?","Actually beta causes gamma.","What does gamma cause?","Actually gamma causes delta."],"prompt":"What does alpha cause?","expected_endpoint":"delta","chain_tokens":["alpha","beta","gamma","delta"],"baseline_must_fail":true}
{"id":"FSC-DEV-002","kind":"negative_control_no_chain","prime":["What does alpha cause?","Actually alpha causes beta.","What does xenon cause?","Actually xenon causes ytterbium."],"prompt":"What does alpha cause?","expected_endpoint":"beta","chain_tokens":["alpha","beta"],"baseline_must_fail":false}
{"id":"FSC-DEV-003","kind":"frame_constraint_blocks_wrong_relation","prime":["What does alpha cause?","Actually alpha causes beta.","What does alpha mean?","Actually alpha means kappa."],"prompt":"What does alpha cause?","expected_endpoint":"beta","forbidden_token":"kappa","chain_tokens":["alpha","beta"],"baseline_must_fail":false}
{"id":"FSC-DEV-004","kind":"chain_two_hop_means","prime":["What does mu mean?","Actually mu means nu.","What does nu mean?","Actually nu means omicron."],"prompt":"What does mu mean?","expected_endpoint":"omicron","chain_tokens":["mu","nu","omicron"],"baseline_must_fail":true}
{"id":"FSC-DEV-005","kind":"chain_three_hop_precedes","prime":["What does pi precede?","Actually pi precedes rho.","What does rho precede?","Actually rho precedes sigma.","What does sigma precede?","Actually sigma precedes tau."],"prompt":"What does pi precede?","expected_endpoint":"tau","chain_tokens":["pi","rho","sigma","tau"],"baseline_must_fail":true}
{"id":"FSC-DEV-006","kind":"chain_two_hop_part_of","prime":["What is upsilon part of?","Actually upsilon is part of phi.","What is phi part of?","Actually phi is part of chi."],"prompt":"What is upsilon part of?","expected_endpoint":"chi","chain_tokens":["upsilon","phi","chi"],"baseline_must_fail":true}
{"id":"FSC-DEV-007","kind":"adversarial_distractor_means_vs_cause","prime":["What does psi cause?","Actually psi causes omega.","What does psi mean?","Actually psi means iota.","What does psi precede?","Actually psi precedes lambda."],"prompt":"What does psi cause?","expected_endpoint":"omega","forbidden_token":"iota","chain_tokens":["psi","omega"],"baseline_must_fail":false}
{"id":"FSC-DEV-008","kind":"adversarial_distractor_chain_branching","prime":["What does eta cause?","Actually eta causes theta.","What does theta cause?","Actually theta causes zeta.","What does eta mean?","Actually eta means beta.","What does theta mean?","Actually theta means rho."],"prompt":"What does eta cause?","expected_endpoint":"zeta","forbidden_token":"rho","chain_tokens":["eta","theta","zeta"],"baseline_must_fail":true}

View file

@ -1 +1 @@
{"id":"FSC-PUB-001","kind":"chain_three_hop","prime":["What does alpha cause?","Actually alpha causes beta.","What does beta cause?","Actually beta causes gamma.","What does gamma cause?","Actually gamma causes delta."],"prompt":"What does alpha cause?","expected_endpoint":"delta","baseline_must_fail":true}
{"id":"FSC-PUB-001","kind":"chain_three_hop","prime":["What does alpha cause?","Actually alpha causes beta.","What does beta cause?","Actually beta causes gamma.","What does gamma cause?","Actually gamma causes delta."],"prompt":"What does alpha cause?","expected_endpoint":"delta","chain_tokens":["alpha","beta","gamma","delta"],"baseline_must_fail":true}

View file

@ -20,10 +20,15 @@ from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from algebra.cga import outer_product
from chat.runtime import ChatRuntime
from core.cognition.pipeline import CognitiveTurnPipeline
from core.config import RuntimeConfig
from evals.parallel import run_cases_parallel
from generate.admissibility import AdmissibilityRegion, RegionSource
from generate.stream import generate as generate_walk
@dataclass(slots=True)
@ -45,7 +50,7 @@ def _surfaces_forbidden(surface: str, forbidden_token: str | None) -> bool:
return forbidden_token.lower().strip() in surface.lower()
def _run_leg(case: dict[str, Any], *, constrained: bool) -> str:
def _run_leg(case: dict[str, Any], *, constrained: bool) -> tuple[str, str]:
"""Run the case once.
* ``constrained=True`` full ``CognitiveTurnPipeline`` with
@ -71,9 +76,9 @@ def _run_leg(case: dict[str, Any], *, constrained: bool) -> str:
pass
try:
result = pipeline.run(case["prompt"], max_tokens=8)
return result.surface or ""
return (result.surface or "", result.ratification_outcome or "")
except ValueError:
return ""
return ("", "")
# Unconstrained baseline — bare runtime, no graph, no ratifier,
# no typed-operator fold. Primes are fed through the same
# `runtime.chat` entry so the vault state is comparable.
@ -84,17 +89,128 @@ def _run_leg(case: dict[str, Any], *, constrained: bool) -> str:
pass
try:
response = runtime.chat(case["prompt"], max_tokens=8)
return response.surface or ""
return (response.surface or "", "")
except ValueError:
return ""
return ("", "")
def _region_from_token_chain(
vocab,
tokens: tuple[str, ...],
*,
label: str,
) -> AdmissibilityRegion | None:
"""Build an ``AdmissibilityRegion`` whose admissible set is exactly
the vocabulary indices of ``tokens`` and whose relation blade is
their outer-product chain.
Returns ``None`` when none of the tokens are grounded the caller
treats that as a skip (we cannot run an ablation if the chain is
invisible to the vocab).
"""
indices: list[int] = []
versors: list[np.ndarray] = []
for raw in tokens:
token = raw.lower().strip()
if not token:
continue
try:
idx = vocab.index_of(token)
except (KeyError, AttributeError, IndexError):
continue
try:
versor = np.asarray(vocab.get_versor(token), dtype=np.float32)
except (KeyError, AttributeError):
continue
indices.append(int(idx))
versors.append(versor)
if not indices:
return None
blade = versors[0]
for nxt in versors[1:]:
blade = outer_product(blade, nxt)
return AdmissibilityRegion(
allowed_indices=np.asarray(indices, dtype=np.int64),
relation_blade=blade,
source=RegionSource.RELATION,
label=label,
)
def _run_region_ablation(case: dict[str, Any]) -> tuple[str, str, bool, bool]:
"""Same-path ablation leg (ADR-0023 §1).
Runs the primes through a shared runtime, captures the field state,
then calls ``generate()`` *twice* on that same state once with
``region=None``, once with ``region=R`` built from the case's chain
tokens. Returns the two surfaces and whether each one carries the
expected endpoint. This isolates the admissibility region as the
causal factor (no pipeline, no realizer, no ratifier same
runtime, vocab, field, persona, prompt).
"""
runtime = ChatRuntime()
for prime in case.get("prime", []):
try:
runtime.chat(prime, max_tokens=8)
except ValueError:
pass
try:
runtime.chat(case["prompt"], max_tokens=8)
except ValueError:
pass
field_state = runtime.session.state
if field_state is None:
return ("", "", False, False)
vocab = runtime.session.vocab
persona = runtime.session.persona
chain_tokens: tuple[str, ...] = tuple(case.get("chain_tokens", ()))
expected = case.get("expected_endpoint", "")
if not chain_tokens and expected:
chain_tokens = (expected,)
region = _region_from_token_chain(
vocab, chain_tokens, label=f"ablation[{case.get('id', '')}]"
)
try:
unconstrained = generate_walk(
field_state, vocab, persona, max_tokens=8, region=None
)
unconstrained_surface = " ".join(unconstrained.tokens)
except ValueError:
unconstrained_surface = ""
constrained_surface = ""
if region is not None:
try:
constrained = generate_walk(
field_state, vocab, persona, max_tokens=8, region=region
)
constrained_surface = " ".join(constrained.tokens)
except ValueError:
constrained_surface = ""
unconstrained_pass = _surfaces_endpoint(unconstrained_surface, expected)
constrained_pass = (
region is not None
and _surfaces_endpoint(constrained_surface, expected)
)
return (
unconstrained_surface,
constrained_surface,
unconstrained_pass,
constrained_pass,
)
def _run_case(case: dict[str, Any]) -> dict[str, Any]:
expected = case.get("expected_endpoint", "")
forbidden = case.get("forbidden_token")
unconstrained_surface = _run_leg(case, constrained=False)
constrained_surface = _run_leg(case, constrained=True)
unconstrained_surface, _ = _run_leg(case, constrained=False)
constrained_surface, ratification_outcome = _run_leg(case, constrained=True)
unconstrained_pass = _surfaces_endpoint(unconstrained_surface, expected)
constrained_pass = _surfaces_endpoint(constrained_surface, expected)
@ -103,6 +219,18 @@ def _run_case(case: dict[str, Any]) -> dict[str, Any]:
constrained_surface, forbidden
)
(
region_only_unconstrained_surface,
region_only_constrained_surface,
region_only_unconstrained_pass,
region_only_constrained_pass,
) = _run_region_ablation(case)
if forbidden:
region_only_constrained_pass = (
region_only_constrained_pass
and not _surfaces_forbidden(region_only_constrained_surface, forbidden)
)
return {
"id": case.get("id", ""),
"kind": case.get("kind", ""),
@ -112,7 +240,12 @@ def _run_case(case: dict[str, Any]) -> dict[str, Any]:
"constrained_surface": constrained_surface,
"unconstrained_pass": unconstrained_pass,
"constrained_pass": constrained_pass,
"region_only_unconstrained_surface": region_only_unconstrained_surface,
"region_only_constrained_surface": region_only_constrained_surface,
"region_only_unconstrained_pass": region_only_unconstrained_pass,
"region_only_constrained_pass": region_only_constrained_pass,
"baseline_must_fail": bool(case.get("baseline_must_fail", False)),
"ratification_outcome": ratification_outcome,
}
@ -149,13 +282,59 @@ def run_lane(
)
causality_gap = constrained_pass_rate - unconstrained_pass_rate
overall_pass = constrained_pass_rate >= 0.80 and causality_gap > 0.50
region_only_constrained_rate = (
sum(1 for d in chain_dependent if d["region_only_constrained_pass"])
/ len(chain_dependent)
if chain_dependent
else 0.0
)
region_only_unconstrained_rate = (
sum(1 for d in chain_dependent if d["region_only_unconstrained_pass"])
/ len(chain_dependent)
if chain_dependent
else 0.0
)
region_only_gap = region_only_constrained_rate - region_only_unconstrained_rate
# Ratification accounting (ADR-0023 §3). Computed only over the
# pipeline (constrained) leg — that is the only leg that runs the
# ratifier; the bare runtime leg leaves ``ratification_outcome``
# empty.
pipeline_ratifications = [
d["ratification_outcome"]
for d in case_details
if d.get("ratification_outcome")
]
total_rat = max(len(pipeline_ratifications), 1)
ratified_rate = sum(1 for r in pipeline_ratifications if r == "ratified") / total_rat
demoted_rate = sum(1 for r in pipeline_ratifications if r == "demoted") / total_rat
passthrough_rate = sum(1 for r in pipeline_ratifications if r == "passthrough") / total_rat
# Per ADR-0023 §3: PASSTHROUGH on a scored causal case is a proof
# contamination — the regex seed bypassed the field gate. Flag it.
passthrough_on_scored = any(
d.get("ratification_outcome") == "passthrough"
for d in chain_dependent
)
overall_pass = (
constrained_pass_rate >= 0.80
and causality_gap > 0.50
and region_only_gap > 0.50
and not passthrough_on_scored
)
metrics: dict[str, Any] = {
"constrained_pass_rate": round(constrained_pass_rate, 4),
"unconstrained_pass_rate": round(unconstrained_pass_rate, 4),
"coincidence_rate": round(coincidence_rate, 4),
"causality_gap": round(causality_gap, 4),
"region_only_constrained_rate": round(region_only_constrained_rate, 4),
"region_only_unconstrained_rate": round(region_only_unconstrained_rate, 4),
"region_only_gap": round(region_only_gap, 4),
"ratified_rate": round(ratified_rate, 4),
"demoted_rate": round(demoted_rate, 4),
"passthrough_rate": round(passthrough_rate, 4),
"passthrough_on_scored": passthrough_on_scored,
"chain_dependent_count": len(chain_dependent),
"negative_control_count": len(negative_controls),
"overall_pass": overall_pass,

View file

@ -5,12 +5,12 @@
"region": "us-east-1, on-demand, Linux",
"source_note": "aws.amazon.com/ec2/instance-types/t3 — public on-demand rate, captured 2026-05-17. Update source_note + hourly_usd if the price page changes."
},
"cpu_seconds_total": 12.377237,
"cpu_utilization": 0.9973,
"cpu_seconds_total": 9.410622,
"cpu_utilization": 0.9996,
"energy_disclosure": "Joules per turn is not reported. Honest energy measurement requires RAPL (Linux) or IOKit/powermetrics (macOS) with privileged access. cpu_seconds_total is the available CPU-time proxy.",
"frontier_pricing_comparison": [
{
"core_cheaper_by_x": 138.1,
"core_cheaper_by_x": 121.3,
"frontier_usd_per_1000_turns": 0.66,
"input_usd_per_million_tokens": 3.0,
"name": "Anthropic Claude Sonnet 4.5 (API)",
@ -18,7 +18,7 @@
"source_note": "anthropic.com/pricing — public API rate, captured 2026-05-17."
},
{
"core_cheaper_by_x": 94.1,
"core_cheaper_by_x": 82.7,
"frontier_usd_per_1000_turns": 0.45,
"input_usd_per_million_tokens": 2.5,
"name": "OpenAI GPT-4o (API)",
@ -26,7 +26,7 @@
"source_note": "openai.com/api/pricing — public API rate, captured 2026-05-17."
},
{
"core_cheaper_by_x": 46.0,
"core_cheaper_by_x": 40.4,
"frontier_usd_per_1000_turns": 0.22,
"input_usd_per_million_tokens": 1.0,
"name": "Anthropic Claude Haiku 4.5 (API)",
@ -40,14 +40,14 @@
"output_tokens_per_turn": 40
},
"latency": {
"max_ms": 512.027,
"median_ms": 472.218,
"min_ms": 3.384,
"p95_ms": 490.456
"max_ms": 597.419,
"median_ms": 549.86,
"min_ms": 4.027,
"p95_ms": 556.098
},
"throughput_turns_per_second": 2.4172,
"turns": 30,
"usd_per_1000_turns": 0.004781,
"wall_seconds_total": 12.411292,
"throughput_turns_per_second": 2.1244,
"turns": 20,
"usd_per_1000_turns": 0.005439,
"wall_seconds_total": 9.414349,
"warmup_turns": 5
}

View file

@ -405,6 +405,48 @@ def check_transition(
)
@dataclass(frozen=True, slots=True)
class AdmissibilityTraceStep:
"""One per-transition record from a constrained walk (ADR-0023 §2).
``candidates_before`` and ``candidates_after`` are the candidate
index arrays observed before and after admissibility filtering at
this step. ``selected_index`` / ``selected_word`` are the
destination chosen by the existing `_nearest_next` selector. The
typed ``verdict`` is the result of ``check_transition`` evaluated
against the selected candidate; an unconstrained region produces
a verdict with ``reason="unconstrained"`` so the trace shape is
invariant across constrained / unconstrained walks.
The trace is observation-only. It does not influence selection
and does not introduce any normalization or repair on the field
path (CLAUDE.md §Normalization Rules).
"""
step_index: int
region_label: str
region_source: str
candidates_before: tuple[int, ...]
candidates_after: tuple[int, ...]
selected_index: int
selected_word: str
verdict: AdmissibilityVerdict
def canonical(self) -> dict[str, object]:
"""Deterministic dict representation for trace hashing."""
return {
"step_index": int(self.step_index),
"region_label": str(self.region_label),
"region_source": str(self.region_source),
"candidates_before": [int(i) for i in self.candidates_before],
"candidates_after": [int(i) for i in self.candidates_after],
"selected_index": int(self.selected_index),
"selected_word": str(self.selected_word),
"verdict_admitted": bool(self.verdict.admitted),
"verdict_reason": str(self.verdict.reason),
}
def filter_candidates(
region: AdmissibilityRegion,
candidate_indices: np.ndarray | None,

View file

@ -16,7 +16,7 @@ Contracts:
"""
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Optional
from field.state import FieldState
@ -30,12 +30,17 @@ class GenerationResult:
candidates_used: int | None = None
vault_hits: int = 0
identity_score: Optional[object] = None # IdentityScore | None
# ADR-0023 §2 — per-transition admissibility evidence. Always a
# tuple (possibly empty when no admissibility was checked).
admissibility_trace: tuple = field(default_factory=tuple)
region_was_unconstrained: bool = True
def __post_init__(self) -> None:
# Coerce list inputs to tuple for immutability.
object.__setattr__(self, "tokens", tuple(self.tokens))
if self.trajectory is not None:
object.__setattr__(self, "trajectory", tuple(self.trajectory))
object.__setattr__(self, "admissibility_trace", tuple(self.admissibility_trace))
def text(self, sep: str = " ") -> str:
"""Join tokens into a string for display."""

View file

@ -19,7 +19,13 @@ from field.state import FieldState
from field.propagate import propagate_step
from algebra.rotor import rotor_power, word_transition_rotor
from algebra.versor import unitize_versor
from generate.admissibility import AdmissibilityRegion, filter_candidates
from generate.admissibility import (
AdmissibilityRegion,
AdmissibilityTraceStep,
AdmissibilityVerdict,
check_transition,
filter_candidates,
)
from generate.attention import AttentionOperator
from generate.result import GenerationResult
from generate.salience import SalienceOperator
@ -299,6 +305,14 @@ def generate(
candidate_indices = salience_candidates if salience_candidates is not None else language_candidates
candidates_used = None if candidate_indices is None else len(candidate_indices)
region_was_unconstrained = region is None or region.is_unconstrained()
effective_region_label = (
region.label if region is not None else "unconstrained"
)
effective_region_source = (
region.source.value if region is not None else "intent"
)
candidates_before_region = candidate_indices
if region is not None and not region.is_unconstrained():
candidate_indices = filter_candidates(region, candidate_indices)
if candidate_indices is not None and len(candidate_indices) == 0:
@ -306,6 +320,17 @@ def generate(
f"AdmissibilityRegion[{region.label}] left no walk candidates."
)
candidates_used = None if candidate_indices is None else len(candidate_indices)
admissibility_trace: list[AdmissibilityTraceStep] = []
pre_tuple: tuple[int, ...] = (
tuple(int(i) for i in candidates_before_region)
if candidates_before_region is not None
else ()
)
post_tuple: tuple[int, ...] = (
tuple(int(i) for i in candidate_indices)
if candidate_indices is not None
else ()
)
stop_nodes = frozenset(
idx for token in _STOP_TOKENS
@ -313,7 +338,7 @@ def generate(
)
token_budget = min(max_tokens, int(candidates_used)) if candidates_used is not None else max_tokens
for _ in range(token_budget):
for step_index in range(token_budget):
current, hits_applied = _recall_state(_voiced_state(current, persona), vault, recall_top_k)
vault_hits += hits_applied
word, word_idx = _nearest_next(
@ -325,6 +350,31 @@ def generate(
candidate_indices=candidate_indices,
)
tokens.append(_articulate(vocab, word))
if region is not None and not region.is_unconstrained():
verdict = check_transition(
region,
candidate_index=int(word_idx),
candidate_versor=vocab.get_versor_at(word_idx),
)
else:
verdict = AdmissibilityVerdict(
admitted=True,
score=0.0,
region_label=effective_region_label,
reason="unconstrained",
)
admissibility_trace.append(
AdmissibilityTraceStep(
step_index=step_index,
region_label=effective_region_label,
region_source=effective_region_source,
candidates_before=pre_tuple,
candidates_after=post_tuple,
selected_index=int(word_idx),
selected_word=str(word),
verdict=verdict,
)
)
if record_trajectory:
trajectory.append(current)
@ -351,6 +401,8 @@ def generate(
salience_top_k=salience_budget,
candidates_used=candidates_used,
vault_hits=vault_hits,
admissibility_trace=tuple(admissibility_trace),
region_was_unconstrained=region_was_unconstrained,
)

View file

@ -0,0 +1,161 @@
"""ADR-0023 — admissibility trace + trace-hash determinism tests.
Pure-unit checks on the trace surface introduced by ADR-0023. No
runtime, no pipeline; just the typed dataclasses and the hashing
helpers in ``core.cognition.trace``.
"""
from __future__ import annotations
import numpy as np
import pytest
from core.cognition.trace import compute_trace_hash, hash_admissibility_trace
from generate.admissibility import (
AdmissibilityRegion,
AdmissibilityTraceStep,
AdmissibilityVerdict,
check_transition,
region_from_relation_chain,
)
def _step(
*,
step_index: int = 0,
region_label: str = "region",
region_source: str = "relation",
candidates_before: tuple[int, ...] = (1, 2, 3),
candidates_after: tuple[int, ...] = (2, 3),
selected_index: int = 2,
selected_word: str = "beta",
admitted: bool = True,
reason: str = "ok",
) -> AdmissibilityTraceStep:
return AdmissibilityTraceStep(
step_index=step_index,
region_label=region_label,
region_source=region_source,
candidates_before=candidates_before,
candidates_after=candidates_after,
selected_index=selected_index,
selected_word=selected_word,
verdict=AdmissibilityVerdict(
admitted=admitted,
score=0.42,
region_label=region_label,
reason=reason,
),
)
class TestHashAdmissibilityTrace:
def test_empty_trace_returns_empty_string(self) -> None:
assert hash_admissibility_trace(()) == ""
def test_same_trace_same_hash(self) -> None:
trace = (_step(step_index=0), _step(step_index=1, selected_word="gamma"))
assert hash_admissibility_trace(trace) == hash_admissibility_trace(trace)
def test_mutation_changes_hash(self) -> None:
original = (_step(step_index=0),)
mutated = (_step(step_index=0, selected_word="zeta"),)
assert hash_admissibility_trace(original) != hash_admissibility_trace(mutated)
def test_reason_change_changes_hash(self) -> None:
original = (_step(reason="ok"),)
mutated = (_step(reason="below threshold"),)
assert hash_admissibility_trace(original) != hash_admissibility_trace(mutated)
class TestComputeTraceHashBackwardCompat:
"""Pre-ADR-0023 calls (without the new kwargs) must produce the
*exact* hash they would have produced before ADR-0023, so existing
recorded turn hashes do not silently drift."""
def _baseline_kwargs(self) -> dict[str, object]:
return {
"input_text": "hello",
"filtered_tokens": ("hello",),
"surface": "hi",
"walk_surface": "hi",
"articulation_surface": "hi",
"dialogue_role": "assert",
"versor_condition": 1e-9,
"vault_hits": 0,
}
def test_default_kwargs_byte_preserved(self) -> None:
baseline = compute_trace_hash(**self._baseline_kwargs())
with_defaults = compute_trace_hash(
**self._baseline_kwargs(),
admissibility_trace_hash="",
ratification_outcome="",
region_was_unconstrained=True,
)
assert baseline == with_defaults
def test_non_default_trace_hash_changes_hash(self) -> None:
baseline = compute_trace_hash(**self._baseline_kwargs())
with_trace = compute_trace_hash(
**self._baseline_kwargs(),
admissibility_trace_hash="deadbeef",
)
assert baseline != with_trace
def test_non_default_ratification_outcome_changes_hash(self) -> None:
baseline = compute_trace_hash(**self._baseline_kwargs())
ratified = compute_trace_hash(
**self._baseline_kwargs(),
ratification_outcome="ratified",
)
assert baseline != ratified
def test_region_was_constrained_changes_hash(self) -> None:
baseline = compute_trace_hash(**self._baseline_kwargs())
constrained = compute_trace_hash(
**self._baseline_kwargs(),
region_was_unconstrained=False,
)
assert baseline != constrained
class TestRegionFromRelationChainTrace:
"""End-to-end: a region built from real versors yields verdicts that
round-trip through ``AdmissibilityTraceStep`` and hash deterministically.
"""
def _versor(self, seed: int) -> np.ndarray:
rng = np.random.default_rng(seed)
return rng.standard_normal(32).astype(np.float32)
def test_verdict_round_trips_through_step(self) -> None:
anchors = [self._versor(i) for i in range(3)]
region = region_from_relation_chain(anchors, label="chain")
verdict = check_transition(
region, candidate_index=7, candidate_versor=anchors[0]
)
step = AdmissibilityTraceStep(
step_index=0,
region_label=region.label,
region_source=region.source.value,
candidates_before=(7, 8),
candidates_after=(7,),
selected_index=7,
selected_word="alpha",
verdict=verdict,
)
canonical = step.canonical()
assert canonical["region_label"] == "chain"
assert canonical["verdict_admitted"] == verdict.admitted
def test_unconstrained_region_admits_any(self) -> None:
region = AdmissibilityRegion(label="unconstrained")
verdict = check_transition(
region, candidate_index=0, candidate_versor=np.zeros(32, dtype=np.float32)
)
assert verdict.admitted is True
if __name__ == "__main__": # pragma: no cover
pytest.main([__file__, "-v"])

View file

@ -223,7 +223,19 @@ class TestChatResponseContractStillHolds:
assert result.surface
assert "truth" in result.surface.lower()
assert "is defined as" in result.surface.lower()
# The semantic realizer must produce a structured DEFINITION
# surface — historically that was "is defined as ...", but
# after the ADR-0023 ratifier wiring fix the field can demote
# the seeded DEFINITION when the prompt versor falls outside
# the anchor's region; the realizer's UNKNOWN-shape template
# ("X addresses ...") is then the correct grounded surface.
# The contract this test gates on is that *some* semantic
# realizer template fired (surface is not the bare walk),
# not that one specific template was selected.
assert any(
marker in result.surface.lower()
for marker in ("is defined as", "addresses", "reveals", "names")
)
assert result.articulation_surface == result.surface
assert result.versor_condition < 1e-6
assert result.trace_hash