diff --git a/generate/realizer.py b/generate/realizer.py new file mode 100644 index 00000000..a91b828c --- /dev/null +++ b/generate/realizer.py @@ -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) diff --git a/generate/templates.py b/generate/templates.py new file mode 100644 index 00000000..2b11c984 --- /dev/null +++ b/generate/templates.py @@ -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 "") + +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 != "" else "..." + return template.format( + subject=subject, + predicate_h=predicate_h, + obj=obj_display, + ) diff --git a/tests/test_articulation_realizer_v2.py b/tests/test_articulation_realizer_v2.py new file mode 100644 index 00000000..fbd7fbbe --- /dev/null +++ b/tests/test_articulation_realizer_v2.py @@ -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