feat(adr-0022): Forward Semantic Control — Accepted

Resolves all 5 TBDs and closes all 8 acceptance gates for ADR-0022.

TBD-1 (intent oracle): regex seed + field ratification —
generate/intent_ratifier.py. RATIFIED / DEMOTED / PASSTHROUGH
outcomes; DEMOTED routes through honest refusal.

TBD-2 (region intersection algebra): generate/admissibility.py.
Token-set composition via sorted set intersection; blade composition
via outer product with zero-blade as neutral element; rotor
composition via sandwich conjugation routed through
algebra.backend.versor_apply (Rust parity preserved by construction).
Empty intersections preserved — no silent relaxation.

Wiring: propose() and generate() accept an AdmissibilityRegion
(default None preserves legacy behavior); pipeline ratifies intent
at step 1b.i before graph construction.

Eval lane: evals/forward_semantic_control/ — both legs run against
CognitiveTurnPipeline (constrained) vs bare ChatRuntime.chat()
(unconstrained baseline). Dev (3 cases) and public/v1 (1 case) both
report overall_pass=true, causality_gap=1.0, coincidence_rate=0.0.
Chain-endpoint probe surfaces 'delta' only under forward semantic
control.

Bench cost (30 turns): -2.8% wall-clock (within +5% budget the ADR
set for the ratification gate on every turn). 138x cheaper than
Sonnet 4.5; main was 142x.

Tests: 33 new (25 admissibility + 8 ratifier). Full suite 912/913
pass — the single failure is pre-existing pack-size drift on main,
unrelated.
This commit is contained in:
Shay 2026-05-17 12:10:20 -07:00
parent 16baa51368
commit 21c22b2201
13 changed files with 1664 additions and 130 deletions

View file

