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:
parent
de3f40b549
commit
fd48931838
5 changed files with 262 additions and 80 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
133
tests/test_comb_pass_hot_path.py
Normal file
133
tests/test_comb_pass_hot_path.py
Normal 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
|
||||
Loading…
Reference in a new issue