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 { 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}