- 1
//! `WorkingSetPlanner` (docs/design/68-context-engine.md §4, §10): decides, - 2
//! for one request, which closed turns ride along at `Full` fidelity, which - 3
//! collapse to a one-line `Card`, and which are pushed into a `Packet` — - 4
//! from measured costs against a measured budget, never a fixed count. - 5
//! Pure: no I/O, no locks, no network. The caller - 6
//! (`vak-agent`'s turn loop) supplies the `CapacityProfile`, the - 7
//! `TurnIndex`, and the incoming directive; `plan()` returns a `WorkingSetPlan` - 8
//! that `SessionLog::derive_with_plan` turns into messages. - 9
- 10
pub use vak_session::{Fidelity, WorkingSetPlan}; - 11
use vak_session::{ReadingKey, SessionLog, TurnIndex}; - 12
- 13
use crate::assemble::messages_chars; - 14
use crate::capacity::CapacityProfile; - 15
- 16
/// Directive fragments that refer back to the immediately preceding turn - 17
/// without repeating its subject (§4/§10: "anaphora ... always promotes the - 18
/// immediately preceding turn"). Matched as a case-insensitive substring of - 19
/// the directive text — deliberately loose, since a false positive costs - 20
/// one extra `Full` turn and a false negative costs nothing the relevance - 21
/// search wouldn't otherwise catch. - 22
pub const ANAPHORA_PHRASES: [&str; 7] = [ - 23
"that", "it", "again", "the same", "previous", "above", "this one", - 24
]; - 25
- 26
/// Inputs to one planning pass. `reading` is the current directive's own - 27
/// reading, already resolved by the intent tier before planning runs. - 28
/// `current_turn_tokens` is the OPEN turn's measured size so far (directive - 29
/// plus any steps already taken this turn); it never itself appears in the - 30
/// plan (the open turn is always verbatim), but it floors the reserve - 31
/// subtracted from the budget for it. - 32
pub struct PlanInput<'a> { - 33
pub profile: &'a CapacityProfile, - 34
pub index: &'a TurnIndex, - 35
pub directive: &'a str, - 36
pub reading: Option<&'a ReadingKey>, - 37
pub prefix_tokens: u64, - 38
pub tail_tokens: u64, - 39
pub current_turn_tokens: u64, - 40
} - 41
- 42
/// Whether `directive` contains an anaphoric reference to "the last thing" - 43
/// (§4/§10's anaphora word list). - 44
fn is_anaphoric(directive: &str) -> bool { - 45
let lower = directive.to_lowercase(); - 46
let words: Vec<&str> = lower - 47
.split(|c: char| !c.is_alphanumeric()) - 48
.filter(|w| !w.is_empty()) - 49
.collect(); - 50
ANAPHORA_PHRASES.iter().any(|phrase| { - 51
if phrase.contains(' ') { - 52
lower.contains(phrase) - 53
} else { - 54
words.contains(phrase) - 55
} - 56
}) - 57
} - 58
- 59
/// How many of the most recent closed turns a `minimal` reading may still - 60
/// carry at `Full`. Two: the exchange just before this one, and the one - 61
/// before that, which is what a greeting or a one-line answer plausibly - 62
/// refers to. - 63
const MINIMAL_FULL_TURNS: usize = 2; - 64
- 65
/// When the card tier overflows, the number of turns evicted into the - 66
/// packet is rounded UP to a multiple of this many (item 3 fix). Without - 67
/// batching, a fixed budget means every new turn displaces exactly the one - 68
/// turn that just aged out of the card tier, so `packet_range`'s newest - 69
/// (`last`) boundary moves by one turn on every subsequent turn once - 70
/// overflow starts — and `SessionLog::packet_needs_compaction` needs an - 71
/// EXACT range match to reuse a stored packet, so a boundary that creeps by - 72
/// one turn reran the summariser on almost every turn of a long session. - 73
/// Rounding evicts a few turns early, leaving headroom so the boundary - 74
/// holds for this many turns before jumping by that many at once. - 75
const PACKET_BATCH_TURNS: usize = 8; - 76
- 77
/// A turn's value from recency alone: `1 / (1 + age)` where `age` is how - 78
/// many closed turns came after it. The most recent closed turn is worth - 79
/// 1.0, the one before it 0.5, and so on — a parameter-free decay that a - 80
/// perfectly relevant older turn (normalised lexical score 1.0) ties with - 81
/// rather than loses to. - 82
fn recency_value(age: usize) -> f64 { - 83
1.0 / (1.0 + age as f64) - 84
} - 85
- 86
/// The relevance query: the directive plus the current reading's act and - 87
/// domain words, so reading overlap is scored by the same BM25 as the text - 88
/// instead of being a separate boolean bonus. - 89
fn relevance_query(directive: &str, reading: Option<&ReadingKey>) -> String { - 90
let mut query = directive.to_string(); - 91
if let Some(reading) = reading { - 92
query.push(' '); - 93
query.push_str(&reading.act); - 94
for domain in &reading.domains { - 95
query.push(' '); - 96
query.push_str(domain); - 97
} - 98
} - 99
query - 100
} - 101
- 102
/// Builds the working-set plan for one request (§4, §10). Never splits a - 103
/// turn: every cost check is turn-whole, and a turn that does not fit is - 104
/// deferred to the next tier down (Full → Card → Packet) rather than - 105
/// truncated. - 106
pub fn plan(input: PlanInput) -> WorkingSetPlan { - 107
let reserve = input - 108
.profile - 109
.current_turn_reserve(input.current_turn_tokens); - 110
let budget = input - 111
.profile - 112
.budget(input.prefix_tokens, input.tail_tokens, reserve); - 113
- 114
// Only closed turns with a written card are plannable at all — the - 115
// still-open turn (last, uncarded) is never planned; it is always sent - 116
// verbatim by the caller. - 117
// Turns behind a reset-with-handoff are invisible to the model: not - 118
// candidates for any tier, and never the start of a packet range. - 119
let closed: Vec<&vak_session::Turn> = input - 120
.index - 121
.turns - 122
.iter() - 123
.filter(|turn| turn.closed && turn.card.is_some() && !turn.behind_reset) - 124
.collect(); - 125
- 126
let mut spent: u64 = 0; - 127
let mut full_ids: std::collections::HashSet<String> = std::collections::HashSet::new(); - 128
let mut retrieved: Vec<String> = Vec::new(); - 129
- 130
// Every closed turn is scored once and the budget is filled in - 131
// descending value: the most recent turn and the most relevant turn are - 132
// both worth 1.0, an older or less relevant one proportionally less, so - 133
// no share of the budget is reserved for either signal — recency and - 134
// relevance compete for the same tokens on equal terms, and a turn that - 135
// does not fit is skipped for a cheaper one further down the ranking - 136
// rather than blocking everything behind it. - 137
let anaphoric = is_anaphoric(input.directive); - 138
let preceding = closed.last().map(|turn| turn.id.clone()); - 139
// `ContextProfile::Minimal` (docs/design/47-commitment-kernel.md): just - 140
// the conversation. No relevance retrieval promotes an older turn, and - 141
// only the most recent turns are candidates for `Full`; a greeting does - 142
// not pay for last Tuesday. Anaphora still promotes the preceding turn — - 143
// "thanks, do that again" points at it. - 144
let minimal = input - 145
.reading - 146
.is_some_and(vak_session::ReadingKey::is_minimal); - 147
let query = relevance_query(input.directive, input.reading); - 148
let lexical: std::collections::HashMap<String, f64> = if minimal { - 149
std::collections::HashMap::new() - 150
} else { - 151
input.index.search(&query).into_iter().collect() - 152
}; - 153
let best_lexical = lexical.values().copied().fold(0.0_f64, f64::max); - 154
let mut ranked: Vec<(f64, f64, usize, &vak_session::Turn)> = closed - 155
.iter() - 156
.enumerate() - 157
.map(|(position, turn)| { - 158
let age = closed.len() - 1 - position; - 159
let recency = if minimal && age >= MINIMAL_FULL_TURNS { - 160
0.0 - 161
} else { - 162
recency_value(age) - 163
}; - 164
let relevance = if best_lexical > 0.0 { - 165
lexical.get(&turn.id).copied().unwrap_or(0.0) / best_lexical - 166
} else { - 167
0.0 - 168
}; - 169
let anaphora = if anaphoric && preceding.as_deref() == Some(turn.id.as_str()) { - 170
1.0 - 171
} else { - 172
0.0 - 173
}; - 174
( - 175
recency.max(relevance).max(anaphora), - 176
recency, - 177
position, - 178
*turn, - 179
) - 180
}) - 181
.collect(); - 182
ranked.sort_by(|a, b| { - 183
b.0.partial_cmp(&a.0) - 184
.unwrap_or(std::cmp::Ordering::Equal) - 185
.then(b.2.cmp(&a.2)) - 186
}); - 187
for (value, recency, _, turn) in &ranked { - 188
// A turn worth nothing is not promoted to `Full`, whatever the - 189
// budget; it still gets a card below. - 190
if *value <= 0.0 { - 191
continue; - 192
} - 193
let cost = turn.card.as_ref().map(|c| c.tokens_full).unwrap_or(0); - 194
if spent.saturating_add(cost) <= budget { - 195
spent += cost; - 196
full_ids.insert(turn.id.clone()); - 197
if *value > *recency { - 198
retrieved.push(turn.id.clone()); - 199
} - 200
} - 201
} - 202
- 203
// 3) Card tier over everything still not Full, newest -> oldest, while - 204
// it fits in what remains of `budget`. When some (oldest) not-full - 205
// turns do not fit, they collapse into a single packet range (never - 206
// split, never scattered) — and the number evicted is rounded UP to a - 207
// multiple of `PACKET_BATCH_TURNS` so the packet's boundary does not - 208
// move on every turn (see the constant's doc comment). - 209
let not_full: Vec<&vak_session::Turn> = closed - 210
.iter() - 211
.rev() - 212
.filter(|turn| !full_ids.contains(&turn.id)) - 213
.copied() - 214
.collect(); - 215
// Dry run: the minimal number of newest not-full turns that fit under - 216
// `budget` at Card cost, exactly as before batching existed. This is - 217
// never applied directly — only used to derive how many turns must be - 218
// evicted at minimum, which then gets rounded up to a batch. - 219
let mut keep_count = 0usize; - 220
let mut dry_run_spent = spent; - 221
for turn in ¬_full { - 222
let cost = turn.card.as_ref().map(|c| c.tokens_card).unwrap_or(0); - 223
if dry_run_spent.saturating_add(cost) <= budget { - 224
dry_run_spent += cost; - 225
keep_count += 1; - 226
} else { - 227
break; - 228
} - 229
} - 230
let evict_count = not_full.len() - keep_count; - 231
let keep_count = if evict_count == 0 { - 232
keep_count - 233
} else { - 234
let batches = evict_count.saturating_add(PACKET_BATCH_TURNS - 1) / PACKET_BATCH_TURNS; - 235
let rounded_evict = batches - 236
.saturating_mul(PACKET_BATCH_TURNS) - 237
.min(not_full.len()); - 238
not_full.len() - rounded_evict - 239
}; - 240
- 241
let mut per_turn: Vec<(String, Fidelity)> = Vec::new(); - 242
let mut packet_ids: Vec<String> = Vec::new(); - 243
for (position, turn) in not_full.iter().enumerate() { - 244
if position < keep_count { - 245
let cost = turn.card.as_ref().map(|c| c.tokens_card).unwrap_or(0); - 246
spent += cost; - 247
per_turn.push((turn.id.clone(), Fidelity::Card)); - 248
} else { - 249
packet_ids.push(turn.id.clone()); - 250
} - 251
} - 252
for id in full_ids { - 253
per_turn.push((id, Fidelity::Full)); - 254
} - 255
- 256
// Render in chronological order (oldest first), matching `index.turns`. - 257
let order: std::collections::HashMap<&str, usize> = closed - 258
.iter() - 259
.enumerate() - 260
.map(|(i, t)| (t.id.as_str(), i)) - 261
.collect(); - 262
per_turn.sort_by_key(|(id, _)| order.get(id.as_str()).copied().unwrap_or(usize::MAX)); - 263
- 264
let packet_range = if packet_ids.is_empty() { - 265
None - 266
} else { - 267
// `packet_ids` was collected oldest-appended-last while walking - 268
// newest -> oldest, so the range is (last pushed, first pushed). - 269
let first = packet_ids.last().cloned(); - 270
let last = packet_ids.first().cloned(); - 271
first.zip(last) - 272
}; - 273
- 274
WorkingSetPlan { - 275
per_turn, - 276
packet_range, - 277
retrieved, - 278
budget, - 279
spent, - 280
} - 281
} - 282
- 283
/// Plans one request against the ledger as it stands: builds the - 284
/// `TurnIndex`, gives every closed turn a provisional card so it can be - 285
/// costed, reads the current directive and its resolved reading, sizes the - 286
/// open turn's reserve from what will actually be sent - 287
/// (`open_turn_verbatim`, reset-aware), and delegates to [`plan`]. The one - 288
/// entry point every caller — the agent loop per step, `/compact`, the - 289
/// eval gate — plans through, so they can never disagree on what a plan - 290
/// is computed from. - 291
pub fn plan_for_session( - 292
log: &SessionLog, - 293
profile: &CapacityProfile, - 294
prefix_tokens: u64, - 295
tail_tokens: u64, - 296
) -> WorkingSetPlan { - 297
let mut index = TurnIndex::from_log(log); - 298
index.ensure_cards(&|text| profile.estimate_tokens(text.chars().count() as u64)); - 299
let directive = index - 300
.turns - 301
.last() - 302
.map(|turn| turn.directive.text_content()) - 303
.unwrap_or_default(); - 304
let current_turn_tokens = profile.estimate_tokens(messages_chars(&log.open_turn_verbatim())); - 305
let reading = log.latest_reading(); - 306
plan(PlanInput { - 307
profile, - 308
index: &index, - 309
directive: &directive, - 310
reading: reading.as_ref(), - 311
prefix_tokens, - 312
tail_tokens, - 313
current_turn_tokens, - 314
}) - 315
} - 316
- 317
#[cfg(test)] - 318
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 319
mod tests { - 320
use super::*; - 321
use crate::capacity::{CacheBehaviour, CapacityProfile, Horizon, ProbeProvenance}; - 322
use vak_llm::Message as M; - 323
use vak_session::types::{FrozenContract, MessageRecord, SessionHeader, TurnCardRecord}; - 324
use vak_session::{Answer, SessionLog, SessionPath, TraceLine, TurnCard}; - 325
- 326
fn profile(horizon_tokens: u64) -> CapacityProfile { - 327
CapacityProfile::from_probe( - 328
horizon_tokens, - 329
None, - 330
Horizon { - 331
tokens: horizon_tokens, - 332
confidence: 0.9, - 333
last_confirmed: std::time::SystemTime::now(), - 334
}, - 335
CacheBehaviour::Unknown, - 336
0, - 337
ProbeProvenance { - 338
probed_at: std::time::SystemTime::now(), - 339
rungs: Vec::new(), - 340
signals: Vec::new(), - 341
metadata_digest: "d".into(), - 342
quantisation: None, - 343
}, - 344
) - 345
} - 346
- 347
fn header(cwd: &std::path::Path) -> SessionHeader { - 348
SessionHeader { - 349
agent: None, - 350
session_id: "s1".into(), - 351
created_at: chrono::Utc::now(), - 352
cwd: cwd.to_path_buf(), - 353
parent_session_id: None, - 354
contract_id: None, - 355
work_item_id: None, - 356
conversation: None, - 357
contract: FrozenContract { - 358
app_version: "test".into(), - 359
provider: "scripted".into(), - 360
model: "m".into(), - 361
route_ladder: Vec::new(), - 362
route_objective: String::new(), - 363
route_annotations: Vec::new(), - 364
system_prompt: String::new(), - 365
permission_mode: "workspace-write".into(), - 366
capabilities: Vec::new(), - 367
prompt_layers: Vec::new(), - 368
}, - 369
} - 370
} - 371
- 372
fn card( - 373
turn_id: &str, - 374
text: &str, - 375
tokens_full: u64, - 376
tokens_card: u64, - 377
domains: &[&str], - 378
) -> TurnCard { - 379
TurnCard { - 380
turn_id: turn_id.to_string(), - 381
asked: text.to_string(), - 382
did: Vec::<TraceLine>::new(), - 383
answered: Answer { - 384
presentations: Vec::new(), - 385
narration: format!("answer for {turn_id}"), - 386
}, - 387
outcome: "completed".into(), - 388
reading: ReadingKey { - 389
act: "answer".into(), - 390
domains: domains.iter().map(|d| d.to_string()).collect(), - 391
modalities: Vec::new(), - 392
context: String::new(), - 393
}, - 394
tokens_full, - 395
tokens_card, - 396
} - 397
} - 398
- 399
/// Builds `n` closed turns, each with a directly-constructed `TurnCard` - 400
/// (so `tokens_full`/`tokens_card` are exact, not derived from rendered - 401
/// text) and returns the log plus the turn ids in chronological order. - 402
fn fixture( - 403
dir: &std::path::Path, - 404
specs: &[(&str, u64, u64, &[&str])], - 405
) -> (SessionLog, Vec<String>) { - 406
let path = SessionPath::new_session_file(dir, dir, "s1"); - 407
let mut log = SessionLog::create(path, header(dir)).unwrap(); - 408
let mut ids = Vec::new(); - 409
for (text, tokens_full, tokens_card, domains) in specs { - 410
let id = log - 411
.append_message(MessageRecord { - 412
message: M::user_text(*text), - 413
meta: None, - 414
}) - 415
.unwrap() - 416
.id; - 417
log.append_message(MessageRecord { - 418
message: M::assistant(vec![vak_llm::ContentBlock::text(format!( - 419
"answer for {text}" - 420
))]), - 421
meta: None, - 422
}) - 423
.unwrap(); - 424
log.append_turn_card(TurnCardRecord { - 425
turn_id: id.clone(), - 426
card: card(&id, text, *tokens_full, *tokens_card, domains), - 427
}) - 428
.unwrap(); - 429
ids.push(id); - 430
} - 431
(log, ids) - 432
} - 433
- 434
#[test] - 435
fn fills_newest_first_and_never_splits() { - 436
let dir = tempfile::tempdir().unwrap(); - 437
// Costs chosen so exactly the newest 2 fit; the 3rd would overflow. - 438
let (log, ids) = fixture( - 439
dir.path(), - 440
&[ - 441
("turn one", 400, 40, &[]), - 442
("turn two", 400, 40, &[]), - 443
("turn three", 400, 40, &[]), - 444
], - 445
); - 446
let index = TurnIndex::from_log(&log); - 447
let profile = profile(1_100); - 448
let result = plan(PlanInput { - 449
profile: &profile, - 450
index: &index, - 451
directive: "unrelated question", - 452
reading: None, - 453
prefix_tokens: 100, - 454
tail_tokens: 0, - 455
current_turn_tokens: 0, - 456
}); - 457
// budget = 1100 - 100 = 1000; retrieval_reserve = 150, so recency - 458
// fills against main_budget = 850: turn3(400) fits(400<=850), - 459
// turn2(400) fits(800<=850), turn1(400) would make 1200 > 850 -> - 460
// stops, turn1 stays out of Full. - 461
let full: Vec<&str> = result - 462
.per_turn - 463
.iter() - 464
.filter(|(_, f)| matches!(f, Fidelity::Full)) - 465
.map(|(id, _)| id.as_str()) - 466
.collect(); - 467
assert!(full.contains(&ids[2].as_str())); - 468
assert!(full.contains(&ids[1].as_str())); - 469
assert!( - 470
!full.contains(&ids[0].as_str()), - 471
"turn one must not be Full" - 472
); - 473
assert!(result.spent <= result.budget); - 474
} - 475
- 476
#[test] - 477
fn retrieval_promotes_a_relevant_older_turn() { - 478
let dir = tempfile::tempdir().unwrap(); - 479
let (log, ids) = fixture( - 480
dir.path(), - 481
&[ - 482
("what is the sensex today", 15, 5, &["finance"]), - 483
("unrelated small talk", 60, 10, &[]), - 484
("another unrelated turn", 60, 10, &[]), - 485
], - 486
); - 487
let index = TurnIndex::from_log(&log); - 488
// budget = 130; retrieval_reserve = 19, main_budget = 111. Recency - 489
// (newest -> oldest) fits the newest unrelated turn (60 <= 111) but - 490
// the next one would make 120 > 111 -> stops there, leaving the - 491
// sensex turn (oldest) reachable only through relevance. - 492
let profile = profile(130); - 493
let result = plan(PlanInput { - 494
profile: &profile, - 495
index: &index, - 496
directive: "sensex", - 497
reading: None, - 498
prefix_tokens: 0, - 499
tail_tokens: 0, - 500
current_turn_tokens: 0, - 501
}); - 502
assert!( - 503
result.retrieved.contains(&ids[0]), - 504
"the sensex turn should be promoted by BM25 relevance: {:?}", - 505
result.retrieved - 506
); - 507
assert!(result.spent <= result.budget); - 508
} - 509
- 510
/// `ContextProfile::Minimal` (docs/design/47-commitment-kernel.md): a - 511
/// greeting does not retrieve an older turn on relevance, and only the - 512
/// most recent turns are candidates for `Full`, however much budget - 513
/// there is. - 514
#[test] - 515
fn a_minimal_reading_neither_retrieves_nor_carries_old_turns_at_full() { - 516
let dir = tempfile::tempdir().unwrap(); - 517
let (log, ids) = fixture( - 518
dir.path(), - 519
&[ - 520
("what is the sensex today", 15, 5, &["finance"]), - 521
("unrelated small talk", 60, 10, &[]), - 522
("another unrelated turn", 60, 10, &[]), - 523
("and one more", 60, 10, &[]), - 524
], - 525
); - 526
let index = TurnIndex::from_log(&log); - 527
// Plenty of budget: every turn would be `Full` for a recall reading. - 528
let profile = profile(10_000); - 529
let minimal = ReadingKey { - 530
act: "converse".into(), - 531
domains: Vec::new(), - 532
modalities: Vec::new(), - 533
context: "minimal".into(), - 534
}; - 535
let result = plan(PlanInput { - 536
profile: &profile, - 537
index: &index, - 538
directive: "sensex", - 539
reading: Some(&minimal), - 540
prefix_tokens: 0, - 541
tail_tokens: 0, - 542
current_turn_tokens: 0, - 543
}); - 544
let full: Vec<&str> = result - 545
.per_turn - 546
.iter() - 547
.filter(|(_, fidelity)| *fidelity == Fidelity::Full) - 548
.map(|(id, _)| id.as_str()) - 549
.collect(); - 550
assert!(result.retrieved.is_empty(), "{:?}", result.retrieved); - 551
assert!(full.contains(&ids[3].as_str())); - 552
assert!(full.contains(&ids[2].as_str())); - 553
assert!(!full.contains(&ids[1].as_str()), "{full:?}"); - 554
assert!(!full.contains(&ids[0].as_str()), "{full:?}"); - 555
- 556
// The same request with a recall reading carries everything. - 557
let recall = ReadingKey { - 558
context: "recall".into(), - 559
..minimal.clone() - 560
}; - 561
let result = plan(PlanInput { - 562
profile: &profile, - 563
index: &index, - 564
directive: "sensex", - 565
reading: Some(&recall), - 566
prefix_tokens: 0, - 567
tail_tokens: 0, - 568
current_turn_tokens: 0, - 569
}); - 570
let full = result - 571
.per_turn - 572
.iter() - 573
.filter(|(_, fidelity)| *fidelity == Fidelity::Full) - 574
.count(); - 575
assert_eq!(full, 4); - 576
} - 577
- 578
#[test] - 579
fn anaphora_always_promotes_the_immediately_preceding_turn() { - 580
let dir = tempfile::tempdir().unwrap(); - 581
let (log, ids) = fixture( - 582
dir.path(), - 583
&[ - 584
("filler turn from earlier", 160, 10, &[]), - 585
("show me the chart for AAPL", 180, 10, &[]), - 586
], - 587
); - 588
let index = TurnIndex::from_log(&log); - 589
// budget = 200: only one of the two turns can be Full. The AAPL turn - 590
// is the immediately preceding one, so it is worth 1.0 by recency - 591
// and by anaphora alike and must be the one that rides Full; the - 592
// filler turn falls to a card. - 593
let profile = profile(200); - 594
let result = plan(PlanInput { - 595
profile: &profile, - 596
index: &index, - 597
directive: "do that again", - 598
reading: None, - 599
prefix_tokens: 0, - 600
tail_tokens: 0, - 601
current_turn_tokens: 0, - 602
}); - 603
assert!( - 604
result - 605
.per_turn - 606
.iter() - 607
.any(|(id, fidelity)| id == &ids[1] && *fidelity == Fidelity::Full), - 608
"anaphora must promote the immediately preceding turn: {:?}", - 609
result - 610
); - 611
assert!(result.spent <= result.budget); - 612
} - 613
- 614
/// A turn behind a reset-with-handoff is invisible to the model and - 615
/// therefore never a candidate: not Full, not Card, and never the - 616
/// start of a packet range (which would otherwise trigger a - 617
/// summariser call over turns the model cannot see). - 618
#[test] - 619
fn turns_behind_a_reset_are_never_planned() { - 620
let dir = tempfile::tempdir().unwrap(); - 621
let (mut log, ids) = fixture( - 622
dir.path(), - 623
&[("turn one", 400, 40, &[]), ("turn two", 400, 40, &[])], - 624
); - 625
log.append_handoff_reset("handoff".into(), 1_000).unwrap(); - 626
let id3 = log - 627
.append_message(MessageRecord { - 628
message: M::user_text("turn three"), - 629
meta: None, - 630
}) - 631
.unwrap() - 632
.id; - 633
log.append_message(MessageRecord { - 634
message: M::assistant(vec![vak_llm::ContentBlock::text("answer for turn three")]), - 635
meta: None, - 636
}) - 637
.unwrap(); - 638
log.append_turn_card(TurnCardRecord { - 639
turn_id: id3.clone(), - 640
card: card(&id3, "turn three", 400, 40, &[]), - 641
}) - 642
.unwrap(); - 643
- 644
let index = TurnIndex::from_log(&log); - 645
assert!(index.turns[0].behind_reset && index.turns[1].behind_reset); - 646
assert!(!index.turns[2].behind_reset); - 647
- 648
// Tiny budget: only a packet could hold the pre-reset turns, and - 649
// even that must not happen. - 650
let profile = profile(200); - 651
let result = plan(PlanInput { - 652
profile: &profile, - 653
index: &index, - 654
directive: "turn one again", - 655
reading: None, - 656
prefix_tokens: 100, - 657
tail_tokens: 0, - 658
current_turn_tokens: 0, - 659
}); - 660
let planned: Vec<&str> = result.per_turn.iter().map(|(id, _)| id.as_str()).collect(); - 661
assert!(!planned.contains(&ids[0].as_str()) && !planned.contains(&ids[1].as_str())); - 662
assert!( - 663
result - 664
.packet_range - 665
.as_ref() - 666
.is_none_or(|(first, last)| first != &ids[0] && last != &ids[1]), - 667
"{:?}", - 668
result.packet_range - 669
); - 670
} - 671
- 672
#[test] - 673
fn card_overflow_collapses_the_oldest_into_a_packet() { - 674
let dir = tempfile::tempdir().unwrap(); - 675
let (log, ids) = fixture( - 676
dir.path(), - 677
&[ - 678
("ancient turn", 5_000, 5_000, &[]), - 679
("old turn", 10, 10, &[]), - 680
("recent turn", 10, 10, &[]), - 681
], - 682
); - 683
let index = TurnIndex::from_log(&log); - 684
// Budget fits the two small turns as Full (recency) with nothing - 685
// left for the ancient turn's oversized card -> it becomes a packet. - 686
let profile = profile(50); - 687
let result = plan(PlanInput { - 688
profile: &profile, - 689
index: &index, - 690
directive: "unrelated", - 691
reading: None, - 692
prefix_tokens: 0, - 693
tail_tokens: 0, - 694
current_turn_tokens: 0, - 695
}); - 696
assert!(result.packet_range.is_some(), "{:?}", result); - 697
let (first, last) = result.packet_range.unwrap(); - 698
assert_eq!(first, ids[0]); - 699
assert_eq!(last, ids[0]); - 700
assert!(result.spent <= result.budget); - 701
} - 702
- 703
/// Item 3 fix: once the card tier overflows, the packet's newest - 704
/// (`last`) boundary must hold steady for `PACKET_BATCH_TURNS` turns - 705
/// and then jump by that many at once — never move by one turn on - 706
/// every turn, which reran the compaction summariser almost every turn - 707
/// of a long session (docs/design/68-context-engine.md §4). - 708
#[test] - 709
fn packet_boundary_moves_in_batches_not_on_every_turn() { - 710
const COST: u64 = 50; - 711
const NEVER_FULL: u64 = 100_000; - 712
let profile = profile(1_000); // budget == 1000 (zero prefix/tail/reserve) - 713
- 714
// Builds a fresh session of exactly `t` equal-cost turns and - 715
// returns the index (within that run's own turn order) of the - 716
// packet's newest boundary, or None when nothing is packeted. - 717
let last_at = |t: usize| -> Option<usize> { - 718
let dir = tempfile::tempdir().unwrap(); - 719
let specs: Vec<(&str, u64, u64, &[&str])> = (0..t) - 720
.map(|_| ("filler turn", NEVER_FULL, COST, &[][..])) - 721
.collect(); - 722
let (log, ids) = fixture(dir.path(), &specs); - 723
let index = TurnIndex::from_log(&log); - 724
let result = plan(PlanInput { - 725
profile: &profile, - 726
index: &index, - 727
directive: "unrelated", - 728
reading: None, - 729
prefix_tokens: 0, - 730
tail_tokens: 0, - 731
current_turn_tokens: 0, - 732
}); - 733
result - 734
.packet_range - 735
.map(|(_, last)| ids.iter().position(|id| id == &last).unwrap()) - 736
}; - 737
- 738
// floor(1000/50) == 20 turns fit as Card: no packet below that. - 739
assert_eq!(last_at(20), None); - 740
// First overflow (turns 21-28): evict_count 1..=8 all round up to - 741
// exactly one batch of 8 — the boundary is pinned at turn index 7 - 742
// (the 8th turn) for all eight of these turn counts, not moving by - 743
// one each time. - 744
assert_eq!(last_at(21), Some(7)); - 745
assert_eq!(last_at(24), Some(7)); - 746
assert_eq!(last_at(28), Some(7)); - 747
// One batch later (turns 29-36): the boundary jumps by a whole - 748
// PACKET_BATCH_TURNS at once, to index 15 (the 16th turn) — and - 749
// then holds there for the next batch. - 750
assert_eq!(last_at(29), Some(15)); - 751
assert_eq!(last_at(36), Some(15)); - 752
// A third batch confirms the pattern continues, not a one-off. - 753
assert_eq!(last_at(37), Some(23)); - 754
} - 755
- 756
#[test] - 757
fn tiny_horizon_yields_no_planned_turns() { - 758
let dir = tempfile::tempdir().unwrap(); - 759
let (log, _ids) = fixture( - 760
dir.path(), - 761
&[("only turn", 500, 100, &[]), ("second turn", 500, 100, &[])], - 762
); - 763
let index = TurnIndex::from_log(&log); - 764
// Horizon entirely consumed by prefix/tail/reserve: budget saturates - 765
// to zero, so the open turn is the only thing left (planned turns - 766
// are empty; the caller sends only the open turn verbatim). - 767
let profile = profile(100); - 768
let result = plan(PlanInput { - 769
profile: &profile, - 770
index: &index, - 771
directive: "hello", - 772
reading: None, - 773
prefix_tokens: 60, - 774
tail_tokens: 40, - 775
current_turn_tokens: 50, - 776
}); - 777
assert_eq!(result.budget, 0); - 778
assert!( - 779
result - 780
.per_turn - 781
.iter() - 782
.all(|(_, f)| !matches!(f, Fidelity::Full | Fidelity::Card)) - 783
); - 784
assert_eq!(result.spent, 0); - 785
} - 786
} - 787
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.