core/recognition/depth_canonical.py
Shay 7b0c0ae8ce
Some checks failed
lane-shas / verify pinned lane SHAs (pull_request) Failing after 2m34s
smoke / smoke (-m "not quarantine") (pull_request) Failing after 15m21s
fix(review): address MEDIUM items from final code-reviewer
- anti_unifier: remove arbitrary root fallback when no agent_node_id (only apply root-form when nid known; primary pipeline path unaffected)
- depth_canonical: filter build_node_depths to he/grc (or nodes with root) so English nodes no longer pollute node_depths with language-only entries

Tests re-run green. Matches review recommendations for the 3-lang depth feature.
2026-07-08 08:31:29 -07:00

119 lines
4.4 KiB
Python

"""Pure depth canonicalization helpers for 3-lang root-aware unification.
Extracted per strategy to enable slot-precise, node_id-keyed canonicalization
without heuristics in callers. All functions pure, side-effect free, immutable.
Used by derive_recognizer, recognize (for matching), and assessment enrichment.
Connects to cognitive path: listen/comprehend (depth from packs) -> think (anti-unif canonical) -> articulate.
Preserves exact recall, no drift repair.
"""
from __future__ import annotations
from dataclasses import replace
from typing import Any, Sequence, Tuple
from recognition.outcome import EvidenceSpan, FeatureBundle
# For type in tests, import GraphNode when needed
try:
from generate.graph_planner import GraphNode
except ImportError:
GraphNode = Any # type: ignore
def canonicalize_token(token: str, node_id: str | None, depths: dict[str, dict] | None) -> str:
"""Wrap root_normalize keyed on node_id from depths dict.
depths: {node_id: {"language": , "root": , ...}, ...}
If node_id in depths and lang he/grc and root, return root, else token.
Caller must pass the token associated with that node_id.
"""
if not depths or not node_id:
return token
d = depths.get(node_id)
if not d:
return token
lang = d.get("language")
root = d.get("root")
if lang in ("he", "grc") and root:
return root
return token
def canonicalize_agent_slot(
tokens: Sequence[str], bundle: FeatureBundle | None, depths: dict[str, dict] | None,
*, agent_node_id: str | None = None, start_idx: int | None = None
) -> Tuple[str, ...]:
"""Return copy of tokens with agent slot canonicalized using EvidenceSpan.start + node_id if available.
Single lookup by agent_node_id (node-keyed, requires explicit nid or no-op; no first-key proxy).
If bundle use its start, else start_idx.
Pure.
"""
if not depths:
return tuple(tokens)
start = start_idx
if bundle and start is None:
agent_feat = bundle.get("agent")
if agent_feat and isinstance(agent_feat.evidence, EvidenceSpan):
start = agent_feat.evidence.start
if start is None or start < 0 or start >= len(tokens):
return tuple(tokens)
new_tokens = list(tokens)
nid = agent_node_id
if nid is None or nid not in depths:
return tuple(new_tokens) # require explicit nid, no first-key proxy
d = depths[nid]
if d.get("language") in ("he", "grc") and d.get("root"):
orig = new_tokens[start]
new_tokens[start] = canonicalize_token(orig, nid, depths)
return tuple(new_tokens)
def build_node_depths(nodes: Sequence[Any]) -> dict[str, dict]:
"""Lift the node_depths dict from list of GraphNode (or objects with .node_id, .language etc).
Pure extraction of the comprehension in pipeline.
"""
return {
n.node_id: {
k: v for k, v in {
"language": getattr(n, "language", None),
"root": getattr(n, "root", None),
"morphology_id": getattr(n, "morphology_id", None),
}.items() if v is not None
}
for n in nodes
if getattr(n, "language", None) in ("he", "grc") or getattr(n, "root", None)
}
def enrich_assessments_with_depth(assessments: Tuple[Any, ...], depth: dict | None) -> Tuple[Any, ...]:
"""Immutable enrichment of assessments with root note using dataclasses.replace.
Returns a new tuple. Enrichment is best-effort: if an assessment does not
support replace (e.g. non-dataclass or frozen in an incompatible way), the
original item is kept without the depth note. No __dict__ reconstruction
is performed (avoids producing invalid objects for slots/frozen types).
"""
if not depth:
return assessments
roots = [d.get("root") for d in depth.values() if d.get("root")]
if not roots:
return assessments
note = f" [root:{roots[0]}]"
new_ass = []
for a in assessments:
if hasattr(a, "explanation") and getattr(a, "runnable", False):
try:
new_a = replace(a, explanation=(getattr(a, "explanation", "") or "") + note)
except Exception:
# Best-effort only; do not synthesize copies that could violate
# frozen/slots invariants after CGA substrate types.
new_a = a
new_ass.append(new_a)
else:
new_ass.append(a)
return tuple(new_ass)