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(). # functions actually called from pipeline.run().
from core.cognition import pipeline as pipeline_mod 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_graph_from_intent = pipeline_mod.graph_from_intent
orig_plan_articulation = pipeline_mod.plan_articulation orig_plan_articulation = pipeline_mod.plan_articulation
orig_realize_semantic = pipeline_mod.realize_semantic orig_realize_semantic = pipeline_mod.realize_semantic
@ -144,7 +144,7 @@ def profile_turn(
with _stage(sink, _STAGE_TEACHING): with _stage(sink, _STAGE_TEACHING):
return orig_run_teaching(*args, **kwargs) 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.graph_from_intent = timed_graph_from_intent
pipeline_mod.plan_articulation = timed_plan_articulation pipeline_mod.plan_articulation = timed_plan_articulation
pipeline_mod.realize_semantic = timed_realize_semantic pipeline_mod.realize_semantic = timed_realize_semantic
@ -160,7 +160,7 @@ def profile_turn(
finally: finally:
total_ns = time.perf_counter_ns() - t_total_0 total_ns = time.perf_counter_ns() - t_total_0
# Restore originals (instance and module). # 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.graph_from_intent = orig_graph_from_intent
pipeline_mod.plan_articulation = orig_plan_articulation pipeline_mod.plan_articulation = orig_plan_articulation
pipeline_mod.realize_semantic = orig_realize_semantic 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) manifold = manifolds[0] if len(pack_ids) == 1 else load_mounted_packs(pack_ids)
self._manifests = tuple(manifests) 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_pack_id = resolved_config.identity_pack or DEFAULT_IDENTITY_PACK
identity_manifold = load_identity_manifold(identity_pack_id) identity_manifold = load_identity_manifold(identity_pack_id)
self.safety_pack = load_safety_pack() self.safety_pack = load_safety_pack()
@ -661,15 +672,19 @@ class ChatRuntime:
return self._tokenize(text) return self._tokenize(text)
def _apply_oov_policy(self, tokens: list[str]) -> list[str]: 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] = [] kept: list[str] = []
for token in tokens: for token in tokens:
try: try:
self._context.vocab.get_versor(token) self._context.vocab.get_versor(token)
kept.append(token) kept.append(token)
except KeyError: except KeyError:
if all(manifest.oov_policy is OOVPolicy.FAIL_CLOSED for manifest in self._manifests): if self._all_manifests_fail_closed:
raise 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}") raise KeyError(f"OOV token requires vocab proposal: {token}")
kept.append(token) kept.append(token)
return kept return kept

View file

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

View file

@ -108,11 +108,13 @@ def realize_semantic(
intent = target.source_intent intent = target.source_intent
fragments: list[RealizedFragment] = [] 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: if intent is IntentTag.COMPARISON and len(target.steps) >= 2:
step_a = target.steps[0] step_a = target.steps[0]
step_b = target.steps[1] 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 secondary = step_b.subject if step_b.subject != step_a.subject else obj_a
surface = render_semantic( surface = render_semantic(
intent=intent, intent=intent,
@ -128,7 +130,7 @@ def realize_semantic(
)) ))
else: else:
for step in target.steps: for step in target.steps:
obj = _resolve_obj(step, graph) obj = node_objs.get(step.node_id, "...")
surface = render_semantic( surface = render_semantic(
intent=intent, intent=intent,
subject=step.subject, subject=step.subject,
@ -148,8 +150,27 @@ def realize_semantic(
return RealizedPlan(fragments=tuple(fragments), surface=joined) 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: 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: if graph is None:
return "..." return "..."
for node in graph.nodes: for node in graph.nodes:
@ -181,6 +202,8 @@ def realize_target(
edge_map[edge.source] = (edge.target, edge.relation) edge_map[edge.source] = (edge.target, edge.relation)
step_by_id = {step.node_id: step for step in target.steps} 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() visited: set[str] = set()
fragments: list[RealizedFragment] = [] fragments: list[RealizedFragment] = []
@ -189,7 +212,7 @@ def realize_target(
continue continue
visited.add(step.node_id) visited.add(step.node_id)
obj = _resolve_obj(step, graph) obj = node_objs.get(step.node_id, "...")
move = step.move move = step.move
if move is RhetoricalMove.ASSERT and target.source_intent is IntentTag.CORRECTION: if move is RhetoricalMove.ASSERT and target.source_intent is IntentTag.CORRECTION:
move = RhetoricalMove.CORRECT move = RhetoricalMove.CORRECT
@ -212,7 +235,7 @@ def realize_target(
match relation: match relation:
case Relation.CONJUNCTION | Relation.DISJUNCTION | Relation.COMPLEMENT | Relation.RELATIVE: case Relation.CONJUNCTION | Relation.DISJUNCTION | Relation.COMPLEMENT | Relation.RELATIVE:
visited.add(target_id) visited.add(target_id)
target_obj = _resolve_obj(target_step, graph) target_obj = node_objs.get(target_step.node_id, "...")
target_surface = render_step( target_surface = render_step(
move=RhetoricalMove.ASSERT, move=RhetoricalMove.ASSERT,
subject=target_step.subject, 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