CORE served the affirmative of propositions the user denied. Measured live on
main @ 536d6e55 with realizer_grounded_authority=True:
"evidence does not support truth" -> 'Evidence is verified: what supports truth.'
"evidence supports truth" -> 'Evidence is verified: what supports truth.'
Byte-identical.
Phase 4 (#136) pinned "render_semantic has no negated parameter" as a defect.
Sizing the exposure before acting on it -- the step §7 requires -- showed the
pin was correct and INCOMPLETE. The defect is two drops in series, and the
first is in the graph:
intent.negated parsed from the user's words, always has been
GraphNode NO FIELD FOR IT <-- first drop
ground_graph rebuilds nodes field-by-field; nothing to carry
depth enrichment rebuilds nodes field-by-field; nothing to carry
plan_articulation cannot carry what the node does not hold
realize_semantic never read step.negated
render_semantic no parameter for it <-- second drop
Five separate constructors, each silently defaulting to the affirmative.
Why every gate was green
------------------------
On the default config the ungrounded realizer emits "...", _is_useful_surface
rejects it, and the runtime echo wins the resolver -- and the echo contains the
user's own "does not". The truth path was correct BY ACCIDENT. ADR-0088 Phase B
grounds the graph first, which is exactly when the realizer's surface becomes
useful enough to win. So the defect sat behind a shipped flag, invisible to
every property-of-one-surface test, because nothing compared a denial to its
assertion.
Delivered
---------
- GraphNode.negated, threaded intent -> graph -> ground -> enrich -> step ->
surface. Serialized ONLY when True, so every pre-existing as_dict and every
trace_hash folded from one stays byte-identical -- which is why no lane pin
moves.
- Clause grammar delegated to its one owner. Four of the eight intent
"templates" were never frames; they were plain clauses. They now call
render_step. Writing a second negation implementation in semantic_templates
was the tempting fix and is rejected: Phase 2A spent a unit giving every
linguistic fact one owner, and that design rebuilds the disease one level up.
- Frames that keep a finite verb (VERIFICATION, PROCEDURE, COMPARISON) get an
explicit negated form. RECALL is a speech act with no proposition to deny, so
it falls back to the clause path rather than drop the denial (ADR-0261 §5.1).
Measured
--------
delegation on affirmatives 192/192 byte-identical
serving realizer, all corpora 85/347 -> 109/347 (+24 = the denials)
feature-bearing bucket 49/214 -> 73/214
CONTROL (nothing droppable) 33/33 -> 33/33 unchanged
multi-node (clause joining) 3/100 -> 3/100 unchanged, out of scope
lane pins 11/11 byte-identical, none edited
The control staying 33/33 and multi-node staying 3/100 is the evidence that
this moved the denials and nothing else.
The exhaustive control earned its keep immediately
--------------------------------------------------
It found FIVE more intents serving a denial as its own assertion --
TRANSITIVE_QUERY, FRAME_TRANSFER, NARRATIVE, EXAMPLE, DEDUCTION -- because an
intent with no frame fell back to the UNKNOWN *template* (which cannot say
"not") rather than the UNKNOWN *clause* (which can). The default is now the
capable path, so the next intent added inherits correctness. Found by the
control, not by inspection.
Mutation -- every link reverted individually
--------------------------------------------
baseline 19 pass
graph_from_intent drops intent.negated 8 FAIL
plan_articulation drops node.negated 6 FAIL
ground_graph drops it on rebuild 2 FAIL
realize_semantic stops passing step.negated 6 FAIL
unframed intents fall back to the template 1 FAIL
pipeline depth-enrichment drops it 1 FAIL
The last row was GREEN on the first run -- my fix there was unguarded. That is
what added the structural invariant: every GraphNode(...) built on the serving
path must NAME `negated` or be recorded in an allowlist with a reason. The
defect was five constructors; a per-site test must be written per site, and a
site added without one is invisible. Only recognition/connector.py is exempt
(an EpistemicNode has no polarity to carry).
Four Phase 4 pins are revised, not relaxed -- #136's M2 mutation ("render_semantic
GAINS a negated parameter -> FAIL") has now happened for real, and forced the
deliberate revision it was built to force.
Registered in `smoke` in the same PR that creates it, per the #136 finding that
an unregistered pin runs nowhere.
Still unexpressed, deliberately: quantifier, tense, aspect. NO PRODUCER sets
them anywhere on the serving path, so threading them would be machinery with no
caller. render_step already handles all three the moment a producer exists.
[Verification]: in-worktree on CPython 3.12.13 with `uv sync --locked` --
smoke 641 (was 621), deductive 503, lane pins 11/11 with no pin edited.
438 lines
16 KiB
Python
438 lines
16 KiB
Python
"""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)
|
||
negated: bool = False
|
||
"""Whether the proposition is DENIED rather than asserted.
|
||
|
||
The intent parser has always recovered this from the user's text
|
||
(``intent.negated``, ``generate/intent.py``), and the graph then dropped it
|
||
on the floor: ``GraphNode`` had no slot for it, so ``plan_articulation``
|
||
could not carry it and the realizer could not express it. The measured
|
||
consequence, under ``realizer_grounded_authority``:
|
||
|
||
"evidence does not support truth" -> 'Evidence is verified: what supports truth.'
|
||
"evidence supports truth" -> 'Evidence is verified: what supports truth.'
|
||
|
||
Byte-identical. CORE affirmed what the user denied. A proposition graph that
|
||
cannot represent denial cannot decode a denial, which is the thesis check
|
||
this field exists to pass.
|
||
|
||
Serialized only when True, so every pre-existing ``as_dict`` — and every
|
||
``trace_hash`` folded from one — stays byte-identical.
|
||
"""
|
||
|
||
def as_dict(self) -> dict[str, object]:
|
||
d: dict[str, object] = {
|
||
"node_id": self.node_id,
|
||
"subject": self.subject,
|
||
"predicate": self.predicate,
|
||
"object": self.obj,
|
||
"source_intent": self.source_intent.value,
|
||
}
|
||
if self.negated:
|
||
d["negated"] = True
|
||
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 1–2 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
|
||
negated=intent.negated,
|
||
)
|
||
right = GraphNode(
|
||
node_id="p1",
|
||
subject=intent.secondary_subject or "<pending>",
|
||
predicate=predicate,
|
||
obj=intent.subject,
|
||
source_intent=intent.tag,
|
||
negated=intent.negated,
|
||
)
|
||
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,
|
||
negated=intent.negated,
|
||
)
|
||
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,
|
||
negated=intent.negated,
|
||
)
|
||
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,
|
||
# Same drop-on-rebuild bug this field exists to fix: grounding
|
||
# is the ADR-0088 Phase B path, i.e. exactly the path on which
|
||
# the realizer's surface becomes useful enough to WIN the
|
||
# resolver. Losing negation here would restore the defect on
|
||
# the only turns where it reaches a user.
|
||
negated=node.negated,
|
||
))
|
||
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,
|
||
negated=node.negated,
|
||
)
|
||
)
|
||
|
||
return ArticulationTarget(steps=tuple(steps), source_intent=source_intent)
|