core/generate/intent.py
Shay 948cca44e6 feat(phase3): multi_relation_walk closes Phase 3 v1 to 10/10 splits
Closes the mixed_relation_* (multi-step-reasoning) and composed_predicate
(compositionality) residuals with a single new operator plus a small
intent-classifier loosening. Both residuals shared an underlying shape:
walk any outgoing relation edge from the head, regardless of which
relation predicate appears at each step.

generate/operators.py:
  multi_relation_walk(triples, head, *, max_hops=5) -> WalkResult
    Walks any outgoing edge from head, accumulating a path across
    mixed relation types. Returns WalkResult with relation="<mixed>"
    so trace_hash records the cross-relation provenance explicitly.
    Deterministic, cycle-safe, first-write-wins on duplicate heads
    (across any relation).

generate/intent.py:
  _TRANSITIVE_QUERY_RE relaxed from a closed verb enumeration to any
  single verb-like word. "What does X (any verb)?" now routes to
  TRANSITIVE_QUERY consistently; unrecognised relations are handled
  by the pipeline's multi_relation_walk fallback rather than falling
  through to UNKNOWN. Verified no regression on 30 intent / realizer
  tests.

core/cognition/pipeline.py:
  _maybe_transitive_walk now does precision-first dispatch on
  TRANSITIVE_QUERY: try transitive_walk(relation) literal-match
  first, fall back to multi_relation_walk only when the literal
  walk returns a singleton. DEFINITION intents do not fall back
  (would be too permissive for "What is X?").

tests/test_inference_operators.py: 6 new TestMultiRelationWalk
tests covering single-relation pass-through, cross-relation walks,
cycle termination, max_hops truncation, and determinism.

Phase 3 v1 re-score:

  lane                       split        v1     v2     v3 (now)
  inference-closure          public       0.0    1.0    1.0  pass
  inference-closure          holdouts     0.0    1.0    1.0  pass
  multi-step-reasoning       public       0.0    0.73   1.0  pass
  multi-step-reasoning       holdouts     0.0    0.80   1.0  pass
  compositionality           public       0.06   0.31   0.69 pass
  compositionality           holdouts     0.0    0.30   0.80 pass
  cross-domain-transfer      public       0.0    1.0    1.0  pass
  cross-domain-transfer      holdouts     0.0    1.0    1.0  pass
  introspection              public       0.0    1.0    1.0  pass
  introspection              holdouts     0.0    1.0    1.0  pass

PHASE 3 v1 IS COMPLETE: 10 of 10 splits passing. Phase 3 exit gate
(>= 2 lanes passing v1 by phase exit) is satisfied five times over.
Foundation guarantees (premises_stored_rate, replay_determinism)
remain 1.0 across all lanes. Trace_hash bit-stability preserved
with operator invocation records folded in per ADR-0018.

Compositionality public at 0.69 / holdouts at 0.80 - the residual
failures are the novel_pair_under_seen_relation / novel_relation_on_seen_pair
cases whose contract authoring is itself ambiguous (the leakage
check in the v1 contract fires by design on those patterns). Those
are contract-refinement candidates for v2 of that lane, not
engineering work. Overall_pass threshold (>= 0.50) is comfortably
met on both splits.

CLI suites smoke / cognition / teaching / packs all pass; 53
operator+teaching+pipeline tests green; no regression.
2026-05-16 15:24:44 -07:00

124 lines
4.4 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"
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)
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,
)
_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(),
)
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)