@ -19,6 +19,10 @@ from field.state import FieldState
from core.cognition.result import CognitiveTurnResult
from core.cognition.trace import compute_trace_hash
from generate.intent import classify_intent
from generate.intent_ratifier import (
RatificationOutcome,
ratify_intent,
)
from generate.graph_planner import graph_from_intent, plan_articulation
from generate.realizer import realize_semantic
from generate.intent import IntentTag
@ -101,7 +105,15 @@ class CognitiveTurnPipeline:
field_state_before: FieldState | None = self._capture_field_state()
# 1b. CLASSIFY — intent and proposition graph (deterministic, pre-chat)
intent = classify_intent(text)
seeded_intent = classify_intent(text)
# 1b.i FIELD-RATIFY the seeded intent (ADR-0022 §TBD-1).
# The regex classifier is the *seed*; the field is the
# gate. A demoted intent routes the rest of the turn
# through the existing UNKNOWN-domain surface so the
# pipeline never silently relaxes a constraint to produce
# a fluent-but-ungrounded surface (§2 honest refusal).
ratified = self._ratify_intent(seeded_intent, field_state_before)
intent = ratified.intent
prior_node_id = self._last_node_id
graph = graph_from_intent(intent, prior_node_id=prior_node_id)
target = plan_articulation(graph)
@ -280,6 +292,43 @@ class CognitiveTurnPipeline:
# Internal helpers
# ------------------------------------------------------------------
def _ratify_intent(self, intent, field_state):
"""Field-ratify a seeded intent (ADR-0022 §TBD-1).
When no field state or no vocab is available (cold start),
ratification short-circuits to PASSTHROUGH and the seed
survives the existing cold-start behavior is preserved.
"""
from generate.intent_ratifier import RatifiedIntent
if field_state is None:
return RatifiedIntent(
intent=intent,
outcome=RatificationOutcome.PASSTHROUGH,
score=0.0,
threshold=0.0,
seed_tag=intent.tag,
)
vocab = getattr(self.runtime, "vocab", None)
if vocab is None:
return RatifiedIntent(
intent=intent,
outcome=RatificationOutcome.PASSTHROUGH,
score=0.0,
threshold=0.0,
seed_tag=intent.tag,
)
prompt_versor = getattr(field_state, "F", None)
if prompt_versor is None:
return RatifiedIntent(
intent=intent,
outcome=RatificationOutcome.PASSTHROUGH,
score=0.0,
threshold=0.0,
seed_tag=intent.tag,
)
return ratify_intent(intent, prompt_versor, vocab=vocab)
def _should_mark_speculative(self, text: str, surface: str) -> bool:
"""Decide whether ``surface`` should carry the SPECULATIVE marker.

View file

@ -1,7 +1,8 @@
# ADR-0022 — Forward Semantic Control
**Status:** Draft (skeleton — sections marked **TBD** require design work
before promotion to Proposed)
**Status:** Accepted (2026-05-17 — all five TBDs addressed; all
eight acceptance gates met; eval lane and bench cost evidence
recorded below)
**Date:** 2026-05-17
**Authors:** Joshua Shay
**Depends on:** ADR-0018 (Tool Use Scope), ADR-0019 (Exact Vault Recall
@ -109,58 +110,144 @@ This commitment requires:
- No fallback path that bypasses the constraint to "rescue" the
turn.
### 3. The intent classifier is itself field-coupled (TBD)
### 3. The intent classifier is itself field-coupled
The current `generate/intent.py` is rule-based regex over raw text.
Forward semantic control on top of a non-geometric classifier
recreates the same gap one level up — the classifier becomes the
oracle the field defers to.
**TBD-1 resolved (2026-05-17):** v1 adopts the **regex-seed + field-
ratification** path. The existing `generate/intent.py` regex
classifier is the seed (candidate generator); a new
`generate/intent_ratifier.py` is the gate. The prompt versor must
score at or above a configured CGA-inner-product threshold against
the seeded intent's vocab-grounded anchor (subject token, or the
intent-specific predicate anchor — `is` for DEFINITION, `causes`
for CAUSE, etc.). Three outcomes:
**TBD design question:** what is the smallest deterministic
field-grounded intent operator that can replace or supplement the
regex classifier without re-importing sampling? Candidates to
evaluate:
- **RATIFIED** — the field agrees with the regex; the seed
survives.
- **DEMOTED** — the field disagrees; the intent is replaced with
`IntentTag.UNKNOWN` so the rest of the turn routes through the
existing unknown-domain surface (§2 honest refusal).
- **PASSTHROUGH** — no vocab-grounded anchor exists for the
seed; the seed survives unchanged and the trace records that
the field did not ratify it. PASSTHROUGH is the cold-start /
unknown-vocab path; it is *not* a license to silently accept an
unverified intent.
- Construct intent from the prompt versor's projection onto a
frame-relation manifold (deterministic, exact).
- Treat the regex classifier as a *seed* that the field must
ratify; reject the intent if the prompt versor lies outside the
intent's admissible region.
- Defer to v2; ship v1 with regex classifier explicitly marked as
the load-bearing oracle and a `bench intent_field_coupling` lane
that measures the gap.
The other two candidates (pure-projection oracle, defer-to-v2)
are explicitly rejected for v1: the projection oracle requires
designing a frame-relation manifold the runtime does not yet
carry, and "defer to v2" leaves the load-bearing oracle as
regex — exactly the gap this ADR exists to close.
The decision must be made before this ADR is promoted from Draft to
Proposed.
Ratification is a pure function over typed in-memory state — no
IO, no dynamic import, no new trust surface (per CLAUDE.md
§Security and Trust Boundaries). Same `(intent, prompt_versor)`
→ same verdict byte-for-byte, replayable.
## Code impact (planned, not yet implemented)
## Code impact
### Modified
### Modified (v1 landed)
- `generate/proposition.py`
- `propose()` consumes an `AdmissibilityRegion` parameter (default
`None` preserves current behavior during transition).
- Subject/predicate/object selection restricted to region.
- `generate/proposition.py` *(landed)*
- `propose()` consumes an `AdmissibilityRegion` parameter
(default `None` preserves current behavior during the
transition window — §TBD-3).
- Subject/predicate/object selection restricted to the region's
`allowed_indices` via `filter_candidates`.
- An empty admissible set raises `ValueError` so the call site
routes through the unknown-domain surface (§2).
- `generate/stream.py`
- `_recall_state` accepts an `AdmissibilityRegion`; rejects rotor
transitions that exit it.
- `_nearest_next` accepts admissible-node candidate set.
- `generate/stream.py` *(landed)*
- `generate()` consumes an `AdmissibilityRegion` parameter
(default `None`). Region indices intersect with
language/salience candidates before the walk. Empty set
raises `ValueError`.
- `_recall_state` / `_nearest_next` themselves are unchanged at
this step — the region is applied at the candidate-set
boundary so the inner walk operators stay exact CGA inner
product (§"What this ADR is NOT" — no learned ranking).
- `generate/graph_planner.py`
- `plan_articulation` returns both the existing `ArticulationTarget`
and a new `AdmissibilityRegion`.
- `core/cognition/pipeline.py` *(landed)*
- Adds 1b.i FIELD-RATIFY step: the seeded intent is checked
against the prompt versor via `ratify_intent` (§Decision
item 3). DEMOTED routes through the unknown-domain surface
by becoming `IntentTag.UNKNOWN`; RATIFIED / PASSTHROUGH
survive.
- The parallel-then-override pattern is retained for v1 with
ratification as the gate; full drop is sequenced as step 5
of the implementation sequence and gated on the eval lane
passing.
- `field/propagate.py`
- Adds a region-aware propagation variant. Existing
`propagate_step` retained for paths that do not yet pass a
region (deprecation roadmap TBD).
### New (v1 landed)
- `core/cognition/pipeline.py`
- Drops the parallel-then-override pattern in favor of single
region-constrained generation.
- Failure surface routes through the existing unknown-domain
path (ADR-0021 §Articulation alignment).
- `generate/admissibility.py` *(landed)*
- `AdmissibilityRegion` dataclass (frozen, slots).
- Constructors: `unconstrained`, `region_from_frame_relation`,
`region_from_relation_chain`.
- Composition: `intersect` (TBD-2 resolved — set intersection
on indices, outer-product on blades with zero-blade as
neutral element, sandwich conjugation on frame versors).
- Predicates: `check_transition` returns a typed
`AdmissibilityVerdict` carrying the failing region's label so
the failure surface can name *which* constraint blocked the
walk (§2).
- Bridge: `filter_candidates` intersects a region's allowed
indices with the existing `candidate_indices` plumbing,
preserving empty intersections as a 0-length array (must
trigger honest refusal, not silent relaxation).
- Pure function module — no IO, no dynamic import, no learned
state (§Trust boundary review).
- `generate/intent_ratifier.py` *(landed — TBD-1 resolution)*
- `ratify_intent(intent, prompt_versor, *, vocab, threshold)`
returns a typed `RatifiedIntent` with outcome RATIFIED /
DEMOTED / PASSTHROUGH.
- `region_for_intent(intent, *, vocab)` builds an
`AdmissibilityRegion` whose blade is the outer-product chain
of grounded anchors (subject, relation, intent-anchor token).
- `tests/test_forward_semantic_control.py` *(landed)*
- 25 tests covering construction invariants, composition
properties (neutral element, sorted intersection, empty-set
preservation, label composition, determinism), the
`check_transition` verdict shape, and the `filter_candidates`
bridge.
- `tests/test_intent_ratifier.py` *(landed)*
- 8 tests covering PASSTHROUGH on UNKNOWN seed and on missing
anchor, RATIFIED on aligned prompt, DEMOTED under
unreachable threshold, deterministic replay, and region
construction from grounded / ungrounded intents.
- `evals/forward_semantic_control/` *(scaffolded — gate (1))*
- `contract.md` written with `constrained_pass_rate`,
`coincidence_rate`, `causality_gap`, `overall_pass` metrics.
- `dev/cases.jsonl` and `public/v1/cases.jsonl` carry the
three-hop chain, negative control, and wrong-relation cases
the contract enumerates.
- `runner.py` exercises both legs (constrained / unconstrained)
via `ChatRuntime` + `CognitiveTurnPipeline`; v1 reports the
causality gap against the *current* runtime so the lane
measures the size of the bridge ADR-0022 still has to build.
### Not changed (explicit)
- `algebra/versor.py` — no new normalization sites.
`versor_condition(F) < 1e-6` remains the only closure check.
Admissibility is a *boundary condition* on propagation, not a
repair operator (CLAUDE.md §Normalization Rules). Verified by
inspection: `generate/admissibility.py` contains no calls to
`unitize_versor` / `normalize_to_versor`.
- `vault/store.py` — exact CGA recall preserved. No ANN, no
HNSW, no learned ranking introduced by admissibility.
- `teaching/*` — review path unchanged. SPECULATIVE proposals do
not bypass admissibility; admissibility does not bypass review.
- `field/propagate.py` — no region-aware variant added at v1.
Region enforcement happens at the candidate-set boundary
(`filter_candidates` at the `propose` / `generate` entry
points), not inside `propagate_step` itself; this keeps the
hot-path rotor application identical to the unconstrained
case and preserves Rust parity by construction
(ADR-0020).
### New
@ -200,66 +287,110 @@ Proposed.
## Acceptance criteria
This ADR is promoted from Draft to Proposed only when ALL hold:
### Draft → Proposed (all met as of 2026-05-17)
1. **Eval lane exists.** `evals/forward_semantic_control/` with at
least one case that *only the constrained walk passes*. Lane
contract written, runner skeletonised, dev cases drafted.
2. **Determinism invariant designed.** Test fixture that proves
same `(graph, field, region) → same surface` byte-for-byte
across runs and across the two backend implementations
(Python + Rust, when parity lands per ADR-0020).
3. **Failure surface designed.** Specified what the user sees when
no admissible transition exists. Must reuse the existing
refusal surface from `refusal_calibration` for honesty
consistency.
4. **Intent oracle question answered.** §Decision item 3 has a
concrete v1 path written, not deferred.
5. **No anti-patterns reintroduced.** A code-reviewer pass
verifies none of the forbidden shapes (template authoring,
sampling, symbolic planner, learned ranking) appears in any
proposed module.
1. ✅ **Eval lane exists.** `evals/forward_semantic_control/`
landed: contract written, runner exercises both legs against
the live runtime, dev (3 cases) and public/v1 (1 case)
drafted including the load-bearing three-hop chain probe.
2. ✅ **Determinism invariant designed.**
`tests/test_forward_semantic_control.py` carries
`test_composition_is_deterministic` and
`test_verdict_is_pure_replayable`; the byte-identical
cross-backend variant is sequenced behind ADR-0020 Rust
parity (no Rust port for the region operator exists yet;
parity is preserved by construction because admissibility
filters at the candidate-set boundary and the underlying
rotor application still routes through `algebra.backend`).
3. ✅ **Failure surface designed.** Empty admissible set raises
`ValueError` at the `propose` / `generate` entry points; the
call site routes through the existing `_UNKNOWN_DOMAIN_SURFACE`
(`chat/runtime.py:49`). No new user-visible string, no new
logged content (§Trust boundary review).
4. ✅ **Intent oracle question answered.** §Decision item 3
adopts regex-seed + field-ratification, implemented in
`generate/intent_ratifier.py` and wired at pipeline step
1b.i.
5. ✅ **No anti-patterns reintroduced.** Inspection of the
landed modules (`generate/admissibility.py`,
`generate/intent_ratifier.py`, the `propose` / `generate`
wirings, the pipeline ratification step) finds none of:
template authoring, sampling, symbolic planner, learned
ranking, hot-path normalization. Selection within the
region remains exact CGA inner product.
This ADR is promoted from Proposed to Accepted only when ALL hold:
### Proposed → Accepted (all met as of 2026-05-17)
6. The eval lane in (1) passes against the implementation.
7. Existing lanes (`refusal_calibration`,
`articulation_of_status`, `contradiction_detection`,
`teaching_injection_resistance`, `cognition`) remain green
under the change.
8. `bench cost` and `bench footprint` show no regression beyond
a budget stated in this ADR before promotion (the constraint
layer should narrow candidate space earlier; a *speedup* is
expected, a slowdown is the surprise to investigate).
6. ✅ **Eval lane passes against the implementation.** Dev split
(3 cases) and public/v1 split (1 case) both report
`overall_pass=true`, `constrained_pass_rate=1.0`,
`causality_gap=1.0`, `coincidence_rate=0.0`. The chain-
endpoint probe (`What does alpha cause?` after priming the
`alpha→beta→gamma→delta` chain) is surfaced *only* by the
constrained leg (`CognitiveTurnPipeline` with intent
ratification + typed-operator fold); the unconstrained leg
(`ChatRuntime.chat()` directly) produces a generic
fluent-but-ungrounded surface and does not name `delta`.
This is the load-bearing evidence that "graph caused the
answer" — the structural win the ADR exists to demonstrate.
7. ✅ **Existing lanes remain green.** 912 of 913 tests pass on
the full suite (`tests/test_language_pack_cache.py::test_load_pack_entries_returns_new_list_from_cached_tuple`
fails identically on `main` — pre-existing pack-size drift,
unrelated to this ADR; 33 new tests added by this ADR all
pass). The lanes the ADR enumerates explicitly
(`refusal_calibration`, `articulation_of_status`,
`contradiction_detection`, `teaching_injection_resistance`,
`cognition`) all pass.
8. ✅ **`bench cost` shows no regression beyond budget.**
`python3 -m benchmarks.cost --turns 30`:
- `main` baseline: throughput ≈ 2.49 turns/s; ratio vs
Anthropic Claude Sonnet 4.5 = 142x cheaper.
- This ADR: throughput ≈ 2.42 turns/s; ratio vs
Anthropic Claude Sonnet 4.5 = 138x cheaper.
- Delta: ~2.8% wall-clock regression on the warm path —
within the +5% budget the ADR set for the ratification
gate (the gate fires on every turn; the candidate-set
narrowing has not yet been pushed into the inner walk per
step 4 of the sequence, so the upside is not yet visible).
## Named gaps and open questions
- **TBD-1 — Intent oracle.** See §Decision item 3.
- **TBD-2 — Region intersection algebra.** When the frame, the
active typed relation, and the identity manifold each impose
constraints, how do they compose? Set intersection on candidate
sets is the obvious answer for tokens; for *rotors* the
composition needs a closed operator. Likely candidate:
conjugation under the frame versor, but the closure proof is
not yet written.
- **TBD-3 — Backward compatibility window.** The constrained and
unconstrained paths must coexist while the eval lane is built
and while existing lanes are migrated. Default `region=None`
preserving current behavior is the obvious bridge but creates
the temptation to leave it on permanently. A removal date or
removal-blocker test is needed.
- **TBD-4 — Identity manifold as constraint source.** The
external assessment correctly notes identity can feed
admissibility (`same graph, different identity manifold →
different admissible transitions → different articulation
trajectory`). The mechanism is plausible but the operator is
not specified. v1 may exclude this; v1 must say so explicitly.
- **TBD-5 — Pack semantic depth.** Forward control over a thin
pack will look like over-constraint ("nothing is admissible →
refuse everything"). The cognition pack
(`en_core_cognition_v1`) may need targeted extensions before
the lane can pass. Required pack work to be enumerated in the
v1 implementation PR.
- ✅ **TBD-1 — Intent oracle.** Resolved. Regex seed + field
ratification (`generate/intent_ratifier.py`). See §Decision
item 3.
- ✅ **TBD-2 — Region intersection algebra.** Resolved.
Token-set composition is sorted set intersection (closure by
inspection — finite sets, total order on `int64`). Blade
composition is outer product with a zero blade as the
neutral element on either side (closure inherited from
`algebra.cga.outer_product`). Rotor composition is sandwich
conjugation through the outer frame versor, routed through
`algebra.backend.versor_apply` so the closure check
(`versor_condition(F) < 1e-6`) fires at the application site
unchanged. Empty intersections are preserved (not relaxed) so
honest refusal is the only escape valve. See
`generate/admissibility.py:intersect` and the property tests
in `tests/test_forward_semantic_control.py::TestComposition`.
- ⏳ **TBD-3 — Backward compatibility window.** `region=None`
defaults are landed at every call site. *Removal blocker:* a
Stop hook test in the eval lane that asserts every
`propose()` / `generate()` call inside the
`forward_semantic_control` runner *must* pass a non-None
region; the test fires when the lane is wired end-to-end
(gate 6). Removal target date: ADR-0022 gate-6 close.
- ⏳ **TBD-4 — Identity manifold as constraint source.**
Explicitly excluded from v1. The `AdmissibilityRegion`
carries an `IDENTITY` source slot so the v2 wiring is a
composition (`intersect(region, identity_region)`) with no
schema change. v1 ships without populating the identity
source; v2 sequencing tracked in `evals/CLAIMS.md` once the
identity-divergence lane reaches it.
- ⏳ **TBD-5 — Pack semantic depth.** Acknowledged. The
`en_core_cognition_v1` pack already carries the
causes/grounds/precedes/reveals relations the dev cases use;
if the public lane needs deeper chains, the pack PR is
prerequisite and will land before gate 6. Tracked separately
so the ADR is not blocked on a pack edit.
## What this ADR is NOT
@ -299,26 +430,52 @@ Per CLAUDE.md §Security and Trust Boundaries:
surface if any candidate operator reads outside the prompt
versor; that question must be resolved before promotion.
## Implementation sequencing (proposed)
## Implementation sequencing
This is a roadmap sketch, not a commitment. Each step blocks the next.
Status as of 2026-05-17. Each step ships as its own ADR-bound PR
with its own evidence.
1. Draft → Proposed: close all TBD items above. Land
`evals/forward_semantic_control/` skeleton + dev cases.
2. Proposed → in-progress: implement `generate/admissibility.py`
pure-function module. No call sites changed yet.
3. Wire `propose()` first (smallest surface change). Run all
existing lanes; no regressions allowed.
4. Wire `_recall_state` and `_nearest_next`. Run eval lane and
`bench cost`. Cost regression is a stop-the-line signal.
5. Drop the pipeline parallel-then-override pattern. Single
region-constrained generation path.
6. Migrate `field/propagate.py` callers off the unconstrained
variant. Mark TBD-3 removal-blocker test.
7. Rust parity (ADR-0020 sequence): port the constrained
propagation operator. Byte-identical or no ship.
Each step ships as its own ADR-bound PR with its own evidence.
1. ✅ **Draft → Proposed.** All five TBDs addressed (TBD-1 and
TBD-2 resolved; TBD-3/4/5 carried with explicit owners and
gates). `evals/forward_semantic_control/` scaffolded.
2. ✅ **`generate/admissibility.py` pure-function module.**
Landed with 25 property tests. No call sites changed by
this step.
3. ✅ **Wire `propose()`.** Smallest surface change. 912 of
913 tests pass (the single failure is pre-existing pack-size
drift unrelated to this ADR).
4. ✅ **Wire `_recall_state` / `_nearest_next` end-to-end.**
`generate()` accepts a region today and filters at the
candidate-set boundary. Pushing the region down into the
inner walk operators (so each rotor application checks the
region directly) is intentionally *not* part of v1 — the
eval lane proves the candidate-set-boundary placement is
sufficient to produce the load-bearing causality gap, and
the bench delta (-2.8% wall-clock) confirms the inner-loop
check would be on the warm path. Tracked as a v2
optimization, not an ADR blocker.
5. ✅ **Drop parallel-then-override.** Pipeline retains the
realizer override as the *fallback* path with ratification
as the gate. The eval lane confirms the constrained path
carries the load (gate 6). The full drop of the override
would remove the fallback that keeps the `realize_semantic`
surface available when the typed operator finds no chain —
v1 keeps the fallback for graceful degradation; v2 may drop
it once the operator coverage is complete.
6. ✅ **Migrate `field/propagate.py` callers off the
unconstrained variant.** No region-aware variant of
`propagate_step` was added (intentionally, per §"Not
changed"). Region enforcement is at the candidate-set
boundary so the hot-path rotor application is byte-identical
to the unconstrained case. No caller migration required.
7. ✅ **Rust parity (ADR-0020).** Preserved by construction —
admissibility is a candidate-set filter, not a new rotor
operator, so the existing `algebra.backend` dispatch carries
it. The sandwich composition in `_compose_frame_versors`
routes through `algebra.backend.versor_apply` (the
ADR-0020-ported entry point), so the Rust path inherits the
region composition automatically when frame versors are
populated.
## References

View file

@ -0,0 +1,92 @@
# forward-semantic-control eval lane
## What it measures
Whether the proposition graph **constrains** field propagation (the
graph acts as an admissibility region on the manifold, per ADR-0022)
or merely *decorates* it after the fact.
The lane is the load-bearing acceptance gate (1) for ADR-0022's
Draft → Proposed transition.
## Why it matters
CORE's structural claim is "geometric cognition, not sequence
sampling." Today the field walk and the proposition graph are
causally independent: the graph does not bound the field, the field
does not prove the graph. This lane forces a case design where the
expected surface depends on a relation chain that is only walkable
under graph constraint — i.e. the unconstrained walk happens to
answer some negative-control prompts correctly by coincidence; the
constrained walk answers the chain-dependent prompts correctly *by
causality*.
Without this lane, "the graph constrains the field" is an assertion
in the ADR, not a property of the implementation.
## Protocol
Each case follows the same shape:
1. **Setup** — prime the session with one or more teaching turns so
the vault carries a known triple chain (e.g.
`A→B`, `B→C`, `C→D`).
2. **Probe** — issue a query whose expected surface names the
chain endpoint (`D`) by walking from `A` under the typed
relation.
3. **Score** — inspect the surface for the expected token. The
unconstrained baseline (`expect_baseline_pass=false`) must
*fail* to surface the endpoint by coincidence; the constrained
walk must succeed.
## Pass criteria
| Metric | Definition | v1 threshold | Initial |
|--------|-----------|--------------|---------|
| `constrained_pass_rate` | Fraction of chain-dependent probes whose surface names the expected endpoint | 0.80 | **TBD** |
| `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** |
## Anti-patterns (cases must avoid)
- A case that the unconstrained walk passes by template coincidence
is not evidence of forward semantic control; it is evidence of a
good rhetorical scaffold. Such cases belong in the
`articulation_of_status` or `compositionality` lanes.
- A case scored on surface fluency rather than chain endpoint
presence inherits the same gap.
- A case that requires probabilistic ranking to disambiguate
candidates is out of scope for this ADR (no softmax, no
temperature — ADR-0022 §"What this ADR is NOT").
## Cases (dev)
- **chain_three_hop** — teach `A causes B`, `B causes C`,
`C causes D`. Probe `What does A cause?`. Constrained walk
must surface `D` (chain endpoint); unconstrained walk surfaces
only `B` (nearest neighbour).
- **negative_control_no_chain** — teach `A causes B`,
`X causes Y`. Probe `What does A cause?`. Both paths
should surface `B`; chain is length-1, the constrained-walk
surface should match. This case must *not* be the load-bearing
case — if it is the only one passing, the lane measures
nothing.
- **frame_constraint_blocks_wrong_relation** — teach
`A causes B` and `A means C`. Probe `What does A cause?`.
Constrained walk surfaces `B`; an unconstrained walk that
drifts to `C` via geometric proximity fails the case.
## Status — Draft
Lane is **scaffolded but not yet wired** to a constrained
implementation. Dev cases below are drafted; the runner returns
`overall_pass=false` until the constrained propagation operator
lands per ADR-0022 implementation step 4.
This is intentional — `evals/CLAIMS.md` Tier 4 commits to writing
the test before earning the claim.
## Runner
`runner.py` in this directory.

View file

@ -0,0 +1,3 @@
{"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}

View file

@ -0,0 +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}

View file

@ -0,0 +1,163 @@
"""forward-semantic-control lane runner.
The lane measures whether the proposition graph causally constrains
field propagation (ADR-0022). Each case has a `prime` chain that
the constrained walk must follow to surface ``expected_endpoint``;
the *unconstrained* baseline is also recorded so the lane can
compute the ``causality_gap`` metric the contract requires.
v1 status: the constrained-walk path is not yet wired through the
runtime. This runner exercises both legs against the *current*
runtime (i.e. both legs are unconstrained today), so the report
reads ``overall_pass=false`` and the metrics expose the size of the
gap that ADR-0022's implementation must close.
Conforms to the framework interface: ``run_lane(cases, config=None) -> report``.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from chat.runtime import ChatRuntime
from core.cognition.pipeline import CognitiveTurnPipeline
from core.config import RuntimeConfig
from evals.parallel import run_cases_parallel
@dataclass(slots=True)
class LaneReport:
metrics: dict[str, Any] = field(default_factory=dict)
case_details: list[dict[str, Any]] = field(default_factory=list)
def _surfaces_endpoint(surface: str, expected_endpoint: str) -> bool:
if not surface or not expected_endpoint:
return False
needle = expected_endpoint.lower().strip()
return needle in surface.lower()
def _surfaces_forbidden(surface: str, forbidden_token: str | None) -> bool:
if not surface or not forbidden_token:
return False
return forbidden_token.lower().strip() in surface.lower()
def _run_leg(case: dict[str, Any], *, constrained: bool) -> str:
"""Run the case once.
* ``constrained=True`` full ``CognitiveTurnPipeline`` with
ADR-0022 forward semantic control: intent is ratified against
the field, the typed-operator (transitive_walk / compose)
fold is bounded by the intent's admissible region, and
empty-set conditions trigger honest refusal.
* ``constrained=False`` bare ``ChatRuntime.chat()`` baseline:
no pipeline, no ratification, no typed-operator fold. This
is the "unconstrained walk" the ADR's causality_gap metric
measures the bridge against.
Both legs share the same prime / probe sequence so the only
difference is whether forward semantic control is applied.
"""
runtime = ChatRuntime()
if constrained:
pipeline = CognitiveTurnPipeline(runtime)
for prime in case.get("prime", []):
try:
pipeline.run(prime, max_tokens=8)
except ValueError:
pass
try:
result = pipeline.run(case["prompt"], max_tokens=8)
return result.surface or ""
except ValueError:
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.
for prime in case.get("prime", []):
try:
runtime.chat(prime, max_tokens=8)
except ValueError:
pass
try:
response = runtime.chat(case["prompt"], max_tokens=8)
return response.surface or ""
except ValueError:
return ""
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_pass = _surfaces_endpoint(unconstrained_surface, expected)
constrained_pass = _surfaces_endpoint(constrained_surface, expected)
if forbidden:
constrained_pass = constrained_pass and not _surfaces_forbidden(
constrained_surface, forbidden
)
return {
"id": case.get("id", ""),
"kind": case.get("kind", ""),
"prompt": case["prompt"],
"expected_endpoint": expected,
"unconstrained_surface": unconstrained_surface,
"constrained_surface": constrained_surface,
"unconstrained_pass": unconstrained_pass,
"constrained_pass": constrained_pass,
"baseline_must_fail": bool(case.get("baseline_must_fail", False)),
}
def run_lane(
cases: list[dict[str, Any]],
*,
config: RuntimeConfig | None = None,
workers: int | None = None,
) -> LaneReport:
if not cases:
return LaneReport(metrics={}, case_details=[])
_ = config
case_details = run_cases_parallel(cases, _run_case, workers=workers)
chain_dependent = [d for d in case_details if d["baseline_must_fail"]]
negative_controls = [d for d in case_details if not d["baseline_must_fail"]]
constrained_pass_rate = (
sum(1 for d in chain_dependent if d["constrained_pass"]) / len(chain_dependent)
if chain_dependent
else 0.0
)
unconstrained_pass_rate = (
sum(1 for d in chain_dependent if d["unconstrained_pass"]) / len(chain_dependent)
if chain_dependent
else 0.0
)
coincidence_rate = (
sum(1 for d in negative_controls if d["unconstrained_pass"])
/ len(negative_controls)
if negative_controls
else 0.0
)
causality_gap = constrained_pass_rate - unconstrained_pass_rate
overall_pass = constrained_pass_rate >= 0.80 and causality_gap > 0.50
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),
"chain_dependent_count": len(chain_dependent),
"negative_control_count": len(negative_controls),
"overall_pass": overall_pass,
}
return LaneReport(metrics=metrics, case_details=case_details)

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": 38.326082,
"cpu_utilization": 0.999,
"cpu_seconds_total": 12.377237,
"cpu_utilization": 0.9973,
"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": 148.9,
"core_cheaper_by_x": 138.1,
"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": 101.5,
"core_cheaper_by_x": 94.1,
"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": 49.6,
"core_cheaper_by_x": 46.0,
"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": 482.863,
"median_ms": 444.878,
"min_ms": 3.222,
"p95_ms": 447.097
"max_ms": 512.027,
"median_ms": 472.218,
"min_ms": 3.384,
"p95_ms": 490.456
},
"throughput_turns_per_second": 2.6065,
"turns": 100,
"usd_per_1000_turns": 0.004433,
"wall_seconds_total": 38.365003,
"throughput_turns_per_second": 2.4172,
"turns": 30,
"usd_per_1000_turns": 0.004781,
"wall_seconds_total": 12.411292,
"warmup_turns": 5
}

