Add articulation realizer v2

- add deterministic ArticulationTarget realizer
- add rhetorical move templates and predicate humanization
- handle definition, comparison, correction, unknown, and empty targets
- keep runtime ChatResponse path unchanged
- add focused realizer tests
This commit is contained in:
Shay 2026-05-14 20:14:50 -07:00 committed by GitHub
parent c68b0734a2
commit 58a06124bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 252 additions and 0 deletions

102
generate/realizer.py Normal file
View file

@ -0,0 +1,102 @@
"""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 generate.graph_planner import (
ArticulationStep,
ArticulationTarget,
PropositionGraph,
RhetoricalMove,
)
from generate.intent import IntentTag
from generate.templates import render_step
@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,
}
@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 _resolve_obj(step: ArticulationStep, graph: PropositionGraph | None) -> str:
"""Look up the object slot from the graph node matching this step."""
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.
Each step is rendered through the template for its rhetorical move,
then fragments are joined with sentence-level punctuation.
Returns an empty-but-valid RealizedPlan for empty/None targets.
"""
if target is None or not target.steps:
return RealizedPlan(fragments=(), surface="")
fragments: list[RealizedFragment] = []
for step in target.steps:
obj = _resolve_obj(step, graph)
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,
)
fragments.append(
RealizedFragment(
node_id=step.node_id,
move=move,
surface=surface,
)
)
joined = ". ".join(f.surface for f in fragments)
if joined and not joined.endswith("."):
joined += "."
return RealizedPlan(fragments=tuple(fragments), surface=joined)

57
generate/templates.py Normal file
View file

@ -0,0 +1,57 @@
"""Deterministic surface templates for rhetorical moves.
Each template is a format string keyed by RhetoricalMove. Slots:
{subject} primary subject from the articulation step
{predicate} semantic predicate (e.g. "is_defined_as", "contrasts_with")
{obj} object slot from the graph node (may be "<pending>")
Templates are intentionally simple. The goal is structural correctness,
not fluency fluency comes in a later phase when the generation stream
consumes these as constraints rather than final output.
"""
from __future__ import annotations
from generate.graph_planner import RhetoricalMove
_PREDICATE_DISPLAY: dict[str, str] = {
"is_defined_as": "is defined as",
"is_caused_by": "is caused by",
"has_steps": "has the following steps",
"contrasts_with": "contrasts with",
"corrects": "corrects",
"recalls": "recalls",
"is_verified_as": "is verified as",
"addresses": "addresses",
}
def _humanize_predicate(predicate: str) -> str:
return _PREDICATE_DISPLAY.get(predicate, predicate.replace("_", " "))
_MOVE_TEMPLATES: dict[RhetoricalMove, str] = {
RhetoricalMove.ASSERT: "{subject} {predicate_h} {obj}",
RhetoricalMove.ELABORATE: "furthermore, {subject} {predicate_h} {obj}",
RhetoricalMove.CONTRAST: "in contrast, {subject} {predicate_h} {obj}",
RhetoricalMove.SEQUENCE: "next, {subject} {predicate_h} {obj}",
RhetoricalMove.CORRECT: "correction: {subject} {predicate_h} {obj}",
}
def render_step(
move: RhetoricalMove,
subject: str,
predicate: str,
obj: str,
) -> str:
"""Render a single articulation step into a surface fragment."""
template = _MOVE_TEMPLATES[move]
predicate_h = _humanize_predicate(predicate)
obj_display = obj if obj != "<pending>" else "..."
return template.format(
subject=subject,
predicate_h=predicate_h,
obj=obj_display,
)

View file

@ -0,0 +1,93 @@
"""Tests for ArticulationRealizerV2 — deterministic template-based realization."""
from __future__ import annotations
from generate.graph_planner import (
ArticulationTarget,
graph_from_intent,
plan_articulation,
)
from generate.intent import IntentTag, classify_intent
from generate.realizer import RealizedPlan, realize_target
def _realize_from_prompt(prompt: str, *, prior_node_id: str | None = None) -> RealizedPlan:
intent = classify_intent(prompt)
graph = graph_from_intent(intent, prior_node_id=prior_node_id)
target = plan_articulation(graph)
return realize_target(target, graph)
# ---------------------------------------------------------------------------
# 1. Definition realizer mentions subject
# ---------------------------------------------------------------------------
def test_definition_realizer_mentions_subject() -> None:
plan = _realize_from_prompt("What is a multivector?")
assert len(plan.fragments) == 1
assert "multivector" in plan.surface.lower()
assert "is defined as" in plan.surface.lower()
assert plan.surface.endswith(".")
# ---------------------------------------------------------------------------
# 2. Comparison realizer mentions both terms
# ---------------------------------------------------------------------------
def test_comparison_realizer_mentions_both_terms() -> None:
plan = _realize_from_prompt("Compare MLX and PyTorch")
assert len(plan.fragments) == 2
surface_lower = plan.surface.lower()
assert "mlx" in surface_lower
assert "pytorch" in surface_lower
assert "in contrast" in surface_lower
# ---------------------------------------------------------------------------
# 3. Correction realizer mentions prior or correction
# ---------------------------------------------------------------------------
def test_correction_realizer_mentions_prior_or_correction() -> None:
plan = _realize_from_prompt(
"No, that's wrong — it should be grade 2",
prior_node_id="prev_p0",
)
assert len(plan.fragments) == 1
surface_lower = plan.surface.lower()
assert "correction:" in surface_lower
assert "corrects" in surface_lower
# ---------------------------------------------------------------------------
# 4. Unknown or empty graph is bounded
# ---------------------------------------------------------------------------
def test_unknown_or_empty_graph_is_bounded() -> None:
empty_target = ArticulationTarget(steps=(), source_intent=IntentTag.UNKNOWN)
plan = realize_target(empty_target, graph=None)
assert plan.surface == ""
assert plan.fragments == ()
unknown_plan = _realize_from_prompt("xyzzy foobar")
assert unknown_plan.surface
assert len(unknown_plan.fragments) >= 1
# ---------------------------------------------------------------------------
# 5. Realizer output is deterministic
# ---------------------------------------------------------------------------
def test_realizer_output_is_deterministic() -> None:
plan_a = _realize_from_prompt("What is light?")
plan_b = _realize_from_prompt("What is light?")
assert plan_a.surface == plan_b.surface
assert len(plan_a.fragments) == len(plan_b.fragments)
for fa, fb in zip(plan_a.fragments, plan_b.fragments):
assert fa.surface == fb.surface
assert fa.move == fb.move
assert fa.node_id == fb.node_id