1use std::collections::{BTreeMap, BTreeSet};
11
12use serde::{Deserialize, Serialize};
13
14use crate::canon::CanonicalEncoder;
15use crate::error::{Result, SdkError};
16
17use super::section::PROMPT_DIGEST_TAG;
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum StateDependency {
25 SessionInvariant,
26 AcceptedRoot(String),
27 PartialCheckpointRoot(String),
28 PathBlob { path: String, blob_hash: String },
29}
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct ContextPage {
34 pub page_id: String,
36 pub kind: String,
38 pub source_hashes: Vec<String>,
40 pub dependency: StateDependency,
41 pub bytes: u64,
42 pub tokens: u64,
44 pub obligations: Vec<String>,
48 pub freshness_turn: u64,
50 pub depends_on: Vec<String>,
53}
54
55#[derive(Debug, Clone, Default)]
57pub struct DependencyEnv {
58 pub accepted_root: String,
59 pub partial_root: Option<String>,
60 pub path_blobs: BTreeMap<String, String>,
62}
63
64impl StateDependency {
65 pub fn holds(&self, env: &DependencyEnv) -> bool {
67 match self {
68 StateDependency::SessionInvariant => true,
69 StateDependency::AcceptedRoot(root) => *root == env.accepted_root,
70 StateDependency::PartialCheckpointRoot(root) => {
71 env.partial_root.as_deref() == Some(root.as_str())
72 }
73 StateDependency::PathBlob { path, blob_hash } => {
74 env.path_blobs.get(path) == Some(blob_hash)
75 }
76 }
77 }
78}
79
80impl ContextPage {
81 pub fn dependency_holds(&self, env: &DependencyEnv) -> bool {
83 self.dependency.holds(env)
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89pub struct ContextBudget {
90 pub window_tokens: u64,
91 pub output_reserve: u64,
92 pub tool_reserve: u64,
93 pub guard_reserve: u64,
94}
95
96impl ContextBudget {
97 pub fn input_allowance(&self) -> Result<u64> {
100 self.window_tokens
101 .checked_sub(self.output_reserve)
102 .and_then(|rest| rest.checked_sub(self.tool_reserve))
103 .and_then(|rest| rest.checked_sub(self.guard_reserve))
104 .ok_or_else(|| SdkError::Domain("context reserves exceed the window".into()))
105 }
106}
107
108#[derive(Debug, Clone, PartialEq)]
111pub enum ResidentOutcome {
112 Assembled(ResidentContext),
113 Infeasible {
115 required: u64,
116 allowance: u64,
117 },
118}
119
120#[derive(Debug, Clone, PartialEq)]
122pub struct ResidentContext {
123 pub pages: Vec<ContextPage>,
125 pub mandatory_len: usize,
127 pub resident_digest: String,
128}
129
130pub fn mandatory_closure(pages: &[ContextPage], mandatory: &[String]) -> Vec<ContextPage> {
134 let by_id: BTreeMap<&str, &ContextPage> = pages
135 .iter()
136 .map(|page| (page.page_id.as_str(), page))
137 .collect();
138 let mut wanted: BTreeSet<String> = mandatory.iter().cloned().collect();
139 let mut frontier: Vec<String> = mandatory.to_vec();
140 while let Some(id) = frontier.pop() {
141 if let Some(page) = by_id.get(id.as_str()) {
142 for dependency in &page.depends_on {
143 if wanted.insert(dependency.clone()) {
144 frontier.push(dependency.clone());
145 }
146 }
147 }
148 }
149 pages
150 .iter()
151 .filter(|page| wanted.contains(&page.page_id))
152 .cloned()
153 .collect()
154}
155
156pub fn select_working_set(
160 candidates: &[ContextPage],
161 weights: &BTreeMap<String, f64>,
162 slots: usize,
163) -> Vec<ContextPage> {
164 let mut covered: BTreeSet<&str> = BTreeSet::new();
165 let mut chosen_ids: BTreeSet<&str> = BTreeSet::new();
166 let mut chosen = Vec::new();
167 for _ in 0..slots {
168 let mut best: Option<(&ContextPage, f64)> = None;
169 for page in candidates {
170 if chosen_ids.contains(page.page_id.as_str()) {
171 continue;
172 }
173 let gain: f64 = page
174 .obligations
175 .iter()
176 .filter(|label| !covered.contains(label.as_str()))
177 .map(|label| weights.get(label).copied().unwrap_or(0.0))
178 .sum();
179 let better = match &best {
180 None => true,
181 Some((current, current_gain)) => {
182 gain > *current_gain
183 || (gain == *current_gain && page.page_id < current.page_id)
184 }
185 };
186 if better {
187 best = Some((page, gain));
188 }
189 }
190 let Some((page, gain)) = best else { break };
191 if gain <= 0.0 {
192 break;
195 }
196 chosen_ids.insert(page.page_id.as_str());
197 covered.extend(page.obligations.iter().map(String::as_str));
198 chosen.push(page.clone());
199 }
200 chosen
201}
202
203pub fn resident_page_digest<'a>(ids: impl Iterator<Item = &'a str>) -> String {
207 let ids: Vec<&str> = ids.collect();
208 let mut encoder = CanonicalEncoder::new(PROMPT_DIGEST_TAG);
209 encoder.text("resident-context");
210 encoder.list(ids.into_iter());
211 encoder.digest()
212}
213
214pub fn assemble_resident(
221 pages: &[ContextPage],
222 mandatory_ids: &[String],
223 env: &DependencyEnv,
224 budget: &ContextBudget,
225 instruction_tokens: u64,
226 frame_tokens: u64,
227 weights: &BTreeMap<String, f64>,
228) -> Result<ResidentOutcome> {
229 let allowance = budget.input_allowance()?;
230 let mandatory = mandatory_closure(pages, mandatory_ids);
231 if let Some(stale) = mandatory.iter().find(|page| !page.dependency_holds(env)) {
236 return Err(SdkError::Domain(format!(
237 "gate-af: mandatory page {} depends on a state that no longer holds; \
238 a stale root-dependent page cannot be admitted",
239 stale.page_id
240 )));
241 }
242 let mandatory_cost: u64 = mandatory.iter().map(|page| page.tokens).sum();
243 if instruction_tokens + mandatory_cost > allowance {
244 return Ok(ResidentOutcome::Infeasible {
245 required: instruction_tokens + mandatory_cost,
246 allowance,
247 });
248 }
249 let remaining = allowance - instruction_tokens - mandatory_cost;
250 let slots = remaining.checked_div(frame_tokens).unwrap_or(0) as usize;
251 let mandatory_ids_set: BTreeSet<&str> =
252 mandatory.iter().map(|page| page.page_id.as_str()).collect();
253 let candidates: Vec<ContextPage> = pages
254 .iter()
255 .filter(|page| {
256 !mandatory_ids_set.contains(page.page_id.as_str())
257 && page.dependency_holds(env)
258 && page.tokens <= frame_tokens
259 })
260 .cloned()
261 .collect();
262 let selected = select_working_set(&candidates, weights, slots);
263 let mut resident = mandatory;
264 let mandatory_len = resident.len();
265 resident.extend(selected);
266 let resident_digest = resident_page_digest(resident.iter().map(|page| page.page_id.as_str()));
267 Ok(ResidentOutcome::Assembled(ResidentContext {
268 pages: resident,
269 mandatory_len,
270 resident_digest,
271 }))
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 struct SplitMix64(u64);
281
282 impl SplitMix64 {
283 fn next(&mut self) -> u64 {
284 self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
285 let mut z = self.0;
286 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
287 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
288 z ^ (z >> 31)
289 }
290 }
291
292 fn page(id: &str, tokens: u64, labels: &[&str], dependency: StateDependency) -> ContextPage {
293 ContextPage {
294 page_id: id.into(),
295 kind: "synopsis".into(),
296 source_hashes: vec![],
297 dependency,
298 bytes: tokens * 3,
299 tokens,
300 obligations: labels.iter().map(|label| label.to_string()).collect(),
301 freshness_turn: 0,
302 depends_on: vec![],
303 }
304 }
305
306 fn env() -> DependencyEnv {
307 DependencyEnv {
308 accepted_root: "root-a".into(),
309 partial_root: None,
310 path_blobs: BTreeMap::new(),
311 }
312 }
313
314 #[test]
320 fn a_stale_mandatory_page_refuses_assembly() {
321 let budget = ContextBudget {
322 window_tokens: 2_000,
323 output_reserve: 400,
324 tool_reserve: 100,
325 guard_reserve: 100,
326 };
327 let pages = vec![page(
328 "m0",
329 10,
330 &["task"],
331 StateDependency::AcceptedRoot("old-root".into()),
332 )];
333 let outcome = assemble_resident(
334 &pages,
335 &["m0".to_string()],
336 &env(),
337 &budget,
338 0,
339 64,
340 &BTreeMap::new(),
341 );
342 let error = outcome.expect_err("a stale mandatory page must refuse");
343 assert!(error.to_string().contains("gate-af"), "{error}");
344 }
345
346 #[test]
347 fn randomized_page_sets_never_exceed_the_input_allowance() {
348 for seed in 0..64u64 {
349 let mut rng = SplitMix64(seed);
350 let budget = ContextBudget {
351 window_tokens: 2_000,
352 output_reserve: 400,
353 tool_reserve: 100,
354 guard_reserve: 100,
355 };
356 let allowance = budget.input_allowance().unwrap();
357 let frame = 64;
358 let mut pages = vec![page(
359 "m0",
360 (rng.next() % 300) + 1,
361 &["task"],
362 StateDependency::SessionInvariant,
363 )];
364 for index in 0..(rng.next() % 20) {
365 pages.push(page(
366 &format!("p{index}"),
367 (rng.next() % frame) + 1,
368 &["a", "b"],
369 StateDependency::AcceptedRoot("root-a".into()),
370 ));
371 }
372 let weights: BTreeMap<String, f64> =
373 [("a".to_string(), 1.0), ("b".to_string(), 0.5)].into();
374 let instruction = rng.next() % 500;
375 let outcome = assemble_resident(
376 &pages,
377 &["m0".to_string()],
378 &env(),
379 &budget,
380 instruction,
381 frame,
382 &weights,
383 )
384 .unwrap();
385 if let ResidentOutcome::Assembled(resident) = outcome {
386 let total: u64 = resident.pages.iter().map(|p| p.tokens).sum();
387 assert!(
388 instruction + total <= allowance,
389 "seed {seed}: {instruction} + {total} > {allowance}"
390 );
391 }
392 }
393 }
394
395 #[test]
396 fn infeasible_mandatory_closure_refuses_before_any_call() {
397 let budget = ContextBudget {
398 window_tokens: 500,
399 output_reserve: 100,
400 tool_reserve: 50,
401 guard_reserve: 50,
402 };
403 let pages = vec![page(
404 "m0",
405 400,
406 &["task"],
407 StateDependency::SessionInvariant,
408 )];
409 let outcome = assemble_resident(
410 &pages,
411 &["m0".to_string()],
412 &env(),
413 &budget,
414 0,
415 64,
416 &BTreeMap::new(),
417 )
418 .unwrap();
419 assert!(matches!(outcome, ResidentOutcome::Infeasible { .. }));
420 }
421
422 #[test]
423 fn root_dependent_pages_invalidate_on_root_change() {
424 let stale = page(
425 "p1",
426 10,
427 &["a"],
428 StateDependency::AcceptedRoot("old".into()),
429 );
430 let fresh = page(
431 "p2",
432 10,
433 &["a"],
434 StateDependency::AcceptedRoot("root-a".into()),
435 );
436 let blob = page(
437 "p3",
438 10,
439 &["b"],
440 StateDependency::PathBlob {
441 path: "src/lib.rs".into(),
442 blob_hash: "h1".into(),
443 },
444 );
445 let mut environment = env();
446 assert!(!stale.dependency_holds(&environment));
447 assert!(fresh.dependency_holds(&environment));
448 assert!(!blob.dependency_holds(&environment));
449 environment
450 .path_blobs
451 .insert("src/lib.rs".into(), "h1".into());
452 assert!(blob.dependency_holds(&environment));
453 environment
454 .path_blobs
455 .insert("src/lib.rs".into(), "h2".into());
456 assert!(!blob.dependency_holds(&environment));
457 }
458
459 #[test]
460 fn greedy_selection_matches_exhaustive_optimum_on_small_fixtures() {
461 let weights: BTreeMap<String, f64> = [
466 ("a".to_string(), 3.0),
467 ("b".to_string(), 2.0),
468 ("c".to_string(), 2.0),
469 ]
470 .into();
471 let candidates = vec![
472 page("p1", 10, &["a"], StateDependency::SessionInvariant),
473 page("p2", 10, &["b", "c"], StateDependency::SessionInvariant),
474 page("p3", 10, &["b"], StateDependency::SessionInvariant),
475 ];
476 let one = select_working_set(&candidates, &weights, 1);
477 assert_eq!(one[0].page_id, "p2", "gain 4.0 beats 3.0");
478 let two = select_working_set(&candidates, &weights, 2);
479 let ids: Vec<&str> = two.iter().map(|p| p.page_id.as_str()).collect();
480 assert_eq!(ids, ["p2", "p1"], "optimum coverage 7.0");
481 assert_eq!(select_working_set(&candidates, &weights, 2), two);
483 }
484
485 #[test]
486 fn tool_pairs_stay_atomic_through_the_mandatory_closure() {
487 let mut call = page("call1", 10, &[], StateDependency::SessionInvariant);
488 call.depends_on = vec!["result1".to_string()];
489 let result = page("result1", 10, &[], StateDependency::SessionInvariant);
490 let closure = mandatory_closure(&[call, result], &["call1".to_string()]);
491 let ids: Vec<&str> = closure.iter().map(|p| p.page_id.as_str()).collect();
492 assert_eq!(ids, ["call1", "result1"]);
493 }
494
495 #[test]
496 fn resident_digest_is_deterministic_over_page_order() {
497 let pages = vec![
498 page("m0", 10, &["task"], StateDependency::SessionInvariant),
499 page(
500 "p1",
501 10,
502 &["a"],
503 StateDependency::AcceptedRoot("root-a".into()),
504 ),
505 ];
506 let weights: BTreeMap<String, f64> = [("a".to_string(), 1.0)].into();
507 let budget = ContextBudget {
508 window_tokens: 1_000,
509 output_reserve: 100,
510 tool_reserve: 50,
511 guard_reserve: 50,
512 };
513 let run = || {
514 assemble_resident(
515 &pages,
516 &["m0".to_string()],
517 &env(),
518 &budget,
519 0,
520 64,
521 &weights,
522 )
523 .unwrap()
524 };
525 assert_eq!(run(), run());
526 }
527}