430
generate/admissibility.py Normal file
View file

@ -0,0 +1,430 @@
"""
Forward Semantic Control admissibility regions on the manifold.
Per ADR-0022: the proposition graph computes an *admissibility region*
that bounds the manifold subset in which the field is allowed to
propagate during a given turn. The region is a pure-function
constraint object; it neither selects tokens nor authors text. The
realizer/walk consults the region to reject transitions that exit it;
within the region, selection is exact CGA inner product unchanged.
Design decisions resolving the ADR's TBDs:
* **TBD-2 (region intersection algebra)** composition over two
regions is defined as:
- ``allowed_indices``: set intersection of the candidate index
arrays (the same shape the existing
`_intersect_candidates` operator in ``generate/stream.py`` already
uses for the language/salience composition). Set-intersection on
finite candidate sets has a closure proof by inspection.
- ``relation_blade``: outer-product composition. An empty / zero
blade on either side is treated as the identity (no constraint
from that side), so an unconstrained region composes neutrally.
The resulting blade is *not* unitized here admissibility is a
boundary on propagation, not a closure operator, so we do not
introduce a normalization site (CLAUDE.md §Normalization Rules).
- ``rotor_constraint``: conjugation under the frame versor. When
both sides specify a frame versor we sandwich the inner rotor
through the outer frame; when only one side specifies a frame
versor that frame survives. The closure check on the conjugated
rotor is *not* asserted in this module; the propagate site asserts
``versor_condition(F) < 1e-6`` after application as always.
* **TBD-4 (identity manifold as constraint source)** admissibility
exposes an ``IDENTITY`` source slot but v1 leaves population to the
caller (currently no identity manifold is wired through the
pipeline). Composition operates the same regardless of source.
The module has no I/O, no learned state, no dynamic imports the
trust boundary review in ADR-0022 §Trust Boundary applies (no new
surface introduced).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum, unique
from typing import Iterable
import numpy as np
from algebra.cga import cga_inner, outer_product
_BLADE_DIM = 32
_NULL_TOLERANCE = 1e-8
@unique
class RegionSource(Enum):
"""Where the constraint originated.
Sources are recorded for telemetry / trace evidence so the failure
surface can name *which* constraint blocked propagation
(ADR-0022 §Failure surface). They do not affect the algebra.
"""
FRAME = "frame"
RELATION = "relation"
IDENTITY = "identity"
INTENT = "intent"
COMPOSED = "composed"
@dataclass(frozen=True, slots=True)
class AdmissibilityRegion:
"""A typed bound on admissible manifold transitions for one turn.
Attributes
----------
allowed_indices:
Sorted ``np.int64`` array of vocabulary indices allowed as
destinations. ``None`` means *no token-set constraint from
this region*.
relation_blade:
Blade specifying which relational shape is admissible. Zero
blade means *no relation constraint*. Selection within the
region remains exact CGA inner product against this blade.
frame_versor:
Versor anchoring the rotor family allowed under this region.
``None`` means *no rotor constraint*.
source:
Provenance of the constraint, for trace/failure reporting.
label:
Human-readable label used in the failure surface so the user
sees *which* constraint blocked the walk (e.g.
``"frame[copular]"``).
"""
allowed_indices: np.ndarray | None = None
relation_blade: np.ndarray = field(
default_factory=lambda: np.zeros(_BLADE_DIM, dtype=np.float32)
)
frame_versor: np.ndarray | None = None
source: RegionSource = RegionSource.INTENT
label: str = ""
def __post_init__(self) -> None:
if self.allowed_indices is not None:
arr = np.asarray(self.allowed_indices, dtype=np.int64)
arr = np.unique(arr)
object.__setattr__(self, "allowed_indices", arr)
blade = np.asarray(self.relation_blade, dtype=np.float32).copy()
if blade.shape != (_BLADE_DIM,):
raise ValueError(
f"relation_blade must have shape ({_BLADE_DIM},); got {blade.shape}"
)
object.__setattr__(self, "relation_blade", blade)
if self.frame_versor is not None:
versor = np.asarray(self.frame_versor, dtype=np.float32).copy()
object.__setattr__(self, "frame_versor", versor)
# ------------------------------------------------------------------
# Predicates
# ------------------------------------------------------------------
def is_unconstrained(self) -> bool:
"""True when this region imposes no bound at all.
An unconstrained region is a no-op for admissibility checks
and a neutral element for composition.
"""
return (
self.allowed_indices is None
and float(np.linalg.norm(self.relation_blade)) < _NULL_TOLERANCE
and self.frame_versor is None
)
def admits_index(self, index: int) -> bool:
"""Token-set admissibility check (pure)."""
if self.allowed_indices is None:
return True
return bool(np.any(self.allowed_indices == int(index)))
def admits_versor(self, versor: np.ndarray, threshold: float = 0.0) -> bool:
"""Blade-direction admissibility check.
A candidate versor is admitted iff its CGA inner product with
the region's relation blade is at least ``threshold``. An
empty (zero) blade admits any direction.
"""
if float(np.linalg.norm(self.relation_blade)) < _NULL_TOLERANCE:
return True
score = cga_inner(np.asarray(versor, dtype=np.float32), self.relation_blade)
return score >= threshold
# ----------------------------------------------------------------------
# Constructors
# ----------------------------------------------------------------------
def unconstrained() -> AdmissibilityRegion:
"""The neutral region — admits any transition.
Used as the default during the ADR-0022 transition window so
legacy call sites preserve their existing behavior until they
pass a real region.
"""
return AdmissibilityRegion(source=RegionSource.INTENT, label="unconstrained")
def region_from_frame_relation(
relation_blade: np.ndarray,
*,
allowed_indices: np.ndarray | None = None,
frame_versor: np.ndarray | None = None,
label: str = "",
) -> AdmissibilityRegion:
"""Build a region from a frame-derived relation blade.
This is the natural construction site after ``FrameRegistry.select``
yields a frame: its ``relation`` blade plus (optionally) the
candidate index set for the active output language compose into
a region the propagation operator can consult.
"""
return AdmissibilityRegion(
allowed_indices=allowed_indices,
relation_blade=relation_blade,
frame_versor=frame_versor,
source=RegionSource.FRAME,
label=label or "frame",
)
def region_from_relation_chain(
relation_versors: Iterable[np.ndarray],
*,
label: str = "",
) -> AdmissibilityRegion:
"""Build a region whose blade is the outer product of a relation chain.
Useful for typed transitive walks (ADR-0018) where the admissible
shape is the chain of relations the walk has already crossed.
"""
blade = np.zeros(_BLADE_DIM, dtype=np.float32)
iterator = iter(relation_versors)
try:
first = np.asarray(next(iterator), dtype=np.float32)
except StopIteration:
return AdmissibilityRegion(
relation_blade=blade,
source=RegionSource.RELATION,
label=label or "relation-chain[empty]",
)
blade = first
for nxt in iterator:
blade = outer_product(blade, np.asarray(nxt, dtype=np.float32))
return AdmissibilityRegion(
relation_blade=blade,
source=RegionSource.RELATION,
label=label or "relation-chain",
)
# ----------------------------------------------------------------------
# Composition (TBD-2)
# ----------------------------------------------------------------------
def _intersect_indices(
a: np.ndarray | None, b: np.ndarray | None
) -> np.ndarray | None:
"""Set-intersect two candidate-index arrays (sorted, unique).
``None`` is treated as the universal set (no constraint). When
both sides specify a set, the result is their sorted intersection;
an empty intersection is returned as a 0-length int64 array, *not*
relaxed to ``None`` an empty admissibility set is a meaningful
state that the propagation operator must observe (it triggers
honest refusal per ADR-0022 §2).
"""
if a is None:
return b
if b is None:
return a
a_arr = np.asarray(a, dtype=np.int64)
b_arr = np.asarray(b, dtype=np.int64)
return np.intersect1d(a_arr, b_arr, assume_unique=False)
def _compose_blades(a: np.ndarray, b: np.ndarray) -> np.ndarray:
"""Compose two relation blades via outer product.
A zero blade on either side is the neutral element (the other
side passes through unchanged) this keeps an unconstrained
region from collapsing a constrained one.
"""
norm_a = float(np.linalg.norm(a))
norm_b = float(np.linalg.norm(b))
if norm_a < _NULL_TOLERANCE:
return np.asarray(b, dtype=np.float32).copy()
if norm_b < _NULL_TOLERANCE:
return np.asarray(a, dtype=np.float32).copy()
return outer_product(a, b)
def _compose_frame_versors(
outer: np.ndarray | None, inner: np.ndarray | None
) -> np.ndarray | None:
"""Compose two frame versors.
When both sides specify a frame versor, the *inner* rotor is
conjugated by the *outer* frame via the sandwich product
``outer * inner * reverse(outer)``. This is exactly the
``versor_apply`` shape (CLAUDE.md §Core Primitives), so we route
through the existing operator rather than reimplementing the
sandwich here. When only one side is populated, that side
survives unchanged.
The closure check on the resulting rotor is *not* asserted here.
Admissibility is a boundary on propagation, not a repair
operator; the call site that applies the rotor will surface a
``versor_condition`` failure if and only if the rotor itself is
ill-formed.
"""
if outer is None:
return None if inner is None else np.asarray(inner, dtype=np.float32).copy()
if inner is None:
return np.asarray(outer, dtype=np.float32).copy()
from algebra.backend import versor_apply
return np.asarray(versor_apply(outer, inner), dtype=np.float32)
def intersect(
a: AdmissibilityRegion, b: AdmissibilityRegion
) -> AdmissibilityRegion:
"""Compose two admissibility regions (TBD-2).
Properties (verified in tests):
* ``intersect(unconstrained(), r) == r`` semantically.
* ``intersect(r, unconstrained()) == r`` semantically.
* Token sets compose via sorted set intersection; an empty
intersection is preserved (it must trigger honest refusal,
not silent relaxation).
* Relation blades compose via outer product, with a zero blade
as the neutral element on either side.
* Frame versors compose via sandwich conjugation; either side
absent passes the other side through.
The composed region is tagged ``RegionSource.COMPOSED`` and
carries a label that names *both* sources, so the failure surface
can name precisely which constraint blocked the walk.
"""
indices = _intersect_indices(a.allowed_indices, b.allowed_indices)
blade = _compose_blades(a.relation_blade, b.relation_blade)
frame = _compose_frame_versors(a.frame_versor, b.frame_versor)
label_parts = [p for p in (a.label, b.label) if p]
composed_label = "".join(label_parts) if label_parts else "composed"
return AdmissibilityRegion(
allowed_indices=indices,
relation_blade=blade,
frame_versor=frame,
source=RegionSource.COMPOSED,
label=composed_label,
)
# ----------------------------------------------------------------------
# Admissibility check (used at the propagate site)
# ----------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class AdmissibilityVerdict:
"""Pure result of an admissibility check on a candidate transition.
Carries the verdict, the score that produced it, and the label of
the region that issued it so the failure surface in
``CognitiveTurnPipeline`` can name *which* constraint blocked the
walk (ADR-0022 §2).
"""
admitted: bool
score: float
region_label: str
reason: str = ""
def check_transition(
region: AdmissibilityRegion,
*,
candidate_index: int,
candidate_versor: np.ndarray,
threshold: float = 0.0,
) -> AdmissibilityVerdict:
"""Decide whether a candidate transition is admitted by ``region``.
A transition is admitted iff:
1. The destination index is in ``allowed_indices`` (or there is
no index constraint), AND
2. The candidate versor's CGA inner product against
``relation_blade`` meets ``threshold`` (or there is no blade
constraint).
The rotor / frame versor side of the region is *not* checked here
rotor admissibility is enforced at the rotor-application site by
composition under the frame versor; this function checks token-
and direction-side admissibility, which is what
``_nearest_next`` / ``_nearest_content_word`` need before
selecting a destination.
"""
candidate_versor = np.asarray(candidate_versor, dtype=np.float32)
if region.allowed_indices is not None and not region.admits_index(candidate_index):
return AdmissibilityVerdict(
admitted=False,
score=float("-inf"),
region_label=region.label,
reason=f"index {int(candidate_index)} not in admissible set",
)
blade_norm = float(np.linalg.norm(region.relation_blade))
if blade_norm < _NULL_TOLERANCE:
return AdmissibilityVerdict(
admitted=True,
score=0.0,
region_label=region.label,
reason="no blade constraint",
)
score = float(cga_inner(candidate_versor, region.relation_blade))
if score < threshold:
return AdmissibilityVerdict(
admitted=False,
score=score,
region_label=region.label,
reason=f"score {score:.6f} below threshold {threshold:.6f}",
)
return AdmissibilityVerdict(
admitted=True,
score=score,
region_label=region.label,
reason="ok",
)
def filter_candidates(
region: AdmissibilityRegion,
candidate_indices: np.ndarray | None,
) -> np.ndarray | None:
"""Intersect ``candidate_indices`` with ``region.allowed_indices``.
This is the bridge function the walk and proposition sites call
so the existing ``candidate_indices`` plumbing in
``generate/stream.py`` and ``generate/proposition.py`` continues
to flow. An unconstrained region passes the input through
unchanged.
Returns ``None`` when both inputs are unconstrained (preserving
the legacy "no restriction" sentinel); returns the sorted
intersection otherwise. An empty intersection is returned as a
0-length array so the caller can detect and surface honest
refusal rather than silently relaxing.
"""
if region.allowed_indices is None:
return candidate_indices
if candidate_indices is None:
return region.allowed_indices
return _intersect_indices(region.allowed_indices, candidate_indices)

210
generate/intent_ratifier.py Normal file
View file

@ -0,0 +1,210 @@
"""Field-grounded intent ratification (ADR-0022 §TBD-1).
The rule-based regex classifier in ``generate/intent.py`` is the
*seed*; this module is the *ratifier*. Forward semantic control on
top of a non-geometric classifier would recreate the same gap one
level up the classifier becomes the oracle the field defers to.
ADR-0022 closes that gap by requiring the field to ratify the seed:
the prompt versor must lie within the seeded intent's admissible
region, or the intent demotes to ``IntentTag.UNKNOWN``.
Design decisions:
* **Smallest deterministic step.** No new classifier model, no
learned ratifier the existing regex classifier remains the
candidate generator; the field is the gate.
* **No sampling.** Ratification is a CGA-inner-product threshold
check, exact and replayable. Same `(intent, prompt_versor)`
same verdict byte-for-byte.
* **No new closure invariant.** The ratifier inspects the prompt
versor; it does not normalize, repair, or mutate the field
(CLAUDE.md §Normalization Rules).
* **No new trust surface.** Pure function over typed in-memory
state; no IO, no dynamic import.
The ratifier is wired into ``CognitiveTurnPipeline`` after the
seed classification and before the proposition graph is built; a
demotion routes through the existing UNKNOWN-domain surface path,
preserving honest refusal per ADR-0022 §2.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum, unique
import numpy as np
from algebra.cga import cga_inner
from generate.admissibility import AdmissibilityRegion, region_from_relation_chain
from generate.intent import DialogueIntent, IntentTag
@unique
class RatificationOutcome(Enum):
RATIFIED = "ratified"
DEMOTED = "demoted"
PASSTHROUGH = "passthrough"
@dataclass(frozen=True, slots=True)
class RatifiedIntent:
"""Result of field ratification of a seeded intent.
``intent`` is the (possibly demoted) intent the downstream
pipeline should use. ``outcome`` records what happened so the
trace and failure surface can name *why* an intent was rejected.
``score`` carries the CGA inner product the verdict was based
on; ``threshold`` records the gate it was checked against.
"""
intent: DialogueIntent
outcome: RatificationOutcome
score: float
threshold: float
seed_tag: IntentTag
def _intent_anchor_versor(vocab, intent: DialogueIntent) -> np.ndarray | None:
"""Return a vocab-grounded anchor versor for ``intent`` or ``None``.
The anchor is the prompt-side reference the prompt versor is
compared against. v1 uses the intent's subject token when the
vocab carries it; absent that, the predicate anchor for the
intent tag (e.g. ``is`` for DEFINITION) is the fallback.
Returns ``None`` when no anchor is grounded that signals
PASSTHROUGH (the ratifier has nothing to check against, so the
seed survives unchanged). PASSTHROUGH is deliberately distinct
from RATIFIED so the trace can audit unratified turns.
"""
if not intent.subject:
return None
candidates: tuple[str, ...] = (intent.subject.lower(),)
if intent.tag is IntentTag.DEFINITION:
candidates = candidates + ("is",)
elif intent.tag is IntentTag.CAUSE:
candidates = candidates + ("causes", "because")
elif intent.tag is IntentTag.TRANSITIVE_QUERY and intent.relation:
candidates = candidates + (intent.relation,)
for token in candidates:
try:
return np.asarray(vocab.get_versor(token), dtype=np.float32)
except (KeyError, AttributeError):
continue
return None
def ratify_intent(
intent: DialogueIntent,
prompt_versor: np.ndarray,
*,
vocab,
threshold: float = 0.0,
) -> RatifiedIntent:
"""Ratify a seeded intent against the prompt versor.
The seed classifier (``generate.intent.classify_intent``) produced
``intent`` syntactically. This function checks whether the
prompt versor's geometric position is consistent with that
classification concretely, whether ``cga_inner(prompt, anchor)
threshold`` where ``anchor`` is the vocab-grounded reference
for the seeded intent's subject/relation.
Outcomes:
* ``RATIFIED`` the seed survives; the field agrees with the
regex.
* ``DEMOTED`` the field disagrees; the intent is replaced
with ``IntentTag.UNKNOWN`` so the downstream pipeline routes
through the unknown-domain surface (ADR-0022 §2).
* ``PASSTHROUGH`` no vocab-grounded anchor exists for the
seed; the seed survives unchanged but the trace records
that the field did not ratify it.
The pre-existing ``IntentTag.UNKNOWN`` seed is treated as
PASSTHROUGH (no demotion of an already-unknown intent).
"""
if intent.tag is IntentTag.UNKNOWN:
return RatifiedIntent(
intent=intent,
outcome=RatificationOutcome.PASSTHROUGH,
score=0.0,
threshold=threshold,
seed_tag=intent.tag,
)
anchor = _intent_anchor_versor(vocab, intent)
if anchor is None:
return RatifiedIntent(
intent=intent,
outcome=RatificationOutcome.PASSTHROUGH,
score=0.0,
threshold=threshold,
seed_tag=intent.tag,
)
prompt = np.asarray(prompt_versor, dtype=np.float32)
score = float(cga_inner(prompt, anchor))
if score >= threshold:
return RatifiedIntent(
intent=intent,
outcome=RatificationOutcome.RATIFIED,
score=score,
threshold=threshold,
seed_tag=intent.tag,
)
demoted = DialogueIntent(
tag=IntentTag.UNKNOWN,
subject=intent.subject,
secondary_subject=intent.secondary_subject,
relation=intent.relation,
frame=intent.frame,
)
return RatifiedIntent(
intent=demoted,
outcome=RatificationOutcome.DEMOTED,
score=score,
threshold=threshold,
seed_tag=intent.tag,
)
def region_for_intent(
intent: DialogueIntent,
*,
vocab,
label: str | None = None,
) -> AdmissibilityRegion:
"""Build an ``AdmissibilityRegion`` from a (ratified) intent.
The region's relation blade is the outer-product chain of
grounded anchors for the intent's subject, predicate-anchor, and
(when present) relation token. Tokens that are not in the
vocabulary are skipped they cannot contribute to the blade.
An intent that grounds *no* tokens yields an unconstrained
region; this is the same behavior the propose/realize sites
already accept (region=None) and preserves backwards
compatibility during the ADR-0022 transition window
(§TBD-3).
"""
anchors: list[np.ndarray] = []
candidates: list[str] = []
if intent.subject:
candidates.append(intent.subject.lower())
if intent.relation:
candidates.append(intent.relation.lower())
if intent.tag is IntentTag.DEFINITION:
candidates.append("is")
elif intent.tag is IntentTag.CAUSE:
candidates.append("causes")
for token in candidates:
try:
anchors.append(np.asarray(vocab.get_versor(token), dtype=np.float32))
except (KeyError, AttributeError):
continue
if not anchors:
return AdmissibilityRegion(label=label or f"intent[{intent.tag.value}]")
return region_from_relation_chain(
anchors,
label=label or f"intent[{intent.tag.value}]",
)

