Coverage for /home/admin/Documents/AI/applications/lexigram-dev/lexigram/experimental/ai/lexigram-ai-memory/src/lexigram/ai/memory/pruning/pruner.py: 26%
43 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 07:19 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 07:19 +0800
1"""Dynamic context pruner for token-aware memory management."""
3from __future__ import annotations
5from typing import TYPE_CHECKING, Any
7from lexigram.ai.memory.pruning.scorer import HybridScorerImpl, RecencyScorerImpl
8from lexigram.ai.memory.pruning.types import PruningResult, PruningStrategy
9from lexigram.logging import (
10 get_logger,
11)
13if TYPE_CHECKING:
14 from lexigram.contracts.ai.llm import TokenCounterProtocol
15 from lexigram.contracts.ai.memory import MemoryEntry
17logger = get_logger(__name__)
20class DynamicContextPruner:
21 """Prunes MemoryEntry lists to fit within a token budget.
23 Uses pluggable scoring strategies to rank entries by importance, then
24 greedily selects entries until the token budget is exhausted.
26 Attributes:
27 token_counter: Protocol for counting tokens in text.
28 default_strategy: Default pruning strategy when none is specified.
29 """
31 def __init__(
32 self,
33 token_counter: TokenCounterProtocol,
34 default_strategy: PruningStrategy = PruningStrategy.HYBRID,
35 ) -> None:
36 """Initialize the pruner.
38 Args:
39 token_counter: Implementation of TokenCounterProtocol for token counting.
40 default_strategy: Default strategy to use if not overridden. Default is HYBRID.
41 """
42 self.token_counter = token_counter
43 self.default_strategy = default_strategy
45 async def prune(
46 self,
47 entries: list[MemoryEntry],
48 token_budget: int,
49 query: str | None = None,
50 strategy: PruningStrategy | None = None,
51 **kwargs,
52 ) -> PruningResult:
53 """Prune a list of memory entries to fit within a token budget.
55 Scores all entries using the specified strategy, sorts them by score
56 (descending), then greedily keeps entries until adding the next entry
57 would exceed the remaining budget.
59 Args:
60 entries: List of MemoryEntry objects to prune.
61 token_budget: Maximum number of tokens to keep.
62 query: Optional query context for relevance-based scoring.
63 strategy: Override the default pruning strategy. If None, uses default_strategy.
64 **kwargs: Additional keyword arguments (reserved for future use).
66 Returns:
67 PruningResult containing kept entries (score-ordered), counts,
68 and metadata about the pruning operation.
69 """
70 # Use default strategy if none provided
71 selected_strategy = strategy or self.default_strategy
73 # Handle empty input
74 if not entries:
75 return PruningResult(
76 kept=[],
77 pruned_count=0,
78 original_count=0,
79 token_budget=token_budget,
80 strategy=selected_strategy,
81 metadata={},
82 )
84 # Select scorer based on strategy
85 scorer = self._get_scorer(selected_strategy)
87 # Score all entries
88 if hasattr(scorer, "score_batch"):
89 batch_scores = scorer.score_batch(entries, query)
90 scored_entries = list(zip(batch_scores, entries, strict=True))
91 else:
92 scored_entries = [(scorer.score(entry, query), entry) for entry in entries]
94 # Sort by score descending (highest scores first)
95 scored_entries.sort(key=lambda x: x[0], reverse=True)
97 # Greedily select entries until budget is exhausted
98 kept: list[MemoryEntry] = []
99 remaining_budget = token_budget
101 for _score, entry in scored_entries:
102 # Count tokens in this entry's content
103 entry_tokens = self.token_counter.count(entry.content)
105 # Check if entry fits in remaining budget
106 if entry_tokens <= remaining_budget:
107 kept.append(entry)
108 remaining_budget -= entry_tokens
109 # If we have no entries yet and this entry exceeds budget,
110 # we still add it to avoid returning empty results for single large entries
111 elif not kept and token_budget > 0:
112 # Force include the first (highest-scored) entry only when budget > 0
113 kept.append(entry)
114 remaining_budget = 0
115 break
117 pruned_count = len(entries) - len(kept)
119 logger.debug(
120 "context_pruned",
121 original_count=len(entries),
122 kept_count=len(kept),
123 pruned_count=pruned_count,
124 strategy=selected_strategy,
125 )
127 return PruningResult(
128 kept=kept,
129 pruned_count=pruned_count,
130 original_count=len(entries),
131 token_budget=token_budget,
132 strategy=selected_strategy,
133 metadata={
134 "remaining_budget": remaining_budget,
135 "scorer_type": type(scorer).__name__,
136 },
137 )
139 def _get_scorer(self, strategy: PruningStrategy) -> Any:
140 """Get the scorer implementation for a given strategy.
142 Args:
143 strategy: The pruning strategy to use.
145 Returns:
146 A scorer instance matching the strategy.
148 Raises:
149 ValueError: If the strategy is not recognized.
150 """
151 if strategy == PruningStrategy.RELEVANCE:
152 logger.warning(
153 "pruning_strategy_relevance_not_implemented",
154 fallback="recency",
155 message="RELEVANCE strategy falls back to RecencyScorerImpl; embedding-based scoring not yet available",
156 )
158 # Use a registry dict instead of if/elif chains
159 scorer_registry = {
160 PruningStrategy.RECENCY: RecencyScorerImpl(),
161 PruningStrategy.RELEVANCE: RecencyScorerImpl(), # Same as recency for now
162 PruningStrategy.HYBRID: HybridScorerImpl(),
163 }
165 if strategy not in scorer_registry:
166 msg = f"Unknown pruning strategy: {strategy}"
167 raise ValueError(msg)
169 return scorer_registry[strategy]
172__all__ = ["DynamicContextPruner"]