core/generate/graph_planner.py
Shay 05348e5a79 refactor: consolidate duplicate depth extraction — graph_planner.get_node_depths now delegates to build_node_depths (single source)
Addresses MEDIUM from code-review: eliminates duplication risk between GraphNode method and pure canonical func.
Tests (graph_anti / node_depths) remain green.
2026-07-08 07:21:17 -07:00

406 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Graph planner — converts a PropositionGraph into an ArticulationTarget.
The planner walks the graph in topological order and emits an ordered
sequence of articulation steps that the downstream generation pipeline
can execute. Each step carries the proposition node ID, the rhetorical
move, and any constraints inherited from intent classification.
"""
from __future__ import annotations
from collections import defaultdict, deque
from dataclasses import dataclass
from enum import Enum, unique
from generate.intent import DialogueIntent, IntentTag
@unique
class Relation(Enum):
ELABORATION = "elaboration"
CAUSE = "cause"
CONTRAST = "contrast"
SEQUENCE = "sequence"
CORRECTION = "correction"
CONJUNCTION = "conjunction"
DISJUNCTION = "disjunction"
COMPLEMENT = "complement"
RELATIVE = "relative"
@unique
class RhetoricalMove(Enum):
ASSERT = "assert"
ELABORATE = "elaborate"
CONTRAST = "contrast"
SEQUENCE = "sequence"
CORRECT = "correct"
@dataclass(frozen=True, slots=True)
class GraphEdge:
source: str
target: str
relation: Relation
def as_dict(self) -> dict[str, str]:
return {
"source": self.source,
"target": self.target,
"relation": self.relation.value,
}
@dataclass(frozen=True, slots=True)
class GraphNode:
"""Core node in the PropositionGraph.
The shared substrate for comprehension (grounding), articulation
(realization), and internal reasoning/contemplation.
Optional 3-language depth fields allow Hebrew root density and
Koine Greek precision (plus English base) to travel with the node
through the entire spine without duplication.
"""
node_id: str
subject: str
predicate: str
obj: str
source_intent: IntentTag
language: str | None = None
root: str | None = None
morphology_id: str | None = None
# 3-lang depth support for PropGraph spine (comprehend/articulate/think via roots)
def as_dict(self) -> dict[str, object]:
d = {
"node_id": self.node_id,
"subject": self.subject,
"predicate": self.predicate,
"object": self.obj,
"source_intent": self.source_intent.value,
}
if self.language is not None:
d["language"] = self.language
if self.root is not None:
d["root"] = self.root
if self.morphology_id is not None:
d["morphology_id"] = self.morphology_id
return d
@dataclass(frozen=True, slots=True)
class PropositionGraph:
nodes: tuple[GraphNode, ...] = ()
edges: tuple[GraphEdge, ...] = ()
def add_node(self, node: GraphNode) -> PropositionGraph:
return PropositionGraph(nodes=(*self.nodes, node), edges=self.edges)
def add_edge(self, edge: GraphEdge) -> PropositionGraph:
return PropositionGraph(nodes=self.nodes, edges=(*self.edges, edge))
def roots(self) -> tuple[str, ...]:
targets = frozenset(e.target for e in self.edges)
return tuple(n.node_id for n in self.nodes if n.node_id not in targets)
def get_node_depths(self) -> dict[str, dict]:
"""Return nid -> {language, root, morphology_id} for nodes carrying 3-lang depth.
Delegates to the canonical pure extractor in recognition.depth_canonical
(single source of truth; avoids duplication with build_node_depths).
Enables graph-level consumers (anti-unif, framing, realizer)
to operate on root forms for he/grc without string hacks.
"""
from recognition.depth_canonical import build_node_depths
return build_node_depths(self.nodes)
def topo_order(self) -> tuple[str, ...]:
"""Kahn's topological sort over the graph's edges.
Comb pass 2026-05-21 — pre-fix this implementation had two
compounding inefficiencies:
* ``queue.pop(0)`` on a list is O(N) per pop ⇒ O(N²) total
* The inner ``for e in self.edges`` rescanned every edge on
every iteration ⇒ O(N × E) overall
Properly implemented Kahn's is O(N + E) and produces the same
deterministic order for the same input (queue seeded with
sorted zero-in-degree nodes; ties on later iterations break
by insertion order, identical to the pre-fix list).
Today's graphs are 12 nodes so cost is invisible — but
ADR-0089 Phase C2 (compound-intent multi-node dispatch) and
ADR-0088 Phase B (grounded realizer) both make multi-node
graphs realistic on the hot path. Fix lands before the
usage scales.
"""
# Build out-edge adjacency once: O(E).
out_edges: dict[str, list[str]] = defaultdict(list)
in_degree: dict[str, int] = {n.node_id: 0 for n in self.nodes}
for e in self.edges:
out_edges[e.source].append(e.target)
in_degree[e.target] = in_degree.get(e.target, 0) + 1
# Seed with sorted zero-in-degree nodes (deterministic).
queue: deque[str] = deque(
sorted(nid for nid, deg in in_degree.items() if deg == 0)
)
order: list[str] = []
while queue:
nid = queue.popleft() # O(1) on a deque
order.append(nid)
# Decrement in-degree of direct successors only: O(deg(nid))
# amortised to O(E) total across the loop.
for target in out_edges[nid]:
in_degree[target] -= 1
if in_degree[target] == 0:
queue.append(target)
return tuple(order)
def as_dict(self) -> dict[str, object]:
return {
"nodes": tuple(n.as_dict() for n in self.nodes),
"edges": tuple(e.as_dict() for e in self.edges),
}
def to_json(self) -> str:
import json
return json.dumps(self.as_dict(), sort_keys=True)
def is_fully_grounded(self) -> bool:
"""True iff every node has a concrete object referent (no <pending>).
This predicate is the geometric/substrate half of the Shadow Coherence
Gate. It is deliberately structural and cheap.
- Mechanical Sympathy: pure tuple walk; zero allocation in hot path;
maps to the same CPU domain as the rest of cognition orchestration.
- Semantic Rigor: "fully grounded" has one precise meaning — the
recall step (or direct construction) supplied a non-sentinel object
for every proposition node. The sentinel "<pending>" is the lexical
marker that exact CGA recall did not bind a referent.
- Third Door: we refuse to paper over missing referents with
similarity, defaults, or post-hoc repair. If any slot remains
pending the substrate withholds authority and emits a precise
bypass hazard for the data-driven backlog.
The continuous versor_condition < 1e-6 remains enforced exclusively
at owned construction boundaries (algebra/versor._close_applied_versor,
VersorBinding.__post_init__, ingest gate, etc.). This method never
mutates or "fixes" geometry.
"""
if not self.nodes:
return False
for n in self.nodes:
obj = getattr(n, "obj", None)
if obj in (None, "", "<pending>"):
return False
if isinstance(obj, str) and "..." in obj:
return False
return True
def get_unresolved_topology(self) -> tuple[str, ...]:
"""Node IDs that remain ungrounded.
Used exclusively for SUBSTRATE_BYPASS_HAZARD telemetry so that
the exact missing structure (not a score) drives Layer 1/2/3 work.
"""
unresolved: list[str] = []
for n in self.nodes:
obj = getattr(n, "obj", None)
if obj in (None, "", "<pending>") or (isinstance(obj, str) and "..." in obj):
unresolved.append(n.node_id)
return tuple(unresolved)
@dataclass(frozen=True, slots=True)
class ArticulationStep:
node_id: str
move: RhetoricalMove
predicate: str
subject: str
negated: bool = False
quantifier: str | None = None
tense: str | None = None
aspect: str | None = None
def as_dict(self) -> dict[str, str]:
return {
"node_id": self.node_id,
"move": self.move.value,
"predicate": self.predicate,
"subject": self.subject,
}
@dataclass(frozen=True, slots=True)
class ArticulationTarget:
steps: tuple[ArticulationStep, ...]
source_intent: IntentTag
def as_dict(self) -> dict[str, object]:
return {
"steps": tuple(s.as_dict() for s in self.steps),
"source_intent": self.source_intent.value,
}
_RELATION_TO_MOVE: dict[Relation, RhetoricalMove] = {
Relation.ELABORATION: RhetoricalMove.ELABORATE,
Relation.CAUSE: RhetoricalMove.ELABORATE,
Relation.CONTRAST: RhetoricalMove.CONTRAST,
Relation.SEQUENCE: RhetoricalMove.SEQUENCE,
Relation.CORRECTION: RhetoricalMove.CORRECT,
}
_INTENT_PREDICATES: dict[IntentTag, str] = {
IntentTag.DEFINITION: "is_defined_as",
IntentTag.CAUSE: "is_caused_by",
IntentTag.PROCEDURE: "has_steps",
IntentTag.COMPARISON: "contrasts_with",
IntentTag.CORRECTION: "corrects",
IntentTag.RECALL: "recalls",
IntentTag.VERIFICATION: "is_verified_as",
}
def graph_from_intent(
intent: DialogueIntent,
*,
prior_node_id: str | None = None,
) -> PropositionGraph:
"""Build a minimal proposition graph from a classified intent.
Uses structural pattern matching for exhaustive, readable dispatch
over IntentTag modern, clear, and easier to extend without
hidden fallthroughs.
"""
graph = PropositionGraph()
match intent.tag:
case IntentTag.COMPARISON:
predicate = _INTENT_PREDICATES[IntentTag.COMPARISON]
left = GraphNode(
node_id="p0",
subject=intent.subject,
predicate=predicate,
obj=intent.secondary_subject or "<pending>",
source_intent=intent.tag,
# depth fields populated later via resolve_entry + grounding enrichment
)
right = GraphNode(
node_id="p1",
subject=intent.secondary_subject or "<pending>",
predicate=predicate,
obj=intent.subject,
source_intent=intent.tag,
)
edge = GraphEdge(source="p0", target="p1", relation=Relation.CONTRAST)
return graph.add_node(left).add_node(right).add_edge(edge)
case IntentTag.CORRECTION:
predicate = _INTENT_PREDICATES[IntentTag.CORRECTION]
root = GraphNode(
node_id="p0",
subject=intent.subject,
predicate=predicate,
obj=prior_node_id or "<prior>",
source_intent=intent.tag,
)
graph = graph.add_node(root)
if prior_node_id is not None:
graph = graph.add_edge(
GraphEdge(source="p0", target=prior_node_id, relation=Relation.CORRECTION)
)
return graph
case _:
predicate = _INTENT_PREDICATES.get(intent.tag, "addresses")
root = GraphNode(
node_id="p0",
subject=intent.subject,
predicate=predicate,
obj="<pending>",
source_intent=intent.tag,
)
return graph.add_node(root)
def ground_graph(
graph: PropositionGraph,
recalled_words: tuple[str, ...],
*,
depth: dict[str, tuple[str | None, str | None, str | None]] | None = None,
) -> PropositionGraph:
"""Fill <pending> obj slots with recalled words from vault recall.
Each node whose obj is '<pending>' gets the next available recalled
word. If there are more nodes than words, remaining slots stay as
'<pending>'. Comparison nodes get paired words when available.
depth: optional node_id -> (language, root, morphology_id) to attach
alongside recalled_words. Supports passing 3-lang depth without
requiring pre-enrichment of the input graph. Falls back to any
depth already on the input node.
"""
words = deque(recalled_words)
new_nodes: list[GraphNode] = []
for node in graph.nodes:
if node.obj == "<pending>" and words:
obj = words.popleft()
lang, rt, mid = node.language, node.root, node.morphology_id
if depth and node.node_id in depth:
dlang, drt, dmid = depth.get(node.node_id, (None, None, None))
lang = dlang or lang
rt = drt or rt
mid = dmid or mid
new_nodes.append(GraphNode(
node_id=node.node_id,
subject=node.subject,
predicate=node.predicate,
obj=obj,
source_intent=node.source_intent,
language=lang,
root=rt,
morphology_id=mid,
))
else:
new_nodes.append(node)
return PropositionGraph(nodes=tuple(new_nodes), edges=graph.edges)
def plan_articulation(graph: PropositionGraph) -> ArticulationTarget:
"""Walk *graph* in topological order and emit an articulation target."""
node_map = {n.node_id: n for n in graph.nodes}
incoming: dict[str, Relation | None] = {n.node_id: None for n in graph.nodes}
for edge in graph.edges:
if edge.target in incoming:
incoming[edge.target] = edge.relation
source_intent = IntentTag.UNKNOWN
if graph.nodes:
source_intent = graph.nodes[0].source_intent
steps: list[ArticulationStep] = []
for node_id in graph.topo_order():
node = node_map.get(node_id)
if node is None:
continue
relation = incoming.get(node_id)
match relation:
case None:
move = RhetoricalMove.ASSERT
case _:
move = _RELATION_TO_MOVE.get(relation, RhetoricalMove.ASSERT)
steps.append(
ArticulationStep(
node_id=node_id,
move=move,
predicate=node.predicate,
subject=node.subject,
)
)
return ArticulationTarget(steps=tuple(steps), source_intent=source_intent)