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.
306 lines
11 KiB
Python
306 lines
11 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,
|
||
negated=step_a.negated,
|
||
)
|
||
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,
|
||
# The step has carried this since the graph learned to hold a
|
||
# denial; the realizer discarded it silently until Phase 5.
|
||
negated=step.negated,
|
||
)
|
||
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)
|
||
|