Files
bc0edb1827 feat(nodes)!: fold Knowledge Base Loader into the Configuration node (#149)
The Knowledge Base directory was entered in two places — the Configuration node's
knowledge_base path and the Knowledge Base Loader node's path — a duplication with
no benefit. Remove the Knowledge Base Loader node and have the Configuration node
load the Knowledge Base itself, emitting it on a new knowledge_base output wired
into the Resolver.

- New nodes/kb_loading.py holds the shared, process-wide load-once cache and
  package-relative path resolution (extracted from the deleted loader node).
- Configuration node: gains a knowledge_base_reload input and a KNOWLEDGE_BASE
  output; RETURN is now (config, knowledge_base, warnings, errors). A KB load
  failure surfaces on errors while the config is still emitted (the Analyzer and
  Validator do not need the KB).
- Remove SceneCompilerKnowledgeBaseLoader from the node registrations.
- Rebuild the example workflow to wire Configuration.knowledge_base -> Resolver.
- Docs (README, docs/nodes.md) updated; tests moved to test_kb_loading.py.

BREAKING CHANGE: the Knowledge Base Loader node is removed. Workflows that used it
must load the Knowledge Base via the Configuration node's knowledge_base output.

Co-authored-by: kunail0804 <kunail0804@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:05:53 +02:00

150 lines
5.4 KiB
Python

"""Performance tests and benchmark smoke test (issue #29, §26.6, NFR-004)."""
from __future__ import annotations
from pathlib import Path
import compiler.common.knowledge_base as kb_module
from scripts.benchmark import (
measure_full_pipeline,
measure_kb_load,
measure_resolution,
run_benchmarks,
)
from tests.regression.golden_scenes import (
SCENES,
compile_prompt,
load_reference_knowledge_base,
)
BASELINE_FILE = Path(__file__).resolve().parent.parent / "docs" / "benchmarks.md"
def test_benchmark_functions_return_positive_durations() -> None:
kb = load_reference_knowledge_base()
scene = SCENES["complex_multi_character"]
assert measure_kb_load(iterations=2) > 0
assert measure_resolution(kb, scene, iterations=5) > 0
assert measure_full_pipeline(kb, scene, iterations=5) > 0
def test_run_benchmarks_reports_all_metrics() -> None:
results = run_benchmarks()
assert set(results) == {"knowledge_base_load", "single_scene_resolution", "full_pipeline"}
assert all(value > 0 for value in results.values())
def test_full_pipeline_is_fast() -> None:
# Generous, non-flaky bound: the non-Analyzer pipeline is sub-millisecond in
# practice; 100 ms leaves ample margin under CI load.
kb = load_reference_knowledge_base()
scene = SCENES["complex_multi_character"]
assert measure_full_pipeline(kb, scene, iterations=20) < 0.1
def test_recorded_baseline_exists() -> None:
assert BASELINE_FILE.is_file()
assert "Performance baselines" in BASELINE_FILE.read_text(encoding="utf-8")
def test_performance_regression_guard() -> None:
"""Guard against gross perf regressions (issue #134, epic #38).
Uses generous ceilings and a relative ratio rather than machine-specific
absolute timings, so it is non-flaky under CI load but still fails on a
deliberate slowdown (e.g. reloading the Knowledge Base or rebuilding the
normalized index on every resolve).
"""
kb = load_reference_knowledge_base()
scene = SCENES["complex_multi_character"]
kb_load = measure_kb_load(iterations=3) # few iterations: a full load is costly
resolution = measure_resolution(kb, scene, iterations=50)
full_pipeline = measure_full_pipeline(kb, scene, iterations=50)
# Generous absolute ceilings (sub-millisecond in practice; ample headroom).
assert resolution < 0.05
assert full_pipeline < 0.1
# Relative guard: a single resolution and a full non-Analyzer pipeline must be
# a small fraction of a full Knowledge Base load. Disabling the KB cache or the
# compiled-index cache (reloading/rebuilding per resolve) would break this.
assert resolution < kb_load
assert full_pipeline < kb_load
def test_knowledge_base_loaded_once_and_reused(tmp_path, monkeypatch) -> None:
(tmp_path / "hair.json").write_text(
'[{"id": "long_hair", "tags": ["long hair"], "category": "hair"}]',
encoding="utf-8",
)
calls = {"n": 0}
real_load = kb_module.load_knowledge_base
def counting_load(*args, **kwargs):
calls["n"] += 1
return real_load(*args, **kwargs)
monkeypatch.setattr(kb_module, "load_knowledge_base", counting_load)
loader = kb_module.KnowledgeBaseLoader(tmp_path)
loaded = [loader.get() for _ in range(5)]
assert calls["n"] == 1 # loaded once
assert all(kb is loaded[0] for kb in loaded) # reused
def test_pipeline_does_not_reload_knowledge_base(monkeypatch) -> None:
kb = load_reference_knowledge_base()
calls = {"n": 0}
monkeypatch.setattr(
kb_module, "load_knowledge_base", lambda *a, **k: calls.__setitem__("n", calls["n"] + 1)
)
for scene in SCENES.values():
compile_prompt(scene, kb)
assert calls["n"] == 0 # compilation reuses the passed Knowledge Base
def test_kb_loading_reuses_cached_kb_across_runs(tmp_path, monkeypatch) -> None:
from nodes import kb_loading
(tmp_path / "hair.json").write_text(
'[{"id": "long_hair", "tags": ["long hair"], "category": "hair"}]', encoding="utf-8"
)
calls = {"n": 0}
real_load = kb_module.load_knowledge_base
def counting_load(*args, **kwargs):
calls["n"] += 1
return real_load(*args, **kwargs)
monkeypatch.setattr(kb_module, "load_knowledge_base", counting_load)
# Isolate the process-wide cache for this test.
monkeypatch.setattr(kb_loading, "_LOADERS", {})
monkeypatch.setattr(kb_loading, "_LAST_RELOAD", {})
kb1 = kb_loading.load_cached_knowledge_base(str(tmp_path))[0]
kb2 = kb_loading.load_cached_knowledge_base(str(tmp_path))[0]
assert kb1 is kb2 # same cached object reused across calls
assert calls["n"] == 1 # loaded once
kb3 = kb_loading.load_cached_knowledge_base(str(tmp_path), reload=1)[0] # forces a fresh read
assert calls["n"] == 2
assert kb3 is not kb1
def test_resolution_reuses_compiled_normalized_index(monkeypatch) -> None:
# The normalized lookup table is compiled once per KB and reused across
# resolves, not rebuilt on every resolve_scene (#131).
kb = load_reference_knowledge_base()
builds = {"n": 0}
real = type(kb).normalized_index
def counting(self, include_nsfw):
if include_nsfw not in self._normalized_index:
builds["n"] += 1
return real(self, include_nsfw)
monkeypatch.setattr(type(kb), "normalized_index", counting)
for scene in SCENES.values():
compile_prompt(scene, kb)
assert builds["n"] == 1 # built once for the general (non-NSFW) gating, then reused