harpe 0.1.0-alpha.2

Deterministic context synthesis and token budgeting for cognitive AI agents.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
//! # harpe
//!
//! Deterministic context synthesis and token budgeting for autonomous AI agents.
//!
//! Harpe provides deterministic, microsecond token budgeting and priority-based
//! context trimming for agentic loops. When language models run multi-turn agent
//! workflows, context windows can quickly exceed hardware or inference limits.
//! Uncontrolled growth results in context truncation, lost system prompts, or
//! excessive inference latency.
//!
//! Harpe solves this by calculating available context headroom, prioritizing
//! pinned instructions, and pruning lower-priority context items before model
//! invocation.
//!
//! ## Architecture Overview
//!
//! 1. **Token Accounting**: [`TokenBudget`] defines the maximum context envelope,
//!    deducting reserved tokens for model output, tool specifications, and safety margin.
//! 2. **Context Entities**: [`ContextItem`] represents individual context components
//!    (system prompts, active tool outputs, conversation turns) tagged with numeric priorities.
//! 3. **Synthesis Engine**: [`ContextBudgetEngine`] resolves token counts and applies
//!    pruning strategies such as [`PrunePolicy::LowestPriority`] or [`PrunePolicy::Fifo`]
//!    to produce an exact [`SynthesisReport`] fitting within the budget.
//!
//! ## Quick Start
//!
//! ```rust
//! use harpe::{ContextBudgetEngine, ContextItem, ContextSynthesizer, PrunePolicy, TokenBudget};
//!
//! // Define a total context envelope of 4096 tokens, reserving 1024 for output and tools
//! let mut budget = TokenBudget::new(4096);
//! budget.reserved_completion = 512;
//! budget.reserved_tools = 256;
//! budget.safety_margin = 256;
//!
//! assert_eq!(budget.available_context(), 3072);
//!
//! // Prepare context items with explicit priorities (0 is pinned/highest priority)
//! let system_prompt = ContextItem::new("sys", "You are an autonomous systems assistant.", 0);
//! let conversation = ContextItem::new("hist", "Previous user query and reasoning traces.", 50);
//! let scratchpad = ContextItem::new("scratch", "Temporary debug buffer with logs.", 90);
//!
//! let engine = ContextBudgetEngine::default();
//! let report = engine.synthesize(
//!     &[system_prompt, conversation, scratchpad],
//!     &budget,
//!     PrunePolicy::LowestPriority,
//! ).expect("Synthesis should succeed");
//!
//! println!("Consumed tokens: {}", report.consumed_tokens);
//! println!("Remaining tokens: {}", report.budget_remaining);
//! ```

pub const VERSION: &str = env!("CARGO_PKG_VERSION");

/// Pruning policies supported by the context synthesis engine.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrunePolicy {
    /// First-In First-Out: Prune older context items first based on `created_at` timestamp.
    Fifo,
    /// Priority-based: Retain items with lower priority numbers first (0 = highest priority).
    LowestPriority,
    /// Drop tail items that exceed available capacity in sequential order.
    TruncateTail,
    /// Discard items considered least relevant to the current objective.
    LeastRelevant,
}

/// Token budget configuration for an agent invocation turn.
///
/// Tracks the total available model context window and subtracts necessary reserves
/// for generation and tools to calculate safe available context headroom.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TokenBudget {
    /// Total token limit of the target model (for example, 8192 or 128000).
    pub total_budget: usize,
    /// Tokens reserved strictly for the model response generation.
    pub reserved_completion: usize,
    /// Tokens reserved for JSON schemas and tool definitions.
    pub reserved_tools: usize,
    /// Safety headroom buffer to absorb tokenizer discrepancies.
    pub safety_margin: usize,
}

impl TokenBudget {
    /// Create a new budget with the specified total token capacity.
    pub fn new(total_budget: usize) -> Self {
        Self {
            total_budget,
            reserved_completion: 0,
            reserved_tools: 0,
            safety_margin: 0,
        }
    }

    /// Calculate the usable context token capacity after all reserves are deducted.
    pub fn available_context(&self) -> usize {
        let reservations = self.reserved_completion + self.reserved_tools + self.safety_margin;
        if self.total_budget > reservations {
            self.total_budget - reservations
        } else {
            0
        }
    }

    /// Returns true if the provided token usage exceeds available context capacity.
    pub fn is_exceeded(&self, used_tokens: usize) -> bool {
        used_tokens > self.available_context()
    }

    /// Calculate context utilization percentage.
    pub fn utilization_pct(&self, used_tokens: usize) -> f64 {
        let avail = self.available_context();
        if avail == 0 {
            100.0
        } else {
            (used_tokens as f64 / avail as f64) * 100.0
        }
    }
}

