core/generate/intent.py
Shay b5d6ad6510 feat(compositionality): compose_relations operator lifts lane 68.8% → 100%
Closes the residual `novel_pair_under_seen_relation` pattern that
neither `transitive_walk` nor `multi_relation_walk` could synthesise.

- new `compose_relations(triples, head, frame, relation)` operator —
  pure lookup, returns both `R(head, ?)` and `R(frame, ?)` tails
- new `FRAME_TRANSFER` intent + `_FRAME_TRANSFER_RE` regex tried
  before generic TRANSITIVE_QUERY so "in Y" isn't truncated; handles
  "X belong to in Y" → belongs_to normalisation
- pipeline wiring: `_maybe_compose_relations`, `_fold_compose_into_surface`,
  `_serialize_compose` (folded into operator_invocation so trace_hash
  stays bit-identical across replay)
- regression: inference_closure, multi_step_reasoning,
  cross_domain_transfer all still 100% on public + holdouts

discourse_paragraph v2:
- per-sentence grammar rubric (length, capitalization, subject
  alignment) gated on `require_per_sentence_grammar`
- scaling cases at 10 / 20 / 50 sentences — 3/3 pass, 100% per-sentence
- 3 runtime round-trip cases (`mode: runtime_roundtrip`) that prime
  vault, ask question, verify bit-identical across two fresh runtimes
- new `per_sentence_grammar_pass_rate` lane metric

Long-form replay benchmark (benchmarks/replay_vs_llm.py):
- `replay_determinism_report(prompts, runs, priming)` — CORE-only
- `compare_to_llm(prompts, llm_callable)` — BYO API client, no
  provider lock-in; reports per-prompt determinism on both sides
- ships with default cognition-pack prompts; 100% bit-identical at runs=3

Lanes green: cognition 121/121, runtime 19/19, teaching 17/17,
packs 6/6, compositionality 16/16 + 10/10, inference_closure 20/20 +
12/12, multi_step_reasoning 15/15 + 10/10, cross_domain_transfer
10/10 + 8/8, discourse_paragraph v1 12/12 + v2 6/6.
2026-05-16 22:44:06 -07:00

154 lines
5.7 KiB
Python

