core/generate/intent_ratifier.py

212 lines
7.5 KiB
Python

"""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,
object=intent.object,
relation=intent.relation,
negated=intent.negated,
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}]",
)