/// A discreet unit of context presented to the synthesis engine.
#[derive(Debug, Clone, PartialEq)]
pub struct ContextItem {
    /// Unique identifier for this context entry.
    pub id: String,
    /// Logical role (for example: system, user, assistant, context).
    pub role: String,
    /// Raw textual content.
    pub content: String,
    /// Measured token count (computed dynamically if initialized as 0).
    pub token_count: usize,
    /// Retention priority. Lower values indicate higher importance. Priority 0 is pinned.
    pub priority: u8,
    /// If true, this item will never be pruned regardless of budget pressure.
    pub pinned: bool,
    /// Timestamp (UNIX epoch in seconds or fractional seconds) for FIFO ordering.
    pub created_at: f64,
}

impl ContextItem {
    /// Create a new context item with an identifier, content, and priority.
    /// Priority 0 automatically marks the item as pinned.
    pub fn new(id: impl Into<String>, content: impl Into<String>, priority: u8) -> Self {
        Self {
            id: id.into(),
            role: "context".to_string(),
            content: content.into(),
            token_count: 0,
            priority,
            pinned: priority == 0,
            created_at: 0.0,
        }
    }
}

/// Detailed outcome of a context synthesis execution.
#[derive(Debug, Clone, PartialEq)]
pub struct SynthesisReport {
    /// Context items selected to fit within the available budget.
    pub included_items: Vec<ContextItem>,
    /// Context items pruned due to budget constraints.
    pub pruned_items: Vec<ContextItem>,
    /// Total tokens consumed by included items.
    pub consumed_tokens: usize,
    /// Unallocated tokens remaining in available context space.
    pub budget_remaining: usize,
    /// Total tokens discarded across all pruned items.
    pub pruned_tokens: usize,
    /// The pruning strategy that was applied during synthesis.
    pub policy_applied: PrunePolicy,
}

/// Errors returned during context synthesis and budgeting operations.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HarpeError {
    /// Pinned items alone exceed the entire available context budget.
    BudgetOverflow(String),
    /// Internal failure during context synthesis or pruning calculation.
    SynthesisFailed(String),
}

impl std::fmt::Display for HarpeError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::BudgetOverflow(msg) => write!(f, "Budget overflow: {}", msg),
            Self::SynthesisFailed(msg) => write!(f, "Synthesis failed: {}", msg),
        }
    }
}

impl std::error::Error for HarpeError {}

/// Trait for measuring token counts of text slices.
pub trait TokenCounter: Send + Sync {
    /// Return the measured or estimated token count for the given text.
    fn count_tokens(&self, text: &str) -> usize;
}

/// Hook for lifecycle interception before and after pruning runs.
pub trait PruningHook: Send + Sync {
    /// Invoked before candidate pruning begins.
    fn pre_prune(&self, items: &[ContextItem]);
    /// Invoked after synthesis completes with the finalized report.
    fn post_prune(&self, report: &SynthesisReport);
}

/// Trait implemented by context synthesis engines.
pub trait ContextSynthesizer: Send + Sync {
    /// Synthesize and prune context items according to the supplied budget and policy.
    fn synthesize(
        &self,
        items: &[ContextItem],
        budget: &TokenBudget,
        policy: PrunePolicy,
    ) -> Result<SynthesisReport, HarpeError>;
}

/// Fast heuristic token counter estimating tokens via word and character ratios.
#[derive(Debug, Default, Clone)]
pub struct HeuristicTokenCounter;

impl TokenCounter for HeuristicTokenCounter {
    fn count_tokens(&self, text: &str) -> usize {
        if text.is_empty() {
            return 0;
        }
        let words = text.split_whitespace().count();
        let chars = text.chars().count();
        let char_estimate = (chars + 3) / 4;
        std::cmp::max(words, char_estimate)
    }
}

/// Primary context synthesis and budgeting engine.
pub struct ContextBudgetEngine {
    counter: Box<dyn TokenCounter>,
}

impl Default for ContextBudgetEngine {
    fn default() -> Self {
        Self {
            counter: Box::new(HeuristicTokenCounter),
        }
    }
}

impl ContextBudgetEngine {
    /// Instantiate a budget engine with a custom token counter implementation.
    pub fn new(counter: Box<dyn TokenCounter>) -> Self {
        Self { counter }
    }
}

