core/tests/test_speculative_subject_lifecycle.py
Shay 4f9e00a6a5
fix(cognition): bound speculative-subject cache + evict on COHERENT promotion (#85)
Closes audit Finding 5 (2026-05-20).

Pre-fix ``CognitiveTurnPipeline._speculative_subjects`` was a bare
``set[str]`` that only grew over a session.  Two correctness gaps:

  * A subject promoted to ``EpistemicStatus.COHERENT`` via the teaching
    review loop kept appearing with the "(speculative, not yet
    reviewed)" marker forever, contaminating reviewed material on
    later probes.
  * Long teaching sessions widened the per-turn substring scan in
    ``_should_mark_speculative`` without bound.

Fix:

  * Back the cache with ``OrderedDict[str, None]`` (LRU) capped at
    ``_MAX_SPECULATIVE_SUBJECTS = 64``.
  * Introduce ``_remember_speculative_subject`` (insert / refresh) and
    ``_forget_speculative_subject`` (evict) helpers; route all
    SPECULATIVE inserts through them.
  * When a proposal lands as ``EpistemicStatus.COHERENT``, evict the
    subject and every long-enough non-stopword token derived from it,
    so the marker stops appearing on reviewed material.

Iteration order in ``_should_mark_speculative`` is unchanged (keys
view); lookups remain O(1).  No surface change for any case the prior
behavior didn't already mishandle, so byte-identical eval surfaces
stay stable (verified locally against ``core eval cognition`` public /
holdout / dev splits — all unchanged from MEMORY baseline).

Tests (7 new, ``tests/test_speculative_subject_lifecycle.py``):

  * storage is an OrderedDict and the cap is 64
  * remember normalizes (lower+strip) and drops empty input
  * remember refreshes LRU position on re-insert
  * cache caps at 64 with insertion-order eviction
  * forget is case-insensitive and removes the entry
  * forget on a missing / empty subject is a no-op
  * ``_should_mark_speculative`` triggers after remember and stops
    triggering after forget

Audit findings referenced:
https://github.com/AssetOverflow/core/pull/76 (Finding 5, "Unbounded
``_speculative_subjects``")
2026-05-20 19:59:21 -07:00

93 lines
3.6 KiB
Python

"""Speculative-subject cache lifecycle (Finding 5, audit 2026-05-20).
Pre-fix ``CognitiveTurnPipeline._speculative_subjects`` was an unbounded
``set[str]`` that only grew over a session. Two correctness gaps:
* A subject promoted to ``EpistemicStatus.COHERENT`` via the teaching
review loop kept appearing with the "(speculative, not yet reviewed)"
marker forever.
* Long teaching sessions widened the per-turn substring scan inside
``_should_mark_speculative`` without bound.
These tests pin the lifecycle so the fix cannot silently regress.
"""
from __future__ import annotations
from collections import OrderedDict
import pytest
from chat.runtime import ChatRuntime
from core.cognition import CognitiveTurnPipeline
from core.cognition.pipeline import _MAX_SPECULATIVE_SUBJECTS
@pytest.fixture()
def pipeline() -> CognitiveTurnPipeline:
return CognitiveTurnPipeline(runtime=ChatRuntime())
def test_storage_is_bounded_ordereddict(pipeline: CognitiveTurnPipeline) -> None:
assert isinstance(pipeline._speculative_subjects, OrderedDict)
assert _MAX_SPECULATIVE_SUBJECTS == 64
def test_remember_inserts_and_normalizes(pipeline: CognitiveTurnPipeline) -> None:
pipeline._remember_speculative_subject(" TRUTH ")
assert "truth" in pipeline._speculative_subjects
pipeline._remember_speculative_subject("")
pipeline._remember_speculative_subject(" ")
# Empty / whitespace inputs are silently dropped
assert list(pipeline._speculative_subjects) == ["truth"]
def test_remember_refreshes_lru_position(pipeline: CognitiveTurnPipeline) -> None:
pipeline._remember_speculative_subject("alpha")
pipeline._remember_speculative_subject("beta")
pipeline._remember_speculative_subject("alpha")
# alpha was re-touched after beta, so alpha is now the MRU entry
assert list(pipeline._speculative_subjects) == ["beta", "alpha"]
def test_cache_caps_at_max(pipeline: CognitiveTurnPipeline) -> None:
for i in range(_MAX_SPECULATIVE_SUBJECTS + 10):
pipeline._remember_speculative_subject(f"subj{i:03d}")
assert len(pipeline._speculative_subjects) == _MAX_SPECULATIVE_SUBJECTS
# Oldest 10 entries (subj000..subj009) evicted; newest survive
keys = list(pipeline._speculative_subjects)
assert "subj000" not in keys
assert "subj009" not in keys
assert "subj010" in keys
assert keys[-1] == f"subj{_MAX_SPECULATIVE_SUBJECTS + 9:03d}"
def test_forget_removes(pipeline: CognitiveTurnPipeline) -> None:
pipeline._remember_speculative_subject("truth")
pipeline._remember_speculative_subject("light")
pipeline._forget_speculative_subject("Truth") # case-insensitive
assert "truth" not in pipeline._speculative_subjects
assert "light" in pipeline._speculative_subjects
def test_forget_missing_is_noop(pipeline: CognitiveTurnPipeline) -> None:
pipeline._remember_speculative_subject("truth")
pipeline._forget_speculative_subject("never-added")
pipeline._forget_speculative_subject("")
assert list(pipeline._speculative_subjects) == ["truth"]
def test_should_mark_speculative_still_iterates_correctly(
pipeline: CognitiveTurnPipeline,
) -> None:
"""The marker decision still triggers on stored subjects."""
pipeline._remember_speculative_subject("wisdom")
assert pipeline._should_mark_speculative(
text="Tell me about wisdom",
surface="Wisdom is the application of knowledge.",
)
pipeline._forget_speculative_subject("wisdom")
assert not pipeline._should_mark_speculative(
text="Tell me about wisdom",
surface="Wisdom is the application of knowledge.",
)