feat: add multi-fidelity compression engine

5-level fidelity manager (L0-Full to L4-Evicted) with helper LLM
(Haiku 4.5) for intelligent summarization during degradation.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Joey Yakimowich-Payne 2026-03-13 11:40:56 -06:00
commit d26c56c2f0
5 changed files with 3003 additions and 0 deletions

615
src/mnemosyne/fidelity.py Normal file
View file

@ -0,0 +1,615 @@
"""Multi-fidelity state machine for semantic objects.
Manages the fidelity ladder (L0-L4) for each semantic object in a session.
Pressure zones drive degradation; access patterns drive upgrades; faults
drive pinning. This is the core scheduling logic it decides what stays
in context at what resolution.
Fidelity levels:
L0: Full content, no compression
L1: Detailed summary, ~30% of original size, with declared losses
L2: Compact summary, ~5% of original size, with accumulated losses
L3: Metadata stub, ~50-100 tokens, one-line description
L4: Evicted, not in context at all
Pressure zones (configurable thresholds):
Normal < 50% no action
Caution 50-70% degrade oldest L0 L1
Warning 70-85% degrade L0L1, L1L2, oldest L2L3
Critical 85-95% aggressive L2+L3, evict L3L4
Emergency >95% force-evict everything except last 2 user turns + system prompt
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from enum import IntEnum
class FidelityLevel(IntEnum):
"""Fidelity levels for semantic objects, ordered from highest to lowest."""
L0 = 0 # Full content, no compression
L1 = 1 # Detailed summary, ~30% of original size
L2 = 2 # Compact summary, ~5% of original size
L3 = 3 # Metadata stub, ~50-100 tokens
L4 = 4 # Evicted, not in context
class PressureZone(IntEnum):
"""Context window pressure zones, ordered by severity."""
NORMAL = 0 # < 50%
CAUTION = 1 # 50-70%
WARNING = 2 # 70-85%
CRITICAL = 3 # 85-95%
EMERGENCY = 4 # > 95%
# Valid object types per ARCHITECTURE.md §5.1
VALID_OBJECT_TYPES = frozenset(
{
"conversation_phase",
"design_decision",
"debugging_session",
"file_context",
"tool_result",
"plan",
"error_context",
"external_reference",
}
)
@dataclass
class SemanticObject:
"""A tracked semantic object with multi-fidelity representations.
Each object carries its full content (L0) and optional compressed
representations (L1-L3). The fidelity manager decides which level
is currently active in the context window.
"""
id: str
object_type: str
# Content at each fidelity level
content_full: str # L0
summary_detailed: str | None = None # L1
summary_compact: str | None = None # L2
stub: str | None = None # L3
# Declared losses — what information was dropped at each level
losses_l1: list[str] = field(default_factory=list)
losses_l2: list[str] = field(default_factory=list)
# Queryability metadata
can_answer: list[str] = field(default_factory=list)
fault_when: list[str] = field(default_factory=list)
key_entities: list[str] = field(default_factory=list)
# State
current_fidelity: FidelityLevel = FidelityLevel.L0
pinned: bool = False
pin_until_turn: int | None = None
created_at_turn: int = 0
last_accessed_turn: int = 0
fault_count: int = 0
# Token estimates (len(content) / 4 heuristic)
token_count_l0: int = 0
token_count_l1: int | None = None
token_count_l2: int | None = None
token_count_l3: int = 25 # Stubs are typically ~25 tokens
def tokens_at(self, level: FidelityLevel) -> int:
"""Return estimated token count at the given fidelity level."""
if level == FidelityLevel.L0:
return self.token_count_l0
if level == FidelityLevel.L1:
return self.token_count_l1 if self.token_count_l1 is not None else 0
if level == FidelityLevel.L2:
return self.token_count_l2 if self.token_count_l2 is not None else 0
if level == FidelityLevel.L3:
return self.token_count_l3
# L4: evicted, zero tokens in context
return 0
@property
def current_tokens(self) -> int:
"""Token count at the object's current fidelity level."""
return self.tokens_at(self.current_fidelity)
def _estimate_tokens(text: str | None) -> int:
"""Estimate token count using ~4 chars per token heuristic."""
if text is None:
return 0
return max(1, len(text) // 4)
def make_object(
*,
object_type: str,
content_full: str,
created_at_turn: int = 0,
summary_detailed: str | None = None,
summary_compact: str | None = None,
stub: str | None = None,
losses_l1: list[str] | None = None,
losses_l2: list[str] | None = None,
can_answer: list[str] | None = None,
fault_when: list[str] | None = None,
key_entities: list[str] | None = None,
) -> SemanticObject:
"""Factory for creating a SemanticObject with auto-computed token estimates.
Preferred over constructing SemanticObject directly handles ID
generation and token estimation.
"""
obj = SemanticObject(
id=uuid.uuid4().hex[:16],
object_type=object_type,
content_full=content_full,
summary_detailed=summary_detailed,
summary_compact=summary_compact,
stub=stub,
losses_l1=losses_l1 or [],
losses_l2=losses_l2 or [],
can_answer=can_answer or [],
fault_when=fault_when or [],
key_entities=key_entities or [],
current_fidelity=FidelityLevel.L0,
created_at_turn=created_at_turn,
last_accessed_turn=created_at_turn,
token_count_l0=_estimate_tokens(content_full),
token_count_l1=_estimate_tokens(summary_detailed) if summary_detailed else None,
token_count_l2=_estimate_tokens(summary_compact) if summary_compact else None,
token_count_l3=_estimate_tokens(stub) if stub else 25,
)
return obj
class FidelityManager:
"""Manages fidelity levels for all semantic objects in a session.
Core responsibilities:
- Register new objects (start at L0)
- Degrade objects under pressure (oldest-first)
- Upgrade objects on access
- Pin objects after faults
- Track token budget
"""
def __init__(self, window_size: int = 200_000) -> None:
"""Initialize the fidelity manager.
Args:
window_size: Total context window size in tokens. Pressure
zones are computed as percentages of this value.
"""
self.window_size = window_size
self._objects: dict[str, SemanticObject] = {}
# Pressure zone thresholds (fraction of window_size)
self.threshold_caution = 0.50
self.threshold_warning = 0.70
self.threshold_critical = 0.85
self.threshold_emergency = 0.95
def register_object(self, obj: SemanticObject) -> str:
"""Register a new semantic object. Returns its ID.
New objects always start at L0 (full content). Token estimates
are recomputed if they look unset.
"""
# Ensure token estimates are populated
if obj.token_count_l0 == 0 and obj.content_full:
obj.token_count_l0 = _estimate_tokens(obj.content_full)
if obj.token_count_l1 is None and obj.summary_detailed:
obj.token_count_l1 = _estimate_tokens(obj.summary_detailed)
if obj.token_count_l2 is None and obj.summary_compact:
obj.token_count_l2 = _estimate_tokens(obj.summary_compact)
if obj.stub and obj.token_count_l3 == 25:
obj.token_count_l3 = _estimate_tokens(obj.stub)
self._objects[obj.id] = obj
return obj.id
def get_object(self, object_id: str) -> SemanticObject | None:
"""Retrieve a semantic object by ID, or None if not found."""
return self._objects.get(object_id)
def total_tokens(self) -> int:
"""Sum of tokens across all objects at their current fidelity level."""
return sum(obj.current_tokens for obj in self._objects.values())
def current_pressure(self) -> PressureZone:
"""Calculate the current pressure zone from total token usage.
Returns the pressure zone enum value based on the ratio of
total tokens to window size.
"""
if self.window_size <= 0:
return PressureZone.EMERGENCY
ratio = self.total_tokens() / self.window_size
if ratio >= self.threshold_emergency:
return PressureZone.EMERGENCY
if ratio >= self.threshold_critical:
return PressureZone.CRITICAL
if ratio >= self.threshold_warning:
return PressureZone.WARNING
if ratio >= self.threshold_caution:
return PressureZone.CAUTION
return PressureZone.NORMAL
def _sorted_by_age(self, objects: list[SemanticObject]) -> list[SemanticObject]:
"""Sort objects oldest-first by last_accessed_turn, then created_at_turn."""
return sorted(objects, key=lambda o: (o.last_accessed_turn, o.created_at_turn))
def _degrade_one(self, obj: SemanticObject) -> FidelityLevel | None:
"""Degrade an object by one fidelity level. Returns new level, or None if already L4."""
old = obj.current_fidelity
if old >= FidelityLevel.L4:
return None
# Check if the next level has content available
new = FidelityLevel(old + 1)
# For L0→L1: need summary_detailed (or degrade anyway — content just won't be shown)
# For L1→L2: need summary_compact
# For L2→L3: need stub
# For L3→L4: always possible (eviction)
obj.current_fidelity = new
return new
def degrade(self, current_turn: int) -> list[tuple[str, FidelityLevel, FidelityLevel]]:
"""Degrade objects based on current pressure zone.
Walks objects oldest-first and degrades based on the pressure zone:
- Normal: no action
- Caution: degrade oldest L0 L1
- Warning: degrade L0L1, L1L2, oldest L2L3
- Critical: aggressive L2+L3, evict L3L4
- Emergency: force-evict everything except pinned objects
Args:
current_turn: Current conversation turn number.
Returns:
List of (object_id, old_level, new_level) for each degradation.
"""
transitions: list[tuple[str, FidelityLevel, FidelityLevel]] = []
zone = self.current_pressure()
if zone == PressureZone.NORMAL:
return transitions
# Expire stale pins
self._expire_pins(current_turn)
if zone == PressureZone.CAUTION:
transitions.extend(self._degrade_caution(current_turn))
elif zone == PressureZone.WARNING:
transitions.extend(self._degrade_warning(current_turn))
elif zone == PressureZone.CRITICAL:
transitions.extend(self._degrade_critical(current_turn))
elif zone == PressureZone.EMERGENCY:
transitions.extend(self._degrade_emergency(current_turn))
return transitions
def _expire_pins(self, current_turn: int) -> None:
"""Unpin objects whose pin duration has expired."""
for obj in self._objects.values():
if obj.pinned and obj.pin_until_turn is not None:
if current_turn >= obj.pin_until_turn:
obj.pinned = False
obj.pin_until_turn = None
def _degradable(self, obj: SemanticObject) -> bool:
"""Check if an object can be degraded (not pinned, not already L4)."""
return not obj.pinned and obj.current_fidelity < FidelityLevel.L4
def _degrade_caution(
self,
current_turn: int,
) -> list[tuple[str, FidelityLevel, FidelityLevel]]:
"""Caution zone: degrade oldest L0 objects to L1."""
transitions: list[tuple[str, FidelityLevel, FidelityLevel]] = []
candidates = [
o
for o in self._objects.values()
if o.current_fidelity == FidelityLevel.L0 and self._degradable(o)
]
candidates = self._sorted_by_age(candidates)
for obj in candidates:
old = obj.current_fidelity
new = self._degrade_one(obj)
if new is not None:
transitions.append((obj.id, old, new))
# Re-check pressure after each degradation
if self.current_pressure() <= PressureZone.NORMAL:
break
return transitions
def _degrade_warning(
self,
current_turn: int,
) -> list[tuple[str, FidelityLevel, FidelityLevel]]:
"""Warning zone: degrade L0→L1, L1→L2, oldest L2→L3."""
transitions: list[tuple[str, FidelityLevel, FidelityLevel]] = []
# First pass: L0 → L1
l0_objs = self._sorted_by_age(
[
o
for o in self._objects.values()
if o.current_fidelity == FidelityLevel.L0 and self._degradable(o)
]
)
for obj in l0_objs:
old = obj.current_fidelity
new = self._degrade_one(obj)
if new is not None:
transitions.append((obj.id, old, new))
if self.current_pressure() <= PressureZone.NORMAL:
return transitions
# Second pass: L1 → L2
l1_objs = self._sorted_by_age(
[
o
for o in self._objects.values()
if o.current_fidelity == FidelityLevel.L1 and self._degradable(o)
]
)
for obj in l1_objs:
old = obj.current_fidelity
new = self._degrade_one(obj)
if new is not None:
transitions.append((obj.id, old, new))
if self.current_pressure() <= PressureZone.NORMAL:
return transitions
# Third pass: oldest L2 → L3
l2_objs = self._sorted_by_age(
[
o
for o in self._objects.values()
if o.current_fidelity == FidelityLevel.L2 and self._degradable(o)
]
)
for obj in l2_objs:
old = obj.current_fidelity
new = self._degrade_one(obj)
if new is not None:
transitions.append((obj.id, old, new))
if self.current_pressure() <= PressureZone.NORMAL:
return transitions
return transitions
def _degrade_critical(
self,
current_turn: int,
) -> list[tuple[str, FidelityLevel, FidelityLevel]]:
"""Critical zone: aggressive L2+→L3, evict L3→L4."""
transitions: list[tuple[str, FidelityLevel, FidelityLevel]] = []
# First: degrade everything L0/L1/L2 down aggressively
for target_level in (FidelityLevel.L0, FidelityLevel.L1, FidelityLevel.L2):
candidates = self._sorted_by_age(
[
o
for o in self._objects.values()
if o.current_fidelity == target_level and self._degradable(o)
]
)
for obj in candidates:
# Degrade all the way to L3
while obj.current_fidelity < FidelityLevel.L3 and self._degradable(obj):
old = obj.current_fidelity
new = self._degrade_one(obj)
if new is not None:
transitions.append((obj.id, old, new))
if self.current_pressure() <= PressureZone.NORMAL:
return transitions
# Then: evict L3 → L4
l3_objs = self._sorted_by_age(
[
o
for o in self._objects.values()
if o.current_fidelity == FidelityLevel.L3 and self._degradable(o)
]
)
for obj in l3_objs:
old = obj.current_fidelity
new = self._degrade_one(obj)
if new is not None:
transitions.append((obj.id, old, new))
if self.current_pressure() <= PressureZone.NORMAL:
return transitions
return transitions
def _degrade_emergency(
self,
current_turn: int,
) -> list[tuple[str, FidelityLevel, FidelityLevel]]:
"""Emergency zone: force-evict everything except pinned objects.
In a real system, "last 2 user turns + system prompt" would be
handled by the context assembler. Here we evict all non-pinned
objects to L4.
"""
transitions: list[tuple[str, FidelityLevel, FidelityLevel]] = []
all_objs = self._sorted_by_age(
[
o
for o in self._objects.values()
if o.current_fidelity < FidelityLevel.L4 and self._degradable(o)
]
)
for obj in all_objs:
while obj.current_fidelity < FidelityLevel.L4:
old = obj.current_fidelity
new = self._degrade_one(obj)
if new is not None:
transitions.append((obj.id, old, new))
return transitions
def upgrade(
self,
object_id: str,
target: FidelityLevel,
current_turn: int,
) -> bool:
"""Upgrade an object's fidelity level (e.g., on model access).
Can only upgrade to a higher fidelity (lower numeric level).
Updates last_accessed_turn as a side effect.
Args:
object_id: The object to upgrade.
target: The target fidelity level (must be < current level).
current_turn: Current conversation turn.
Returns:
True if the upgrade was performed, False otherwise.
"""
obj = self._objects.get(object_id)
if obj is None:
return False
if target >= obj.current_fidelity:
return False # Not an upgrade
# Check that the target level has content available
if target == FidelityLevel.L0 and not obj.content_full:
return False
if target == FidelityLevel.L1 and not obj.summary_detailed:
return False
if target == FidelityLevel.L2 and not obj.summary_compact:
return False
obj.current_fidelity = target
obj.last_accessed_turn = current_turn
return True
def record_fault(
self,
object_id: str,
current_turn: int,
pin_duration: int = 5,
) -> None:
"""Record a fault for an object and pin it at current fidelity.
After a fault (model needed content that was degraded), pin the
object so it won't be degraded again for `pin_duration` turns.
Args:
object_id: The faulted object.
current_turn: Current conversation turn.
pin_duration: Number of turns to pin the object.
"""
obj = self._objects.get(object_id)
if obj is None:
return
obj.fault_count += 1
obj.pinned = True
obj.pin_until_turn = current_turn + pin_duration
obj.last_accessed_turn = current_turn
def mark_accessed(self, object_id: str, current_turn: int) -> None:
"""Update an object's last_accessed_turn.
Called when the model references an object, even without
upgrading its fidelity level.
Args:
object_id: The accessed object.
current_turn: Current conversation turn.
"""
obj = self._objects.get(object_id)
if obj is None:
return
obj.last_accessed_turn = current_turn
def eviction_candidates(self, current_turn: int) -> list[SemanticObject]:
"""Return objects sorted by eviction priority (most evictable first).
Priority order:
1. Not pinned before pinned
2. Lower fidelity (closer to eviction) before higher
3. Older last_accessed_turn before newer
4. Older created_at_turn before newer
Only includes objects not already at L4.
Args:
current_turn: Current conversation turn (used for pin expiry check).
Returns:
Objects sorted most-evictable-first.
"""
self._expire_pins(current_turn)
candidates = [o for o in self._objects.values() if o.current_fidelity < FidelityLevel.L4]
# Sort: not-pinned first, then by fidelity (higher numeric = closer to eviction),
# then oldest-accessed first
return sorted(
candidates,
key=lambda o: (
o.pinned, # False (0) before True (1) — not-pinned first
-o.current_fidelity, # Higher fidelity number = closer to eviction
o.last_accessed_turn, # Older access = more evictable
o.created_at_turn, # Older creation = more evictable
),
)
def objects_at_fidelity(self, level: FidelityLevel) -> list[SemanticObject]:
"""Return all objects currently at the given fidelity level.
Args:
level: The fidelity level to filter by.
Returns:
List of objects at that level.
"""
return [o for o in self._objects.values() if o.current_fidelity == level]
def summary(self) -> dict:
"""Current state summary for telemetry and debugging.
Returns:
Dict with object counts per fidelity level, total tokens,
pressure zone, pinned count, and fault count.
"""
by_level = {}
for level in FidelityLevel:
objs = self.objects_at_fidelity(level)
by_level[level.name] = len(objs)
total_faults = sum(o.fault_count for o in self._objects.values())
pinned_count = sum(1 for o in self._objects.values() if o.pinned)
return {
"total_objects": len(self._objects),
"total_tokens": self.total_tokens(),
"window_size": self.window_size,
"pressure_zone": self.current_pressure().name,
"objects_by_level": by_level,
"pinned_count": pinned_count,
"total_faults": total_faults,
}

442
src/mnemosyne/helper_llm.py Normal file
View file

@ -0,0 +1,442 @@
"""Helper LLM client for multi-fidelity compression and micro-fault QA.
Wraps the Anthropic API (Haiku) for cheap, fast operations:
- Summarization with declared losses (L0L1, L1L2, L2L3)
- Micro-fault question-answering (query evicted content without page-in)
- Goal classification for context-aware retrieval
Cost per session (~200 turns): ~$0.006 total helper spend.
See ARCHITECTURE.md §6 and §11.3 for full specification.
"""
from __future__ import annotations
import json
import logging
import os
from dataclasses import dataclass, field
import anthropic
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class SummaryResult:
"""Result of a summarization call at any fidelity level."""
summary: str
losses: list[str] = field(default_factory=list)
can_answer: list[str] = field(default_factory=list)
key_entities: list[str] = field(default_factory=list)
@dataclass
class GoalClassification:
"""Result of classifying the user's current goal."""
goal: str
relevant_types: list[str] = field(default_factory=list)
relevant_tags: list[str] = field(default_factory=list)
predicted_needs: list[str] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Prompt templates
# ---------------------------------------------------------------------------
_L0_TO_L1_PROMPT = """\
You are a context compression engine for a coding agent. Summarize the following \
content while preserving maximum utility for future reference.
CONTENT TYPE: {object_type}
CONTENT:
{content}
INSTRUCTIONS:
1. Write a detailed summary (~30% of original length)
2. MUST preserve: file paths, function names, variable names, library names, \
error messages, decision rationale, specific values that may be referenced later
3. List DECLARED LOSSES: specific information you omitted that someone might need. \
Be precise -- "specific error codes" not "some details"
4. List CAN_ANSWER: categories of questions this summary can answer without \
needing the original content
OUTPUT FORMAT (JSON):
{{
"summary": "...",
"losses": ["exact error code for token expiry", ...],
"can_answer": ["auth approach used", ...],
"key_entities": ["src/auth/middleware.ts", ...]
}}"""
_L1_TO_L2_PROMPT = """\
You are a context compression engine. Compress the following L1 summary into a \
compact L2 summary (~5% of the original content length).
CONTENT TYPE: {object_type}
L1 SUMMARY:
{l1_summary}
KNOWN LOSSES FROM L1:
{l1_losses}
INSTRUCTIONS:
1. Write a compact summary preserving only: what was done, what was decided, key files
2. List ADDITIONAL DECLARED LOSSES beyond those already declared in L1
3. List CAN_ANSWER: what questions this compact summary can still answer
OUTPUT FORMAT (JSON):
{{
"summary": "...",
"losses": ["additional loss 1", ...],
"can_answer": ["what was decided", ...],
"key_entities": ["key/file.ts", ...]
}}"""
_L2_TO_L3_PROMPT = """\
Generate a one-line metadata stub for this content.
CONTENT TYPE: {object_type}
TIMESTAMP: {timestamp}
L2 SUMMARY:
{l2_summary}
Return ONLY a single line in this exact format (no JSON, no markdown):
[{object_type} | {timestamp} | <one-line description> | <N> related objects]"""
_MICRO_FAULT_PROMPT = """\
Answer the following question using ONLY the provided context. \
Be concise and precise (50-200 tokens). If the context doesn't contain \
the answer, say so explicitly.
QUESTION: {question}
CONTEXT:
{context}
ANSWER:"""
_GOAL_CLASSIFICATION_PROMPT = """\
Given the user's message and recent conversation context, classify the user's \
current goal and predict what context would be most relevant.
USER MESSAGE: {user_message}
RECENT CONTEXT:
{recent_context}
Return JSON:
{{
"goal": "one-sentence description of current goal",
"relevant_types": ["conversation_phase", "file_context", ...],
"relevant_tags": ["auth", "testing", ...],
"predicted_needs": ["auth middleware implementation", "test patterns", ...]
}}"""
# ---------------------------------------------------------------------------
# Helper LLM client
# ---------------------------------------------------------------------------
class HelperLLM:
"""Async client for cheap helper model (Haiku) operations.
Handles summarization, declared loss generation, micro-fault QA,
and goal classification. All methods return structured results
with graceful fallbacks on error.
"""
def __init__(
self,
api_key: str | None = None,
model: str = "claude-haiku-4-5-20251001",
base_url: str = "https://api.anthropic.com",
) -> None:
"""Initialize the helper LLM client.
Args:
api_key: Anthropic API key. Falls back to ANTHROPIC_API_KEY env var.
model: Model identifier for helper calls.
base_url: Anthropic API base URL.
"""
resolved_key = api_key or os.environ.get("ANTHROPIC_API_KEY") or ""
auth_token = os.environ.get("ANTHROPIC_AUTH_TOKEN") or ""
# OAuth: use auth_token (Bearer) when available. The SDK does NOT
# add the required beta header automatically — we must pass it.
# Falls back to api_key when no OAuth token is available.
if auth_token and not resolved_key:
self._client = anthropic.AsyncAnthropic(
auth_token=auth_token,
base_url=base_url,
timeout=10.0,
max_retries=2,
default_headers={
"anthropic-beta": "oauth-2025-04-20",
"user-agent": "claude-cli/2.1.2 (external, cli)",
},
)
else:
self._client = anthropic.AsyncAnthropic(
api_key=resolved_key,
base_url=base_url,
timeout=10.0,
max_retries=2,
)
self._model = model
async def summarize_l0_to_l1(
self,
content: str,
object_type: str,
max_summary_tokens: int = 1024,
) -> SummaryResult:
"""Summarize full content (L0) into a detailed summary (L1).
Produces a ~30% compression with declared losses and entity
extraction. See ARCHITECTURE.md §6.1 for the prompt template.
Args:
content: Full L0 content to summarize.
object_type: Semantic object type (e.g. 'file_context').
max_summary_tokens: Max tokens for the response.
Returns:
SummaryResult with summary, losses, can_answer, key_entities.
"""
prompt = _L0_TO_L1_PROMPT.format(
object_type=object_type,
content=content,
)
raw = await self._call(prompt, max_tokens=max_summary_tokens)
result = self._parse_summary_json(raw)
if result is not None:
return result
# Fallback: first ~30% of content as summary
logger.warning("L0→L1 JSON parse failed, using fallback summary")
cutoff = max(1, len(content) * 30 // 100)
return SummaryResult(summary=content[:cutoff])
async def compress_l1_to_l2(
self,
l1_summary: str,
l1_losses: list[str],
object_type: str,
max_tokens: int = 256,
) -> SummaryResult:
"""Compress an L1 summary into a compact L2 summary (~5% of original).
Accumulates losses from L1 into the L2 result.
Args:
l1_summary: The L1 detailed summary.
l1_losses: Declared losses from the L1 level.
object_type: Semantic object type.
max_tokens: Max tokens for the response.
Returns:
SummaryResult with accumulated losses from both L1 and L2.
"""
losses_text = "\n".join(f"- {loss}" for loss in l1_losses) if l1_losses else "(none)"
prompt = _L1_TO_L2_PROMPT.format(
object_type=object_type,
l1_summary=l1_summary,
l1_losses=losses_text,
)
raw = await self._call(prompt, max_tokens=max_tokens)
result = self._parse_summary_json(raw)
if result is not None:
# Accumulate L1 losses into L2
result.losses = l1_losses + result.losses
return result
logger.warning("L1→L2 JSON parse failed, using fallback summary")
cutoff = max(1, len(l1_summary) * 30 // 100)
return SummaryResult(summary=l1_summary[:cutoff], losses=list(l1_losses))
async def generate_stub(
self,
l2_summary: str,
object_type: str,
timestamp: str,
) -> str:
"""Generate a one-line L3 metadata stub from an L2 summary.
Format: [{type} | {timestamp} | {description} | {n} related objects]
Args:
l2_summary: The L2 compact summary.
object_type: Semantic object type.
timestamp: ISO-ish timestamp string.
Returns:
A single-line stub string.
"""
prompt = _L2_TO_L3_PROMPT.format(
object_type=object_type,
timestamp=timestamp,
l2_summary=l2_summary,
)
raw = await self._call(prompt, max_tokens=100)
# Take only the first line, strip whitespace
stub = raw.strip().split("\n")[0].strip()
if not stub:
stub = f"[{object_type} | {timestamp} | (summary unavailable) | 0 related objects]"
return stub
async def answer_micro_fault(
self,
question: str,
relevant_contents: list[str],
max_tokens: int = 200,
) -> str:
"""Answer a question using retrieved full-content from the backing store.
Used by the memory_query phantom tool to avoid full page-in.
Returns a targeted 50-200 token answer.
Args:
question: The question to answer.
relevant_contents: List of full-content strings from backing store.
max_tokens: Max tokens for the answer.
Returns:
Concise answer string.
"""
context = "\n\n---\n\n".join(relevant_contents)
prompt = _MICRO_FAULT_PROMPT.format(
question=question,
context=context,
)
raw = await self._call(prompt, max_tokens=max_tokens)
return raw.strip() if raw.strip() else "Unable to answer from available context."
async def classify_goal(
self,
user_message: str,
recent_context: str,
) -> GoalClassification:
"""Classify the user's current goal for context-aware retrieval.
Takes the current user message and last ~2 turns of context.
Returns structured goal classification for the Context Assembler.
Args:
user_message: The current user message.
recent_context: Last 2 turns of conversation context.
Returns:
GoalClassification with goal, relevant_types, tags, predicted_needs.
"""
prompt = _GOAL_CLASSIFICATION_PROMPT.format(
user_message=user_message,
recent_context=recent_context,
)
raw = await self._call(prompt, max_tokens=200)
result = self._parse_goal_json(raw)
if result is not None:
return result
logger.warning("Goal classification JSON parse failed, using fallback")
return GoalClassification(goal=user_message[:200])
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
async def _call(self, prompt: str, max_tokens: int) -> str:
"""Make a single Haiku API call. Returns raw text or empty on error."""
try:
response = await self._client.messages.create(
model=self._model,
max_tokens=max_tokens,
messages=[{"role": "user", "content": prompt}],
)
# Extract text from content blocks
parts: list[str] = []
for block in response.content:
if block.type == "text":
parts.append(block.text)
return "\n".join(parts)
except anthropic.APITimeoutError:
logger.warning("Helper LLM call timed out")
return ""
except anthropic.APIError as exc:
logger.warning("Helper LLM API error: %s", exc)
return ""
@staticmethod
def _parse_summary_json(raw: str) -> SummaryResult | None:
"""Try to parse a SummaryResult from raw LLM output.
Handles JSON embedded in markdown code fences or bare JSON.
Returns None on parse failure.
"""
text = raw.strip()
if not text:
return None
# Strip markdown code fences if present
if text.startswith("```"):
lines = text.split("\n")
# Remove first and last fence lines
lines = [ln for ln in lines if not ln.strip().startswith("```")]
text = "\n".join(lines).strip()
try:
data = json.loads(text)
except json.JSONDecodeError:
# Try to find JSON object in the text
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
try:
data = json.loads(text[start : end + 1])
except json.JSONDecodeError:
return None
else:
return None
if not isinstance(data, dict):
return None
return SummaryResult(
summary=data.get("summary", ""),
losses=data.get("losses", []),
can_answer=data.get("can_answer", []),
key_entities=data.get("key_entities", []),
)
@staticmethod
def _parse_goal_json(raw: str) -> GoalClassification | None:
"""Try to parse a GoalClassification from raw LLM output.
Returns None on parse failure.
"""
text = raw.strip()
if not text:
return None
if text.startswith("```"):
lines = text.split("\n")
lines = [ln for ln in lines if not ln.strip().startswith("```")]
text = "\n".join(lines).strip()
try:
data = json.loads(text)
except json.JSONDecodeError:
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
try:
data = json.loads(text[start : end + 1])
except json.JSONDecodeError:
return None
else:
return None
if not isinstance(data, dict):
return None
return GoalClassification(
goal=data.get("goal", ""),
relevant_types=data.get("relevant_types", []),
relevant_tags=data.get("relevant_tags", []),
predicted_needs=data.get("predicted_needs", []),
)

861
tests/test_fidelity.py Normal file
View file

@ -0,0 +1,861 @@
"""Tests for the multi-fidelity state machine."""
from __future__ import annotations
from mnemosyne.fidelity import (
VALID_OBJECT_TYPES,
FidelityLevel,
FidelityManager,
PressureZone,
SemanticObject,
_estimate_tokens,
make_object,
)
# ── Helpers ──────────────────────────────────────────────────
def _make_obj(
content: str = "x" * 400,
*,
object_type: str = "file_context",
turn: int = 0,
summary_detailed: str | None = None,
summary_compact: str | None = None,
stub: str | None = None,
) -> SemanticObject:
"""Create a SemanticObject with sensible defaults for testing."""
return make_object(
object_type=object_type,
content_full=content,
created_at_turn=turn,
summary_detailed=summary_detailed or ("summary " * 20), # ~140 chars
summary_compact=summary_compact or ("compact " * 5), # ~40 chars
stub=stub or "file_context: test object",
)
def _fill_manager(
manager: FidelityManager,
count: int,
*,
content_size: int = 400,
turn: int = 0,
) -> list[str]:
"""Register `count` objects and return their IDs."""
ids = []
for i in range(count):
obj = _make_obj("x" * content_size, turn=turn + i)
oid = manager.register_object(obj)
ids.append(oid)
return ids
# ── FidelityLevel enum ───────────────────────────────────────
class TestFidelityLevel:
def test_five_levels(self):
assert len(FidelityLevel) == 5
def test_ordering(self):
assert FidelityLevel.L0 < FidelityLevel.L1 < FidelityLevel.L2
assert FidelityLevel.L2 < FidelityLevel.L3 < FidelityLevel.L4
def test_values(self):
assert FidelityLevel.L0 == 0
assert FidelityLevel.L4 == 4
def test_names(self):
assert FidelityLevel.L0.name == "L0"
assert FidelityLevel.L4.name == "L4"
# ── PressureZone enum ────────────────────────────────────────
class TestPressureZone:
def test_five_zones(self):
assert len(PressureZone) == 5
def test_ordering(self):
assert PressureZone.NORMAL < PressureZone.CAUTION
assert PressureZone.CAUTION < PressureZone.WARNING
assert PressureZone.WARNING < PressureZone.CRITICAL
assert PressureZone.CRITICAL < PressureZone.EMERGENCY
def test_names(self):
expected = {"NORMAL", "CAUTION", "WARNING", "CRITICAL", "EMERGENCY"}
assert {z.name for z in PressureZone} == expected
# ── SemanticObject ────────────────────────────────────────────
class TestSemanticObject:
def test_required_fields(self):
obj = _make_obj()
assert obj.id
assert obj.object_type == "file_context"
assert obj.content_full == "x" * 400
assert obj.current_fidelity == FidelityLevel.L0
assert obj.pinned is False
assert obj.pin_until_turn is None
assert obj.fault_count == 0
def test_token_estimation(self):
obj = _make_obj("a" * 1000)
assert obj.token_count_l0 == 250 # 1000 / 4
def test_tokens_at_each_level(self):
obj = _make_obj(
"a" * 400,
summary_detailed="b" * 120,
summary_compact="c" * 20,
stub="d" * 100,
)
assert obj.tokens_at(FidelityLevel.L0) == 100 # 400/4
assert obj.tokens_at(FidelityLevel.L1) == 30 # 120/4
assert obj.tokens_at(FidelityLevel.L2) == 5 # 20/4
assert obj.tokens_at(FidelityLevel.L3) == 25 # 100/4
assert obj.tokens_at(FidelityLevel.L4) == 0 # evicted
def test_current_tokens_tracks_fidelity(self):
obj = _make_obj("a" * 400, summary_detailed="b" * 120)
assert obj.current_tokens == 100 # L0: 400/4
obj.current_fidelity = FidelityLevel.L1
assert obj.current_tokens == 30 # L1: 120/4
def test_losses_default_empty(self):
obj = _make_obj()
assert obj.losses_l1 == []
assert obj.losses_l2 == []
def test_losses_populated(self):
obj = make_object(
object_type="design_decision",
content_full="decision content",
losses_l1=["exact error codes"],
losses_l2=["exact error codes", "function signatures"],
)
assert "exact error codes" in obj.losses_l1
assert len(obj.losses_l2) == 2
def test_valid_object_types(self):
expected = {
"conversation_phase",
"design_decision",
"debugging_session",
"file_context",
"tool_result",
"plan",
"error_context",
"external_reference",
}
assert VALID_OBJECT_TYPES == expected
def test_queryability_fields(self):
obj = make_object(
object_type="file_context",
content_full="content",
can_answer=["what functions are defined"],
fault_when=["need exact line numbers"],
key_entities=["auth.py", "middleware"],
)
assert obj.can_answer == ["what functions are defined"]
assert obj.fault_when == ["need exact line numbers"]
assert obj.key_entities == ["auth.py", "middleware"]
# ── Token estimation ──────────────────────────────────────────
class TestTokenEstimation:
def test_basic(self):
assert _estimate_tokens("a" * 100) == 25
def test_none(self):
assert _estimate_tokens(None) == 0
def test_empty(self):
# len("") = 0, 0 // 4 = 0, max(1, 0) = 1
assert _estimate_tokens("") == 1
def test_empty_returns_minimum(self):
# Empty string: len=0, 0//4=0, max(1,0)=1
assert _estimate_tokens("") == 1
def test_short(self):
assert _estimate_tokens("hi") == 1 # 2//4=0, max(1,0)=1
def test_exact_multiple(self):
assert _estimate_tokens("a" * 400) == 100
# ── make_object factory ───────────────────────────────────────
class TestMakeObject:
def test_generates_id(self):
obj = make_object(object_type="plan", content_full="plan content")
assert len(obj.id) == 16
assert obj.id.isalnum()
def test_unique_ids(self):
ids = {make_object(object_type="plan", content_full="x").id for _ in range(100)}
assert len(ids) == 100
def test_auto_token_estimates(self):
obj = make_object(
object_type="file_context",
content_full="a" * 800,
summary_detailed="b" * 240,
summary_compact="c" * 40,
stub="d" * 100,
)
assert obj.token_count_l0 == 200
assert obj.token_count_l1 == 60
assert obj.token_count_l2 == 10
assert obj.token_count_l3 == 25 # 100/4
def test_default_stub_tokens(self):
obj = make_object(object_type="plan", content_full="content")
assert obj.token_count_l3 == 25 # default when no stub provided
def test_starts_at_l0(self):
obj = make_object(object_type="plan", content_full="content")
assert obj.current_fidelity == FidelityLevel.L0
# ── FidelityManager basics ────────────────────────────────────
class TestFidelityManagerBasics:
def test_default_window_size(self):
fm = FidelityManager()
assert fm.window_size == 200_000
def test_custom_window_size(self):
fm = FidelityManager(window_size=100_000)
assert fm.window_size == 100_000
def test_register_and_get(self):
fm = FidelityManager()
obj = _make_obj()
oid = fm.register_object(obj)
assert fm.get_object(oid) is obj
def test_get_nonexistent(self):
fm = FidelityManager()
assert fm.get_object("nonexistent") is None
def test_register_returns_id(self):
fm = FidelityManager()
obj = _make_obj()
oid = fm.register_object(obj)
assert oid == obj.id
def test_total_tokens_empty(self):
fm = FidelityManager()
assert fm.total_tokens() == 0
def test_total_tokens_sums_current_fidelity(self):
fm = FidelityManager()
obj1 = _make_obj("a" * 400) # 100 tokens at L0
obj2 = _make_obj("b" * 800) # 200 tokens at L0
fm.register_object(obj1)
fm.register_object(obj2)
assert fm.total_tokens() == 300
def test_total_tokens_respects_fidelity_change(self):
fm = FidelityManager()
obj = _make_obj("a" * 400, summary_detailed="b" * 120)
fm.register_object(obj)
assert fm.total_tokens() == 100 # L0: 400/4
obj.current_fidelity = FidelityLevel.L1
assert fm.total_tokens() == 30 # L1: 120/4
# ── Pressure zones ────────────────────────────────────────────
class TestPressureZones:
def test_normal_zone(self):
fm = FidelityManager(window_size=1000)
# 400 chars = 100 tokens, 10% of 1000 → NORMAL
_fill_manager(fm, 1, content_size=400)
assert fm.current_pressure() == PressureZone.NORMAL
def test_caution_zone(self):
fm = FidelityManager(window_size=1000)
# 2400 chars = 600 tokens, 60% of 1000 → CAUTION
_fill_manager(fm, 1, content_size=2400)
assert fm.current_pressure() == PressureZone.CAUTION
def test_warning_zone(self):
fm = FidelityManager(window_size=1000)
# 3200 chars = 800 tokens, 80% of 1000 → WARNING
_fill_manager(fm, 1, content_size=3200)
assert fm.current_pressure() == PressureZone.WARNING
def test_critical_zone(self):
fm = FidelityManager(window_size=1000)
# 3600 chars = 900 tokens, 90% of 1000 → CRITICAL
_fill_manager(fm, 1, content_size=3600)
assert fm.current_pressure() == PressureZone.CRITICAL
def test_emergency_zone(self):
fm = FidelityManager(window_size=1000)
# 3840 chars = 960 tokens, 96% of 1000 → EMERGENCY
_fill_manager(fm, 1, content_size=3840)
assert fm.current_pressure() == PressureZone.EMERGENCY
def test_exact_boundary_50pct(self):
fm = FidelityManager(window_size=1000)
# 2000 chars = 500 tokens, exactly 50% → CAUTION (>= threshold)
_fill_manager(fm, 1, content_size=2000)
assert fm.current_pressure() == PressureZone.CAUTION
def test_just_below_50pct(self):
fm = FidelityManager(window_size=1000)
# 1996 chars = 499 tokens, 49.9% → NORMAL
_fill_manager(fm, 1, content_size=1996)
assert fm.current_pressure() == PressureZone.NORMAL
def test_zero_window_is_emergency(self):
fm = FidelityManager(window_size=0)
assert fm.current_pressure() == PressureZone.EMERGENCY
# ── Degradation ───────────────────────────────────────────────
class TestDegradation:
def test_no_degradation_in_normal(self):
fm = FidelityManager(window_size=10_000)
_fill_manager(fm, 1, content_size=400) # 100 tokens, 1% → NORMAL
transitions = fm.degrade(current_turn=1)
assert transitions == []
def test_caution_degrades_l0_to_l1(self):
fm = FidelityManager(window_size=1000)
# 2400 chars = 600 tokens → CAUTION
_fill_manager(fm, 3, content_size=800)
assert fm.current_pressure() == PressureZone.CAUTION
transitions = fm.degrade(current_turn=5)
# Should have degraded some L0 objects to L1
assert len(transitions) > 0
for _oid, old, new in transitions:
assert old == FidelityLevel.L0
assert new == FidelityLevel.L1
def test_caution_degrades_oldest_first(self):
fm = FidelityManager(window_size=1000)
# Create objects at different turns
# 1200 chars each = 300 tokens each, 600 total = 60% → CAUTION
obj_old = _make_obj("a" * 1200, turn=0)
obj_new = _make_obj("b" * 1200, turn=5)
id_old = fm.register_object(obj_old)
id_new = fm.register_object(obj_new)
# Mark new one as recently accessed
fm.mark_accessed(id_new, current_turn=10)
assert fm.current_pressure() == PressureZone.CAUTION
transitions = fm.degrade(current_turn=10)
# Oldest should be degraded first
if transitions:
assert transitions[0][0] == id_old
def test_warning_degrades_multiple_levels(self):
fm = FidelityManager(window_size=1000)
# 3200 chars = 800 tokens → WARNING
_fill_manager(fm, 4, content_size=800)
assert fm.current_pressure() == PressureZone.WARNING
transitions = fm.degrade(current_turn=10)
assert len(transitions) > 0
# Should see L0→L1 transitions at minimum
levels_seen = {(old, new) for _, old, new in transitions}
assert (FidelityLevel.L0, FidelityLevel.L1) in levels_seen
def test_critical_degrades_aggressively(self):
fm = FidelityManager(window_size=1000)
# 3600 chars = 900 tokens → CRITICAL
_fill_manager(fm, 3, content_size=1200)
assert fm.current_pressure() == PressureZone.CRITICAL
transitions = fm.degrade(current_turn=10)
assert len(transitions) > 0
# Should see objects pushed to L3 or L4
final_levels = {new for _, _, new in transitions}
assert FidelityLevel.L3 in final_levels or FidelityLevel.L4 in final_levels
def test_emergency_evicts_all_unpinned(self):
fm = FidelityManager(window_size=1000)
# 3840 chars = 960 tokens → EMERGENCY
ids = _fill_manager(fm, 4, content_size=960)
assert fm.current_pressure() == PressureZone.EMERGENCY
fm.degrade(current_turn=10)
# All objects should end at L4
for obj_id in ids:
obj = fm.get_object(obj_id)
assert obj is not None
assert obj.current_fidelity == FidelityLevel.L4
def test_emergency_respects_pins(self):
fm = FidelityManager(window_size=1000)
ids = _fill_manager(fm, 4, content_size=960)
# Pin one object
fm.record_fault(ids[0], current_turn=5, pin_duration=20)
fm.degrade(current_turn=10)
# Pinned object should NOT be at L4
pinned_obj = fm.get_object(ids[0])
assert pinned_obj is not None
assert pinned_obj.current_fidelity < FidelityLevel.L4
# Others should be at L4
for oid in ids[1:]:
obj = fm.get_object(oid)
assert obj is not None
assert obj.current_fidelity == FidelityLevel.L4
def test_degrade_returns_transitions(self):
fm = FidelityManager(window_size=1000)
_fill_manager(fm, 3, content_size=800)
transitions = fm.degrade(current_turn=5)
for oid, old, new in transitions:
assert isinstance(oid, str)
assert isinstance(old, FidelityLevel)
assert isinstance(new, FidelityLevel)
assert new > old # Degradation means higher numeric level
def test_degrade_stops_when_pressure_relieved(self):
fm = FidelityManager(window_size=1000)
# Create objects with large L0 but small L1
for i in range(3):
obj = _make_obj(
"a" * 800, # 200 tokens at L0
summary_detailed="b" * 40, # 10 tokens at L1
turn=i,
)
fm.register_object(obj)
# 600 tokens → CAUTION
assert fm.current_pressure() == PressureZone.CAUTION
fm.degrade(current_turn=5)
# After degrading enough objects, pressure should drop
# Not all objects need to be degraded
assert fm.current_pressure() <= PressureZone.CAUTION
# ── Upgrade ───────────────────────────────────────────────────
class TestUpgrade:
def test_upgrade_l1_to_l0(self):
fm = FidelityManager()
obj = _make_obj("content" * 50)
oid = fm.register_object(obj)
obj.current_fidelity = FidelityLevel.L1
result = fm.upgrade(oid, FidelityLevel.L0, current_turn=5)
assert result is True
assert obj.current_fidelity == FidelityLevel.L0
def test_upgrade_updates_last_accessed(self):
fm = FidelityManager()
obj = _make_obj("content" * 50)
oid = fm.register_object(obj)
obj.current_fidelity = FidelityLevel.L2
fm.upgrade(oid, FidelityLevel.L0, current_turn=42)
assert obj.last_accessed_turn == 42
def test_upgrade_rejects_same_level(self):
fm = FidelityManager()
obj = _make_obj()
oid = fm.register_object(obj)
result = fm.upgrade(oid, FidelityLevel.L0, current_turn=5)
assert result is False # Already at L0
def test_upgrade_rejects_downgrade(self):
fm = FidelityManager()
obj = _make_obj()
oid = fm.register_object(obj)
result = fm.upgrade(oid, FidelityLevel.L1, current_turn=5)
assert result is False # L1 > L0, not an upgrade
def test_upgrade_nonexistent_object(self):
fm = FidelityManager()
result = fm.upgrade("nonexistent", FidelityLevel.L0, current_turn=5)
assert result is False
def test_upgrade_l3_to_l1(self):
fm = FidelityManager()
obj = _make_obj(summary_detailed="detailed summary content")
oid = fm.register_object(obj)
obj.current_fidelity = FidelityLevel.L3
result = fm.upgrade(oid, FidelityLevel.L1, current_turn=10)
assert result is True
assert obj.current_fidelity == FidelityLevel.L1
def test_upgrade_requires_content_at_target(self):
fm = FidelityManager()
obj = make_object(
object_type="file_context",
content_full="full content",
# No summary_detailed provided
)
oid = fm.register_object(obj)
obj.current_fidelity = FidelityLevel.L3
# Can upgrade to L0 (has content_full)
result = fm.upgrade(oid, FidelityLevel.L0, current_turn=5)
assert result is True
def test_upgrade_rejects_missing_l1_content(self):
fm = FidelityManager()
obj = SemanticObject(
id="test123",
object_type="file_context",
content_full="full",
summary_detailed=None, # No L1 content
token_count_l0=1,
)
fm.register_object(obj)
obj.current_fidelity = FidelityLevel.L3
result = fm.upgrade("test123", FidelityLevel.L1, current_turn=5)
assert result is False
# ── Fault-driven pinning ──────────────────────────────────────
class TestFaultPinning:
def test_record_fault_pins_object(self):
fm = FidelityManager()
obj = _make_obj()
oid = fm.register_object(obj)
fm.record_fault(oid, current_turn=10, pin_duration=5)
assert obj.pinned is True
assert obj.pin_until_turn == 15
assert obj.fault_count == 1
def test_record_fault_updates_access(self):
fm = FidelityManager()
obj = _make_obj()
oid = fm.register_object(obj)
fm.record_fault(oid, current_turn=10)
assert obj.last_accessed_turn == 10
def test_multiple_faults_increment_count(self):
fm = FidelityManager()
obj = _make_obj()
oid = fm.register_object(obj)
fm.record_fault(oid, current_turn=10)
fm.record_fault(oid, current_turn=12)
assert obj.fault_count == 2
def test_pin_prevents_degradation(self):
fm = FidelityManager(window_size=1000)
ids = _fill_manager(fm, 3, content_size=800)
# Pin the oldest object
fm.record_fault(ids[0], current_turn=5, pin_duration=20)
transitions = fm.degrade(current_turn=10)
# Pinned object should not appear in transitions
degraded_ids = {oid for oid, _, _ in transitions}
assert ids[0] not in degraded_ids
def test_pin_expires(self):
fm = FidelityManager(window_size=1000)
ids = _fill_manager(fm, 3, content_size=800)
fm.record_fault(ids[0], current_turn=5, pin_duration=3)
# Pin expires at turn 8
fm.degrade(current_turn=9)
# Now the object can be degraded
obj = fm.get_object(ids[0])
assert obj is not None
assert obj.pinned is False
def test_default_pin_duration(self):
fm = FidelityManager()
obj = _make_obj()
oid = fm.register_object(obj)
fm.record_fault(oid, current_turn=10)
assert obj.pin_until_turn == 15 # default duration = 5
def test_fault_nonexistent_object(self):
fm = FidelityManager()
# Should not raise
fm.record_fault("nonexistent", current_turn=10)
# ── mark_accessed ─────────────────────────────────────────────
class TestMarkAccessed:
def test_updates_last_accessed(self):
fm = FidelityManager()
obj = _make_obj(turn=0)
oid = fm.register_object(obj)
fm.mark_accessed(oid, current_turn=42)
assert obj.last_accessed_turn == 42
def test_nonexistent_object(self):
fm = FidelityManager()
# Should not raise
fm.mark_accessed("nonexistent", current_turn=10)
# ── eviction_candidates ──────────────────────────────────────
class TestEvictionCandidates:
def test_empty_manager(self):
fm = FidelityManager()
assert fm.eviction_candidates(current_turn=0) == []
def test_excludes_l4_objects(self):
fm = FidelityManager()
obj = _make_obj()
fm.register_object(obj)
obj.current_fidelity = FidelityLevel.L4
candidates = fm.eviction_candidates(current_turn=0)
assert len(candidates) == 0
def test_unpinned_before_pinned(self):
fm = FidelityManager()
obj_pinned = _make_obj(turn=0)
obj_free = _make_obj(turn=1)
id_pinned = fm.register_object(obj_pinned)
id_free = fm.register_object(obj_free)
fm.record_fault(id_pinned, current_turn=5, pin_duration=20)
candidates = fm.eviction_candidates(current_turn=6)
assert len(candidates) == 2
assert candidates[0].id == id_free # Unpinned first
def test_older_access_more_evictable(self):
fm = FidelityManager()
obj_old = _make_obj(turn=0)
obj_new = _make_obj(turn=0)
id_old = fm.register_object(obj_old)
id_new = fm.register_object(obj_new)
fm.mark_accessed(id_new, current_turn=10)
candidates = fm.eviction_candidates(current_turn=10)
assert candidates[0].id == id_old
def test_lower_fidelity_more_evictable(self):
fm = FidelityManager()
obj_l0 = _make_obj(turn=0)
obj_l3 = _make_obj(turn=0)
fm.register_object(obj_l0)
id_l3 = fm.register_object(obj_l3)
obj_l3.current_fidelity = FidelityLevel.L3
candidates = fm.eviction_candidates(current_turn=0)
# L3 (closer to eviction) should come first
assert candidates[0].id == id_l3
def test_expired_pins_are_evictable(self):
fm = FidelityManager()
obj = _make_obj(turn=0)
oid = fm.register_object(obj)
fm.record_fault(oid, current_turn=5, pin_duration=3)
# Pin expires at turn 8
candidates = fm.eviction_candidates(current_turn=9)
assert len(candidates) == 1
assert candidates[0].pinned is False
# ── objects_at_fidelity ───────────────────────────────────────
class TestObjectsAtFidelity:
def test_all_at_l0(self):
fm = FidelityManager()
_fill_manager(fm, 3)
assert len(fm.objects_at_fidelity(FidelityLevel.L0)) == 3
assert len(fm.objects_at_fidelity(FidelityLevel.L1)) == 0
def test_mixed_levels(self):
fm = FidelityManager()
ids = _fill_manager(fm, 3)
obj0 = fm.get_object(ids[0])
obj1 = fm.get_object(ids[1])
assert obj0 is not None
assert obj1 is not None
obj0.current_fidelity = FidelityLevel.L1
obj1.current_fidelity = FidelityLevel.L3
assert len(fm.objects_at_fidelity(FidelityLevel.L0)) == 1
assert len(fm.objects_at_fidelity(FidelityLevel.L1)) == 1
assert len(fm.objects_at_fidelity(FidelityLevel.L3)) == 1
# ── summary ───────────────────────────────────────────────────
class TestSummary:
def test_summary_structure(self):
fm = FidelityManager(window_size=10_000)
_fill_manager(fm, 2, content_size=400)
s = fm.summary()
assert "total_objects" in s
assert "total_tokens" in s
assert "window_size" in s
assert "pressure_zone" in s
assert "objects_by_level" in s
assert "pinned_count" in s
assert "total_faults" in s
def test_summary_values(self):
fm = FidelityManager(window_size=10_000)
ids = _fill_manager(fm, 3, content_size=400)
fm.record_fault(ids[0], current_turn=5)
s = fm.summary()
assert s["total_objects"] == 3
assert s["total_tokens"] == 300 # 3 * 100
assert s["window_size"] == 10_000
assert s["pressure_zone"] == "NORMAL"
assert s["objects_by_level"]["L0"] == 3
assert s["pinned_count"] == 1
assert s["total_faults"] == 1
# ── Integration: full lifecycle ───────────────────────────────
class TestIntegration:
def test_register_degrade_upgrade_cycle(self):
"""Full lifecycle: register → pressure → degrade → access → upgrade."""
fm = FidelityManager(window_size=500)
# Register objects that push into CAUTION
ids = []
for i in range(5):
obj = _make_obj(
"x" * 400, # 100 tokens each
summary_detailed="y" * 120, # 30 tokens
summary_compact="z" * 20, # 5 tokens
stub="stub text",
turn=i,
)
ids.append(fm.register_object(obj))
# 500 tokens in 500 window → 100% → EMERGENCY
assert fm.current_pressure() == PressureZone.EMERGENCY
# Degrade
transitions = fm.degrade(current_turn=10)
assert len(transitions) > 0
# All should be evicted (emergency)
for oid in ids:
obj = fm.get_object(oid)
assert obj is not None
assert obj.current_fidelity == FidelityLevel.L4
# Upgrade one back to L0
result = fm.upgrade(ids[0], FidelityLevel.L0, current_turn=11)
assert result is True
upgraded = fm.get_object(ids[0])
assert upgraded is not None
assert upgraded.current_fidelity == FidelityLevel.L0
def test_fault_pin_degrade_cycle(self):
"""Fault → pin → degrade respects pin → pin expires → degrade works."""
fm = FidelityManager(window_size=1000)
ids = _fill_manager(fm, 4, content_size=800)
# Record fault on first object
fm.record_fault(ids[0], current_turn=5, pin_duration=3)
# Degrade at turn 6 — pinned object survives
transitions = fm.degrade(current_turn=6)
degraded_ids = {oid for oid, _, _ in transitions}
assert ids[0] not in degraded_ids
# At turn 9, pin expired — now it can be degraded
obj = fm.get_object(ids[0])
assert obj is not None
# Reset to L0 for clean test
obj.current_fidelity = FidelityLevel.L0
# Re-fill to get pressure back up
_fill_manager(fm, 2, content_size=800, turn=9)
fm.degrade(current_turn=9)
# Now the previously-pinned object should be degradable
obj_after = fm.get_object(ids[0])
assert obj_after is not None
assert obj_after.pinned is False
def test_token_accounting_through_degradation(self):
"""Token count decreases as objects are degraded."""
fm = FidelityManager(window_size=1000)
for i in range(3):
obj = _make_obj(
"a" * 800, # 200 tokens at L0
summary_detailed="b" * 120, # 30 tokens at L1
turn=i,
)
fm.register_object(obj)
initial_tokens = fm.total_tokens()
assert initial_tokens == 600 # 3 * 200
fm.degrade(current_turn=10)
# Tokens should have decreased
assert fm.total_tokens() < initial_tokens
def test_multiple_degrade_passes(self):
"""Multiple degrade calls progressively reduce fidelity."""
fm = FidelityManager(window_size=200)
for i in range(3):
obj = _make_obj(
"a" * 400, # 100 tokens at L0
summary_detailed="b" * 120, # 30 tokens at L1
summary_compact="c" * 20, # 5 tokens at L2
stub="stub",
turn=i,
)
fm.register_object(obj)
# 300 tokens in 200 window → EMERGENCY
fm.degrade(current_turn=10)
# After emergency, all should be L4
for obj in fm._objects.values():
assert obj.current_fidelity == FidelityLevel.L4

View file

@ -0,0 +1,513 @@
"""Integration tests for FidelityManager integration in the gateway.
Tests the Phase 2.4 fidelity pipeline: object registration, pressure
calculation, degradation, and content replacement in ephemeral payloads.
Does NOT test HelperLLM integration (requires mocking).
"""
from __future__ import annotations
import copy
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
from mnemosyne.fidelity import FidelityLevel, FidelityManager, PressureZone, make_object
from mnemosyne.gateway import (
Session,
_apply_fidelity,
_auto_stub,
_block_text,
_content_key,
)
# ── Fixtures ─────────────────────────────────────────────────────────────
@pytest.fixture
def tmp_log_dir():
with TemporaryDirectory() as d:
yield Path(d)
@pytest.fixture
def session(tmp_log_dir):
return Session("test01", tmp_log_dir)
def _make_large_text(size: int = 600) -> str:
"""Generate a text string of approximately `size` bytes."""
return "x" * size
def _tool_result_block(tool_use_id: str, content: str) -> dict:
return {
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": content,
}
def _text_block(text: str) -> dict:
return {"type": "text", "text": text}
def _msg(role: str, blocks: list[dict]) -> dict:
return {"role": role, "content": blocks}
# ── Test: FidelityManager created per session ────────────────────────────
class TestSessionFidelityManager:
def test_session_has_fidelity_manager(self, session):
assert hasattr(session, "fidelity_manager")
assert isinstance(session.fidelity_manager, FidelityManager)
def test_fidelity_manager_default_window(self, session):
assert session.fidelity_manager.window_size == 200_000
def test_fidelity_manager_per_session(self, tmp_log_dir):
s1 = Session("sess_a", tmp_log_dir)
s2 = Session("sess_b", tmp_log_dir)
assert s1.fidelity_manager is not s2.fidelity_manager
def test_session_has_content_map(self, session):
assert hasattr(session, "_fidelity_content_map")
assert isinstance(session._fidelity_content_map, dict)
assert len(session._fidelity_content_map) == 0
# ── Test: Content key derivation ─────────────────────────────────────────
class TestContentKey:
def test_tool_result_key(self):
block = _tool_result_block("toolu_abc123", "some content")
key = _content_key(block, {})
assert key == "tool:toolu_abc123"
def test_large_text_key(self):
text = _make_large_text(600)
block = _text_block(text)
key = _content_key(block, {})
assert key is not None
assert key.startswith("text:")
def test_small_text_returns_none(self):
block = _text_block("short")
key = _content_key(block, {})
assert key is None
def test_stable_key_for_same_content(self):
text = _make_large_text(600)
block1 = _text_block(text)
block2 = _text_block(text)
assert _content_key(block1, {}) == _content_key(block2, {})
def test_different_key_for_different_content(self):
block1 = _text_block("a" * 600)
block2 = _text_block("b" * 600)
assert _content_key(block1, {}) != _content_key(block2, {})
# ── Test: Block text extraction ──────────────────────────────────────────
class TestBlockText:
def test_text_block(self):
assert _block_text(_text_block("hello")) == "hello"
def test_tool_result_string_content(self):
block = _tool_result_block("id1", "result text")
assert _block_text(block) == "result text"
def test_tool_result_list_content(self):
block = {
"type": "tool_result",
"tool_use_id": "id2",
"content": [
{"type": "text", "text": "line 1"},
{"type": "text", "text": "line 2"},
],
}
assert _block_text(block) == "line 1\nline 2"
# ── Test: Auto stub generation ───────────────────────────────────────────
class TestAutoStub:
def test_short_content(self):
stub = _auto_stub("Hello world")
assert stub == "[evicted content: Hello world]"
def test_multiline_uses_first_line(self):
stub = _auto_stub("First line\nSecond line\nThird line")
assert "First line" in stub
assert "Second line" not in stub
def test_long_first_line_truncated(self):
long_line = "x" * 200
stub = _auto_stub(long_line)
assert len(stub) < 200
assert "..." in stub
# ── Test: Object registration via _apply_fidelity ───────────────────────
class TestApplyFidelityRegistration:
def test_registers_tool_result(self, session):
session.token_state["turn"] = 1
payload = {
"messages": [
_msg("user", [_tool_result_block("tool_1", _make_large_text(600))]),
]
}
_apply_fidelity(payload, session)
fm = session.fidelity_manager
assert fm.total_tokens() > 0
assert len(session._fidelity_content_map) == 1
assert "tool:tool_1" in session._fidelity_content_map
def test_registers_large_text_block(self, session):
session.token_state["turn"] = 1
large_text = _make_large_text(800)
payload = {
"messages": [
_msg("assistant", [_text_block(large_text)]),
]
}
_apply_fidelity(payload, session)
fm = session.fidelity_manager
assert fm.total_tokens() > 0
assert len(session._fidelity_content_map) == 1
def test_ignores_small_text_block(self, session):
session.token_state["turn"] = 1
payload = {
"messages": [
_msg("user", [_text_block("small content")]),
]
}
_apply_fidelity(payload, session)
assert len(session._fidelity_content_map) == 0
def test_multiple_blocks_registered(self, session):
session.token_state["turn"] = 1
payload = {
"messages": [
_msg(
"user",
[
_tool_result_block("tool_a", _make_large_text(600)),
_tool_result_block("tool_b", _make_large_text(700)),
_text_block(_make_large_text(800)),
],
),
]
}
_apply_fidelity(payload, session)
assert len(session._fidelity_content_map) == 3
def test_idempotent_on_second_call(self, session):
session.token_state["turn"] = 1
payload = {
"messages": [
_msg("user", [_tool_result_block("tool_1", _make_large_text(600))]),
]
}
_apply_fidelity(payload, session)
count_after_first = len(session._fidelity_content_map)
# Second call with same content — should not re-register
payload2 = {
"messages": [
_msg("user", [_tool_result_block("tool_1", _make_large_text(600))]),
]
}
session.token_state["turn"] = 2
_apply_fidelity(payload2, session)
assert len(session._fidelity_content_map) == count_after_first
def test_new_objects_start_at_l0(self, session):
session.token_state["turn"] = 1
payload = {
"messages": [
_msg("user", [_tool_result_block("tool_1", _make_large_text(600))]),
]
}
_apply_fidelity(payload, session)
obj_id = session._fidelity_content_map["tool:tool_1"]
obj = session.fidelity_manager.get_object(obj_id)
assert obj is not None
assert obj.current_fidelity == FidelityLevel.L0
# ── Test: Pressure calculation with real payloads ────────────────────────
class TestPressureCalculation:
def test_normal_pressure_small_payload(self, session):
session.token_state["turn"] = 1
payload = {
"messages": [
_msg("user", [_tool_result_block("t1", _make_large_text(600))]),
]
}
_apply_fidelity(payload, session)
# 600 bytes ≈ 150 tokens, window=200k → well under 50%
assert session.fidelity_manager.current_pressure() == PressureZone.NORMAL
def test_high_pressure_large_payload(self, session):
# Fill the fidelity manager with enough objects to exceed caution threshold
fm = session.fidelity_manager
# 200k window, caution at 50% = 100k tokens
# Each object: ~25k tokens (100k chars / 4)
for i in range(5):
obj = make_object(
object_type="tool_result",
content_full="x" * 100_000,
created_at_turn=1,
stub=f"[stub {i}]",
)
fm.register_object(obj)
# 5 * 25k = 125k tokens > 100k caution threshold
assert fm.current_pressure() >= PressureZone.CAUTION
def test_pressure_zones_ordered(self, session):
fm = session.fidelity_manager
# Verify zone thresholds are ordered correctly
assert fm.threshold_caution < fm.threshold_warning
assert fm.threshold_warning < fm.threshold_critical
assert fm.threshold_critical < fm.threshold_emergency
# ── Test: Degradation replaces content in ephemeral payload ──────────────
class TestDegradationReplacement:
def test_degraded_tool_result_replaced_with_stub(self, session):
session.token_state["turn"] = 1
original_content = _make_large_text(600)
tool_id = "tool_degrade"
# Register the object
payload1 = {
"messages": [
_msg("user", [_tool_result_block(tool_id, original_content)]),
]
}
_apply_fidelity(payload1, session)
# Manually degrade the object to L3 (stub)
obj_id = session._fidelity_content_map[f"tool:{tool_id}"]
obj = session.fidelity_manager.get_object(obj_id)
obj.current_fidelity = FidelityLevel.L3
# Apply fidelity again — should replace content with stub
session.token_state["turn"] = 2
payload2 = {
"messages": [
_msg("user", [_tool_result_block(tool_id, original_content)]),
]
}
_apply_fidelity(payload2, session)
replaced_content = payload2["messages"][0]["content"][0]["content"]
assert replaced_content != original_content
assert "[evicted content:" in replaced_content
def test_degraded_text_block_replaced_with_stub(self, session):
session.token_state["turn"] = 1
original_text = "Important data: " + _make_large_text(600)
payload1 = {
"messages": [
_msg("assistant", [_text_block(original_text)]),
]
}
_apply_fidelity(payload1, session)
# Find the content key and degrade
assert len(session._fidelity_content_map) == 1
obj_id = list(session._fidelity_content_map.values())[0]
obj = session.fidelity_manager.get_object(obj_id)
obj.current_fidelity = FidelityLevel.L3
# Apply again
session.token_state["turn"] = 2
payload2 = {
"messages": [
_msg("assistant", [_text_block(original_text)]),
]
}
_apply_fidelity(payload2, session)
replaced_text = payload2["messages"][0]["content"][0]["text"]
assert replaced_text != original_text
assert "[evicted content:" in replaced_text
def test_l0_content_not_replaced(self, session):
session.token_state["turn"] = 1
original_content = _make_large_text(600)
tool_id = "tool_keep"
payload = {
"messages": [
_msg("user", [_tool_result_block(tool_id, original_content)]),
]
}
_apply_fidelity(payload, session)
# Object stays at L0 — content should be unchanged
result_content = payload["messages"][0]["content"][0]["content"]
assert result_content == original_content
def test_l4_evicted_also_replaced(self, session):
session.token_state["turn"] = 1
original_content = _make_large_text(600)
tool_id = "tool_evict"
payload1 = {
"messages": [
_msg("user", [_tool_result_block(tool_id, original_content)]),
]
}
_apply_fidelity(payload1, session)
# Degrade to L4 (evicted)
obj_id = session._fidelity_content_map[f"tool:{tool_id}"]
obj = session.fidelity_manager.get_object(obj_id)
obj.current_fidelity = FidelityLevel.L4
# Apply again — L4 is >= L3, so should still replace
session.token_state["turn"] = 2
payload2 = {
"messages": [
_msg("user", [_tool_result_block(tool_id, original_content)]),
]
}
_apply_fidelity(payload2, session)
replaced_content = payload2["messages"][0]["content"][0]["content"]
assert "[evicted content:" in replaced_content
# ── Test: Degradation triggered by pressure ──────────────────────────────
class TestPressureDegradation:
def test_degrade_under_pressure(self, session):
"""When pressure exceeds NORMAL, _apply_fidelity triggers degradation."""
fm = session.fidelity_manager
# Use a tiny window to force pressure
fm.window_size = 1000 # 1000 tokens
session.token_state["turn"] = 1
# Register objects that exceed the window
# 2000 bytes ≈ 500 tokens per object, 3 objects = 1500 tokens > 1000
payload = {
"messages": [
_msg(
"user",
[
_tool_result_block("t1", _make_large_text(2000)),
_tool_result_block("t2", _make_large_text(2000)),
_tool_result_block("t3", _make_large_text(2000)),
],
),
]
}
_apply_fidelity(payload, session)
# After apply, some objects should have been degraded
degraded = [obj for obj in fm._objects.values() if obj.current_fidelity > FidelityLevel.L0]
# At least some degradation should have occurred
assert len(degraded) > 0 or fm.current_pressure() == PressureZone.NORMAL
def test_mark_accessed_updates_turn(self, session):
"""Objects seen again get their last_accessed_turn updated."""
session.token_state["turn"] = 1
payload = {
"messages": [
_msg("user", [_tool_result_block("t1", _make_large_text(600))]),
]
}
_apply_fidelity(payload, session)
obj_id = session._fidelity_content_map["tool:t1"]
obj = session.fidelity_manager.get_object(obj_id)
assert obj.last_accessed_turn == 1
# See it again at turn 5
session.token_state["turn"] = 5
payload2 = {
"messages": [
_msg("user", [_tool_result_block("t1", _make_large_text(600))]),
]
}
_apply_fidelity(payload2, session)
assert obj.last_accessed_turn == 5
# ── Test: Mixed content payloads ─────────────────────────────────────────
class TestMixedPayloads:
def test_mixed_small_and_large_blocks(self, session):
"""Only large blocks get tracked; small ones pass through."""
session.token_state["turn"] = 1
payload = {
"messages": [
_msg(
"user",
[
_text_block("small question"),
_tool_result_block("t1", _make_large_text(600)),
_text_block("another small bit"),
],
),
]
}
_apply_fidelity(payload, session)
# Only the tool_result should be tracked
assert len(session._fidelity_content_map) == 1
assert "tool:t1" in session._fidelity_content_map
def test_string_content_messages_ignored(self, session):
"""Messages with string content (not list) are skipped."""
session.token_state["turn"] = 1
payload = {
"messages": [
{"role": "user", "content": "Just a plain string message"},
]
}
_apply_fidelity(payload, session)
assert len(session._fidelity_content_map) == 0
def test_multiple_messages_all_tracked(self, session):
"""Objects across multiple messages are all registered."""
session.token_state["turn"] = 1
payload = {
"messages": [
_msg("user", [_tool_result_block("t1", _make_large_text(600))]),
_msg("assistant", [_text_block(_make_large_text(800))]),
_msg("user", [_tool_result_block("t2", _make_large_text(700))]),
]
}
_apply_fidelity(payload, session)
# t1, large text, t2 = 3 tracked objects
assert len(session._fidelity_content_map) == 3

572
tests/test_helper_llm.py Normal file
View file

@ -0,0 +1,572 @@
"""Tests for the Helper LLM client.
All tests use mocked Anthropic API responses no real API calls.
"""
from __future__ import annotations
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from mnemosyne.helper_llm import (
GoalClassification,
HelperLLM,
SummaryResult,
_L0_TO_L1_PROMPT,
_L1_TO_L2_PROMPT,
_GOAL_CLASSIFICATION_PROMPT,
_MICRO_FAULT_PROMPT,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_api_response(text: str) -> MagicMock:
"""Build a mock Anthropic Messages response with a single text block."""
block = MagicMock()
block.type = "text"
block.text = text
response = MagicMock()
response.content = [block]
return response
@pytest.fixture()
def helper() -> HelperLLM:
"""Return a HelperLLM with a mocked async client."""
with patch.dict("os.environ", {"ANTHROPIC_API_KEY": "test-key-000"}):
h = HelperLLM()
h._client = MagicMock()
h._client.messages = MagicMock()
h._client.messages.create = AsyncMock()
return h
# ---------------------------------------------------------------------------
# Dataclass tests
# ---------------------------------------------------------------------------
class TestDataclasses:
def test_summary_result_defaults(self) -> None:
r = SummaryResult(summary="hello")
assert r.summary == "hello"
assert r.losses == []
assert r.can_answer == []
assert r.key_entities == []
def test_summary_result_with_fields(self) -> None:
r = SummaryResult(
summary="s",
losses=["a"],
can_answer=["b"],
key_entities=["c"],
)
assert r.losses == ["a"]
assert r.can_answer == ["b"]
assert r.key_entities == ["c"]
def test_goal_classification_defaults(self) -> None:
g = GoalClassification(goal="write tests")
assert g.goal == "write tests"
assert g.relevant_types == []
assert g.relevant_tags == []
assert g.predicted_needs == []
# ---------------------------------------------------------------------------
# JSON parsing tests (static methods, no API calls)
# ---------------------------------------------------------------------------
class TestParseSummaryJson:
def test_valid_json(self) -> None:
raw = json.dumps(
{
"summary": "Auth middleware uses JWT.",
"losses": ["exact error codes"],
"can_answer": ["auth approach"],
"key_entities": ["src/auth/middleware.ts"],
}
)
result = HelperLLM._parse_summary_json(raw)
assert result is not None
assert result.summary == "Auth middleware uses JWT."
assert result.losses == ["exact error codes"]
assert result.can_answer == ["auth approach"]
assert result.key_entities == ["src/auth/middleware.ts"]
def test_json_in_code_fence(self) -> None:
raw = (
'```json\n{"summary": "test", "losses": [], "can_answer": [], "key_entities": []}\n```'
)
result = HelperLLM._parse_summary_json(raw)
assert result is not None
assert result.summary == "test"
def test_json_with_surrounding_text(self) -> None:
raw = 'Here is the result:\n{"summary": "ok", "losses": ["x"]}\nDone.'
result = HelperLLM._parse_summary_json(raw)
assert result is not None
assert result.summary == "ok"
assert result.losses == ["x"]
def test_invalid_json_returns_none(self) -> None:
assert HelperLLM._parse_summary_json("not json at all") is None
def test_empty_string_returns_none(self) -> None:
assert HelperLLM._parse_summary_json("") is None
def test_non_dict_json_returns_none(self) -> None:
assert HelperLLM._parse_summary_json("[1, 2, 3]") is None
def test_missing_fields_default_to_empty(self) -> None:
raw = '{"summary": "minimal"}'
result = HelperLLM._parse_summary_json(raw)
assert result is not None
assert result.summary == "minimal"
assert result.losses == []
assert result.can_answer == []
assert result.key_entities == []
class TestParseGoalJson:
def test_valid_json(self) -> None:
raw = json.dumps(
{
"goal": "write auth tests",
"relevant_types": ["file_context", "design_decision"],
"relevant_tags": ["auth", "testing"],
"predicted_needs": ["auth middleware impl"],
}
)
result = HelperLLM._parse_goal_json(raw)
assert result is not None
assert result.goal == "write auth tests"
assert "file_context" in result.relevant_types
assert "auth" in result.relevant_tags
def test_invalid_json_returns_none(self) -> None:
assert HelperLLM._parse_goal_json("garbage") is None
def test_json_in_code_fence(self) -> None:
raw = '```\n{"goal": "deploy", "relevant_types": [], "relevant_tags": [], "predicted_needs": []}\n```'
result = HelperLLM._parse_goal_json(raw)
assert result is not None
assert result.goal == "deploy"
# ---------------------------------------------------------------------------
# Prompt construction tests
# ---------------------------------------------------------------------------
class TestPromptConstruction:
def test_l0_to_l1_prompt_contains_content_and_type(self) -> None:
prompt = _L0_TO_L1_PROMPT.format(
object_type="file_context",
content="def hello(): pass",
)
assert "file_context" in prompt
assert "def hello(): pass" in prompt
assert "DECLARED LOSSES" in prompt
assert "CAN_ANSWER" in prompt
assert "OUTPUT FORMAT (JSON)" in prompt
def test_l1_to_l2_prompt_includes_losses(self) -> None:
prompt = _L1_TO_L2_PROMPT.format(
object_type="debugging_session",
l1_summary="Fixed race condition",
l1_losses="- exact error codes\n- line numbers",
)
assert "debugging_session" in prompt
assert "Fixed race condition" in prompt
assert "exact error codes" in prompt
def test_goal_prompt_includes_message_and_context(self) -> None:
prompt = _GOAL_CLASSIFICATION_PROMPT.format(
user_message="now write tests",
recent_context="We just implemented auth.",
)
assert "now write tests" in prompt
assert "We just implemented auth." in prompt
def test_micro_fault_prompt_includes_question_and_context(self) -> None:
prompt = _MICRO_FAULT_PROMPT.format(
question="What error code?",
context="Error 401 unauthorized",
)
assert "What error code?" in prompt
assert "Error 401 unauthorized" in prompt
# ---------------------------------------------------------------------------
# API call tests (mocked)
# ---------------------------------------------------------------------------
class TestSummarizeL0ToL1:
async def test_successful_summarization(self, helper: HelperLLM) -> None:
api_response = _make_api_response(
json.dumps(
{
"summary": "Auth middleware validates JWT tokens.",
"losses": ["exact error codes for token expiry"],
"can_answer": ["auth approach used"],
"key_entities": ["src/auth/middleware.ts", "jsonwebtoken"],
}
)
)
helper._client.messages.create = AsyncMock(return_value=api_response)
result = await helper.summarize_l0_to_l1(
content="Full auth middleware source code here...",
object_type="file_context",
)
assert isinstance(result, SummaryResult)
assert result.summary == "Auth middleware validates JWT tokens."
assert "exact error codes for token expiry" in result.losses
assert "src/auth/middleware.ts" in result.key_entities
# Verify the API was called with correct model
call_kwargs = helper._client.messages.create.call_args.kwargs
assert call_kwargs["model"] == "claude-haiku-4-5-20251001"
# Verify prompt contains the content
user_msg = call_kwargs["messages"][0]["content"]
assert "file_context" in user_msg
assert "Full auth middleware source code here..." in user_msg
async def test_json_parse_failure_returns_fallback(self, helper: HelperLLM) -> None:
api_response = _make_api_response("This is not valid JSON at all.")
helper._client.messages.create = AsyncMock(return_value=api_response)
content = "A" * 1000
result = await helper.summarize_l0_to_l1(
content=content,
object_type="file_context",
)
assert isinstance(result, SummaryResult)
# Fallback: first 30% of content
assert len(result.summary) == 300
assert result.summary == "A" * 300
assert result.losses == []
async def test_respects_max_summary_tokens(self, helper: HelperLLM) -> None:
api_response = _make_api_response('{"summary": "ok"}')
helper._client.messages.create = AsyncMock(return_value=api_response)
await helper.summarize_l0_to_l1(
content="test",
object_type="tool_result",
max_summary_tokens=512,
)
call_kwargs = helper._client.messages.create.call_args.kwargs
assert call_kwargs["max_tokens"] == 512
class TestCompressL1ToL2:
async def test_successful_compression(self, helper: HelperLLM) -> None:
api_response = _make_api_response(
json.dumps(
{
"summary": "Auth uses JWT with refresh tokens.",
"losses": ["function signatures"],
"can_answer": ["what was decided"],
"key_entities": ["middleware.ts"],
}
)
)
helper._client.messages.create = AsyncMock(return_value=api_response)
l1_losses = ["exact error codes", "line-by-line implementation"]
result = await helper.compress_l1_to_l2(
l1_summary="Detailed auth summary...",
l1_losses=l1_losses,
object_type="file_context",
)
assert isinstance(result, SummaryResult)
assert result.summary == "Auth uses JWT with refresh tokens."
# L1 losses should be accumulated (prepended)
assert result.losses[0] == "exact error codes"
assert result.losses[1] == "line-by-line implementation"
assert "function signatures" in result.losses
async def test_loss_accumulation(self, helper: HelperLLM) -> None:
api_response = _make_api_response(
json.dumps(
{
"summary": "compact",
"losses": ["new_loss"],
"can_answer": [],
"key_entities": [],
}
)
)
helper._client.messages.create = AsyncMock(return_value=api_response)
result = await helper.compress_l1_to_l2(
l1_summary="summary",
l1_losses=["old_loss_1", "old_loss_2"],
object_type="design_decision",
)
assert result.losses == ["old_loss_1", "old_loss_2", "new_loss"]
async def test_fallback_on_parse_failure(self, helper: HelperLLM) -> None:
api_response = _make_api_response("broken response")
helper._client.messages.create = AsyncMock(return_value=api_response)
l1_losses = ["loss_a"]
result = await helper.compress_l1_to_l2(
l1_summary="A" * 100,
l1_losses=l1_losses,
object_type="file_context",
)
assert isinstance(result, SummaryResult)
# Fallback: 30% of l1_summary
assert len(result.summary) == 30
# L1 losses preserved in fallback
assert result.losses == ["loss_a"]
class TestGenerateStub:
async def test_successful_stub(self, helper: HelperLLM) -> None:
stub_text = "[debugging_session | 2026-03-13 14:30 | Fixed race condition in auth | 12 related objects]"
api_response = _make_api_response(stub_text)
helper._client.messages.create = AsyncMock(return_value=api_response)
result = await helper.generate_stub(
l2_summary="Fixed race condition in auth token refresh.",
object_type="debugging_session",
timestamp="2026-03-13 14:30",
)
assert result == stub_text
assert "debugging_session" in result
assert "2026-03-13 14:30" in result
async def test_multiline_response_takes_first_line(self, helper: HelperLLM) -> None:
api_response = _make_api_response(
"[type | ts | desc | 0 related objects]\nExtra line\nAnother"
)
helper._client.messages.create = AsyncMock(return_value=api_response)
result = await helper.generate_stub("summary", "type", "ts")
assert "\n" not in result
assert result == "[type | ts | desc | 0 related objects]"
async def test_empty_response_fallback(self, helper: HelperLLM) -> None:
api_response = _make_api_response("")
helper._client.messages.create = AsyncMock(return_value=api_response)
result = await helper.generate_stub("summary", "file_context", "2026-01-01")
assert "file_context" in result
assert "2026-01-01" in result
assert "summary unavailable" in result
class TestAnswerMicroFault:
async def test_successful_answer(self, helper: HelperLLM) -> None:
api_response = _make_api_response("The error code is 401 UNAUTHORIZED.")
helper._client.messages.create = AsyncMock(return_value=api_response)
result = await helper.answer_micro_fault(
question="What error code does auth return for expired tokens?",
relevant_contents=[
"Auth middleware returns 401 for expired tokens.",
"Token refresh logic in refresh.ts.",
],
)
assert result == "The error code is 401 UNAUTHORIZED."
# Verify context was joined with separator
call_kwargs = helper._client.messages.create.call_args.kwargs
prompt = call_kwargs["messages"][0]["content"]
assert "What error code" in prompt
assert "Auth middleware returns 401" in prompt
assert "---" in prompt # separator between contents
async def test_empty_response_fallback(self, helper: HelperLLM) -> None:
api_response = _make_api_response(" ")
helper._client.messages.create = AsyncMock(return_value=api_response)
result = await helper.answer_micro_fault(
question="anything",
relevant_contents=["content"],
)
assert result == "Unable to answer from available context."
async def test_respects_max_tokens(self, helper: HelperLLM) -> None:
api_response = _make_api_response("answer")
helper._client.messages.create = AsyncMock(return_value=api_response)
await helper.answer_micro_fault("q", ["c"], max_tokens=150)
call_kwargs = helper._client.messages.create.call_args.kwargs
assert call_kwargs["max_tokens"] == 150
class TestClassifyGoal:
async def test_successful_classification(self, helper: HelperLLM) -> None:
api_response = _make_api_response(
json.dumps(
{
"goal": "write unit tests for auth module",
"relevant_types": ["file_context", "design_decision"],
"relevant_tags": ["auth", "testing"],
"predicted_needs": ["auth middleware implementation", "test patterns"],
}
)
)
helper._client.messages.create = AsyncMock(return_value=api_response)
result = await helper.classify_goal(
user_message="Now write tests for the auth middleware",
recent_context="We just finished implementing JWT auth.",
)
assert isinstance(result, GoalClassification)
assert result.goal == "write unit tests for auth module"
assert "file_context" in result.relevant_types
assert "auth" in result.relevant_tags
assert "auth middleware implementation" in result.predicted_needs
async def test_fallback_on_parse_failure(self, helper: HelperLLM) -> None:
api_response = _make_api_response("I don't understand the format")
helper._client.messages.create = AsyncMock(return_value=api_response)
result = await helper.classify_goal(
user_message="deploy to production",
recent_context="context",
)
assert isinstance(result, GoalClassification)
assert result.goal == "deploy to production"
assert result.relevant_types == []
# ---------------------------------------------------------------------------
# Error handling tests
# ---------------------------------------------------------------------------
class TestErrorHandling:
async def test_timeout_returns_fallback(self, helper: HelperLLM) -> None:
import anthropic as anth
helper._client.messages.create = AsyncMock(
side_effect=anth.APITimeoutError(request=MagicMock())
)
result = await helper.summarize_l0_to_l1(
content="some content here",
object_type="file_context",
)
# Should get fallback (30% of content)
assert isinstance(result, SummaryResult)
assert len(result.summary) == 5 # 30% of 18 chars ≈ 5
async def test_api_error_returns_fallback(self, helper: HelperLLM) -> None:
import anthropic as anth
helper._client.messages.create = AsyncMock(
side_effect=anth.APIError(
message="Internal server error",
request=MagicMock(),
body=None,
)
)
result = await helper.summarize_l0_to_l1(
content="test content",
object_type="tool_result",
)
assert isinstance(result, SummaryResult)
# Fallback summary
assert result.summary == "tes" # 30% of 12 chars = 3
async def test_timeout_on_micro_fault(self, helper: HelperLLM) -> None:
import anthropic as anth
helper._client.messages.create = AsyncMock(
side_effect=anth.APITimeoutError(request=MagicMock())
)
result = await helper.answer_micro_fault("question", ["content"])
assert result == "Unable to answer from available context."
async def test_timeout_on_goal_classification(self, helper: HelperLLM) -> None:
import anthropic as anth
helper._client.messages.create = AsyncMock(
side_effect=anth.APITimeoutError(request=MagicMock())
)
result = await helper.classify_goal("do something", "context")
assert isinstance(result, GoalClassification)
assert result.goal == "do something"
async def test_timeout_on_generate_stub(self, helper: HelperLLM) -> None:
import anthropic as anth
helper._client.messages.create = AsyncMock(
side_effect=anth.APITimeoutError(request=MagicMock())
)
result = await helper.generate_stub("summary", "file_context", "2026-01-01")
assert "file_context" in result
assert "summary unavailable" in result
# ---------------------------------------------------------------------------
# Constructor tests
# ---------------------------------------------------------------------------
class TestConstructor:
def test_uses_provided_api_key(self) -> None:
with patch("mnemosyne.helper_llm.anthropic.AsyncAnthropic") as mock_cls:
HelperLLM(api_key="sk-test-123")
call_kwargs = mock_cls.call_args.kwargs
assert call_kwargs["api_key"] == "sk-test-123"
def test_falls_back_to_env_var(self) -> None:
with (
patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-env-456"}),
patch("mnemosyne.helper_llm.anthropic.AsyncAnthropic") as mock_cls,
):
HelperLLM()
call_kwargs = mock_cls.call_args.kwargs
assert call_kwargs["api_key"] == "sk-env-456"
def test_custom_model_and_base_url(self) -> None:
with patch("mnemosyne.helper_llm.anthropic.AsyncAnthropic") as mock_cls:
h = HelperLLM(
api_key="key",
model="claude-3-haiku-20240307",
base_url="http://localhost:8080",
)
call_kwargs = mock_cls.call_args.kwargs
assert call_kwargs["base_url"] == "http://localhost:8080"
assert h._model == "claude-3-haiku-20240307"
def test_default_timeout_and_retries(self) -> None:
with patch("mnemosyne.helper_llm.anthropic.AsyncAnthropic") as mock_cls:
HelperLLM(api_key="key")
call_kwargs = mock_cls.call_args.kwargs
assert call_kwargs["timeout"] == 10.0
assert call_kwargs["max_retries"] == 2