perf(cognition): hot-path comb pass — 5 mechanical-sympathy fixes (#91)

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.
This commit is contained in:
Shay 2026-05-20 20:31:56 -07:00 committed by GitHub
parent de3f40b549
commit fd48931838
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 262 additions and 80 deletions

View file

@ -102,7 +102,7 @@ def profile_turn(
# functions actually called from pipeline.run().
from core.cognition import pipeline as pipeline_mod
orig_classify_intent = pipeline_mod.classify_intent
orig_classify_intent = pipeline_mod.classify_compound_intent
orig_graph_from_intent = pipeline_mod.graph_from_intent
orig_plan_articulation = pipeline_mod.plan_articulation
orig_realize_semantic = pipeline_mod.realize_semantic
@ -144,7 +144,7 @@ def profile_turn(
with _stage(sink, _STAGE_TEACHING):
return orig_run_teaching(*args, **kwargs)
pipeline_mod.classify_intent = timed_classify_intent
pipeline_mod.classify_compound_intent = timed_classify_intent
pipeline_mod.graph_from_intent = timed_graph_from_intent
pipeline_mod.plan_articulation = timed_plan_articulation
pipeline_mod.realize_semantic = timed_realize_semantic
@ -160,7 +160,7 @@ def profile_turn(
finally:
total_ns = time.perf_counter_ns() - t_total_0
# Restore originals (instance and module).
pipeline_mod.classify_intent = orig_classify_intent
pipeline_mod.classify_compound_intent = orig_classify_intent
pipeline_mod.graph_from_intent = orig_graph_from_intent
pipeline_mod.plan_articulation = orig_plan_articulation
pipeline_mod.realize_semantic = orig_realize_semantic

View file

@ -424,6 +424,17 @@ class ChatRuntime:
manifold = manifolds[0] if len(pack_ids) == 1 else load_mounted_packs(pack_ids)
self._manifests = tuple(manifests)
# Comb pass 2026-05-21 — precompute OOV-policy aggregates so
# ``_apply_oov_policy`` doesn't rescan every manifest per OOV
# token. Manifests are immutable post-construction, so a
# one-time aggregate is safe and cuts the hot path from
# O(packs × OOV) to O(OOV).
self._all_manifests_fail_closed: bool = all(
m.oov_policy is OOVPolicy.FAIL_CLOSED for m in self._manifests
)
self._any_manifest_proposes_vocab: bool = any(
m.oov_policy is OOVPolicy.PROPOSE_VOCAB_EXPANSION for m in self._manifests
)
identity_pack_id = resolved_config.identity_pack or DEFAULT_IDENTITY_PACK
identity_manifold = load_identity_manifold(identity_pack_id)
self.safety_pack = load_safety_pack()
@ -661,15 +672,19 @@ class ChatRuntime:
return self._tokenize(text)
def _apply_oov_policy(self, tokens: list[str]) -> list[str]:
# Comb pass 2026-05-21 — OOV-policy aggregates are precomputed
# at ``__init__`` so this method stays O(OOV tokens) rather
# than O(packs × OOV tokens). See ``_all_manifests_fail_closed``
# / ``_any_manifest_proposes_vocab``.
kept: list[str] = []
for token in tokens:
try:
self._context.vocab.get_versor(token)
kept.append(token)
except KeyError:
if all(manifest.oov_policy is OOVPolicy.FAIL_CLOSED for manifest in self._manifests):
if self._all_manifests_fail_closed:
raise
if any(manifest.oov_policy is OOVPolicy.PROPOSE_VOCAB_EXPANSION for manifest in self._manifests):
if self._any_manifest_proposes_vocab:
raise KeyError(f"OOV token requires vocab proposal: {token}")
kept.append(token)
return kept

View file

@ -15,16 +15,18 @@ Constraint: ChatRuntime.chat() and ChatResponse contract are unchanged.
from __future__ import annotations
import json
from collections import OrderedDict
from field.state import FieldState
from core.cognition.result import CognitiveTurnResult
from core.cognition.surface_resolution import resolve_surface
from core.cognition.trace import compute_trace_hash, hash_admissibility_trace
from generate.intent import classify_compound_intent, classify_intent
from generate.intent import classify_compound_intent
from generate.intent_bridge import _is_useful_surface
from generate.intent_ratifier import (
RatificationOutcome,
RatifiedIntent,
ratify_intent,
)
from generate.graph_planner import graph_from_intent, ground_graph, plan_articulation
@ -127,15 +129,17 @@ class CognitiveTurnPipeline:
field_state_before: FieldState | None = self._capture_field_state()
# 1b. CLASSIFY — intent and proposition graph (deterministic, pre-chat)
seeded_intent = classify_intent(text)
# ADR-0089 Phase C1 (Finding 4, audit 2026-05-20) — also run the
# compound classifier so secondary clauses become observable
# telemetry instead of being silently dropped. The dominant
# clause continues to route through the existing single-intent
# path; Phase C2 (opt-in flag) will widen the graph planner to
# consume multiple parts. Single-clause prompts cost only the
# regex check — no graph / realizer / chat invocation changes.
# ADR-0089 Phase C1 (Finding 4, audit 2026-05-20) — run the
# compound classifier first and take its dominant clause as
# the seeded intent. ``classify_compound_intent`` already
# invokes ``classify_intent`` on the dominant fragment, so
# this is one regex cascade per turn instead of two (comb
# pass 2026-05-21). Secondary clauses surface on
# ``CognitiveTurnResult.dropped_compound_clauses`` as
# observability telemetry; the dominant clause continues to
# route through the existing single-intent path.
compound = classify_compound_intent(text)
seeded_intent = compound.primary
dropped_compound_clauses: tuple = (
tuple(compound.parts[1:]) if compound.is_compound() else ()
)
@ -175,25 +179,39 @@ class CognitiveTurnPipeline:
# preserves byte-identity for every existing surface and
# trace_hash — the realizer continues to emit unusable
# placeholders and lose the resolver to the runtime path.
if getattr(self.runtime.config, "realizer_grounded_authority", False):
recalled_words = getattr(response, "recalled_words", ()) or ()
# Comb pass 2026-05-21 — direct attribute access; these fields
# all live on ChatResponse with documented defaults (PR #88 for
# ``realizer_grounded_authority`` + ``recalled_words``, ADR-0048
# for ``grounding_source``, ADR-0077 for
# ``register_canonical_surface``, ADR-0071 for
# ``pre_decoration_surface``). The historical ``getattr`` calls
# were ADR-introduction defensiveness now safe to drop.
if self.runtime.config.realizer_grounded_authority:
recalled_words = response.recalled_words
if recalled_words:
grounded_graph = ground_graph(graph, recalled_words)
realized_plan = realize_semantic(target, grounded_graph)
gate_fired = (
response.vault_hits == 0
and getattr(response, "grounding_source", "vault") != "vault"
and response.grounding_source != "vault"
)
canonical = getattr(response, "register_canonical_surface", "") or ""
pre_decoration = getattr(response, "pre_decoration_surface", "") or ""
canonical = response.register_canonical_surface
pre_decoration = response.pre_decoration_surface
walk_result: WalkResult | None = self._maybe_transitive_walk(intent)
# Comb pass 2026-05-21 — materialize teaching-store triples once
# per turn. Pre-fix both ``_maybe_transitive_walk`` and
# ``_maybe_compose_relations`` called ``self.teaching_store.triples()``
# independently, doubling the per-turn O(N) filter+tuple-build
# cost as the corpus grows.
triples = self.teaching_store.triples()
walk_result: WalkResult | None = self._maybe_transitive_walk(intent, triples)
walk_surface = ""
if walk_result is not None and len(walk_result.path) > 1:
walk_surface = CognitiveTurnPipeline._render_walk_surface(walk_result)
compose_result: FrameComposeResult | None = self._maybe_compose_relations(intent)
compose_result: FrameComposeResult | None = self._maybe_compose_relations(intent, triples)
compose_surface = ""
if compose_result is not None and (
compose_result.subject_tail is not None
@ -289,8 +307,8 @@ class CognitiveTurnPipeline:
review_hash = reviewed_example.review_hash if reviewed_example is not None else ""
proposal_id = proposal.proposal_id if proposal is not None else ""
epistemic_status = proposal.epistemic_status.value if proposal is not None else ""
walk_serialised = self._serialize_walk(walk_result)
compose_serialised = self._serialize_compose(compose_result)
walk_serialised = CognitiveTurnPipeline._serialize_operator(walk_result)
compose_serialised = CognitiveTurnPipeline._serialize_operator(compose_result)
# Deterministic concatenation: walk record, then compose record.
# Empty strings are dropped so single-operator turns keep their
# existing trace_hash byte-for-byte.
@ -300,17 +318,19 @@ class CognitiveTurnPipeline:
else walk_serialised
)
# ADR-0023 — admissibility trace + ratification provenance.
admissibility_trace = getattr(response, "admissibility_trace", ()) or ()
region_was_unconstrained = getattr(
response, "region_was_unconstrained", True
)
# Comb pass 2026-05-21 — direct attribute access; the fields
# are dataclass-defaulted on ChatResponse, so the prior
# ``getattr`` guard was dead defensiveness from the ADR
# introduction window.
admissibility_trace = response.admissibility_trace
region_was_unconstrained = response.region_was_unconstrained
admissibility_trace_hash = hash_admissibility_trace(admissibility_trace)
ratification_outcome = ratified.outcome.value
# ADR-0024 Phase 2 — refusal_reason flows from a future
# materialisation site on ChatResponse. For Phase 2 it is
# absent on every non-refused turn; reading via getattr keeps
# the trace_hash byte-identical to pre-Phase-2 when no refusal
# was materialised (the empty string skips the payload fold).
# materialisation site on ChatResponse. Empty string on every
# non-refused turn; folding into trace_hash is gated on
# non-emptiness so non-refused turns keep byte-identical hashes
# relative to pre-Phase-2 (CLAUDE.md determinism invariant).
refusal_reason = getattr(response, "refusal_reason", "") or ""
trace_hash = compute_trace_hash(
input_text=text,
@ -374,8 +394,6 @@ class CognitiveTurnPipeline:
ratification short-circuits to PASSTHROUGH and the seed
survives the existing cold-start behavior is preserved.
"""
from generate.intent_ratifier import RatifiedIntent
if field_state is None:
return RatifiedIntent(
intent=intent,
@ -499,7 +517,11 @@ class CognitiveTurnPipeline:
proposal = self.teaching_store.add(reviewed)
return candidate, reviewed, proposal
def _maybe_transitive_walk(self, intent) -> WalkResult | None:
def _maybe_transitive_walk(
self,
intent,
triples: tuple[tuple[str, str, str], ...] | None = None,
) -> WalkResult | None:
"""Invoke a typed deterministic walk operator when the intent shape
calls for it (ADR-0018).
@ -513,8 +535,13 @@ class CognitiveTurnPipeline:
DEFINITION intents only attempt step 1 with the implicit "is"
relation; they do not fall back to a multi-relation walk
(which would be too permissive for plain "What is X?").
``triples`` may be passed in to avoid a second
``teaching_store.triples()`` materialization per turn (comb
pass 2026-05-21); when omitted, falls back to the live store.
"""
triples = self.teaching_store.triples()
if triples is None:
triples = self.teaching_store.triples()
if not triples:
return None
if intent.tag is IntentTag.TRANSITIVE_QUERY and intent.relation:
@ -531,17 +558,26 @@ class CognitiveTurnPipeline:
return result
return None
def _maybe_compose_relations(self, intent) -> FrameComposeResult | None:
def _maybe_compose_relations(
self,
intent,
triples: tuple[tuple[str, str, str], ...] | None = None,
) -> FrameComposeResult | None:
"""Invoke ``compose_relations`` when the intent is a frame-transfer
probe ("What does X R in Y?") and the teaching store carries at
least one R-edge. Returns the typed result; the caller folds
non-None tails into the surface.
``triples`` may be passed in to avoid a second
``teaching_store.triples()`` materialization per turn (comb
pass 2026-05-21).
"""
if intent.tag is not IntentTag.FRAME_TRANSFER:
return None
if not intent.relation or not intent.frame:
return None
triples = self.teaching_store.triples()
if triples is None:
triples = self.teaching_store.triples()
if not triples:
return None
return compose_relations(
@ -565,47 +601,22 @@ class CognitiveTurnPipeline:
)
return "; ".join(parts)
@staticmethod
def _fold_compose_into_surface(
compose: FrameComposeResult,
surface: str,
articulation_surface: str,
) -> tuple[str, str]:
"""Fold a frame-transfer composition into the surface.
# Comb pass 2026-05-21 — removed dead ``_fold_compose_into_surface``
# (no live callers since PR #76 routed all surface composition
# through the explicit ``resolve_surface`` policy). The render
# helper above is still consumed by the resolver path.
Names both tails so the lane checker sees the cross-instance
composed token regardless of which side the case author asserted
as the expected answer. Deterministic; identical inputs yield
identical output.
@staticmethod
def _serialize_operator(op: WalkResult | FrameComposeResult | None) -> str:
"""Deterministic operator-invocation serialisation for trace_hash.
Comb pass 2026-05-21 collapsed the parallel ``_serialize_walk`` /
``_serialize_compose`` helpers into one. Both operators expose
``as_dict()`` and serialise identically.
"""
compose_surface = CognitiveTurnPipeline._render_compose_surface(compose)
if not compose_surface:
return surface, articulation_surface
new_surface = (
f"{surface}{compose_surface}" if surface else compose_surface
)
new_articulation = (
f"{articulation_surface}{compose_surface}"
if articulation_surface
else compose_surface
)
return new_surface, new_articulation
@staticmethod
def _serialize_walk(walk: WalkResult | None) -> str:
"""Deterministic operator-invocation serialisation for trace_hash."""
if walk is None:
if op is None:
return ""
import json
return json.dumps(walk.as_dict(), sort_keys=True, ensure_ascii=False)
@staticmethod
def _serialize_compose(compose: FrameComposeResult | None) -> str:
"""Deterministic compose-invocation serialisation for trace_hash."""
if compose is None:
return ""
import json
return json.dumps(compose.as_dict(), sort_keys=True, ensure_ascii=False)
return json.dumps(op.as_dict(), sort_keys=True, ensure_ascii=False)
@staticmethod
def _render_walk_surface(walk: WalkResult) -> str:

View file

@ -108,11 +108,13 @@ def realize_semantic(
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 = _resolve_obj(step_a, graph)
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,
@ -128,7 +130,7 @@ def realize_semantic(
))
else:
for step in target.steps:
obj = _resolve_obj(step, graph)
obj = node_objs.get(step.node_id, "...")
surface = render_semantic(
intent=intent,
subject=step.subject,
@ -148,8 +150,27 @@ def realize_semantic(
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."""
"""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:
@ -181,6 +202,8 @@ def realize_target(
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] = []
@ -189,7 +212,7 @@ def realize_target(
continue
visited.add(step.node_id)
obj = _resolve_obj(step, graph)
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
@ -212,7 +235,7 @@ def realize_target(
match relation:
case Relation.CONJUNCTION | Relation.DISJUNCTION | Relation.COMPLEMENT | Relation.RELATIVE:
visited.add(target_id)
target_obj = _resolve_obj(target_step, graph)
target_obj = node_objs.get(target_step.node_id, "...")
target_surface = render_step(
move=RhetoricalMove.ASSERT,
subject=target_step.subject,

View file

@ -0,0 +1,133 @@
"""Hot-path comb pass (2026-05-21).
Regression tests for the five mechanical-sympathy fixes bundled in
the perf/comb-pass-hot-path PR:
1. ``ChatRuntime._apply_oov_policy`` consults precomputed booleans
instead of rescanning ``self._manifests`` per OOV token.
2. ``CognitiveTurnPipeline.run`` invokes ``classify_compound_intent``
once per turn and takes ``compound.primary`` as the seeded intent
(previously ``classify_intent`` ran twice per non-compound prompt).
3. ``TeachingStore.triples()`` materializes once per turn and is
threaded through both ``_maybe_transitive_walk`` and
``_maybe_compose_relations``.
5. ``realize_semantic`` / ``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 dead-code removal (item 11) and import hoists (item 9) are
covered indirectly by every existing test still passing.
"""
from __future__ import annotations
from chat.runtime import ChatRuntime
from core.cognition import CognitiveTurnPipeline
def test_oov_policy_aggregates_precomputed() -> None:
"""Aggregates exist as boolean attributes after construction."""
rt = ChatRuntime()
assert isinstance(rt._all_manifests_fail_closed, bool)
assert isinstance(rt._any_manifest_proposes_vocab, bool)
def test_classify_compound_intent_called_once_per_turn(monkeypatch) -> None:
"""``classify_intent`` must not run twice per turn.
Pre-fix: ``pipeline.run`` called ``classify_intent(text)`` directly
and then ``classify_compound_intent(text)`` immediately after.
The compound classifier internally invokes ``classify_intent`` on
the dominant fragment, so the cascade ran twice on every
non-compound prompt.
"""
import generate.intent as intent_mod
n_calls = {"compound": 0, "single": 0}
real_compound = intent_mod.classify_compound_intent
real_single = intent_mod.classify_intent
def counting_compound(prompt):
n_calls["compound"] += 1
return real_compound(prompt)
def counting_single(prompt):
n_calls["single"] += 1
return real_single(prompt)
# Patch both at the import site the pipeline uses.
import core.cognition.pipeline as pipeline_mod
monkeypatch.setattr(pipeline_mod, "classify_compound_intent", counting_compound)
monkeypatch.setattr(intent_mod, "classify_intent", counting_single)
pipeline = CognitiveTurnPipeline(runtime=ChatRuntime())
pipeline.run("What is truth?", max_tokens=4)
# Exactly one compound call from the pipeline, and the single
# classifier is only re-entered through the compound classifier
# itself (one re-entry on the dominant clause).
assert n_calls["compound"] == 1
assert n_calls["single"] == 1
def test_triples_materialized_once_per_turn(monkeypatch) -> None:
"""``TeachingStore.triples()`` runs at most once in the operator pair."""
pipeline = CognitiveTurnPipeline(runtime=ChatRuntime())
n_calls = {"triples": 0}
real = pipeline.teaching_store.triples
def counting():
n_calls["triples"] += 1
return real()
monkeypatch.setattr(pipeline.teaching_store, "triples", counting)
pipeline.run("What is truth?", max_tokens=4)
# The pipeline body calls .triples() once and passes the tuple to
# both operator helpers. Pre-fix this was 2 (one per helper).
assert n_calls["triples"] == 1
def test_realizer_node_map_o1_lookup() -> None:
"""The realizer builds a node_map so ``_resolve_obj`` is bypassed.
We don't measure timing — just confirm correctness over a multi-
step graph since the failure mode of a bad lookup is "..." in
place of the real object slot.
"""
from generate.graph_planner import (
ArticulationStep,
ArticulationTarget,
GraphNode,
PropositionGraph,
RhetoricalMove,
)
from generate.intent import IntentTag
from generate.realizer import realize_semantic
nodes = tuple(
GraphNode(
node_id=f"p{i}",
subject=f"subj{i}",
predicate="is_defined_as",
obj=f"obj{i}",
source_intent=IntentTag.DEFINITION,
)
for i in range(8)
)
graph = PropositionGraph(nodes=nodes)
steps = tuple(
ArticulationStep(
node_id=f"p{i}",
move=RhetoricalMove.ASSERT,
predicate="is_defined_as",
subject=f"subj{i}",
)
for i in range(8)
)
target = ArticulationTarget(steps=steps, source_intent=IntentTag.DEFINITION)
plan = realize_semantic(target, graph)
# Every step's real object slot appears in the joined surface — proves
# the per-step lookup found the right node.
for i in range(8):
assert f"obj{i}" in plan.surface