diff --git a/core/cognition/pipeline.py b/core/cognition/pipeline.py index 6d31b7c8..2649f5b8 100644 --- a/core/cognition/pipeline.py +++ b/core/cognition/pipeline.py @@ -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. diff --git a/docs/decisions/ADR-0022-forward-semantic-control.md b/docs/decisions/ADR-0022-forward-semantic-control.md index 05b8ebd9..de387e03 100644 --- a/docs/decisions/ADR-0022-forward-semantic-control.md +++ b/docs/decisions/ADR-0022-forward-semantic-control.md @@ -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 diff --git a/evals/forward_semantic_control/contract.md b/evals/forward_semantic_control/contract.md new file mode 100644 index 00000000..b5dcaa8d --- /dev/null +++ b/evals/forward_semantic_control/contract.md @@ -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. diff --git a/evals/forward_semantic_control/dev/cases.jsonl b/evals/forward_semantic_control/dev/cases.jsonl new file mode 100644 index 00000000..6d64ee99 --- /dev/null +++ b/evals/forward_semantic_control/dev/cases.jsonl @@ -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} diff --git a/evals/forward_semantic_control/public/v1/cases.jsonl b/evals/forward_semantic_control/public/v1/cases.jsonl new file mode 100644 index 00000000..a534c1af --- /dev/null +++ b/evals/forward_semantic_control/public/v1/cases.jsonl @@ -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} diff --git a/evals/forward_semantic_control/runner.py b/evals/forward_semantic_control/runner.py new file mode 100644 index 00000000..2415f950 --- /dev/null +++ b/evals/forward_semantic_control/runner.py @@ -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) diff --git a/evals/reports/cost_latest.json b/evals/reports/cost_latest.json index 52b08e0c..2bffba6e 100644 --- a/evals/reports/cost_latest.json +++ b/evals/reports/cost_latest.json @@ -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 } diff --git a/generate/admissibility.py b/generate/admissibility.py new file mode 100644 index 00000000..20dd655e --- /dev/null +++ b/generate/admissibility.py @@ -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) diff --git a/generate/intent_ratifier.py b/generate/intent_ratifier.py new file mode 100644 index 00000000..faa7dda8 --- /dev/null +++ b/generate/intent_ratifier.py @@ -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}]", + ) diff --git a/generate/proposition.py b/generate/proposition.py index 708980cc..1088df6d 100644 --- a/generate/proposition.py +++ b/generate/proposition.py @@ -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, diff --git a/generate/stream.py b/generate/stream.py index d10a5dc3..5b5a4a47 100644 --- a/generate/stream.py +++ b/generate/stream.py @@ -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 diff --git a/tests/test_forward_semantic_control.py b/tests/test_forward_semantic_control.py new file mode 100644 index 00000000..26fe0414 --- /dev/null +++ b/tests/test_forward_semantic_control.py @@ -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 diff --git a/tests/test_intent_ratifier.py b/tests/test_intent_ratifier.py new file mode 100644 index 00000000..1ede15d8 --- /dev/null +++ b/tests/test_intent_ratifier.py @@ -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