impl ContextSynthesizer for ContextBudgetEngine {
    fn synthesize(
        &self,
        items: &[ContextItem],
        budget: &TokenBudget,
        policy: PrunePolicy,
    ) -> Result<SynthesisReport, HarpeError> {
        let mut resolved: Vec<ContextItem> = Vec::with_capacity(items.len());
        for it in items {
            let mut clone = it.clone();
            if clone.token_count == 0 {
                clone.token_count = self.counter.count_tokens(&clone.content);
            }
            resolved.push(clone);
        }

        let capacity = budget.available_context();
        let mut pinned = Vec::new();
        let mut unpinned = Vec::new();

        for it in resolved.into_iter() {
            if it.pinned || it.priority == 0 {
                pinned.push(it);
            } else {
                unpinned.push(it);
            }
        }

        let pinned_tokens: usize = pinned.iter().map(|it| it.token_count).sum();

        if pinned_tokens >= capacity {
            let pruned_tokens = unpinned.iter().map(|it| it.token_count).sum();
            return Ok(SynthesisReport {
                included_items: pinned,
                pruned_items: unpinned,
                consumed_tokens: pinned_tokens,
                budget_remaining: 0,
                pruned_tokens,
                policy_applied: policy,
            });
        }

        let mut remaining = capacity - pinned_tokens;
        let mut included_unpinned = Vec::new();
        let mut pruned_unpinned = Vec::new();

        match policy {
            PrunePolicy::LowestPriority => {
                let mut sorted = unpinned;
                sorted.sort_by(|a, b| a.priority.cmp(&b.priority));
                for it in sorted {
                    if it.token_count <= remaining {
                        remaining -= it.token_count;
                        included_unpinned.push(it);
                    } else {
                        pruned_unpinned.push(it);
                    }
                }
            }
            PrunePolicy::Fifo => {
                let mut sorted = unpinned;
                sorted.sort_by(|a, b| {
                    b.created_at
                        .partial_cmp(&a.created_at)
                        .unwrap_or(std::cmp::Ordering::Equal)
                });
                for it in sorted {
                    if it.token_count <= remaining {
                        remaining -= it.token_count;
                        included_unpinned.push(it);
                    } else {
                        pruned_unpinned.push(it);
                    }
                }
            }
            PrunePolicy::TruncateTail | PrunePolicy::LeastRelevant => {
                for it in unpinned {
                    if it.token_count <= remaining {
                        remaining -= it.token_count;
                        included_unpinned.push(it);
                    } else {
                        pruned_unpinned.push(it);
                    }
                }
            }
        }

        let consumed: usize = pinned
            .iter()
            .chain(included_unpinned.iter())
            .map(|it| it.token_count)
            .sum();
        let pruned: usize = pruned_unpinned.iter().map(|it| it.token_count).sum();

        let mut final_included = pinned;
        final_included.extend(included_unpinned);

        Ok(SynthesisReport {
            included_items: final_included,
            pruned_items: pruned_unpinned,
            consumed_tokens: consumed,
            budget_remaining: capacity.saturating_sub(consumed),
            pruned_tokens: pruned,
            policy_applied: policy,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_budget_available_context() {
        let mut budget = TokenBudget::new(5000);
        budget.reserved_completion = 1000;
        budget.reserved_tools = 500;
        budget.safety_margin = 200;

        assert_eq!(budget.available_context(), 3300);
        assert!(!budget.is_exceeded(3300));
        assert!(budget.is_exceeded(3301));
    }

    #[test]
    fn test_heuristic_token_counter() {
        let counter = HeuristicTokenCounter;
        assert_eq!(counter.count_tokens(""), 0);
        assert!(counter.count_tokens("Hello world from Harpe") >= 4);
    }

    #[test]
    fn test_lowest_priority_pruning() {
        let engine = ContextBudgetEngine::default();
        let budget = TokenBudget::new(100);

        let items = vec![
            ContextItem {
                id: "pin".into(),
                role: "system".into(),
                content: "Keep this".into(),
                token_count: 20,
                priority: 0,
                pinned: true,
                created_at: 1.0,
            },
            ContextItem {
                id: "high".into(),
                role: "user".into(),
                content: "Important message".into(),
                token_count: 30,
                priority: 10,
                pinned: false,
                created_at: 2.0,
            },
            ContextItem {
                id: "low".into(),
                role: "context".into(),
                content: "Low importance".into(),
                token_count: 60,
                priority: 80,
                pinned: false,
                created_at: 3.0,
            },
        ];

        let report = engine
            .synthesize(&items, &budget, PrunePolicy::LowestPriority)
            .unwrap();
        assert_eq!(report.included_items.len(), 2);
        assert_eq!(report.pruned_items.len(), 1);
        assert_eq!(report.pruned_items[0].id, "low");
        assert_eq!(report.consumed_tokens, 50);
        assert_eq!(report.budget_remaining, 50);
    }
}