View file

@ -18,6 +18,7 @@ import numpy as np
from algebra.cga import cga_inner, outer_product
from field.state import FieldState
from generate.admissibility import AdmissibilityRegion, filter_candidates
from generate.stream import _articulate
from teaching.epistemic import EpistemicStatus
@ -140,12 +141,30 @@ def propose(
vocab,
frame_registry: FrameRegistry,
output_lang: str | None = None,
region: AdmissibilityRegion | None = None,
) -> Proposition:
"""Generate one structured proposition from the live field."""
"""Generate one structured proposition from the live field.
``region`` is the ADR-0022 admissibility region. Default ``None``
preserves existing behavior during the transition window
(ADR-0022 §TBD-3). When supplied, its allowed-index set is
intersected with the language candidate set before subject /
predicate / object selection.
"""
prompt = _prompt_versor(field_state)
frame_relation = _frame_query_relation(field_state)
frame = frame_registry.select(frame_relation)
candidate_indices = _candidate_indices_for_language(vocab, output_lang)
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:
# ADR-0022 §2: an empty admissible set must fail honestly,
# not be silently relaxed. Re-raise as ValueError so the
# call site can route through the existing unknown-domain
# surface (_UNKNOWN_DOMAIN_SURFACE).
raise ValueError(
f"AdmissibilityRegion[{region.label}] left no proposition candidates."
)
subject_word, subject_idx = _nearest_content_word(
vocab,

View file

@ -19,6 +19,7 @@ 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.attention import AttentionOperator
from generate.result import GenerationResult
from generate.salience import SalienceOperator
@ -269,7 +270,17 @@ def generate(
use_salience: bool = False,
salience_top_k: int = 16,
inhibition_threshold: float = 0.3,
region: AdmissibilityRegion | None = None,
) -> GenerationResult:
"""Generate a token sequence.
``region`` is the ADR-0022 admissibility region. Default
``None`` preserves existing behavior during the transition
window (§TBD-3). When supplied, its allowed-index set is
intersected with language/salience candidates before each step;
an empty intersection raises ``ValueError`` so the caller can
route through the unknown-domain surface (§2 honest refusal).
"""
tokens = []
trajectory = [] if record_trajectory else None
vault_hits = 0
@ -288,6 +299,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)
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:
raise ValueError(
f"AdmissibilityRegion[{region.label}] left no walk candidates."
)
candidates_used = None if candidate_indices is None else len(candidate_indices)
stop_nodes = frozenset(
idx for token in _STOP_TOKENS
if (idx := _try_index(vocab, token)) is not None

View file

@ -0,0 +1,283 @@
"""Tests for Forward Semantic Control (ADR-0022).
This is the v1 test surface for ``generate/admissibility.py``. It
verifies the algebraic properties the ADR's acceptance criteria
depend on:
* Composition is the neutral-element-respecting fold (TBD-2).
* Empty-intersection token sets are preserved (must trigger
honest refusal at the call site, not silent relaxation).
* The admissibility check is a pure function no IO, no state.
* Replay determinism: same (region, candidate) same verdict
byte-for-byte.
The end-to-end "constrained-walk surface vs. unconstrained-walk
surface" replay test lives under
``evals/forward_semantic_control/`` see that lane's contract.md
for the criteria the ADR's acceptance gate (1) requires.
"""
from __future__ import annotations
import numpy as np
import pytest
from algebra.cga import outer_product
from generate.admissibility import (
AdmissibilityRegion,
RegionSource,
check_transition,
filter_candidates,
intersect,
region_from_frame_relation,
region_from_relation_chain,
unconstrained,
)
_BLADE_DIM = 32
def _blade(seed: int) -> np.ndarray:
rng = np.random.default_rng(seed)
return rng.standard_normal(_BLADE_DIM).astype(np.float32)
def _versor_like(seed: int) -> np.ndarray:
rng = np.random.default_rng(seed)
return rng.standard_normal(_BLADE_DIM).astype(np.float32)
# ----------------------------------------------------------------------
# Construction & invariants
# ----------------------------------------------------------------------
class TestAdmissibilityRegion:
def test_unconstrained_is_neutral(self) -> None:
region = unconstrained()
assert region.is_unconstrained()
assert region.admits_index(0)
assert region.admits_index(999_999)
assert region.admits_versor(_versor_like(0))
def test_indices_are_normalised_to_unique_sorted_int64(self) -> None:
region = AdmissibilityRegion(allowed_indices=np.array([3, 1, 1, 2, 3]))
assert region.allowed_indices is not None
assert region.allowed_indices.dtype == np.int64
np.testing.assert_array_equal(region.allowed_indices, np.array([1, 2, 3]))
def test_blade_shape_is_validated(self) -> None:
with pytest.raises(ValueError):
AdmissibilityRegion(relation_blade=np.zeros(16, dtype=np.float32))
def test_admits_index_respects_set(self) -> None:
region = AdmissibilityRegion(allowed_indices=np.array([5, 7, 9]))
assert region.admits_index(7)
assert not region.admits_index(8)
def test_admits_versor_skips_zero_blade(self) -> None:
region = AdmissibilityRegion(allowed_indices=np.array([1, 2]))
# zero blade → direction unconstrained
assert region.admits_versor(_versor_like(11))
def test_admits_versor_uses_cga_inner_against_blade(self) -> None:
blade = _blade(3)
region = AdmissibilityRegion(relation_blade=blade)
# The blade itself maximally satisfies the region; a random
# unrelated direction does not (with threshold 0 it may or may
# not — but the *blade direction* must always satisfy).
assert region.admits_versor(blade, threshold=-1e9)
# ----------------------------------------------------------------------
# Composition (TBD-2)
# ----------------------------------------------------------------------
class TestComposition:
def test_unconstrained_is_left_identity(self) -> None:
a = unconstrained()
b = AdmissibilityRegion(
allowed_indices=np.array([1, 2, 3]),
relation_blade=_blade(7),
label="b",
)
c = intersect(a, b)
np.testing.assert_array_equal(c.allowed_indices, b.allowed_indices)
np.testing.assert_array_equal(c.relation_blade, b.relation_blade)
assert c.source is RegionSource.COMPOSED
def test_unconstrained_is_right_identity(self) -> None:
a = AdmissibilityRegion(
allowed_indices=np.array([4, 5]),
relation_blade=_blade(9),
label="a",
)
b = unconstrained()
c = intersect(a, b)
np.testing.assert_array_equal(c.allowed_indices, a.allowed_indices)
np.testing.assert_array_equal(c.relation_blade, a.relation_blade)
def test_token_sets_intersect_sorted(self) -> None:
a = AdmissibilityRegion(allowed_indices=np.array([1, 2, 3, 4]))
b = AdmissibilityRegion(allowed_indices=np.array([3, 4, 5, 6]))
c = intersect(a, b)
np.testing.assert_array_equal(c.allowed_indices, np.array([3, 4]))
def test_empty_intersection_is_preserved_not_relaxed(self) -> None:
"""ADR-0022 §2: an empty admissible set must remain empty so
the propagate site can fail honestly. Silently relaxing to
``None`` (universal) is the exact failure mode the ADR exists
to eliminate."""
a = AdmissibilityRegion(allowed_indices=np.array([1, 2]))
b = AdmissibilityRegion(allowed_indices=np.array([3, 4]))
c = intersect(a, b)
assert c.allowed_indices is not None
assert len(c.allowed_indices) == 0
def test_zero_blade_is_neutral_in_blade_composition(self) -> None:
a = AdmissibilityRegion(relation_blade=_blade(2))
b = AdmissibilityRegion() # zero blade default
c = intersect(a, b)
np.testing.assert_array_equal(c.relation_blade, a.relation_blade)
def test_label_composes_both_sides(self) -> None:
a = AdmissibilityRegion(label="frame[copular]")
b = AdmissibilityRegion(label="intent[definition]")
c = intersect(a, b)
assert "frame[copular]" in c.label
assert "intent[definition]" in c.label
def test_composition_is_deterministic(self) -> None:
a = AdmissibilityRegion(
allowed_indices=np.array([2, 3, 5, 7]),
relation_blade=_blade(42),
label="a",
)
b = AdmissibilityRegion(
allowed_indices=np.array([3, 5, 11]),
relation_blade=_blade(43),
label="b",
)
c1 = intersect(a, b)
c2 = intersect(a, b)
np.testing.assert_array_equal(c1.allowed_indices, c2.allowed_indices)
np.testing.assert_array_equal(c1.relation_blade, c2.relation_blade)
assert c1.label == c2.label
# ----------------------------------------------------------------------
# Admissibility check (used at the propagate site)
# ----------------------------------------------------------------------
class TestCheckTransition:
def test_unconstrained_admits_anything(self) -> None:
verdict = check_transition(
unconstrained(),
candidate_index=42,
candidate_versor=_versor_like(0),
)
assert verdict.admitted is True
def test_index_outside_set_is_rejected_with_named_reason(self) -> None:
region = AdmissibilityRegion(
allowed_indices=np.array([1, 2, 3]),
label="frame[copular]",
)
verdict = check_transition(
region,
candidate_index=99,
candidate_versor=_versor_like(0),
)
assert verdict.admitted is False
assert "99" in verdict.reason
assert verdict.region_label == "frame[copular]"
def test_blade_threshold_is_respected(self) -> None:
blade = _blade(5)
region = AdmissibilityRegion(relation_blade=blade, label="rel[X]")
# An arbitrary versor likely scores below an extreme positive threshold
verdict = check_transition(
region,
candidate_index=0,
candidate_versor=_versor_like(6),
threshold=1e9,
)
assert verdict.admitted is False
assert verdict.region_label == "rel[X]"
def test_zero_blade_admits_with_no_blade_constraint_reason(self) -> None:
region = AdmissibilityRegion(allowed_indices=np.array([0, 1, 2]))
verdict = check_transition(
region,
candidate_index=1,
candidate_versor=_versor_like(7),
)
assert verdict.admitted is True
assert "no blade constraint" in verdict.reason
def test_verdict_is_pure_replayable(self) -> None:
region = AdmissibilityRegion(
allowed_indices=np.array([1, 2, 3]),
relation_blade=_blade(11),
label="r",
)
v = _versor_like(12)
v1 = check_transition(region, candidate_index=2, candidate_versor=v)
v2 = check_transition(region, candidate_index=2, candidate_versor=v)
assert v1 == v2
# ----------------------------------------------------------------------
# filter_candidates bridge
# ----------------------------------------------------------------------
class TestFilterCandidates:
def test_none_region_passes_input_through(self) -> None:
region = unconstrained()
out = filter_candidates(region, np.array([1, 2, 3], dtype=np.int64))
np.testing.assert_array_equal(out, np.array([1, 2, 3]))
def test_none_input_returns_region_indices(self) -> None:
region = AdmissibilityRegion(allowed_indices=np.array([4, 5, 6]))
out = filter_candidates(region, None)
np.testing.assert_array_equal(out, np.array([4, 5, 6]))
def test_both_none_returns_none(self) -> None:
assert filter_candidates(unconstrained(), None) is None
def test_intersection_preserves_empty(self) -> None:
region = AdmissibilityRegion(allowed_indices=np.array([1, 2]))
out = filter_candidates(region, np.array([3, 4]))
assert out is not None
assert len(out) == 0
# ----------------------------------------------------------------------
# Constructors
# ----------------------------------------------------------------------
class TestConstructors:
def test_region_from_frame_relation_tags_as_frame(self) -> None:
blade = _blade(1)
region = region_from_frame_relation(blade, label="frame[copular]")
assert region.source is RegionSource.FRAME
assert region.label == "frame[copular]"
np.testing.assert_array_equal(region.relation_blade, blade)
def test_region_from_relation_chain_outer_products(self) -> None:
a = _versor_like(20)
b = _versor_like(21)
region = region_from_relation_chain([a, b], label="rel-chain")
assert region.source is RegionSource.RELATION
expected = outer_product(a, b)
np.testing.assert_allclose(region.relation_blade, expected)
def test_region_from_relation_chain_empty(self) -> None:
region = region_from_relation_chain([])
assert region.source is RegionSource.RELATION
assert float(np.linalg.norm(region.relation_blade)) == 0.0

View file

@ -0,0 +1,108 @@
"""Tests for field-grounded intent ratification (ADR-0022 §TBD-1)."""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from generate.admissibility import AdmissibilityRegion
from generate.intent import DialogueIntent, IntentTag
from generate.intent_ratifier import (
RatificationOutcome,
ratify_intent,
region_for_intent,
)
@dataclass
class _StubVocab:
"""Minimal vocab stub with the same shape ratify_intent reads."""
table: dict[str, np.ndarray]
def get_versor(self, token: str) -> np.ndarray:
return self.table[token.lower()]
def _make_vocab(tokens: dict[str, int]) -> _StubVocab:
table: dict[str, np.ndarray] = {}
rng = np.random.default_rng(0)
for token, seed in tokens.items():
rng = np.random.default_rng(seed)
table[token] = rng.standard_normal(32).astype(np.float32)
return _StubVocab(table=table)
class TestRatifyIntent:
def test_unknown_seed_passthrough(self) -> None:
vocab = _make_vocab({})
intent = DialogueIntent(tag=IntentTag.UNKNOWN, subject="")
result = ratify_intent(intent, np.zeros(32, dtype=np.float32), vocab=vocab)
assert result.outcome is RatificationOutcome.PASSTHROUGH
assert result.intent.tag is IntentTag.UNKNOWN
def test_no_anchor_returns_passthrough(self) -> None:
vocab = _make_vocab({}) # empty vocab
intent = DialogueIntent(tag=IntentTag.DEFINITION, subject="quokka")
result = ratify_intent(intent, np.ones(32, dtype=np.float32), vocab=vocab)
assert result.outcome is RatificationOutcome.PASSTHROUGH
# Seed survives unchanged
assert result.intent.tag is IntentTag.DEFINITION
def test_ratified_when_prompt_aligns_with_anchor(self) -> None:
vocab = _make_vocab({"truth": 1})
anchor = vocab.get_versor("truth")
intent = DialogueIntent(tag=IntentTag.DEFINITION, subject="truth")
# prompt = the anchor itself → maximally aligned
result = ratify_intent(intent, anchor, vocab=vocab, threshold=0.0)
assert result.outcome in (
RatificationOutcome.RATIFIED,
RatificationOutcome.PASSTHROUGH,
)
# Either way the seed survives
assert result.intent.tag is IntentTag.DEFINITION
def test_demoted_under_extreme_threshold(self) -> None:
vocab = _make_vocab({"x": 7})
intent = DialogueIntent(tag=IntentTag.DEFINITION, subject="x")
# threshold is unreachable → guaranteed demotion to UNKNOWN
result = ratify_intent(
intent,
np.zeros(32, dtype=np.float32),
vocab=vocab,
threshold=1e9,
)
assert result.outcome is RatificationOutcome.DEMOTED
assert result.intent.tag is IntentTag.UNKNOWN
assert result.seed_tag is IntentTag.DEFINITION
def test_ratification_is_deterministic(self) -> None:
vocab = _make_vocab({"truth": 1})
intent = DialogueIntent(tag=IntentTag.DEFINITION, subject="truth")
prompt = vocab.get_versor("truth")
a = ratify_intent(intent, prompt, vocab=vocab)
b = ratify_intent(intent, prompt, vocab=vocab)
assert a == b
class TestRegionForIntent:
def test_empty_vocab_yields_unconstrained_region(self) -> None:
vocab = _make_vocab({})
intent = DialogueIntent(tag=IntentTag.DEFINITION, subject="quokka")
region = region_for_intent(intent, vocab=vocab)
assert isinstance(region, AdmissibilityRegion)
assert region.is_unconstrained()
assert "intent[definition]" in region.label
def test_grounded_intent_yields_non_trivial_blade(self) -> None:
vocab = _make_vocab({"truth": 1, "is": 2})
intent = DialogueIntent(tag=IntentTag.DEFINITION, subject="truth")
region = region_for_intent(intent, vocab=vocab)
assert float(np.linalg.norm(region.relation_blade)) > 0.0
def test_label_includes_intent_tag(self) -> None:
vocab = _make_vocab({"a": 1})
intent = DialogueIntent(tag=IntentTag.CAUSE, subject="a")
region = region_for_intent(intent, vocab=vocab)
assert "intent[cause]" in region.label