leviath_core/layout.rs
1//! Context window layouts and memory maps.
2//!
3//! A layout defines the complete memory structure for an agent's context window,
4//! including all regions, their sizes, and eviction priorities. This is analogous
5//! to a hardware memory map that defines where different types of data live and
6//! how they're managed.
7
8use crate::error::ValidationError;
9use crate::region::{RegionKind, RegionSchema};
10use serde::{Deserialize, Serialize};
11
12/// The region a stage's `system_prompt` is written into, when a blueprint
13/// declares one by this name.
14///
15/// Stage instructions are pinned context - that is why they read as
16/// instruction rather than history - and a region of their own is what lets
17/// the stage ledger bill the prompt's tokens under a name that says what they
18/// are, sizes them, and places them in the cached prefix on purpose rather
19/// than wherever the first pinned region happens to sit.
20///
21/// Declaring a region by this name gives the prompt a handle:
22///
23/// ```toml
24/// [context.regions]
25/// stage_instructions = { kind = "pinned", budget = "3%" }
26/// ```
27///
28/// A blueprint that declares nothing by this name still gets one: the runtime
29/// adds it at spawn, sized to the widest stage prompt the blueprint carries.
30pub const STAGE_INSTRUCTIONS_REGION: &str = "stage_instructions";
31
32/// How a region's token ceiling is expressed before it is resolved against a
33/// concrete model context window.
34///
35/// Blueprint authors think in **proportions** (`budget = "35%"`) so their intent
36/// stays correct regardless of the model's context size, while power users can
37/// still pin an exact count. The percentage denominator - the model's context
38/// window - is not known at parse time, so the spec is stored unresolved here and
39/// turned into a concrete token count at window-build time (see
40/// [`BudgetSpec::resolve`] and [`ContextLayout::resolved`]).
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum BudgetSpec {
44 /// A fixed token ceiling, independent of the model. Resolving is a no-op.
45 Absolute(usize),
46
47 /// A ceiling expressed as a fraction of the model's context window, with
48 /// optional absolute guard-rails. `percent` is a fraction (`0.35` for
49 /// `"35%"`). `max` caps the resolved value (so e.g. 2% of a 1M window can't
50 /// balloon a task region to 20K tokens); `min` floors it (so a small-context
51 /// model doesn't starve the region below a usable size).
52 Percent {
53 /// Fraction of the model context window (0.35 == "35%").
54 percent: f64,
55 /// Absolute floor for the resolved value, if any.
56 min: Option<usize>,
57 /// Absolute cap for the resolved value, if any.
58 max: Option<usize>,
59 },
60}
61
62impl Default for BudgetSpec {
63 /// Only a serde-deserialize fallback for older persisted blueprints; live
64 /// code always sets the budget explicitly via [`RegionDefinition::new`].
65 fn default() -> Self {
66 BudgetSpec::Absolute(0)
67 }
68}
69
70impl BudgetSpec {
71 /// Parse a percentage string like `"35%"` into its fraction (`0.35`).
72 ///
73 /// Surrounding whitespace is trimmed and decimals are allowed (`"0.6%"`).
74 /// Rejects a missing `%`, a non-numeric value, and anything outside the
75 /// `(0, 100]` range - a single region can't sensibly claim ≤0% or more than
76 /// the whole window (region budgets may *sum* past 100%, but each is a
77 /// fraction of one window). Returns the human-readable reason on failure so
78 /// the caller can surface it at load time.
79 pub fn parse_budget(s: &str) -> std::result::Result<f64, String> {
80 let trimmed = s.trim();
81 let Some(num) = trimmed.strip_suffix('%') else {
82 return Err(format!("budget '{s}' must end with '%' (e.g. \"35%\")"));
83 };
84 let value: f64 = num
85 .trim()
86 .parse()
87 .map_err(|_| format!("budget '{s}' is not a valid number"))?;
88 if !(value > 0.0 && value <= 100.0) {
89 return Err(format!(
90 "budget '{s}' must be greater than 0% and at most 100%"
91 ));
92 }
93 Ok(value / 100.0)
94 }
95
96 /// Resolve this spec to a concrete token count against a model context
97 /// `window`.
98 ///
99 /// [`Absolute`](BudgetSpec::Absolute) ignores the window (idempotent - a
100 /// fully-absolute layout resolves to itself). [`Percent`](BudgetSpec::Percent)
101 /// rounds `window * percent`, then applies the `max` cap, then the `min`
102 /// floor. The floor is applied **last** so that when `min > max` the floor
103 /// wins: a region starved below a usable size is worse than one slightly over
104 /// its cap.
105 pub fn resolve(&self, window: usize) -> usize {
106 match self {
107 BudgetSpec::Absolute(n) => *n,
108 BudgetSpec::Percent { percent, min, max } => {
109 let mut v = (window as f64 * percent).round() as usize;
110 if let Some(max) = max {
111 v = v.min(*max);
112 }
113 if let Some(min) = min {
114 v = v.max(*min);
115 }
116 v
117 }
118 }
119 }
120
121 /// Whether this is a percentage budget (needs a model window to resolve).
122 pub fn is_percent(&self) -> bool {
123 matches!(self, BudgetSpec::Percent { .. })
124 }
125}
126
127/// A ContextLayout defines the complete memory map for an agent.
128///
129/// Like SNES VRAM layout - every region has a defined purpose, size, and policy.
130/// The layout specifies:
131/// - Which regions exist and their configurations
132/// - Total token budget across all regions
133/// - Eviction order when space is needed
134///
135/// Layouts are typically defined in an agent's blueprint and remain constant
136/// throughout the agent's lifecycle, though the content within regions changes.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct ContextLayout {
139 /// All regions in this layout
140 pub regions: Vec<RegionDefinition>,
141
142 /// Total token budget across all regions
143 pub total_budget_tokens: usize,
144
145 /// Region names in eviction priority order (first = evicted first)
146 ///
147 /// When the context window fills up, regions are processed in this order:
148 /// 1. Temporary regions: evict oldest entries
149 /// 2. Compacting regions: trigger summarization
150 /// 3. SlidingWindow regions: reduce window size
151 /// 4. Pinned regions: NEVER touched (if these fill up, it's a config error)
152 pub eviction_order: Vec<String>,
153}
154
155impl ContextLayout {
156 /// Create a new layout with the specified configuration.
157 pub fn new(regions: Vec<RegionDefinition>, total_budget_tokens: usize) -> Self {
158 Self {
159 regions,
160 total_budget_tokens,
161 eviction_order: Vec::new(),
162 }
163 }
164
165 /// Set the eviction order for this layout.
166 pub fn with_eviction_order(mut self, order: Vec<String>) -> Self {
167 self.eviction_order = order;
168 self
169 }
170
171 /// Validate that the layout is well-formed.
172 ///
173 /// Checks:
174 /// - Sum of max_tokens doesn't exceed total_budget_tokens
175 /// - All region names in eviction_order exist
176 /// - No duplicate region names
177 pub fn validate(&self) -> std::result::Result<(), ValidationError> {
178 // Check for duplicate region names
179 let mut names = std::collections::HashSet::new();
180 for region in &self.regions {
181 if !names.insert(region.name.as_str()) {
182 return Err(ValidationError::Region {
183 region: region.name.clone(),
184 message: "duplicate region name".to_string(),
185 });
186 }
187 }
188
189 // Check that eviction_order regions exist
190 for name in &self.eviction_order {
191 if !names.contains(name.as_str()) {
192 return Err(ValidationError::Layout(format!(
193 "eviction order references unknown region: {}",
194 name
195 )));
196 }
197 }
198
199 // Reject a Custom region whose script path is empty - it could never
200 // resolve to a file, and the runtime would silently fall back to
201 // Temporary-style rendering on every inference.
202 for region in &self.regions {
203 if let RegionKind::Custom { script, .. } = ®ion.kind
204 && script.trim().is_empty()
205 {
206 return Err(ValidationError::Region {
207 region: region.name.clone(),
208 message: "custom region requires a non-empty script path".to_string(),
209 });
210 }
211 }
212
213 // Warn if sum of max tokens exceeds budget (not necessarily an error,
214 // since not all regions will be full simultaneously)
215 // Warn if no SlidingWindow region exists - agents should have a
216 // conversation region for typed message entries, but some agents
217 // (e.g., deep-researcher) use other region kinds exclusively. A Custom
218 // region counts: its script can render typed entries as messages.
219 let has_message_region = self.regions.iter().any(|r| {
220 matches!(
221 r.kind,
222 RegionKind::SlidingWindow { .. } | RegionKind::Custom { .. }
223 )
224 });
225 if !has_message_region {
226 tracing::warn!(
227 "Layout has no SlidingWindow (or custom scripted) region - typed \
228 conversation entries require one"
229 );
230 }
231
232 // The token-sum warning and the fixed-working-budget hard error below
233 // operate on concrete `max_tokens` values. When percentage budgets are
234 // present those values are provisional placeholders until the layout is
235 // resolved against a model window, so the checks are meaningless here -
236 // skip them and rely on the post-resolution `validate()` call at spawn.
237 if self.has_percent_budgets() {
238 return Ok(());
239 }
240
241 let total_max = self
242 .regions
243 .iter()
244 .fold(0usize, |acc, r| acc.saturating_add(r.max_tokens));
245 if total_max > self.total_budget_tokens {
246 tracing::warn!(
247 "Sum of region max tokens ({}) exceeds total budget ({})",
248 total_max,
249 self.total_budget_tokens
250 );
251 }
252
253 // Ensure the fixed, non-evictable regions leave working room, judged
254 // against the whole budget.
255 self.validate_working_room(self.total_budget_tokens)
256 }
257
258 /// Fail when the fixed (non-evictable) regions would leave less than
259 /// `MIN_WORKING_TOKENS` of `window` for the evictable ones.
260 ///
261 /// Split out from [`validate`](Self::validate) so a caller can judge each
262 /// stage against that stage's own context window over just the regions it
263 /// can see, rather than one window for the whole layout: a region budgeted
264 /// against a wide-window stage must not be counted against a narrow-window
265 /// stage that never sees it. Pinned / HashMap / CompactHistory / persistent
266 /// custom regions persist for the whole run and consume budget; if they
267 /// leave too little, the evictable regions operate "blind", so fail loudly
268 /// at load instead of degrading silently at runtime. The floor is only
269 /// enforced on realistically-sized windows (`BUDGET_CHECK_MIN_TOTAL`); a toy
270 /// fixture's tiny window is left alone.
271 pub fn validate_working_room(&self, window: usize) -> std::result::Result<(), ValidationError> {
272 let fixed_tokens: usize = self
273 .regions
274 .iter()
275 .filter(|r| {
276 matches!(
277 r.kind,
278 RegionKind::Pinned
279 | RegionKind::HashMap { .. }
280 | RegionKind::CompactHistory { .. }
281 | RegionKind::Custom { pinned: true, .. }
282 )
283 })
284 .fold(0usize, |acc, r| acc.saturating_add(r.max_tokens));
285 let working_tokens = window.saturating_sub(fixed_tokens);
286 if window >= Self::BUDGET_CHECK_MIN_TOTAL && working_tokens < Self::MIN_WORKING_TOKENS {
287 return Err(ValidationError::Layout(format!(
288 "context layout leaves only {working_tokens} working tokens after fixed \
289 regions (pinned/hashmap/compact_history/persistent custom) consume \
290 {fixed_tokens} of the {window} \
291 window; at least {} are needed for the agent to operate. Reduce the \
292 fixed regions' max_tokens or increase the window.",
293 Self::MIN_WORKING_TOKENS
294 )));
295 }
296 Ok(())
297 }
298
299 /// Minimum token budget that must remain for evictable/working regions
300 /// (conversation, tool results, scratch) after the fixed regions are full,
301 /// so the agent has room to hold recent context and generate. Below this a
302 /// run would operate with almost no working space.
303 const MIN_WORKING_TOKENS: usize = 8000;
304
305 /// The working-budget floor is only enforced when the layout's total budget
306 /// is at least this large - i.e. it's a realistically-sized agent, not a
307 /// toy/illustrative layout where an absolute floor wouldn't make sense.
308 const BUDGET_CHECK_MIN_TOTAL: usize = 20_000;
309
310 /// Get a region definition by name.
311 pub fn get_region(&self, name: &str) -> Option<&RegionDefinition> {
312 self.regions.iter().find(|r| r.name == name)
313 }
314
315 /// Whether any region uses a percentage budget (and therefore needs a model
316 /// context window to resolve to concrete token counts).
317 pub fn has_percent_budgets(&self) -> bool {
318 self.regions.iter().any(|r| r.budget.is_percent())
319 }
320
321 /// Resolve every region's percentage budget against a concrete model context
322 /// `window`, returning a fully-absolute layout.
323 ///
324 /// Each region's `max_tokens` becomes `budget.resolve(window)`, and each
325 /// [`RegionKind::Compacting`] region's `threshold_tokens` is recomputed from
326 /// its [`compact_at`](RegionDefinition::compact_at) fraction (via the private
327 /// `resolve_compacting_threshold` helper). `eviction_order` is preserved. The
328 /// total budget becomes the model `window` when any percentage budget is
329 /// present (percentage ceilings are relative to the whole window and may sum
330 /// past 100%); a pure-absolute layout keeps its summed total unchanged.
331 ///
332 /// Resolving an already-absolute layout is a no-op, so this is safe to call
333 /// unconditionally at window-build time.
334 pub fn resolved(&self, window: usize) -> ContextLayout {
335 let regions = self
336 .regions
337 .iter()
338 .map(|r| {
339 let max_tokens = r.budget.resolve(window);
340 let kind = match &r.kind {
341 RegionKind::Compacting { threshold_tokens } => RegionKind::Compacting {
342 threshold_tokens: Self::resolve_compacting_threshold(
343 r.compact_at,
344 *threshold_tokens,
345 max_tokens,
346 ),
347 },
348 other => other.clone(),
349 };
350 // Emit a fully-absolute region: the percentage has been baked
351 // into `max_tokens` and the compaction threshold into `kind`, so
352 // the resolved layout carries no `Percent` budgets. This makes
353 // `has_percent_budgets()` false on the result, so a post-resolution
354 // `validate()` runs the real token/working-budget checks.
355 RegionDefinition {
356 kind,
357 max_tokens,
358 budget: BudgetSpec::Absolute(max_tokens),
359 compact_at: None,
360 ..r.clone()
361 }
362 })
363 .collect();
364
365 let total_budget_tokens = if self.has_percent_budgets() {
366 window
367 } else {
368 self.total_budget_tokens
369 };
370
371 ContextLayout {
372 regions,
373 total_budget_tokens,
374 eviction_order: self.eviction_order.clone(),
375 }
376 }
377
378 /// Like [`resolved`](Self::resolved), but each region's percentage budget
379 /// is sized against a window chosen per region rather than one window for
380 /// the whole layout.
381 ///
382 /// `window_for(region_name)` gives the window a region is budgeted against:
383 /// the caller passes the smallest context window among the stages that can
384 /// actually see that region, so a region used only in wide-window stages
385 /// keeps a wide budget even when a narrow-window stage exists that never
386 /// sees it. The layout's `total_budget_tokens` becomes the largest of those
387 /// per-region windows; the authoritative fit check is per stage, done by the
388 /// caller against each stage's own window over the regions it sees.
389 ///
390 /// An absolute layout has nothing to resolve, so this is a no-op for it, the
391 /// same as [`resolved`](Self::resolved).
392 pub fn resolved_per_region(&self, window_for: &dyn Fn(&str) -> usize) -> ContextLayout {
393 let regions: Vec<RegionDefinition> = self
394 .regions
395 .iter()
396 .map(|r| {
397 let window = window_for(&r.name);
398 let max_tokens = r.budget.resolve(window);
399 let kind = match &r.kind {
400 RegionKind::Compacting { threshold_tokens } => RegionKind::Compacting {
401 threshold_tokens: Self::resolve_compacting_threshold(
402 r.compact_at,
403 *threshold_tokens,
404 max_tokens,
405 ),
406 },
407 other => other.clone(),
408 };
409 RegionDefinition {
410 kind,
411 max_tokens,
412 budget: BudgetSpec::Absolute(max_tokens),
413 compact_at: None,
414 ..r.clone()
415 }
416 })
417 .collect();
418
419 let total_budget_tokens = if self.has_percent_budgets() {
420 self.regions
421 .iter()
422 .map(|r| window_for(&r.name))
423 .max()
424 .unwrap_or(self.total_budget_tokens)
425 } else {
426 self.total_budget_tokens
427 };
428
429 ContextLayout {
430 regions,
431 total_budget_tokens,
432 eviction_order: self.eviction_order.clone(),
433 }
434 }
435
436 /// A copy holding only the regions (and eviction-order entries) whose name
437 /// satisfies `keep`.
438 ///
439 /// The runtime carries some always-visible regions (conversation, tool
440 /// results) that no layout declares, so a stage's visible-region set may
441 /// name regions absent here; those are simply not present in the result.
442 /// Used to judge a stage's working room over exactly the regions it can see,
443 /// rather than every region the layout declares.
444 pub fn retaining<F: Fn(&str) -> bool>(&self, keep: F) -> ContextLayout {
445 ContextLayout {
446 regions: self
447 .regions
448 .iter()
449 .filter(|r| keep(&r.name))
450 .cloned()
451 .collect(),
452 total_budget_tokens: self.total_budget_tokens,
453 eviction_order: self
454 .eviction_order
455 .iter()
456 .filter(|n| keep(n))
457 .cloned()
458 .collect(),
459 }
460 }
461
462 /// Compute a Compacting region's concrete compaction threshold from its
463 /// `compact_at` fraction, the absolute `threshold_tokens` guard carried on
464 /// the kind, and the region's resolved budget.
465 ///
466 /// - `compact_at = Some(f)` with an explicit `threshold_tokens` cap (any
467 /// value below the [`usize::MAX`] sentinel) → `min(round(budget * f), cap)`:
468 /// compact at the percentage, but never later than the absolute guard-rail.
469 /// - `compact_at = Some(f)` with no cap (`threshold_tokens == usize::MAX`
470 /// sentinel) → `round(budget * f)`.
471 /// - `compact_at = None` → the absolute `threshold_tokens` as-is (back-compat,
472 /// including the parser's `max_tokens * 8 / 10` default).
473 ///
474 /// The `usize::MAX` sentinel is safe: a layout is always resolved before any
475 /// [`Region::needs_compaction`](crate::region::Region::needs_compaction) check.
476 fn resolve_compacting_threshold(
477 compact_at: Option<f64>,
478 threshold_tokens: usize,
479 resolved_budget: usize,
480 ) -> usize {
481 match compact_at {
482 Some(fraction) => {
483 let pct = (resolved_budget as f64 * fraction).round() as usize;
484 pct.min(threshold_tokens)
485 }
486 None => threshold_tokens,
487 }
488 }
489}
490
491/// Where a region's initial content comes from at run start.
492///
493/// A region without a seed starts empty and is populated by the agent. A seeded
494/// region is filled before the first inference: `CallerInput` regions are filled
495/// by the run's caller (a CLI `--<name>` flag, an ACP `---region:<name>---`
496/// marker, or the API `regions` map); the remaining variants are resolved by the
497/// daemon from the run's workdir (which is why this type only *declares* the
498/// source - `leviath-core` stays filesystem-agnostic; resolution lives in the
499/// CLI daemon's spawner).
500#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
501#[serde(rename_all = "snake_case")]
502pub enum RegionSeed {
503 /// Filled at run time by the caller, keyed by `name` (defaults to the
504 /// region's own name; the sentinel `task` maps to the `--task`/prompt text).
505 /// When the owning region is `required`, a missing value is a hard error
506 /// before any inference runs.
507 CallerInput {
508 /// The caller-input key this region is filled from.
509 name: String,
510 },
511 /// Concatenated contents of the workdir files matching a glob pattern.
512 Glob {
513 /// Glob pattern, resolved relative to the run's workdir.
514 pattern: String,
515 },
516 /// Concatenated contents of an explicit list of workdir-relative files.
517 Files {
518 /// File paths, resolved relative to the run's workdir.
519 paths: Vec<String>,
520 },
521 /// A static literal string baked into the blueprint.
522 Literal {
523 /// The verbatim seed text.
524 text: String,
525 },
526 /// The `String` returned by running a Rhai script from the workdir.
527 Rhai {
528 /// Script path, resolved relative to the run's workdir.
529 script: String,
530 },
531 /// The combined stdout/stderr of a shell command run in the workdir at spawn.
532 ///
533 /// Unlike every other variant this *executes* something, and it does so
534 /// before the first inference - so before any tool-approval prompt. The
535 /// daemon runs it inside the entry stage's sandbox when one is configured,
536 /// caps its runtime and output, and honours the `[security]
537 /// allow_seed_commands` kill switch. A failure is non-fatal unless the
538 /// owning region is `required`.
539 Command {
540 /// The shell command line, run with the platform shell in the workdir.
541 command: String,
542 },
543 /// The combined output of one or more tool calls, run at spawn.
544 ///
545 /// Like [`Command`](Self::Command) this *executes* something, but through
546 /// the run's own tool layer rather than a shell: any tool the agent could
547 /// call is callable here - a built-in, an MCP server's, a Rhai script's -
548 /// and each call answers to the same `tool_permissions` and taint rules it
549 /// would answer to mid-run. That is what makes an unrestricted list safe:
550 /// a seed can reach nothing the agent was not already granted.
551 ///
552 /// Several calls write into one region, in the order given, each under its
553 /// own heading. A failed call is skipped with a warning unless the region is
554 /// `required`, so one unavailable tool does not cost the others.
555 Tools {
556 /// The calls to run, in order.
557 calls: Vec<SeedToolCall>,
558 /// Whether the calls run once, or again on every stage entry.
559 refresh: SeedRefresh,
560 },
561}
562
563/// When a [`RegionSeed::Tools`] seed runs again.
564///
565/// Every other seed kind resolves once, at spawn, and this defaults to the
566/// same: a region seeded from the filesystem or a literal has no reason to be
567/// re-read, and re-running a call on every stage entry costs a tool call and
568/// rewrites a region the cache was holding still.
569///
570/// [`EachStage`](Self::EachStage) is for the seeds where the answer moves.
571/// A clock is the clear case: a run that spends an hour in one stage and then
572/// enters another should date the second stage from when it started, not from
573/// when the run did.
574#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
575#[serde(rename_all = "snake_case")]
576pub enum SeedRefresh {
577 /// Resolve once, at spawn. The default, and what every other seed does.
578 #[default]
579 Once,
580 /// Resolve again whenever a stage is entered, replacing the region.
581 EachStage,
582}
583
584impl SeedRefresh {
585 /// Parse the manifest spelling, or `None` for a word that is neither.
586 ///
587 /// A wrong spelling is rejected rather than defaulted, so
588 /// `refresh = "each stage"` is reported instead of quietly meaning `once` -
589 /// which would read as the feature not working.
590 pub fn from_str_loose(value: &str) -> Option<Self> {
591 match value.trim().to_ascii_lowercase().as_str() {
592 "once" | "spawn" => Some(Self::Once),
593 "each_stage" | "stage" => Some(Self::EachStage),
594 _ => None,
595 }
596 }
597}
598
599/// One tool call in a [`RegionSeed::Tools`] seed.
600#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
601pub struct SeedToolCall {
602 /// The tool to call, spelled as the agent would spell it (so an MCP tool
603 /// keeps its `<server>__<tool>` qualification).
604 pub name: String,
605 /// The arguments object. Empty for the many tools that take none.
606 pub args: serde_json::Value,
607}
608
609impl SeedToolCall {
610 /// A call with no arguments.
611 pub fn new(name: impl Into<String>) -> Self {
612 Self {
613 name: name.into(),
614 args: serde_json::Value::Object(serde_json::Map::new()),
615 }
616 }
617
618 /// A call with an arguments object.
619 pub fn with_args(name: impl Into<String>, args: serde_json::Value) -> Self {
620 Self {
621 name: name.into(),
622 args,
623 }
624 }
625}
626
627#[cfg(test)]
628mod seed_refresh_tests {
629 use super::*;
630
631 #[test]
632 fn both_spellings_and_their_aliases_parse() {
633 assert_eq!(SeedRefresh::from_str_loose("once"), Some(SeedRefresh::Once));
634 assert_eq!(
635 SeedRefresh::from_str_loose("spawn"),
636 Some(SeedRefresh::Once)
637 );
638 assert_eq!(
639 SeedRefresh::from_str_loose("each_stage"),
640 Some(SeedRefresh::EachStage)
641 );
642 assert_eq!(
643 SeedRefresh::from_str_loose("stage"),
644 Some(SeedRefresh::EachStage)
645 );
646 // Case and surrounding space are not the author's problem.
647 assert_eq!(
648 SeedRefresh::from_str_loose(" EACH_STAGE "),
649 Some(SeedRefresh::EachStage)
650 );
651 }
652
653 /// A word that is neither is rejected rather than defaulted. Defaulting
654 /// would make `refresh = "each stage"` silently mean `once`, which reads as
655 /// the feature not working rather than as a typo.
656 #[test]
657 fn an_unrecognised_word_is_not_quietly_once() {
658 assert_eq!(SeedRefresh::from_str_loose("each stage"), None);
659 assert_eq!(SeedRefresh::from_str_loose("always"), None);
660 assert_eq!(SeedRefresh::from_str_loose(""), None);
661 }
662
663 /// The default matches every other seed kind: resolve at spawn, once.
664 #[test]
665 fn the_default_is_once() {
666 assert_eq!(SeedRefresh::default(), SeedRefresh::Once);
667 }
668}
669
670/// Definition of a region in a layout.
671///
672/// This is the blueprint for creating a Region instance. It specifies the
673/// region's configuration but doesn't contain actual content.
674#[derive(Debug, Clone, Serialize, Deserialize)]
675pub struct RegionDefinition {
676 /// Unique name for this region
677 pub name: String,
678
679 /// Region lifecycle policy
680 pub kind: RegionKind,
681
682 /// **Resolved** maximum tokens for this region. This is the concrete ceiling
683 /// every downstream consumer reads; for a percentage budget it is populated
684 /// when the layout is resolved against a model window (see
685 /// [`ContextLayout::resolved`]). [`Self::budget`] is the source of truth for
686 /// how this value is derived.
687 pub max_tokens: usize,
688
689 /// How this region's ceiling is expressed. Defaults (via [`Self::new`]) to
690 /// [`BudgetSpec::Absolute`] holding `max_tokens`, so a region that names a
691 /// token count directly gets an absolute ceiling. A percentage budget is
692 /// resolved against the model context window at window-build time.
693 #[serde(default)]
694 pub budget: BudgetSpec,
695
696 /// For [`RegionKind::Compacting`] regions only: compact when the region
697 /// reaches this fraction of its resolved budget (`0.80` for `compact_at =
698 /// "80%"`). `None` keeps the absolute `threshold_tokens` carried on the kind.
699 /// See [`ContextLayout::resolved`] for how this becomes a concrete threshold.
700 #[serde(default)]
701 pub compact_at: Option<f64>,
702
703 /// Optional validation schema
704 pub schema: Option<RegionSchema>,
705
706 /// Human-readable description of this region's purpose
707 pub description: Option<String>,
708
709 /// Whether `description` is also shown to the model, under the region's
710 /// name. Off by default - see [`crate::region::Region::describe_in_prompt`].
711 #[serde(default)]
712 pub describe_in_prompt: bool,
713
714 /// When true, this region must be non-empty before a stage that can write
715 /// to it is allowed to complete. Guards against an agent skipping a
716 /// context-population step (e.g. never writing the `plan` region). Enforced
717 /// in the run loop, which re-runs the stage with [`Self::required_message`]
718 /// until the region is populated.
719 #[serde(default)]
720 pub required: bool,
721
722 /// Whether an edge transform may hand this region to the summarizer.
723 ///
724 /// `transform = "compact"` reads as "summarize the transcript on the way
725 /// out" and means "summarize every region that is not pinned", which
726 /// includes the ones holding the run's results. Figures that survive a
727 /// paraphrase are no longer figures: without this, a `results` region
728 /// carrying computed values is rewritten into prose before the stage that
729 /// reports them ever sees it.
730 ///
731 /// Setting this false protects the region wherever it is used, rather than
732 /// at each of the N edges that might touch it. `clear` still applies - this
733 /// says "do not paraphrase my content", not "keep it forever".
734 #[serde(default = "crate::default_true")]
735 pub summarizable: bool,
736
737 /// What this region does when a write does not fit.
738 ///
739 /// Declared per region rather than per stage: whether losing the oldest
740 /// entry is acceptable is a property of what the region holds, and does not
741 /// change depending on which stage is writing to it.
742 #[serde(default)]
743 pub admission: crate::region::Admission,
744 /// How much this region's contents move between requests. See
745 /// [`crate::region::Volatility`].
746 ///
747 /// Defaulted on the wire so a definition written before this existed still
748 /// loads, and loads as the pessimistic value - which is what an unclassified
749 /// region should be.
750 #[serde(default)]
751 pub volatility: crate::region::Volatility,
752
753 /// Optional custom message shown to the agent when this region is required
754 /// but empty. Falls back to a generated default when `None`.
755 #[serde(default)]
756 pub required_message: Option<String>,
757
758 /// Where this region's initial content comes from at run start. `None`
759 /// means the region starts empty (the agent populates it). See
760 /// [`RegionSeed`].
761 #[serde(default)]
762 pub seed: Option<RegionSeed>,
763
764 /// Mime type patterns this region takes; empty means anything. See
765 /// [`crate::region::Region::accepts`].
766 #[serde(default, skip_serializing_if = "Vec::is_empty")]
767 pub accepts: Vec<String>,
768}
769
770impl RegionDefinition {
771 /// Create a new region definition with an absolute token ceiling.
772 ///
773 /// The `budget` is set to [`BudgetSpec::Absolute`] holding `max_tokens` and
774 /// `compact_at` to `None`, so every existing caller (and every region without
775 /// a percentage budget) is unaffected - resolving such a layout is a no-op.
776 pub fn new(name: String, kind: RegionKind, max_tokens: usize) -> Self {
777 Self {
778 name,
779 kind,
780 max_tokens,
781 budget: BudgetSpec::Absolute(max_tokens),
782 compact_at: None,
783 schema: None,
784 description: None,
785 describe_in_prompt: false,
786 required: false,
787 required_message: None,
788 summarizable: true,
789 admission: crate::region::Admission::default(),
790 volatility: crate::region::Volatility::default(),
791 seed: None,
792 accepts: Vec::new(),
793 }
794 }
795
796 /// Set this region's budget spec (e.g. a percentage of the model window).
797 /// `max_tokens` is left as the provisional/resolved value; it is (re)computed
798 /// from the budget when the owning layout is resolved.
799 pub fn with_budget(mut self, budget: BudgetSpec) -> Self {
800 self.budget = budget;
801 self
802 }
803
804 /// Set the compaction trigger fraction for a [`RegionKind::Compacting`]
805 /// region (`0.80` == compact at 80% of the resolved budget).
806 pub fn with_compact_at(mut self, fraction: f64) -> Self {
807 self.compact_at = Some(fraction);
808 self
809 }
810
811 /// Set this region's seed source.
812 pub fn with_seed(mut self, seed: RegionSeed) -> Self {
813 self.seed = Some(seed);
814 self
815 }
816
817 /// Mark this region as required, with an optional custom nudge message.
818 pub fn with_required(mut self, required: bool, message: Option<String>) -> Self {
819 self.required = required;
820 self.required_message = message;
821 self
822 }
823
824 /// Add a schema to this region definition.
825 pub fn with_schema(mut self, schema: RegionSchema) -> Self {
826 self.schema = Some(schema);
827 self
828 }
829
830 /// Add a description to this region definition.
831 pub fn with_description(mut self, description: String) -> Self {
832 self.description = Some(description);
833 self
834 }
835}
836
837#[cfg(test)]
838mod tests {
839 use super::*;
840 use leviath_testkit::with_tracing;
841
842 #[test]
843 fn test_layout_creation() {
844 let regions = vec![
845 RegionDefinition::new("pinned".to_string(), RegionKind::Pinned, 5000),
846 RegionDefinition::new("temp".to_string(), RegionKind::Temporary, 10000),
847 ];
848 let layout = ContextLayout::new(regions, 20000);
849 assert_eq!(layout.regions.len(), 2);
850 assert_eq!(layout.total_budget_tokens, 20000);
851 }
852
853 #[test]
854 fn test_layout_validation() {
855 let regions = vec![RegionDefinition::new(
856 "test".to_string(),
857 RegionKind::Pinned,
858 5000,
859 )];
860 let layout =
861 ContextLayout::new(regions, 10000).with_eviction_order(vec!["test".to_string()]);
862
863 assert!(layout.validate().is_ok());
864 }
865
866 #[test]
867 fn test_duplicate_region_names() {
868 let regions = vec![
869 RegionDefinition::new("test".to_string(), RegionKind::Pinned, 5000),
870 RegionDefinition::new("test".to_string(), RegionKind::Temporary, 3000),
871 ];
872 let layout = ContextLayout::new(regions, 10000);
873
874 assert!(layout.validate().is_err());
875 }
876
877 #[test]
878 fn test_eviction_order_unknown_region_is_error() {
879 let regions = vec![RegionDefinition::new(
880 "test".to_string(),
881 RegionKind::Pinned,
882 5000,
883 )];
884 let layout =
885 ContextLayout::new(regions, 10000).with_eviction_order(vec!["nonexistent".to_string()]);
886
887 let err = layout.validate().unwrap_err();
888 assert_eq!(
889 err,
890 ValidationError::Layout(
891 "eviction order references unknown region: nonexistent".to_string()
892 )
893 );
894 }
895
896 #[test]
897 fn test_validate_warns_but_does_not_error_when_max_tokens_exceed_budget() {
898 // Sum of region max_tokens (5000 + 10000 = 15000) exceeds the total
899 // budget (10000) - this should only warn, not fail validation, since
900 // not all regions are full simultaneously.
901 let regions = vec![
902 RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000),
903 RegionDefinition::new("b".to_string(), RegionKind::Temporary, 10000),
904 ];
905 let layout = ContextLayout::new(regions, 10000);
906 with_tracing(|| {
907 assert!(layout.validate().is_ok());
908 });
909 }
910
911 #[test]
912 fn validate_errors_when_fixed_regions_starve_working_budget() {
913 // Realistically-sized layout (>= 20k) where a huge fixed (pinned) region
914 // leaves < 8000 working tokens for conversation/tool-results → hard error.
915 let regions = vec![
916 RegionDefinition::new("big_pinned".to_string(), RegionKind::Pinned, 95_000),
917 RegionDefinition::new("work".to_string(), RegionKind::Temporary, 5_000),
918 ];
919 let layout = ContextLayout::new(regions, 100_000);
920 with_tracing(|| {
921 let err = layout.validate().unwrap_err();
922 assert!(
923 err.to_string().contains("working tokens"),
924 "actionable budget error: {err}"
925 );
926 });
927 }
928
929 #[test]
930 fn validate_ok_for_realistic_layout_with_working_room() {
931 let regions = vec![
932 RegionDefinition::new("task".to_string(), RegionKind::Pinned, 4_000),
933 RegionDefinition::new("conversation".to_string(), RegionKind::Temporary, 40_000),
934 ];
935 let layout = ContextLayout::new(regions, 44_000);
936 with_tracing(|| {
937 assert!(layout.validate().is_ok());
938 });
939 }
940
941 #[test]
942 fn validate_working_room_judges_fixed_regions_against_the_passed_window() {
943 // The same layout passes against a wide window and fails against a
944 // narrow one: the fixed region is fine when the stage seeing it has room
945 // and starves the working budget when the stage's window is small. This
946 // is what lets each stage be judged against its own model's window over
947 // just the regions it can see.
948 let regions = vec![
949 RegionDefinition::new("task".to_string(), RegionKind::Pinned, 30_000),
950 RegionDefinition::new("work".to_string(), RegionKind::Temporary, 10_000),
951 ];
952 let layout = ContextLayout::new(regions, 200_000);
953 with_tracing(|| {
954 assert!(layout.validate_working_room(200_000).is_ok());
955 let err = layout.validate_working_room(32_000).unwrap_err();
956 assert!(err.to_string().contains("working tokens"), "{err}");
957 // A tiny (toy-sized) window below the check floor is left alone.
958 assert!(layout.validate_working_room(10_000).is_ok());
959 });
960 }
961
962 fn custom_kind(script: &str, pinned: bool) -> RegionKind {
963 RegionKind::Custom {
964 script: script.to_string(),
965 pinned,
966 }
967 }
968
969 #[test]
970 fn validate_rejects_custom_region_with_empty_script() {
971 // Whitespace-only counts as empty: it could never resolve to a file
972 // and the runtime would silently fall back on every inference.
973 let regions = vec![RegionDefinition::new(
974 "brain".to_string(),
975 custom_kind(" ", false),
976 5000,
977 )];
978 let layout = ContextLayout::new(regions, 10_000);
979 let err = with_tracing(|| layout.validate().unwrap_err());
980 assert!(
981 err.to_string().contains("non-empty script path"),
982 "actionable error: {err}"
983 );
984 }
985
986 #[test]
987 fn validate_counts_persistent_custom_as_fixed_budget() {
988 // A persistent custom region is Pinned-like: protected from eviction,
989 // so it must count toward the fixed budget that can starve the
990 // working room.
991 let regions = vec![
992 RegionDefinition::new("vault".to_string(), custom_kind("v.rhai", true), 95_000),
993 RegionDefinition::new("work".to_string(), RegionKind::Temporary, 5_000),
994 ];
995 let layout = ContextLayout::new(regions, 100_000);
996 let err = with_tracing(|| layout.validate().unwrap_err());
997 assert!(err.to_string().contains("working tokens"), "{err}");
998 }
999
1000 #[test]
1001 fn validate_counts_non_persistent_custom_as_working_budget() {
1002 // Same shape, but the custom region is evictable - it IS the working
1003 // room, so validation passes.
1004 let regions = vec![
1005 RegionDefinition::new("brain".to_string(), custom_kind("b.rhai", false), 95_000),
1006 RegionDefinition::new("task".to_string(), RegionKind::Pinned, 4_000),
1007 ];
1008 let layout = ContextLayout::new(regions, 100_000);
1009 with_tracing(|| {
1010 assert!(layout.validate().is_ok());
1011 });
1012 }
1013
1014 #[test]
1015 fn custom_region_satisfies_the_message_region_check() {
1016 // A layout whose only region is custom must not trip the "no
1017 // SlidingWindow region" warning path - its script can render typed
1018 // entries as messages. (Mirrors the sliding-window-present test: the
1019 // skip branch is exercised, validation succeeds.)
1020 let regions = vec![RegionDefinition::new(
1021 "everything".to_string(),
1022 custom_kind("all.rhai", false),
1023 9_000,
1024 )];
1025 let layout = ContextLayout::new(regions, 10_000);
1026 with_tracing(|| {
1027 assert!(layout.validate().is_ok());
1028 });
1029 }
1030
1031 #[test]
1032 fn resolved_percent_budget_applies_to_custom_region() {
1033 // The "recreate built-ins in Rhai" guarantee: percentage budgets work
1034 // on custom regions exactly as on built-in kinds, resolved against
1035 // the stage model's context window at spawn.
1036 let def = RegionDefinition::new("brain".to_string(), custom_kind("b.rhai", false), 0)
1037 .with_budget(BudgetSpec::Percent {
1038 percent: 0.40,
1039 min: Some(10_000),
1040 max: None,
1041 });
1042 let layout = ContextLayout::new(vec![def], 0);
1043 let resolved = layout.resolved(200_000);
1044 assert_eq!(resolved.regions[0].max_tokens, 80_000);
1045 assert!(matches!(
1046 resolved.regions[0].kind,
1047 RegionKind::Custom { ref script, pinned: false } if script == "b.rhai"
1048 ));
1049 // The min floor wins on a small window.
1050 let small = layout.resolved(8_192);
1051 assert_eq!(small.regions[0].max_tokens, 10_000);
1052 }
1053
1054 #[test]
1055 fn test_get_region_found() {
1056 let regions = vec![
1057 RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000),
1058 RegionDefinition::new("b".to_string(), RegionKind::Temporary, 3000),
1059 ];
1060 let layout = ContextLayout::new(regions, 10000);
1061
1062 let found = layout.get_region("b").unwrap();
1063 assert_eq!(found.name, "b");
1064 assert_eq!(found.max_tokens, 3000);
1065 }
1066
1067 #[test]
1068 fn test_get_region_not_found() {
1069 let regions = vec![RegionDefinition::new(
1070 "a".to_string(),
1071 RegionKind::Pinned,
1072 5000,
1073 )];
1074 let layout = ContextLayout::new(regions, 10000);
1075 assert!(layout.get_region("missing").is_none());
1076 }
1077
1078 #[test]
1079 fn test_region_definition_with_schema() {
1080 let schema = crate::region::RegionSchema::new(crate::region::ContentFormat::Json);
1081 let def =
1082 RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000).with_schema(schema);
1083 assert_eq!(
1084 def.schema.as_ref().unwrap().format,
1085 crate::region::ContentFormat::Json
1086 );
1087 }
1088
1089 #[test]
1090 fn test_region_definition_with_description() {
1091 let def = RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000)
1092 .with_description("holds architecture notes".to_string());
1093 assert_eq!(def.description.as_deref(), Some("holds architecture notes"));
1094 }
1095
1096 #[test]
1097 fn parse_budget_accepts_plain_and_decimal_percentages() {
1098 assert_eq!(BudgetSpec::parse_budget("35%").unwrap(), 0.35);
1099 assert_eq!(BudgetSpec::parse_budget("100%").unwrap(), 1.0);
1100 assert!((BudgetSpec::parse_budget("0.6%").unwrap() - 0.006).abs() < 1e-9);
1101 }
1102
1103 #[test]
1104 fn parse_budget_trims_surrounding_and_inner_whitespace() {
1105 assert_eq!(BudgetSpec::parse_budget(" 35 % ").unwrap(), 0.35);
1106 }
1107
1108 #[test]
1109 fn parse_budget_rejects_missing_percent_sign() {
1110 let err = BudgetSpec::parse_budget("35").unwrap_err();
1111 assert!(err.contains("must end with '%'"), "{err}");
1112 }
1113
1114 #[test]
1115 fn parse_budget_rejects_non_numeric() {
1116 let err = BudgetSpec::parse_budget("abc%").unwrap_err();
1117 assert!(err.contains("not a valid number"), "{err}");
1118 }
1119
1120 #[test]
1121 fn parse_budget_rejects_zero_and_negative() {
1122 let zero = BudgetSpec::parse_budget("0%").unwrap_err();
1123 assert!(zero.contains("greater than 0%"), "{zero}");
1124 let neg = BudgetSpec::parse_budget("-10%").unwrap_err();
1125 assert!(neg.contains("greater than 0%"), "{neg}");
1126 }
1127
1128 #[test]
1129 fn parse_budget_rejects_over_one_hundred() {
1130 let err = BudgetSpec::parse_budget("150%").unwrap_err();
1131 assert!(err.contains("at most 100%"), "{err}");
1132 }
1133
1134 #[test]
1135 fn resolve_absolute_ignores_window() {
1136 assert_eq!(BudgetSpec::Absolute(4000).resolve(1_000_000), 4000);
1137 assert!(!BudgetSpec::Absolute(4000).is_percent());
1138 }
1139
1140 #[test]
1141 fn resolve_percent_of_window() {
1142 let spec = BudgetSpec::Percent {
1143 percent: 0.35,
1144 min: None,
1145 max: None,
1146 };
1147 assert_eq!(spec.resolve(1_000_000), 350_000);
1148 assert!(spec.is_percent());
1149 }
1150
1151 #[test]
1152 fn resolve_percent_applies_max_cap() {
1153 let spec = BudgetSpec::Percent {
1154 percent: 0.02,
1155 min: None,
1156 max: Some(4000),
1157 };
1158 // 2% of 1M = 20_000, capped to 4000.
1159 assert_eq!(spec.resolve(1_000_000), 4000);
1160 }
1161
1162 #[test]
1163 fn resolve_percent_applies_min_floor() {
1164 let spec = BudgetSpec::Percent {
1165 percent: 0.02,
1166 min: Some(2000),
1167 max: None,
1168 };
1169 // 2% of 8000 = 160, floored to 2000.
1170 assert_eq!(spec.resolve(8000), 2000);
1171 }
1172
1173 #[test]
1174 fn resolve_percent_within_bounds_takes_neither_clamp() {
1175 let spec = BudgetSpec::Percent {
1176 percent: 0.10,
1177 min: Some(1000),
1178 max: Some(50_000),
1179 };
1180 // 10% of 200k = 20_000, between the floor and cap.
1181 assert_eq!(spec.resolve(200_000), 20_000);
1182 }
1183
1184 #[test]
1185 fn resolve_percent_floor_wins_when_min_exceeds_max() {
1186 let spec = BudgetSpec::Percent {
1187 percent: 0.10,
1188 min: Some(9000),
1189 max: Some(4000),
1190 };
1191 // 10% of 200k = 20_000 → capped to 4000 → floored up to 9000 (floor wins).
1192 assert_eq!(spec.resolve(200_000), 9000);
1193 }
1194
1195 #[test]
1196 fn has_percent_budgets_detects_percentage_regions() {
1197 let absolute = ContextLayout::new(
1198 vec![RegionDefinition::new(
1199 "a".to_string(),
1200 RegionKind::Pinned,
1201 5000,
1202 )],
1203 5000,
1204 );
1205 assert!(!absolute.has_percent_budgets());
1206
1207 let percent = ContextLayout::new(
1208 vec![
1209 RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000).with_budget(
1210 BudgetSpec::Percent {
1211 percent: 0.05,
1212 min: None,
1213 max: None,
1214 },
1215 ),
1216 ],
1217 5000,
1218 );
1219 assert!(percent.has_percent_budgets());
1220 }
1221
1222 #[test]
1223 fn resolved_is_noop_for_absolute_layout() {
1224 let layout = ContextLayout::new(
1225 vec![RegionDefinition::new(
1226 "a".to_string(),
1227 RegionKind::Pinned,
1228 5000,
1229 )],
1230 5000,
1231 );
1232 let resolved = layout.resolved(1_000_000);
1233 assert_eq!(resolved.regions[0].max_tokens, 5000);
1234 // An absolute layout keeps its summed total, not the window.
1235 assert_eq!(resolved.total_budget_tokens, 5000);
1236 }
1237
1238 #[test]
1239 fn resolved_percent_layout_uses_window_as_total() {
1240 let layout = ContextLayout::new(
1241 vec![
1242 RegionDefinition::new("a".to_string(), RegionKind::Pinned, 0).with_budget(
1243 BudgetSpec::Percent {
1244 percent: 0.10,
1245 min: None,
1246 max: None,
1247 },
1248 ),
1249 ],
1250 0,
1251 )
1252 .with_eviction_order(vec!["a".to_string()]);
1253 let resolved = layout.resolved(1_000_000);
1254 assert_eq!(resolved.regions[0].max_tokens, 100_000);
1255 assert_eq!(resolved.total_budget_tokens, 1_000_000);
1256 // eviction order carried through.
1257 assert_eq!(resolved.eviction_order, vec!["a".to_string()]);
1258 }
1259
1260 #[test]
1261 fn resolved_per_region_sizes_each_region_against_its_own_window() {
1262 let pct = || BudgetSpec::Percent {
1263 percent: 0.10,
1264 min: None,
1265 max: None,
1266 };
1267 // A Compacting region whose threshold is a fraction of its resolved
1268 // budget, so the per-region path must recompute it against the region's
1269 // own window like `resolved()` does.
1270 let mut compact = RegionDefinition::new(
1271 "roll".to_string(),
1272 RegionKind::Compacting {
1273 threshold_tokens: usize::MAX,
1274 },
1275 0,
1276 )
1277 .with_budget(pct());
1278 compact.compact_at = Some(0.5);
1279 let layout = ContextLayout::new(
1280 vec![
1281 RegionDefinition::new("wide".to_string(), RegionKind::Pinned, 0).with_budget(pct()),
1282 RegionDefinition::new("narrow".to_string(), RegionKind::Pinned, 0)
1283 .with_budget(pct()),
1284 compact,
1285 ],
1286 0,
1287 );
1288 let resolved = layout.resolved_per_region(&|name| match name {
1289 "wide" => 200_000,
1290 _ => 32_768,
1291 });
1292 let max_of = |n: &str| {
1293 resolved
1294 .regions
1295 .iter()
1296 .find(|r| r.name == n)
1297 .unwrap()
1298 .max_tokens
1299 };
1300 assert_eq!(max_of("wide"), 20_000); // 10% of 200k
1301 assert_eq!(max_of("narrow"), 3_277); // 10% of 32768, rounded
1302 // roll: 10% of 32768 = 3277 budget; threshold = 50% of that, sized
1303 // against the region's own window, not the widest.
1304 assert_eq!(max_of("roll"), 3_277);
1305 let roll = resolved.regions.iter().find(|r| r.name == "roll").unwrap();
1306 assert!(
1307 matches!(roll.kind, RegionKind::Compacting { threshold_tokens } if threshold_tokens == 1_639),
1308 "compacting threshold resolved per region: {:?}",
1309 roll.kind
1310 );
1311 // The total is the largest per-region window; the real fit check is per
1312 // stage, done by the caller.
1313 assert_eq!(resolved.total_budget_tokens, 200_000);
1314
1315 // An absolute layout has nothing to resolve, so this is a no-op.
1316 let absolute = ContextLayout::new(
1317 vec![RegionDefinition::new(
1318 "x".to_string(),
1319 RegionKind::Pinned,
1320 5_000,
1321 )],
1322 5_000,
1323 );
1324 let same = absolute.resolved_per_region(&|_| 999_999);
1325 assert_eq!(same.regions[0].max_tokens, 5_000);
1326 assert_eq!(same.total_budget_tokens, 5_000);
1327 }
1328
1329 #[test]
1330 fn retaining_keeps_only_the_named_regions_and_eviction_entries() {
1331 let layout = ContextLayout {
1332 regions: vec![
1333 RegionDefinition::new("keep".to_string(), RegionKind::Pinned, 1_000),
1334 RegionDefinition::new("drop".to_string(), RegionKind::Temporary, 1_000),
1335 ],
1336 total_budget_tokens: 10_000,
1337 eviction_order: vec!["keep".to_string(), "drop".to_string()],
1338 };
1339 let kept = layout.retaining(|name| name == "keep");
1340 assert_eq!(kept.regions.len(), 1);
1341 assert_eq!(kept.regions[0].name, "keep");
1342 assert_eq!(kept.eviction_order, vec!["keep".to_string()]);
1343 // total_budget rides along unchanged; the working-room check judges
1344 // against a window the caller passes, not this field.
1345 assert_eq!(kept.total_budget_tokens, 10_000);
1346 }
1347
1348 #[test]
1349 fn resolved_compacting_threshold_all_cases() {
1350 // compact_at + explicit threshold cap → min(pct, cap).
1351 let both = RegionDefinition::new(
1352 "c".to_string(),
1353 RegionKind::Compacting {
1354 threshold_tokens: 25_000,
1355 },
1356 0,
1357 )
1358 .with_budget(BudgetSpec::Percent {
1359 percent: 0.20,
1360 min: None,
1361 max: None,
1362 })
1363 .with_compact_at(0.80);
1364 let r = ContextLayout::new(vec![both], 0).resolved(200_000);
1365 // budget = 40_000; 80% = 32_000; capped to 25_000.
1366 assert_eq!(
1367 r.regions[0].kind,
1368 RegionKind::Compacting {
1369 threshold_tokens: 25_000
1370 }
1371 );
1372
1373 // compact_at with no cap (usize::MAX sentinel) → pct only.
1374 let pct_only = RegionDefinition::new(
1375 "c".to_string(),
1376 RegionKind::Compacting {
1377 threshold_tokens: usize::MAX,
1378 },
1379 0,
1380 )
1381 .with_budget(BudgetSpec::Percent {
1382 percent: 0.20,
1383 min: None,
1384 max: None,
1385 })
1386 .with_compact_at(0.80);
1387 let r = ContextLayout::new(vec![pct_only], 0).resolved(200_000);
1388 assert_eq!(
1389 r.regions[0].kind,
1390 RegionKind::Compacting {
1391 threshold_tokens: 32_000
1392 }
1393 );
1394
1395 // compact_at = None → absolute threshold passes through unchanged.
1396 let absolute = RegionDefinition::new(
1397 "c".to_string(),
1398 RegionKind::Compacting {
1399 threshold_tokens: 8000,
1400 },
1401 10_000,
1402 );
1403 let r = ContextLayout::new(vec![absolute], 10_000).resolved(1_000_000);
1404 assert_eq!(
1405 r.regions[0].kind,
1406 RegionKind::Compacting {
1407 threshold_tokens: 8000
1408 }
1409 );
1410 }
1411
1412 #[test]
1413 fn validate_skips_token_checks_for_percent_layouts() {
1414 // A percentage layout whose provisional max_tokens are tiny/zero must not
1415 // trip the fixed-working-budget hard error - that check is deferred to
1416 // post-resolution. Wrap in tracing so no warn-arg lines read uncovered.
1417 let regions = vec![
1418 RegionDefinition::new("big_pinned".to_string(), RegionKind::Pinned, 0).with_budget(
1419 BudgetSpec::Percent {
1420 percent: 0.95,
1421 min: None,
1422 max: None,
1423 },
1424 ),
1425 ];
1426 let layout = ContextLayout::new(regions, 100_000);
1427 with_tracing(|| {
1428 assert!(layout.validate().is_ok());
1429 });
1430 }
1431
1432 #[test]
1433 fn region_definition_default_budget_matches_max_tokens() {
1434 let def = RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000);
1435 assert_eq!(def.budget, BudgetSpec::Absolute(5000));
1436 assert_eq!(def.compact_at, None);
1437 }
1438
1439 #[test]
1440 fn budget_spec_default_is_absolute_zero() {
1441 assert_eq!(BudgetSpec::default(), BudgetSpec::Absolute(0));
1442 }
1443
1444 #[test]
1445 fn test_validate_with_sliding_window_present() {
1446 // A layout that DOES contain a SlidingWindow region exercises the
1447 // has_sliding_window detection returning true, so the "no sliding
1448 // window" warning branch is skipped.
1449 let regions = vec![
1450 RegionDefinition::new("pinned".to_string(), RegionKind::Pinned, 5000),
1451 RegionDefinition::new(
1452 "conv".to_string(),
1453 RegionKind::SlidingWindow {
1454 max_items: 50,
1455 eviction_strategy: crate::region::EvictionStrategy::PerItem,
1456 },
1457 5000,
1458 ),
1459 ];
1460 let layout = ContextLayout::new(regions, 20000);
1461 with_tracing(|| {
1462 assert!(layout.validate().is_ok());
1463 });
1464 }
1465
1466 /// `validate` sums every region's ceiling to warn when they exceed the
1467 /// budget. Two saturated ceilings must warn rather than overflow that sum
1468 /// and abort.
1469 #[test]
1470 fn validate_does_not_abort_when_region_ceilings_sum_past_usize_max() {
1471 let layout = ContextLayout::new(
1472 vec![
1473 RegionDefinition::new("a".to_string(), RegionKind::Pinned, usize::MAX),
1474 RegionDefinition::new("b".to_string(), RegionKind::Pinned, usize::MAX),
1475 ],
1476 1_000,
1477 );
1478 let _ = layout.validate();
1479 }
1480}