From fd4893183872c282f720ccbf176cdf3ccd2ab87c Mon Sep 17 00:00:00 2001 From: Shay Date: Wed, 20 May 2026 20:31:56 -0700 Subject: [PATCH] =?UTF-8?q?perf(cognition):=20hot-path=20comb=20pass=20?= =?UTF-8?q?=E2=80=94=205=20mechanical-sympathy=20fixes=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, "", 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. --- benchmarks/pipeline_profiler.py | 6 +- chat/runtime.py | 19 +++- core/cognition/pipeline.py | 151 +++++++++++++++++-------------- generate/realizer.py | 33 ++++++- tests/test_comb_pass_hot_path.py | 133 +++++++++++++++++++++++++++ 5 files changed, 262 insertions(+), 80 deletions(-) create mode 100644 tests/test_comb_pass_hot_path.py diff --git a/benchmarks/pipeline_profiler.py b/benchmarks/pipeline_profiler.py index 4eeda8ad..70298935 100644 --- a/benchmarks/pipeline_profiler.py +++ b/benchmarks/pipeline_profiler.py @@ -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 diff --git a/chat/runtime.py b/chat/runtime.py index 8957cb7c..0c6478ce 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -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 diff --git a/core/cognition/pipeline.py b/core/cognition/pipeline.py index a81c8d88..ac236546 100644 --- a/core/cognition/pipeline.py +++ b/core/cognition/pipeline.py @@ -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: diff --git a/generate/realizer.py b/generate/realizer.py index c1851e1a..aa6b8376 100644 --- a/generate/realizer.py +++ b/generate/realizer.py @@ -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, diff --git a/tests/test_comb_pass_hot_path.py b/tests/test_comb_pass_hot_path.py new file mode 100644 index 00000000..202329e1 --- /dev/null +++ b/tests/test_comb_pass_hot_path.py @@ -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