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.
128 lines
4.1 KiB
Python
128 lines
4.1 KiB
Python
"""Typed relation parser — extract (head, relation, tail) triples from corrections.
|
|
|
|
A correction utterance like "Actually wisdom is judgment." carries a typed
|
|
proposition that until now was kept only as opaque text in the teaching
|
|
store. This module lifts the proposition into a typed triple so the
|
|
inference operators in ``generate/operators.py`` can walk the typed
|
|
relation graph that the teaching store represents.
|
|
|
|
Determinism: pure regex-driven extraction; no learned classifier; no
|
|
external IO. The relation vocabulary is drawn from the cognition pack's
|
|
relation predicates (see ``language_packs/data/en_core_cognition_v1``).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Final
|
|
|
|
# Relation predicates drawn from en_core_cognition_v1 (entries with
|
|
# semantic_domains containing "relation.*" or "predicate.*"). Order matters:
|
|
# multi-token forms must be tried before single-token forms so "belongs_to"
|
|
# is not split into "belongs" + "to".
|
|
_RELATIONS: Final[tuple[str, ...]] = (
|
|
"belongs_to",
|
|
"contrasts_with",
|
|
"is_caused_by",
|
|
"is_defined_as",
|
|
"is_verified_as",
|
|
"has_steps",
|
|
"corrects",
|
|
"recalls",
|
|
"grounds",
|
|
"reveals",
|
|
"precedes",
|
|
"follows",
|
|
"produces",
|
|
"causes",
|
|
"means",
|
|
"is",
|
|
"has",
|
|
)
|
|
|
|
# Sentence-leading discourse markers that may prefix the proposition.
|
|
_LEADING_MARKERS: Final[tuple[str, ...]] = (
|
|
"actually",
|
|
"no,",
|
|
"no",
|
|
"indeed",
|
|
"really",
|
|
"in fact",
|
|
"rather",
|
|
"instead",
|
|
)
|
|
|
|
_WHITESPACE = re.compile(r"\s+")
|
|
_PUNCT_TAIL = re.compile(r"[\.\?!,;:]+$")
|
|
|
|
|
|
def _strip_leading_marker(text: str) -> str:
|
|
lower = text.lower()
|
|
for marker in _LEADING_MARKERS:
|
|
prefix = marker + " "
|
|
if lower.startswith(prefix):
|
|
return text[len(prefix):]
|
|
if lower.startswith(marker + ",") or lower.startswith(marker + ";"):
|
|
return text[len(marker) + 1:].lstrip()
|
|
return text
|
|
|
|
|
|
def _normalize(text: str) -> str:
|
|
text = _strip_leading_marker(text.strip())
|
|
text = _WHITESPACE.sub(" ", text)
|
|
text = _PUNCT_TAIL.sub("", text)
|
|
return text.lower().strip()
|
|
|
|
|
|
def _split_head_relation_tail(text: str) -> tuple[str, str, str] | None:
|
|
"""Find the first matching relation predicate; split around it."""
|
|
# Word-boundary form for each relation so "is" does not match inside
|
|
# "wisdom" or similar. Multi-token relations are matched literally with
|
|
# surrounding spaces.
|
|
for relation in _RELATIONS:
|
|
if "_" in relation or " " in relation:
|
|
# Compound predicates use underscore in the lexicon but appear
|
|
# with underscores in correction text (per test corpus).
|
|
pattern = rf"\b{re.escape(relation)}\b"
|
|
else:
|
|
pattern = rf"\b{re.escape(relation)}\b"
|
|
match = re.search(pattern, text)
|
|
if match is None:
|
|
continue
|
|
head = text[: match.start()].strip()
|
|
tail = text[match.end():].strip()
|
|
if not head or not tail:
|
|
continue
|
|
# Drop trailing/leading articles ("a", "an", "the") from head/tail.
|
|
head = _strip_articles(head)
|
|
tail = _strip_articles(tail)
|
|
if not head or not tail:
|
|
continue
|
|
return head, relation, tail
|
|
return None
|
|
|
|
|
|
_ARTICLES: Final[frozenset[str]] = frozenset({"a", "an", "the"})
|
|
|
|
|
|
def _strip_articles(phrase: str) -> str:
|
|
tokens = phrase.split()
|
|
if tokens and tokens[0] in _ARTICLES:
|
|
tokens = tokens[1:]
|
|
if tokens and tokens[-1] in _ARTICLES:
|
|
tokens = tokens[:-1]
|
|
return " ".join(tokens)
|
|
|
|
|
|
def parse_triple(correction_text: str) -> tuple[str, str, str] | None:
|
|
"""Return (head, relation, tail) if the text parses cleanly, else None.
|
|
|
|
Pure function; deterministic. Returns None when no relation predicate
|
|
is found or when either side of the predicate is empty. Callers may
|
|
treat None as "this correction has no typed-graph content" and fall
|
|
back to the existing opaque-text storage path.
|
|
"""
|
|
if not correction_text:
|
|
return None
|
|
normalized = _normalize(correction_text)
|
|
return _split_head_relation_tail(normalized)
|