- Add LexicalResolution dataclass + resolve_entry() in chat/pack_resolver.py
that returns language, root, morphology_id, gloss, semantic_domains from
he/grc/en packs (lru-cached, first-match, full depth support).
- Extend GraphNode (generate/graph_planner.py) with optional language/root/
morphology_id fields (defaults preserve all call sites). Update as_dict()
to include them conditionally. ground_graph() now propagates depth.
- Generalize enrichment in core/cognition/pipeline.py:
- Per-subject resolution map using depth packs.
- Enrich all matching nodes before ground (subject→node map).
- Pass depth alongside recalled_words to ground_graph().
- Consume depth on articulation side:
- realize_semantic() and render_semantic() now accept/use language+root
for etymological/Logos framing on Hebrew/Greek nodes (e.g. "אמת (Hebrew
root: א-מ-ן) is defined as..."). English unchanged.
- Enrich oov_geometric_context with node_depths for future geometric
anti-unification using roots.
- Extend recognition/connector.py to forward depth from EpistemicNode
paths into GraphNode.
- Add full Hebrew turn test under realizer_grounded_authority flag.
- Update related tests (semantic realizer, OOV context, surface resolution).
- Cleaned legacy type() hack immediately on discovery (hard-stop rule).
All targeted tests green (52+ in slices), broad relevant suite 581 passed.
Invariants preserved: versor only at owned boundaries, exact recall,
immutable updates, no new legacy parsers. 3 pillars upheld.
Work continues tomorrow from this checkpoint.
302 lines
10 KiB
Python
302 lines
10 KiB
Python
"""ArticulationRealizerV2 — deterministic template-based realization.
|
||
|
||
Converts an ArticulationTarget (ordered rhetorical steps from the graph
|
||
planner) into a RealizedPlan: an ordered sequence of surface fragments
|
||
joined into a single deterministic surface string.
|
||
|
||
Design constraints:
|
||
- No LLM fallback
|
||
- No broad grammar engine
|
||
- Deterministic: same ArticulationTarget → same RealizedPlan, always
|
||
- Composable: does not replace the existing realize() path yet
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
|
||
from core.physics.energy import EnergyClass
|
||
from generate.graph_planner import (
|
||
ArticulationStep,
|
||
ArticulationTarget,
|
||
PropositionGraph,
|
||
RhetoricalMove,
|
||
)
|
||
from generate.intent import IntentTag
|
||
from generate.semantic_templates import render_semantic
|
||
from generate.templates import render_step
|
||
|
||
|
||
_ENERGY_SURFACE_PREFIX: dict[EnergyClass, str] = {
|
||
EnergyClass.E0: "From memory: ",
|
||
EnergyClass.E1: "I seem to recall: ",
|
||
EnergyClass.E2: "I recall: ",
|
||
EnergyClass.E3: "",
|
||
EnergyClass.E4: "",
|
||
}
|
||
|
||
|
||
def energy_modulated_surface(base_surface: str, energy_class: EnergyClass) -> str:
|
||
"""Prepend energy-class framing per ADR-0006 §Integration Points."""
|
||
prefix = _ENERGY_SURFACE_PREFIX.get(energy_class, "")
|
||
if not prefix or not base_surface:
|
||
return base_surface
|
||
return prefix + base_surface
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class RealizedFragment:
|
||
node_id: str
|
||
move: RhetoricalMove
|
||
surface: str
|
||
|
||
def as_dict(self) -> dict[str, str]:
|
||
return {
|
||
"node_id": self.node_id,
|
||
"move": self.move.value,
|
||
"surface": self.surface,
|
||
}
|
||
|
||
|
||
def _capitalize_sentence(s: str) -> str:
|
||
"""Capitalize the first alphabetic character of a sentence.
|
||
|
||
Skips leading whitespace/punctuation so fragments that start with
|
||
discourse markers ("next, knowledge…") still emit a capital first
|
||
letter ("Next, knowledge…") at the sentence boundary. Leaves the
|
||
rest of the string untouched — proper nouns and embedded all-caps
|
||
tokens are preserved.
|
||
"""
|
||
if not s:
|
||
return s
|
||
for i, ch in enumerate(s):
|
||
if ch.isalpha():
|
||
return s[:i] + ch.upper() + s[i + 1:]
|
||
return s
|
||
|
||
|
||
def _join_as_paragraph(fragments: list["RealizedFragment"]) -> str:
|
||
"""Join fragments into a paragraph with sentence-initial capitalization.
|
||
|
||
Each fragment becomes one sentence; sentence-initial letters are
|
||
capitalized; the paragraph ends with a single terminal period.
|
||
"""
|
||
if not fragments:
|
||
return ""
|
||
pieces: list[str] = []
|
||
for f in fragments:
|
||
s = f.surface.strip()
|
||
if not s:
|
||
continue
|
||
s = _capitalize_sentence(s)
|
||
pieces.append(s)
|
||
joined = ". ".join(pieces)
|
||
if joined and not joined.endswith("."):
|
||
joined += "."
|
||
return joined
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class RealizedPlan:
|
||
fragments: tuple[RealizedFragment, ...]
|
||
surface: str
|
||
|
||
def as_dict(self) -> dict[str, object]:
|
||
return {
|
||
"fragments": tuple(f.as_dict() for f in self.fragments),
|
||
"surface": self.surface,
|
||
}
|
||
|
||
|
||
def realize_semantic(
|
||
target: ArticulationTarget,
|
||
graph: PropositionGraph | None = None,
|
||
) -> RealizedPlan:
|
||
"""Realize using intent-aware semantic templates.
|
||
|
||
Uses the source intent to select a template that produces structurally
|
||
better surfaces (e.g. "X is defined as Y" for definition intents)
|
||
rather than the generic rhetorical-move templates.
|
||
|
||
Returns an empty RealizedPlan for empty/None targets so the caller
|
||
can fall back to the older articulation path.
|
||
"""
|
||
if target is None or not target.steps:
|
||
return RealizedPlan(fragments=(), surface="")
|
||
|
||
intent = target.source_intent
|
||
fragments: list[RealizedFragment] = []
|
||
# Comb pass 2026-05-21 — O(1) object-slot lookup per step.
|
||
node_objs = _build_node_map(graph)
|
||
|
||
# Depth map for 3-language articulation enrichment (Hebrew roots, Greek precision).
|
||
# Consulted when realizing surfaces for higher-fidelity etymological/Logos framing.
|
||
depth_by_id: dict[str, tuple[str | None, str | None]] = {}
|
||
if graph:
|
||
for n in graph.nodes:
|
||
depth_by_id[n.node_id] = (getattr(n, "language", None), getattr(n, "root", None))
|
||
|
||
if intent is IntentTag.COMPARISON and len(target.steps) >= 2:
|
||
step_a = target.steps[0]
|
||
step_b = target.steps[1]
|
||
obj_a = node_objs.get(step_a.node_id, "...")
|
||
secondary = step_b.subject if step_b.subject != step_a.subject else obj_a
|
||
lang_a, root_a = depth_by_id.get(step_a.node_id, (None, None))
|
||
surface = render_semantic(
|
||
intent=intent,
|
||
subject=step_a.subject,
|
||
predicate=step_a.predicate,
|
||
obj=obj_a,
|
||
secondary=secondary,
|
||
language=lang_a,
|
||
root=root_a,
|
||
)
|
||
fragments.append(RealizedFragment(
|
||
node_id=step_a.node_id,
|
||
move=RhetoricalMove.CONTRAST,
|
||
surface=surface,
|
||
))
|
||
else:
|
||
for step in target.steps:
|
||
obj = node_objs.get(step.node_id, "...")
|
||
lang, rt = depth_by_id.get(step.node_id, (None, None))
|
||
surface = render_semantic(
|
||
intent=intent,
|
||
subject=step.subject,
|
||
predicate=step.predicate,
|
||
obj=obj,
|
||
language=lang,
|
||
root=rt,
|
||
)
|
||
move = step.move
|
||
if move is RhetoricalMove.ASSERT and intent is IntentTag.CORRECTION:
|
||
move = RhetoricalMove.CORRECT
|
||
fragments.append(RealizedFragment(
|
||
node_id=step.node_id,
|
||
move=move,
|
||
surface=surface,
|
||
))
|
||
|
||
joined = _join_as_paragraph(fragments)
|
||
return RealizedPlan(fragments=tuple(fragments), surface=joined)
|
||
|
||
|
||
def _build_node_map(graph: PropositionGraph | None) -> dict[str, str]:
|
||
"""Index graph nodes by node_id for O(1) ``obj`` lookup.
|
||
|
||
Comb pass 2026-05-21 — pre-fix ``_resolve_obj`` did an O(N) linear
|
||
scan of ``graph.nodes`` per step, so a target with S steps over an
|
||
N-node graph cost O(S × N). Building the map once in the realizer
|
||
and indexing into it makes the realizer linear in (S + N) overall.
|
||
Returns an empty mapping when the graph is None or empty.
|
||
"""
|
||
if graph is None:
|
||
return {}
|
||
return {node.node_id: node.obj for node in graph.nodes}
|
||
|
||
|
||
def _resolve_obj(step: ArticulationStep, graph: PropositionGraph | None) -> str:
|
||
"""Look up the object slot from the graph node matching this step.
|
||
|
||
Retained as the legacy single-step accessor for callers that do
|
||
not have a node_map handy. Hot paths in ``realize_semantic`` and
|
||
``realize_target`` build the map once and bypass this function.
|
||
"""
|
||
if graph is None:
|
||
return "..."
|
||
for node in graph.nodes:
|
||
if node.node_id == step.node_id:
|
||
return node.obj
|
||
return "..."
|
||
|
||
|
||
def realize_target(
|
||
target: ArticulationTarget,
|
||
graph: PropositionGraph | None = None,
|
||
) -> RealizedPlan:
|
||
"""Realize an ArticulationTarget into a deterministic surface plan.
|
||
|
||
Handles compound constructions (conjunction, disjunction, complement,
|
||
relative clause) by detecting graph edges and joining surfaces with
|
||
appropriate connectors rather than sentence-level punctuation.
|
||
|
||
Returns an empty-but-valid RealizedPlan for empty/None targets.
|
||
"""
|
||
from generate.graph_planner import Relation
|
||
|
||
if target is None or not target.steps:
|
||
return RealizedPlan(fragments=(), surface="")
|
||
|
||
edge_map: dict[str, tuple[str, Relation]] = {}
|
||
if graph is not None:
|
||
for edge in graph.edges:
|
||
edge_map[edge.source] = (edge.target, edge.relation)
|
||
|
||
step_by_id = {step.node_id: step for step in target.steps}
|
||
# Comb pass 2026-05-21 — O(1) object-slot lookup per step.
|
||
node_objs = _build_node_map(graph)
|
||
visited: set[str] = set()
|
||
fragments: list[RealizedFragment] = []
|
||
|
||
for step in target.steps:
|
||
if step.node_id in visited:
|
||
continue
|
||
visited.add(step.node_id)
|
||
|
||
obj = node_objs.get(step.node_id, "...")
|
||
move = step.move
|
||
if move is RhetoricalMove.ASSERT and target.source_intent is IntentTag.CORRECTION:
|
||
move = RhetoricalMove.CORRECT
|
||
|
||
surface = render_step(
|
||
move=move,
|
||
subject=step.subject,
|
||
predicate=step.predicate,
|
||
obj=obj,
|
||
negated=step.negated,
|
||
quantifier=step.quantifier,
|
||
tense=step.tense,
|
||
aspect=step.aspect,
|
||
)
|
||
|
||
if step.node_id in edge_map:
|
||
target_id, relation = edge_map[step.node_id]
|
||
target_step = step_by_id.get(target_id)
|
||
if target_step is not None and target_id not in visited:
|
||
match relation:
|
||
case Relation.CONJUNCTION | Relation.DISJUNCTION | Relation.COMPLEMENT | Relation.RELATIVE:
|
||
visited.add(target_id)
|
||
target_obj = node_objs.get(target_step.node_id, "...")
|
||
target_surface = render_step(
|
||
move=RhetoricalMove.ASSERT,
|
||
subject=target_step.subject,
|
||
predicate=target_step.predicate,
|
||
obj=target_obj,
|
||
negated=target_step.negated,
|
||
quantifier=target_step.quantifier,
|
||
tense=target_step.tense,
|
||
aspect=target_step.aspect,
|
||
)
|
||
match relation:
|
||
case Relation.CONJUNCTION:
|
||
surface = f"{surface} and {target_surface}"
|
||
case Relation.DISJUNCTION:
|
||
surface = f"{surface} or {target_surface}"
|
||
case Relation.COMPLEMENT:
|
||
surface = f"{step.subject} {step.predicate} that {target_surface}"
|
||
case Relation.RELATIVE:
|
||
surface = f"{step.subject}, which {target_step.predicate} {target_obj}, {step.predicate} {obj}"
|
||
case _:
|
||
pass
|
||
|
||
fragments.append(
|
||
RealizedFragment(
|
||
node_id=step.node_id,
|
||
move=move,
|
||
surface=surface,
|
||
)
|
||
)
|
||
|
||
joined = _join_as_paragraph(fragments)
|
||
return RealizedPlan(fragments=tuple(fragments), surface=joined)
|
||
|