Bundle of 5 hot-path optimizations + 1 dead-code removal + 1 import
sweep + 1 helper fold, surfaced by a comb pass through the cognitive
spine starting from ``CognitiveTurnPipeline.run()`` and walking
outward through ChatRuntime, intent classification, the graph
planner, the realizer, and the vault. All eval lanes byte-identical
to MEMORY baseline; null-lift confirmed by ``core eval cognition``
across public / dev / holdout splits.
Hot-path fixes:
1. ``ChatRuntime._apply_oov_policy`` no longer rescans every
manifest per OOV token. Two precomputed booleans on
``self`` capture the FAIL_CLOSED-all and PROPOSE_VOCAB-any
aggregates at construction time. Manifests are immutable
post-construction so the cache is safe. Turns the path from
O(packs × OOV) to O(OOV).
2. ``CognitiveTurnPipeline.run`` calls ``classify_compound_intent``
once and takes its dominant ``compound.primary`` as the seeded
intent. Pre-fix the pipeline called both ``classify_intent``
and ``classify_compound_intent`` on every turn — and
``classify_compound_intent`` internally invokes
``classify_intent`` on the dominant fragment, so every non-
compound prompt walked the 15-regex cascade twice.
3. ``TeachingStore.triples()`` materializes once per turn.
Pre-fix ``_maybe_transitive_walk`` and ``_maybe_compose_relations``
each called ``self.teaching_store.triples()`` independently,
doubling the per-turn O(N) filter+tuple-build cost. Both
helpers now accept an optional ``triples`` arg; the pipeline
computes once and passes through.
5. ``realize_semantic`` and ``realize_target`` build a
``node_id → obj`` map once and look up each step in O(1)
instead of an O(N) linear scan of ``graph.nodes`` per step.
The cost was invisible on today's 1-2 node graphs but would
have become an O(N²) regression on the multi-node graphs
ADR-0089 Phase C2 plans to introduce.
Dead-code / cleanup:
- Removed dead ``CognitiveTurnPipeline._fold_compose_into_surface``
(no callers since PR #76 routed all surface composition
through ``resolve_surface``).
- Folded ``_serialize_walk`` + ``_serialize_compose`` (identical
bodies) into one ``_serialize_operator`` helper.
- Hoisted ``import json`` and ``RatifiedIntent`` from inside hot
method bodies to module top (same pattern PR #76 applied to
``_is_useful_surface``).
- Dead-defensiveness sweep on ``ChatResponse`` field reads in
``pipeline.run()``: ``getattr(response, "<field>", default)``
where the field always exists on the dataclass with a default
is replaced by direct attribute access (6 sites:
``realizer_grounded_authority``, ``recalled_words``,
``grounding_source``, ``register_canonical_surface``,
``pre_decoration_surface``, ``admissibility_trace``,
``region_was_unconstrained``). ``refusal_reason`` retains the
guarded read because ADR-0024 Phase 2 leaves its
materialisation site dormant.
Benchmark profiler:
- ``benchmarks/pipeline_profiler.py`` rebound from
``classify_intent`` to ``classify_compound_intent`` (the new
single-classification site). All other timing hooks unchanged.
Tests:
- 4 new tests in ``tests/test_comb_pass_hot_path.py`` pin: OOV
aggregates exist as bools; compound classifier runs exactly
once per turn; ``triples()`` materializes exactly once per
turn; realizer correctly resolves obj slots across an 8-node
graph.
- All existing tests pass. ``core eval cognition`` byte-identical:
public 100/100/91.7/100, dev 100/100/78.6/100, holdout
100/100/83.3/100.
- ``core test --suite cognition`` 120/0/1, ``smoke`` 67/0,
``runtime`` 19/0.
272 lines
9.3 KiB
Python
272 lines
9.3 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 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
|
||
|
||
|
||
@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)
|
||
|
||
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
|
||
surface = render_semantic(
|
||
intent=intent,
|
||
subject=step_a.subject,
|
||
predicate=step_a.predicate,
|
||
obj=obj_a,
|
||
secondary=secondary,
|
||
)
|
||
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, "...")
|
||
surface = render_semantic(
|
||
intent=intent,
|
||
subject=step.subject,
|
||
predicate=step.predicate,
|
||
obj=obj,
|
||
)
|
||
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)
|
||
|
||
|