Implements the Phase 3 v2 inference-depth bundle per ADR-0018:
typed deterministic operators over CORE's typed state. Closes the
inference-closure / multi-step-reasoning / cross-domain-transfer
v1 gaps; partial close on compositionality.
New modules:
teaching/relation_parse.py - parse_triple(correction_text) lifts
a correction utterance into a typed (head, relation, tail) over
the en_core_cognition_v1 relation vocabulary. Pure regex,
deterministic, no learned classifier.
generate/operators.py - transitive_walk(triples, head, relation,
*, max_hops=5) walks single-relation chains. path_recall walks
a relation-chain tuple (e.g. ("is", "precedes")). Both bounded,
cycle-safe, case-insensitive, first-write-wins on duplicates.
Schema extensions:
teaching.store.PackMutationProposal gains optional triple field,
populated by TeachingStore.add via parse_triple. Plus new
TeachingStore.triples() helper returning all parsed triples.
generate.intent.IntentTag gains TRANSITIVE_QUERY plus a relation
field on DialogueIntent. New regex rules for "What does X R?"
and "Where does X belong?" forms with relation normalisation.
core.cognition.result.CognitiveTurnResult gains operator_invocation
field (deterministic serialisation of any operator that ran).
core.cognition.trace.compute_trace_hash gains operator_invocation
kwarg; trace_hash_from_result threads it through. Operator
invocation is now load-bearing for replay equality.
Pipeline wiring:
CognitiveTurnPipeline.run dispatches transitive_walk after
runtime.chat() when the intent is TRANSITIVE_QUERY (with the
parsed relation) or DEFINITION (implicit "is"). Non-trivial walks
fold the chain endpoint into surface and articulation_surface.
Verification:
tests/test_inference_operators.py - 27 unit tests covering
parser, transitive_walk (cycles, max_hops, case-insensitivity,
determinism, first-write-wins), path_recall, and WalkResult shape.
Re-score on Phase 3 v1 case sets:
lane split v1 after bundle
inference-closure public/v1 0.0 1.0 pass
inference-closure holdouts/v1 0.0 1.0 pass
multi-step-reasoning public/v1 0.0 0.7333 pass
multi-step-reasoning holdouts/v1 0.0 0.8 pass
cross-domain-transfer public/v1 0.0 1.0 pass
cross-domain-transfer holdouts/v1 0.0 1.0 pass
compositionality public/v1 0.0625 0.3125 partial
compositionality holdouts/v1 0.0 0.3 partial
Six of eight splits now pass v1. Foundation guarantees
(premises_stored, replay_determinism) remain 1.0 across all lanes.
Trace_hash determinism preserved (operator records fold in
deterministically).
Residuals (filed as Phase 3 v2 follow-up):
- multi-step-reasoning mixed_relation_3/4 patterns need path_recall
wired into the pipeline for multi-relation probes; the operator
exists but the pipeline only invokes transitive_walk today.
- compositionality novel-combination patterns need a genuinely
new operator shape (composed_relation_walk) - the literal
transitive walk does not synthesise novel pairs by construction.
CLI suites smoke / cognition / teaching pass; no regression. 47
pipeline + teaching + operator tests all green.
124 lines
4.4 KiB
Python
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 precede/cause/ground/reveal/mean/follow?" -> (X, R)
|
|
# "Where does X belong?" -> (X, belongs_to)
|
|
# The trailing-?-and-optional-trailing-tokens form keeps the pattern total.
|
|
_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>precede|precedes|cause|causes|ground|grounds|reveal|reveals|"
|
|
r"mean|means|follow|follows|contrast(?:_with|s_with|s\s+with)?|"
|
|
r"produce|produces)\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)
|