core/benchmarks/pipeline_profiler.py
Shay fd48931838
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.
2026-05-20 20:31:56 -07:00

182 lines
7 KiB
Python

"""Pipeline-stage profiler for CognitiveTurnPipeline.
External instrumentation only — no edits to pipeline/runtime/algebra/vault
source files. Uses lightweight monkey-patching of bound methods on the
pipeline instance and the runtime instance for the duration of a single
``profile_turn`` call. All patches are reverted in a ``finally`` block so
the pipeline is left untouched.
Per CLAUDE.md: no hidden normalization, no semantic mutation, no algebra
hot-path touch. Overhead per stage: a single ``time.perf_counter_ns``
read on entry and on exit, and a list append. Stage label strings are
pre-interned at module load time (no f-strings inside timed regions).
"""
from __future__ import annotations
import time
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Any, Iterator
from core.cognition.pipeline import CognitiveTurnPipeline
from core.cognition.result import CognitiveTurnResult
# Pre-interned stage label constants — avoid string construction in
# the timed hot path.
_STAGE_INTENT = "intent"
_STAGE_GRAPH = "graph_planner"
_STAGE_REALIZE = "realize_semantic"
_STAGE_RUNTIME_CHAT = "runtime_chat"
_STAGE_TRANSITIVE_WALK = "maybe_transitive_walk"
_STAGE_FOLD_WALK = "fold_walk_into_surface"
_STAGE_TEACHING = "run_teaching"
_STAGE_TRACE = "trace_hash"
_STAGE_TOTAL = "total"
@dataclass(frozen=True)
class ProfileReport:
"""Immutable timing report for a single profiled turn."""
stages: dict[str, int]
total_ns: int
result: CognitiveTurnResult
def as_dict(self) -> dict[str, Any]:
return {
"stages": dict(self.stages),
"total_ns": int(self.total_ns),
}
@dataclass
class _ProfileSink:
"""Mutable per-call accumulator. Not shared across calls — instantiated
fresh in every ``profile_turn`` invocation, so no global state."""
stages: dict[str, int] = field(default_factory=dict)
def record(self, name: str, elapsed_ns: int) -> None:
# Multiple invocations of the same stage in a turn are summed.
prior = self.stages.get(name, 0)
self.stages[name] = prior + elapsed_ns
@contextmanager
def _stage(sink: _ProfileSink, name: str) -> Iterator[None]:
"""Lightweight context manager: two perf_counter_ns reads plus a dict update."""
t0 = time.perf_counter_ns()
try:
yield
finally:
sink.record(name, time.perf_counter_ns() - t0)
def profile_turn(
pipeline: CognitiveTurnPipeline,
text: str,
max_tokens: int | None = None,
) -> ProfileReport:
"""Profile one CognitiveTurnPipeline.run() invocation.
Wraps the pipeline's existing internal methods and the runtime's
``chat`` method with timing decorators for the duration of this call,
then restores them. Patches live on the *instances*, not on the
classes, so concurrent profiling of distinct pipeline instances is
safe.
"""
sink = _ProfileSink()
# Capture originals (instance attrs win over class attrs in resolution,
# so reassigning attrs on the instance does not mutate the class).
runtime = pipeline.runtime
orig_chat = runtime.chat
orig_maybe_walk = pipeline._maybe_transitive_walk
orig_fold = pipeline._fold_walk_into_surface
orig_run_teaching = pipeline._run_teaching
# We patch generate.intent / graph_planner / realizer via per-call
# module-attribute swaps on the pipeline module so we only time the
# functions actually called from pipeline.run().
from core.cognition import pipeline as pipeline_mod
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
orig_compute_trace_hash = pipeline_mod.compute_trace_hash
def timed_classify_intent(*args: Any, **kwargs: Any) -> Any:
with _stage(sink, _STAGE_INTENT):
return orig_classify_intent(*args, **kwargs)
def timed_graph_from_intent(*args: Any, **kwargs: Any) -> Any:
with _stage(sink, _STAGE_GRAPH):
return orig_graph_from_intent(*args, **kwargs)
def timed_plan_articulation(*args: Any, **kwargs: Any) -> Any:
with _stage(sink, _STAGE_GRAPH):
return orig_plan_articulation(*args, **kwargs)
def timed_realize_semantic(*args: Any, **kwargs: Any) -> Any:
with _stage(sink, _STAGE_REALIZE):
return orig_realize_semantic(*args, **kwargs)
def timed_compute_trace_hash(*args: Any, **kwargs: Any) -> Any:
with _stage(sink, _STAGE_TRACE):
return orig_compute_trace_hash(*args, **kwargs)
def timed_chat(*args: Any, **kwargs: Any) -> Any:
with _stage(sink, _STAGE_RUNTIME_CHAT):
return orig_chat(*args, **kwargs)
def timed_maybe_walk(*args: Any, **kwargs: Any) -> Any:
with _stage(sink, _STAGE_TRANSITIVE_WALK):
return orig_maybe_walk(*args, **kwargs)
def timed_fold(*args: Any, **kwargs: Any) -> Any:
with _stage(sink, _STAGE_FOLD_WALK):
return orig_fold(*args, **kwargs)
def timed_run_teaching(*args: Any, **kwargs: Any) -> Any:
with _stage(sink, _STAGE_TEACHING):
return orig_run_teaching(*args, **kwargs)
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
pipeline_mod.compute_trace_hash = timed_compute_trace_hash
runtime.chat = timed_chat # type: ignore[assignment]
pipeline._maybe_transitive_walk = timed_maybe_walk # type: ignore[assignment]
pipeline._fold_walk_into_surface = timed_fold # type: ignore[assignment]
pipeline._run_teaching = timed_run_teaching # type: ignore[assignment]
t_total_0 = time.perf_counter_ns()
try:
result = pipeline.run(text, max_tokens=max_tokens)
finally:
total_ns = time.perf_counter_ns() - t_total_0
# Restore originals (instance and module).
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
pipeline_mod.compute_trace_hash = orig_compute_trace_hash
runtime.chat = orig_chat # type: ignore[assignment]
try:
del pipeline._maybe_transitive_walk # restore class-bound method
except AttributeError:
pipeline._maybe_transitive_walk = orig_maybe_walk # type: ignore[assignment]
try:
del pipeline._fold_walk_into_surface
except AttributeError:
pipeline._fold_walk_into_surface = orig_fold # type: ignore[assignment]
try:
del pipeline._run_teaching
except AttributeError:
pipeline._run_teaching = orig_run_teaching # type: ignore[assignment]
return ProfileReport(stages=dict(sink.stages), total_ns=total_ns, result=result)