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:
parent
16baa51368
commit
21c22b2201
13 changed files with 1664 additions and 130 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
92
evals/forward_semantic_control/contract.md
Normal file
92
evals/forward_semantic_control/contract.md
Normal 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.
|
||||
3
evals/forward_semantic_control/dev/cases.jsonl
Normal file
3
evals/forward_semantic_control/dev/cases.jsonl
Normal 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}
|
||||
1
evals/forward_semantic_control/public/v1/cases.jsonl
Normal file
1
evals/forward_semantic_control/public/v1/cases.jsonl
Normal 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}
|
||||
163
evals/forward_semantic_control/runner.py
Normal file
163
evals/forward_semantic_control/runner.py
Normal 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)
|
||||
|
|
@ -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
430
generate/admissibility.py
Normal 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
210
generate/intent_ratifier.py
Normal 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}]",
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
283
tests/test_forward_semantic_control.py
Normal file
283
tests/test_forward_semantic_control.py
Normal 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
|
||||
108
tests/test_intent_ratifier.py
Normal file
108
tests/test_intent_ratifier.py
Normal 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
|
||||
Loading…
Reference in a new issue