"""Dialogue intent classification.
Maps a raw prompt string to a typed intent tag. The classifier is rule-based
(prefix/pattern matching) — no ML dependency. Downstream, the intent selects
the proposition frame family and graph shape before generation begins.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from enum import Enum, unique
@unique
class IntentTag(Enum):
DEFINITION = "definition"
CAUSE = "cause"
PROCEDURE = "procedure"
COMPARISON = "comparison"
CORRECTION = "correction"
RECALL = "recall"
VERIFICATION = "verification"
TRANSITIVE_QUERY = "transitive_query"
FRAME_TRANSFER = "frame_transfer"
UNKNOWN = "unknown"
@dataclass(frozen=True, slots=True)
class DialogueIntent:
tag: IntentTag
subject: str
secondary_subject: str | None = None
relation: str | None = None # populated for TRANSITIVE_QUERY (ADR-0018)
frame: str | None = None # populated for FRAME_TRANSFER (compose_relations)
def requires_prior_turn(self) -> bool:
return self.tag is IntentTag.CORRECTION
_COMPARE_RE = re.compile(
r"^compare\s+(.+?)\s+(?:and|vs\.?|versus|with)\s+(.+)",
re.IGNORECASE,
)
# Transitive-query forms (ADR-0018):
# "What does X <verb>?" -> (X, R) where R is any verb-like word
# "Where does X belong?" -> (X, belongs_to)
# The verb slot accepts any single word — `multi_relation_walk` in the
# operator layer handles unrecognised relations by falling back to a
# cross-relation traversal (rather than a strict literal-relation match).
_TRANSITIVE_QUERY_RE = re.compile(
r"^what\s+does\s+(?P<subject>[a-z][a-z\-]*(?:\s+[a-z][a-z\-]*)?)\s+"
r"(?P<relation>[a-z][a-z\-]*)\b",
re.IGNORECASE,
)
# Frame-transfer form:
# "What does X R in Y?" -> compose_relations(triples, X, Y, R)
# This is the compositionality lane's `novel_pair_under_seen_relation`
# probe shape. Must be tried before the generic transitive-query rule
# so the "in Y" tail is not silently truncated.
_FRAME_TRANSFER_RE = re.compile(
r"^what\s+does\s+(?P<subject>[a-z][a-z\-]+)\s+"
r"(?P<relation>[a-z][a-z\-]+)(?P<rel_tail>\s+to)?\s+in\s+"
r"(?P<frame>[a-z][a-z\-]+)\b",
re.IGNORECASE,
)
_BELONG_QUERY_RE = re.compile(
r"^where\s+does\s+(?P<subject>[a-z][a-z\-]*(?:\s+[a-z][a-z\-]*)?)\s+"
r"belong(?:s?)\b",
re.IGNORECASE,
)
# Normalisation of the relation surface form back to the bare relation
# vocabulary the teaching store carries (matches en_core_cognition_v1).
_RELATION_NORMALIZE: dict[str, str] = {
"precede": "precedes", "precedes": "precedes",
"cause": "causes", "causes": "causes",
"ground": "grounds", "grounds": "grounds",
"reveal": "reveals", "reveals": "reveals",
"mean": "means", "means": "means",
"follow": "follows", "follows": "follows",
"contrast": "contrasts_with", "contrast_with": "contrasts_with",
"contrasts_with": "contrasts_with", "contrasts with": "contrasts_with",
"produce": "produces", "produces": "produces",
}
_RULES: tuple[tuple[re.Pattern[str], IntentTag], ...] = (
(re.compile(r"^what\s+(?:is|are)\s+", re.IGNORECASE), IntentTag.DEFINITION),
(re.compile(r"^why\s+", re.IGNORECASE), IntentTag.CAUSE),
(re.compile(r"^how\s+(?:do|can|should|would)\s+(?:I|we|you)\s+", re.IGNORECASE), IntentTag.PROCEDURE),
(re.compile(r"^(?:is|are|does|do|can|could|would|should|was|were|has|have|will)\s+.+\??\s*$", re.IGNORECASE), IntentTag.VERIFICATION),
(re.compile(r"^(?:no|that'?s\s+(?:not|wrong)|incorrect|actually|correction)", re.IGNORECASE), IntentTag.CORRECTION),
(re.compile(r"^remember\s+", re.IGNORECASE), IntentTag.RECALL),
)
def classify_intent(prompt: str) -> DialogueIntent:
text = prompt.strip()
if not text:
return DialogueIntent(tag=IntentTag.UNKNOWN, subject="")
compare_match = _COMPARE_RE.match(text)
if compare_match:
return DialogueIntent(
tag=IntentTag.COMPARISON,
subject=compare_match.group(1).strip(),
secondary_subject=compare_match.group(2).strip(),
)
frame_match = _FRAME_TRANSFER_RE.match(text)
if frame_match:
raw_relation = frame_match.group("relation").lower().strip()
# "X belong to in Y" — normalize to belongs_to since the optional
# " to" token after the relation indicates the same paraphrase
# the BELONG_QUERY rule handles for single-entity probes.
if frame_match.group("rel_tail") and raw_relation in {"belong", "belongs"}:
relation = "belongs_to"
else:
relation = _RELATION_NORMALIZE.get(raw_relation, raw_relation)
return DialogueIntent(
tag=IntentTag.FRAME_TRANSFER,
subject=frame_match.group("subject").strip(),
relation=relation,
frame=frame_match.group("frame").strip(),
)
transitive_match = _TRANSITIVE_QUERY_RE.match(text)
if transitive_match:
raw_relation = transitive_match.group("relation").lower().strip()
relation = _RELATION_NORMALIZE.get(raw_relation, raw_relation)
return DialogueIntent(
tag=IntentTag.TRANSITIVE_QUERY,
subject=transitive_match.group("subject").strip(),
relation=relation,
)
belong_match = _BELONG_QUERY_RE.match(text)
if belong_match:
return DialogueIntent(
tag=IntentTag.TRANSITIVE_QUERY,
subject=belong_match.group("subject").strip(),
relation="belongs_to",
)
for pattern, tag in _RULES:
match = pattern.match(text)
if match:
subject = text[match.end():].rstrip("?").strip()
if not subject:
subject = text
return DialogueIntent(tag=tag, subject=subject)
return DialogueIntent(tag=IntentTag.UNKNOWN, subject=text)