Skip to main content

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