core/generate/realizer.py
Shay dccce6a7b9 fix(generate): a proposition graph must be able to represent denial (ADR-0265)
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.
2026-07-27 13:08:17 -07:00

306 lines
11 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.

"""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)