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/// How a region's token ceiling is expressed before it is resolved against a
13/// concrete model context window.
14///
15/// Blueprint authors think in **proportions** (`budget = "35%"`) so their intent
16/// stays correct regardless of the model's context size, while power users can
17/// still pin an exact count. The percentage denominator - the model's context
18/// window - is not known at parse time, so the spec is stored unresolved here and
19/// turned into a concrete token count at window-build time (see
20/// [`BudgetSpec::resolve`] and [`ContextLayout::resolved`]).
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum BudgetSpec {
24    /// A fixed token ceiling, independent of the model. Resolving is a no-op.
25    Absolute(usize),
26
27    /// A ceiling expressed as a fraction of the model's context window, with
28    /// optional absolute guard-rails. `percent` is a fraction (`0.35` for
29    /// `"35%"`). `max` caps the resolved value (so e.g. 2% of a 1M window can't
30    /// balloon a task region to 20K tokens); `min` floors it (so a small-context
31    /// model doesn't starve the region below a usable size).
32    Percent {
33        /// Fraction of the model context window (0.35 == "35%").
34        percent: f64,
35        /// Absolute floor for the resolved value, if any.
36        min: Option<usize>,
37        /// Absolute cap for the resolved value, if any.
38        max: Option<usize>,
39    },
40}
41
42impl Default for BudgetSpec {
43    /// Only a serde-deserialize fallback for older persisted blueprints; live
44    /// code always sets the budget explicitly via [`RegionDefinition::new`].
45    fn default() -> Self {
46        BudgetSpec::Absolute(0)
47    }
48}
49
50impl BudgetSpec {
51    /// Parse a percentage string like `"35%"` into its fraction (`0.35`).
52    ///
53    /// Surrounding whitespace is trimmed and decimals are allowed (`"0.6%"`).
54    /// Rejects a missing `%`, a non-numeric value, and anything outside the
55    /// `(0, 100]` range - a single region can't sensibly claim ≤0% or more than
56    /// the whole window (region budgets may *sum* past 100%, but each is a
57    /// fraction of one window). Returns the human-readable reason on failure so
58    /// the caller can surface it at load time.
59    pub fn parse_budget(s: &str) -> std::result::Result<f64, String> {
60        let trimmed = s.trim();
61        let Some(num) = trimmed.strip_suffix('%') else {
62            return Err(format!("budget '{s}' must end with '%' (e.g. \"35%\")"));
63        };
64        let value: f64 = num
65            .trim()
66            .parse()
67            .map_err(|_| format!("budget '{s}' is not a valid number"))?;
68        if !(value > 0.0 && value <= 100.0) {
69            return Err(format!(
70                "budget '{s}' must be greater than 0% and at most 100%"
71            ));
72        }
73        Ok(value / 100.0)
74    }
75
76    /// Resolve this spec to a concrete token count against a model context
77    /// `window`.
78    ///
79    /// [`Absolute`](BudgetSpec::Absolute) ignores the window (idempotent - a
80    /// fully-absolute layout resolves to itself). [`Percent`](BudgetSpec::Percent)
81    /// rounds `window * percent`, then applies the `max` cap, then the `min`
82    /// floor. The floor is applied **last** so that when `min > max` the floor
83    /// wins: a region starved below a usable size is worse than one slightly over
84    /// its cap.
85    pub fn resolve(&self, window: usize) -> usize {
86        match self {
87            BudgetSpec::Absolute(n) => *n,
88            BudgetSpec::Percent { percent, min, max } => {
89                let mut v = (window as f64 * percent).round() as usize;
90                if let Some(max) = max {
91                    v = v.min(*max);
92                }
93                if let Some(min) = min {
94                    v = v.max(*min);
95                }
96                v
97            }
98        }
99    }
100
101    /// Whether this is a percentage budget (needs a model window to resolve).
102    pub fn is_percent(&self) -> bool {
103        matches!(self, BudgetSpec::Percent { .. })
104    }
105}
106
107/// A ContextLayout defines the complete memory map for an agent.
108///
109/// Like SNES VRAM layout - every region has a defined purpose, size, and policy.
110/// The layout specifies:
111/// - Which regions exist and their configurations
112/// - Total token budget across all regions
113/// - Eviction order when space is needed
114///
115/// Layouts are typically defined in an agent's blueprint and remain constant
116/// throughout the agent's lifecycle, though the content within regions changes.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct ContextLayout {
119    /// All regions in this layout
120    pub regions: Vec<RegionDefinition>,
121
122    /// Total token budget across all regions
123    pub total_budget_tokens: usize,
124
125    /// Region names in eviction priority order (first = evicted first)
126    ///
127    /// When the context window fills up, regions are processed in this order:
128    /// 1. Temporary regions: evict oldest entries
129    /// 2. Compacting regions: trigger summarization
130    /// 3. SlidingWindow regions: reduce window size
131    /// 4. Pinned regions: NEVER touched (if these fill up, it's a config error)
132    pub eviction_order: Vec<String>,
133}
134
135impl ContextLayout {
136    /// Create a new layout with the specified configuration.
137    pub fn new(regions: Vec<RegionDefinition>, total_budget_tokens: usize) -> Self {
138        Self {
139            regions,
140            total_budget_tokens,
141            eviction_order: Vec::new(),
142        }
143    }
144
145    /// Set the eviction order for this layout.
146    pub fn with_eviction_order(mut self, order: Vec<String>) -> Self {
147        self.eviction_order = order;
148        self
149    }
150
151    /// Validate that the layout is well-formed.
152    ///
153    /// Checks:
154    /// - Sum of max_tokens doesn't exceed total_budget_tokens
155    /// - All region names in eviction_order exist
156    /// - No duplicate region names
157    pub fn validate(&self) -> std::result::Result<(), ValidationError> {
158        // Check for duplicate region names
159        let mut names = std::collections::HashSet::new();
160        for region in &self.regions {
161            if !names.insert(region.name.as_str()) {
162                return Err(ValidationError::Region {
163                    region: region.name.clone(),
164                    message: "duplicate region name".to_string(),
165                });
166            }
167        }
168
169        // Check that eviction_order regions exist
170        for name in &self.eviction_order {
171            if !names.contains(name.as_str()) {
172                return Err(ValidationError::Layout(format!(
173                    "eviction order references unknown region: {}",
174                    name
175                )));
176            }
177        }
178
179        // Reject a Custom region whose script path is empty - it could never
180        // resolve to a file, and the runtime would silently fall back to
181        // Temporary-style rendering on every inference.
182        for region in &self.regions {
183            if let RegionKind::Custom { script, .. } = &region.kind
184                && script.trim().is_empty()
185            {
186                return Err(ValidationError::Region {
187                    region: region.name.clone(),
188                    message: "custom region requires a non-empty script path".to_string(),
189                });
190            }
191        }
192
193        // Warn if sum of max tokens exceeds budget (not necessarily an error,
194        // since not all regions will be full simultaneously)
195        // Warn if no SlidingWindow region exists - agents should have a
196        // conversation region for typed message entries, but some agents
197        // (e.g., deep-researcher) use other region kinds exclusively. A Custom
198        // region counts: its script can render typed entries as messages.
199        let has_message_region = self.regions.iter().any(|r| {
200            matches!(
201                r.kind,
202                RegionKind::SlidingWindow { .. } | RegionKind::Custom { .. }
203            )
204        });
205        if !has_message_region {
206            tracing::warn!(
207                "Layout has no SlidingWindow (or custom scripted) region - typed \
208                 conversation entries require one"
209            );
210        }
211
212        // The token-sum warning and the fixed-working-budget hard error below
213        // operate on concrete `max_tokens` values. When percentage budgets are
214        // present those values are provisional placeholders until the layout is
215        // resolved against a model window, so the checks are meaningless here -
216        // skip them and rely on the post-resolution `validate()` call at spawn.
217        if self.has_percent_budgets() {
218            return Ok(());
219        }
220
221        let total_max: usize = self.regions.iter().map(|r| r.max_tokens).sum();
222        if total_max > self.total_budget_tokens {
223            tracing::warn!(
224                "Sum of region max tokens ({}) exceeds total budget ({})",
225                total_max,
226                self.total_budget_tokens
227            );
228        }
229
230        // Ensure the layout leaves a minimum working budget once the fixed,
231        // non-evictable regions are full. Pinned / HashMap / CompactHistory
232        // regions persist for the whole run and consume budget; if they leave
233        // too little room, the conversation/tool-result (evictable) regions have
234        // almost no space and the agent operates "blind". Fail loudly at load
235        // instead of degrading silently at runtime.
236        let fixed_tokens: usize = self
237            .regions
238            .iter()
239            .filter(|r| {
240                matches!(
241                    r.kind,
242                    RegionKind::Pinned
243                        | RegionKind::HashMap { .. }
244                        | RegionKind::CompactHistory { .. }
245                        | RegionKind::Custom {
246                            persistent: true,
247                            ..
248                        }
249                )
250            })
251            .map(|r| r.max_tokens)
252            .sum();
253        // Only enforce the absolute working-budget floor on realistically-sized
254        // layouts. Tiny illustrative layouts (toy examples, unit-test fixtures)
255        // have small budgets by design and are not real agent runs; applying an
256        // absolute floor to them would be nonsensical.
257        let working_tokens = self.total_budget_tokens.saturating_sub(fixed_tokens);
258        if self.total_budget_tokens >= Self::BUDGET_CHECK_MIN_TOTAL
259            && working_tokens < Self::MIN_WORKING_TOKENS
260        {
261            return Err(ValidationError::Layout(format!(
262                "context layout leaves only {working_tokens} working tokens after fixed \
263                 regions (pinned/hashmap/compact_history/persistent custom) consume \
264                 {fixed_tokens} of the {} \
265                 total budget; at least {} are needed for the agent to operate. Reduce the \
266                 fixed regions' max_tokens or increase the total budget.",
267                self.total_budget_tokens,
268                Self::MIN_WORKING_TOKENS
269            )));
270        }
271
272        Ok(())
273    }
274
275    /// Minimum token budget that must remain for evictable/working regions
276    /// (conversation, tool results, scratch) after the fixed regions are full,
277    /// so the agent has room to hold recent context and generate. Below this a
278    /// run would operate with almost no working space.
279    const MIN_WORKING_TOKENS: usize = 8000;
280
281    /// The working-budget floor is only enforced when the layout's total budget
282    /// is at least this large - i.e. it's a realistically-sized agent, not a
283    /// toy/illustrative layout where an absolute floor wouldn't make sense.
284    const BUDGET_CHECK_MIN_TOTAL: usize = 20_000;
285
286    /// Get a region definition by name.
287    pub fn get_region(&self, name: &str) -> Option<&RegionDefinition> {
288        self.regions.iter().find(|r| r.name == name)
289    }
290
291    /// Whether any region uses a percentage budget (and therefore needs a model
292    /// context window to resolve to concrete token counts).
293    pub fn has_percent_budgets(&self) -> bool {
294        self.regions.iter().any(|r| r.budget.is_percent())
295    }
296
297    /// Resolve every region's percentage budget against a concrete model context
298    /// `window`, returning a fully-absolute layout.
299    ///
300    /// Each region's `max_tokens` becomes `budget.resolve(window)`, and each
301    /// [`RegionKind::Compacting`] region's `threshold_tokens` is recomputed from
302    /// its [`compact_at`](RegionDefinition::compact_at) fraction (via the private
303    /// `resolve_compacting_threshold` helper). `eviction_order` is preserved. The
304    /// total budget becomes the model `window` when any percentage budget is
305    /// present (percentage ceilings are relative to the whole window and may sum
306    /// past 100%); a pure-absolute layout keeps its legacy summed total unchanged.
307    ///
308    /// Resolving an already-absolute layout is a no-op, so this is safe to call
309    /// unconditionally at window-build time.
310    pub fn resolved(&self, window: usize) -> ContextLayout {
311        let regions = self
312            .regions
313            .iter()
314            .map(|r| {
315                let max_tokens = r.budget.resolve(window);
316                let kind = match &r.kind {
317                    RegionKind::Compacting { threshold_tokens } => RegionKind::Compacting {
318                        threshold_tokens: Self::resolve_compacting_threshold(
319                            r.compact_at,
320                            *threshold_tokens,
321                            max_tokens,
322                        ),
323                    },
324                    other => other.clone(),
325                };
326                // Emit a fully-absolute region: the percentage has been baked
327                // into `max_tokens` and the compaction threshold into `kind`, so
328                // the resolved layout carries no `Percent` budgets. This makes
329                // `has_percent_budgets()` false on the result, so a post-resolution
330                // `validate()` runs the real token/working-budget checks.
331                RegionDefinition {
332                    kind,
333                    max_tokens,
334                    budget: BudgetSpec::Absolute(max_tokens),
335                    compact_at: None,
336                    ..r.clone()
337                }
338            })
339            .collect();
340
341        let total_budget_tokens = if self.has_percent_budgets() {
342            window
343        } else {
344            self.total_budget_tokens
345        };
346
347        ContextLayout {
348            regions,
349            total_budget_tokens,
350            eviction_order: self.eviction_order.clone(),
351        }
352    }
353
354    /// Compute a Compacting region's concrete compaction threshold from its
355    /// `compact_at` fraction, the absolute `threshold_tokens` guard carried on
356    /// the kind, and the region's resolved budget.
357    ///
358    /// - `compact_at = Some(f)` with an explicit `threshold_tokens` cap (any
359    ///   value below the [`usize::MAX`] sentinel) → `min(round(budget * f), cap)`:
360    ///   compact at the percentage, but never later than the absolute guard-rail.
361    /// - `compact_at = Some(f)` with no cap (`threshold_tokens == usize::MAX`
362    ///   sentinel) → `round(budget * f)`.
363    /// - `compact_at = None` → the absolute `threshold_tokens` as-is (back-compat,
364    ///   including the parser's `max_tokens * 8 / 10` default).
365    ///
366    /// The `usize::MAX` sentinel is safe: a layout is always resolved before any
367    /// [`Region::needs_compaction`](crate::region::Region::needs_compaction) check.
368    fn resolve_compacting_threshold(
369        compact_at: Option<f64>,
370        threshold_tokens: usize,
371        resolved_budget: usize,
372    ) -> usize {
373        match compact_at {
374            Some(fraction) => {
375                let pct = (resolved_budget as f64 * fraction).round() as usize;
376                pct.min(threshold_tokens)
377            }
378            None => threshold_tokens,
379        }
380    }
381}
382
383/// Where a region's initial content comes from at run start.
384///
385/// A region without a seed starts empty and is populated by the agent. A seeded
386/// region is filled before the first inference: `CallerInput` regions are filled
387/// by the run's caller (a CLI `--<name>` flag, an ACP `---region:<name>---`
388/// marker, or the API `regions` map); the remaining variants are resolved by the
389/// daemon from the run's workdir (which is why this type only *declares* the
390/// source - `leviath-core` stays filesystem-agnostic; resolution lives in the
391/// CLI daemon's spawner).
392#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
393#[serde(rename_all = "snake_case")]
394pub enum RegionSeed {
395    /// Filled at run time by the caller, keyed by `name` (defaults to the
396    /// region's own name; the sentinel `task` maps to the `--task`/prompt text).
397    /// When the owning region is `required`, a missing value is a hard error
398    /// before any inference runs.
399    CallerInput {
400        /// The caller-input key this region is filled from.
401        name: String,
402    },
403    /// Concatenated contents of the workdir files matching a glob pattern.
404    Glob {
405        /// Glob pattern, resolved relative to the run's workdir.
406        pattern: String,
407    },
408    /// Concatenated contents of an explicit list of workdir-relative files.
409    Files {
410        /// File paths, resolved relative to the run's workdir.
411        paths: Vec<String>,
412    },
413    /// A static literal string baked into the blueprint.
414    Literal {
415        /// The verbatim seed text.
416        text: String,
417    },
418    /// The `String` returned by running a Rhai script from the workdir.
419    Rhai {
420        /// Script path, resolved relative to the run's workdir.
421        script: String,
422    },
423    /// The combined stdout/stderr of a shell command run in the workdir at spawn.
424    ///
425    /// Unlike every other variant this *executes* something, and it does so
426    /// before the first inference - so before any tool-approval prompt. The
427    /// daemon runs it inside the entry stage's sandbox when one is configured,
428    /// caps its runtime and output, and honours the `[security]
429    /// allow_seed_commands` kill switch. A failure is non-fatal unless the
430    /// owning region is `required`.
431    Command {
432        /// The shell command line, run with the platform shell in the workdir.
433        command: String,
434    },
435}
436
437/// Definition of a region in a layout.
438///
439/// This is the blueprint for creating a Region instance. It specifies the
440/// region's configuration but doesn't contain actual content.
441#[derive(Debug, Clone, Serialize, Deserialize)]
442pub struct RegionDefinition {
443    /// Unique name for this region
444    pub name: String,
445
446    /// Region lifecycle policy
447    pub kind: RegionKind,
448
449    /// **Resolved** maximum tokens for this region. This is the concrete ceiling
450    /// every downstream consumer reads; for a percentage budget it is populated
451    /// when the layout is resolved against a model window (see
452    /// [`ContextLayout::resolved`]). [`Self::budget`] is the source of truth for
453    /// how this value is derived.
454    pub max_tokens: usize,
455
456    /// How this region's ceiling is expressed. Defaults (via [`Self::new`]) to
457    /// [`BudgetSpec::Absolute`] holding `max_tokens`, so a region built the old
458    /// way behaves exactly as before. A percentage budget is resolved against the
459    /// model context window at window-build time.
460    #[serde(default)]
461    pub budget: BudgetSpec,
462
463    /// For [`RegionKind::Compacting`] regions only: compact when the region
464    /// reaches this fraction of its resolved budget (`0.80` for `compact_at =
465    /// "80%"`). `None` keeps the absolute `threshold_tokens` carried on the kind.
466    /// See [`ContextLayout::resolved`] for how this becomes a concrete threshold.
467    #[serde(default)]
468    pub compact_at: Option<f64>,
469
470    /// Optional validation schema
471    pub schema: Option<RegionSchema>,
472
473    /// Human-readable description of this region's purpose
474    pub description: Option<String>,
475
476    /// When true, this region must be non-empty before a stage that can write
477    /// to it is allowed to complete. Guards against an agent skipping a
478    /// context-population step (e.g. never writing the `plan` region). Enforced
479    /// in the run loop, which re-runs the stage with [`Self::required_message`]
480    /// until the region is populated.
481    #[serde(default)]
482    pub required: bool,
483
484    /// Optional custom message shown to the agent when this region is required
485    /// but empty. Falls back to a generated default when `None`.
486    #[serde(default)]
487    pub required_message: Option<String>,
488
489    /// Where this region's initial content comes from at run start. `None`
490    /// means the region starts empty (the agent populates it). See
491    /// [`RegionSeed`].
492    #[serde(default)]
493    pub seed: Option<RegionSeed>,
494}
495
496impl RegionDefinition {
497    /// Create a new region definition with an absolute token ceiling.
498    ///
499    /// The `budget` is set to [`BudgetSpec::Absolute`] holding `max_tokens` and
500    /// `compact_at` to `None`, so every existing caller (and every region without
501    /// a percentage budget) is unaffected - resolving such a layout is a no-op.
502    pub fn new(name: String, kind: RegionKind, max_tokens: usize) -> Self {
503        Self {
504            name,
505            kind,
506            max_tokens,
507            budget: BudgetSpec::Absolute(max_tokens),
508            compact_at: None,
509            schema: None,
510            description: None,
511            required: false,
512            required_message: None,
513            seed: None,
514        }
515    }
516
517    /// Set this region's budget spec (e.g. a percentage of the model window).
518    /// `max_tokens` is left as the provisional/resolved value; it is (re)computed
519    /// from the budget when the owning layout is resolved.
520    pub fn with_budget(mut self, budget: BudgetSpec) -> Self {
521        self.budget = budget;
522        self
523    }
524
525    /// Set the compaction trigger fraction for a [`RegionKind::Compacting`]
526    /// region (`0.80` == compact at 80% of the resolved budget).
527    pub fn with_compact_at(mut self, fraction: f64) -> Self {
528        self.compact_at = Some(fraction);
529        self
530    }
531
532    /// Set this region's seed source.
533    pub fn with_seed(mut self, seed: RegionSeed) -> Self {
534        self.seed = Some(seed);
535        self
536    }
537
538    /// Mark this region as required, with an optional custom nudge message.
539    pub fn with_required(mut self, required: bool, message: Option<String>) -> Self {
540        self.required = required;
541        self.required_message = message;
542        self
543    }
544
545    /// Add a schema to this region definition.
546    pub fn with_schema(mut self, schema: RegionSchema) -> Self {
547        self.schema = Some(schema);
548        self
549    }
550
551    /// Add a description to this region definition.
552    pub fn with_description(mut self, description: String) -> Self {
553        self.description = Some(description);
554        self
555    }
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    /// Minimal no-op `Subscriber` that reports every callsite as enabled.
563    ///
564    /// Without an active subscriber, `tracing::warn!`/`info!`/`debug!` calls
565    /// short-circuit their field-argument evaluation before ever reaching it
566    /// (no subscriber means the "is this level enabled" check fails first) --
567    /// so a multi-line `tracing::warn!(...)` call's field-list lines show as
568    /// uncovered by `cargo llvm-cov` even when the surrounding branch
569    /// genuinely executes and is asserted on. This bare `Subscriber` impl is
570    /// the proven-working pattern used across this workspace.
571    struct AlwaysOnSubscriber;
572
573    impl tracing::Subscriber for AlwaysOnSubscriber {
574        fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
575            true
576        }
577        fn register_callsite(
578            &self,
579            _metadata: &'static tracing::Metadata<'static>,
580        ) -> tracing::subscriber::Interest {
581            tracing::subscriber::Interest::always()
582        }
583        fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
584            tracing::span::Id::from_u64(1)
585        }
586        fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
587        fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {}
588        fn event(&self, event: &tracing::Event<'_>) {
589            // `register_callsite` always returns `Interest::always()`, so
590            // tracing's dispatch macros cache every callsite as
591            // "always enabled" and never call `enabled` again afterward.
592            // Call it directly here (with real metadata from a live event)
593            // so this trait-impl boilerplate method isn't itself left
594            // uncovered.
595            assert!(self.enabled(event.metadata()));
596        }
597        fn enter(&self, _span: &tracing::span::Id) {}
598        fn exit(&self, _span: &tracing::span::Id) {}
599        fn max_level_hint(&self) -> Option<tracing::metadata::LevelFilter> {
600            Some(tracing::metadata::LevelFilter::TRACE)
601        }
602    }
603
604    fn with_tracing<T>(f: impl FnOnce() -> T) -> T {
605        static INSTALLED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
606        INSTALLED.get_or_init(|| {
607            let _ = tracing::subscriber::set_global_default(AlwaysOnSubscriber);
608            tracing::callsite::rebuild_interest_cache();
609        });
610        f()
611    }
612
613    #[test]
614    fn always_on_subscriber_span_methods_are_all_no_ops() {
615        // This file only ever uses `tracing::warn!` event macros, never
616        // `tracing::span!`, so the span-related trait methods above are
617        // otherwise dead code from `with_tracing`'s callers. Exercise them
618        // directly via a real span so they're not left uncovered themselves.
619        with_tracing(|| {
620            let span = tracing::info_span!("test-span", field = tracing::field::Empty);
621            span.record("field", 1);
622            let other = tracing::info_span!("other-span");
623            span.follows_from(&other);
624            let _enter = span.enter();
625            tracing::info!(parent: &span, "inside span");
626        });
627    }
628
629    #[test]
630    fn test_layout_creation() {
631        let regions = vec![
632            RegionDefinition::new("pinned".to_string(), RegionKind::Pinned, 5000),
633            RegionDefinition::new("temp".to_string(), RegionKind::Temporary, 10000),
634        ];
635        let layout = ContextLayout::new(regions, 20000);
636        assert_eq!(layout.regions.len(), 2);
637        assert_eq!(layout.total_budget_tokens, 20000);
638    }
639
640    #[test]
641    fn test_layout_validation() {
642        let regions = vec![RegionDefinition::new(
643            "test".to_string(),
644            RegionKind::Pinned,
645            5000,
646        )];
647        let layout =
648            ContextLayout::new(regions, 10000).with_eviction_order(vec!["test".to_string()]);
649
650        assert!(layout.validate().is_ok());
651    }
652
653    #[test]
654    fn test_duplicate_region_names() {
655        let regions = vec![
656            RegionDefinition::new("test".to_string(), RegionKind::Pinned, 5000),
657            RegionDefinition::new("test".to_string(), RegionKind::Temporary, 3000),
658        ];
659        let layout = ContextLayout::new(regions, 10000);
660
661        assert!(layout.validate().is_err());
662    }
663
664    #[test]
665    fn test_eviction_order_unknown_region_is_error() {
666        let regions = vec![RegionDefinition::new(
667            "test".to_string(),
668            RegionKind::Pinned,
669            5000,
670        )];
671        let layout =
672            ContextLayout::new(regions, 10000).with_eviction_order(vec!["nonexistent".to_string()]);
673
674        let err = layout.validate().unwrap_err();
675        assert_eq!(
676            err,
677            ValidationError::Layout(
678                "eviction order references unknown region: nonexistent".to_string()
679            )
680        );
681    }
682
683    #[test]
684    fn test_validate_warns_but_does_not_error_when_max_tokens_exceed_budget() {
685        // Sum of region max_tokens (5000 + 10000 = 15000) exceeds the total
686        // budget (10000) - this should only warn, not fail validation, since
687        // not all regions are full simultaneously.
688        let regions = vec![
689            RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000),
690            RegionDefinition::new("b".to_string(), RegionKind::Temporary, 10000),
691        ];
692        let layout = ContextLayout::new(regions, 10000);
693        with_tracing(|| {
694            assert!(layout.validate().is_ok());
695        });
696    }
697
698    #[test]
699    fn validate_errors_when_fixed_regions_starve_working_budget() {
700        // Realistically-sized layout (>= 20k) where a huge fixed (pinned) region
701        // leaves < 8000 working tokens for conversation/tool-results → hard error.
702        let regions = vec![
703            RegionDefinition::new("big_pinned".to_string(), RegionKind::Pinned, 95_000),
704            RegionDefinition::new("work".to_string(), RegionKind::Temporary, 5_000),
705        ];
706        let layout = ContextLayout::new(regions, 100_000);
707        with_tracing(|| {
708            let err = layout.validate().unwrap_err();
709            assert!(
710                err.to_string().contains("working tokens"),
711                "actionable budget error: {err}"
712            );
713        });
714    }
715
716    #[test]
717    fn validate_ok_for_realistic_layout_with_working_room() {
718        let regions = vec![
719            RegionDefinition::new("task".to_string(), RegionKind::Pinned, 4_000),
720            RegionDefinition::new("conversation".to_string(), RegionKind::Temporary, 40_000),
721        ];
722        let layout = ContextLayout::new(regions, 44_000);
723        with_tracing(|| {
724            assert!(layout.validate().is_ok());
725        });
726    }
727
728    fn custom_kind(script: &str, persistent: bool) -> RegionKind {
729        RegionKind::Custom {
730            script: script.to_string(),
731            persistent,
732        }
733    }
734
735    #[test]
736    fn validate_rejects_custom_region_with_empty_script() {
737        // Whitespace-only counts as empty: it could never resolve to a file
738        // and the runtime would silently fall back on every inference.
739        let regions = vec![RegionDefinition::new(
740            "brain".to_string(),
741            custom_kind("   ", false),
742            5000,
743        )];
744        let layout = ContextLayout::new(regions, 10_000);
745        let err = with_tracing(|| layout.validate().unwrap_err());
746        assert!(
747            err.to_string().contains("non-empty script path"),
748            "actionable error: {err}"
749        );
750    }
751
752    #[test]
753    fn validate_counts_persistent_custom_as_fixed_budget() {
754        // A persistent custom region is Pinned-like: protected from eviction,
755        // so it must count toward the fixed budget that can starve the
756        // working room.
757        let regions = vec![
758            RegionDefinition::new("vault".to_string(), custom_kind("v.rhai", true), 95_000),
759            RegionDefinition::new("work".to_string(), RegionKind::Temporary, 5_000),
760        ];
761        let layout = ContextLayout::new(regions, 100_000);
762        let err = with_tracing(|| layout.validate().unwrap_err());
763        assert!(err.to_string().contains("working tokens"), "{err}");
764    }
765
766    #[test]
767    fn validate_counts_non_persistent_custom_as_working_budget() {
768        // Same shape, but the custom region is evictable - it IS the working
769        // room, so validation passes.
770        let regions = vec![
771            RegionDefinition::new("brain".to_string(), custom_kind("b.rhai", false), 95_000),
772            RegionDefinition::new("task".to_string(), RegionKind::Pinned, 4_000),
773        ];
774        let layout = ContextLayout::new(regions, 100_000);
775        with_tracing(|| {
776            assert!(layout.validate().is_ok());
777        });
778    }
779
780    #[test]
781    fn custom_region_satisfies_the_message_region_check() {
782        // A layout whose only region is custom must not trip the "no
783        // SlidingWindow region" warning path - its script can render typed
784        // entries as messages. (Mirrors the sliding-window-present test: the
785        // skip branch is exercised, validation succeeds.)
786        let regions = vec![RegionDefinition::new(
787            "everything".to_string(),
788            custom_kind("all.rhai", false),
789            9_000,
790        )];
791        let layout = ContextLayout::new(regions, 10_000);
792        with_tracing(|| {
793            assert!(layout.validate().is_ok());
794        });
795    }
796
797    #[test]
798    fn resolved_percent_budget_applies_to_custom_region() {
799        // The "recreate built-ins in Rhai" guarantee: percentage budgets work
800        // on custom regions exactly as on built-in kinds, resolved against
801        // the stage model's context window at spawn.
802        let def = RegionDefinition::new("brain".to_string(), custom_kind("b.rhai", false), 0)
803            .with_budget(BudgetSpec::Percent {
804                percent: 0.40,
805                min: Some(10_000),
806                max: None,
807            });
808        let layout = ContextLayout::new(vec![def], 0);
809        let resolved = layout.resolved(200_000);
810        assert_eq!(resolved.regions[0].max_tokens, 80_000);
811        assert!(matches!(
812            resolved.regions[0].kind,
813            RegionKind::Custom { ref script, persistent: false } if script == "b.rhai"
814        ));
815        // The min floor wins on a small window.
816        let small = layout.resolved(8_192);
817        assert_eq!(small.regions[0].max_tokens, 10_000);
818    }
819
820    #[test]
821    fn test_get_region_found() {
822        let regions = vec![
823            RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000),
824            RegionDefinition::new("b".to_string(), RegionKind::Temporary, 3000),
825        ];
826        let layout = ContextLayout::new(regions, 10000);
827
828        let found = layout.get_region("b").unwrap();
829        assert_eq!(found.name, "b");
830        assert_eq!(found.max_tokens, 3000);
831    }
832
833    #[test]
834    fn test_get_region_not_found() {
835        let regions = vec![RegionDefinition::new(
836            "a".to_string(),
837            RegionKind::Pinned,
838            5000,
839        )];
840        let layout = ContextLayout::new(regions, 10000);
841        assert!(layout.get_region("missing").is_none());
842    }
843
844    #[test]
845    fn test_region_definition_with_schema() {
846        let schema = crate::region::RegionSchema::new(crate::region::ContentFormat::Json);
847        let def =
848            RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000).with_schema(schema);
849        assert_eq!(
850            def.schema.as_ref().unwrap().format,
851            crate::region::ContentFormat::Json
852        );
853    }
854
855    #[test]
856    fn test_region_definition_with_description() {
857        let def = RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000)
858            .with_description("holds architecture notes".to_string());
859        assert_eq!(def.description.as_deref(), Some("holds architecture notes"));
860    }
861
862    #[test]
863    fn parse_budget_accepts_plain_and_decimal_percentages() {
864        assert_eq!(BudgetSpec::parse_budget("35%").unwrap(), 0.35);
865        assert_eq!(BudgetSpec::parse_budget("100%").unwrap(), 1.0);
866        assert!((BudgetSpec::parse_budget("0.6%").unwrap() - 0.006).abs() < 1e-9);
867    }
868
869    #[test]
870    fn parse_budget_trims_surrounding_and_inner_whitespace() {
871        assert_eq!(BudgetSpec::parse_budget("  35 %  ").unwrap(), 0.35);
872    }
873
874    #[test]
875    fn parse_budget_rejects_missing_percent_sign() {
876        let err = BudgetSpec::parse_budget("35").unwrap_err();
877        assert!(err.contains("must end with '%'"), "{err}");
878    }
879
880    #[test]
881    fn parse_budget_rejects_non_numeric() {
882        let err = BudgetSpec::parse_budget("abc%").unwrap_err();
883        assert!(err.contains("not a valid number"), "{err}");
884    }
885
886    #[test]
887    fn parse_budget_rejects_zero_and_negative() {
888        let zero = BudgetSpec::parse_budget("0%").unwrap_err();
889        assert!(zero.contains("greater than 0%"), "{zero}");
890        let neg = BudgetSpec::parse_budget("-10%").unwrap_err();
891        assert!(neg.contains("greater than 0%"), "{neg}");
892    }
893
894    #[test]
895    fn parse_budget_rejects_over_one_hundred() {
896        let err = BudgetSpec::parse_budget("150%").unwrap_err();
897        assert!(err.contains("at most 100%"), "{err}");
898    }
899
900    #[test]
901    fn resolve_absolute_ignores_window() {
902        assert_eq!(BudgetSpec::Absolute(4000).resolve(1_000_000), 4000);
903        assert!(!BudgetSpec::Absolute(4000).is_percent());
904    }
905
906    #[test]
907    fn resolve_percent_of_window() {
908        let spec = BudgetSpec::Percent {
909            percent: 0.35,
910            min: None,
911            max: None,
912        };
913        assert_eq!(spec.resolve(1_000_000), 350_000);
914        assert!(spec.is_percent());
915    }
916
917    #[test]
918    fn resolve_percent_applies_max_cap() {
919        let spec = BudgetSpec::Percent {
920            percent: 0.02,
921            min: None,
922            max: Some(4000),
923        };
924        // 2% of 1M = 20_000, capped to 4000.
925        assert_eq!(spec.resolve(1_000_000), 4000);
926    }
927
928    #[test]
929    fn resolve_percent_applies_min_floor() {
930        let spec = BudgetSpec::Percent {
931            percent: 0.02,
932            min: Some(2000),
933            max: None,
934        };
935        // 2% of 8000 = 160, floored to 2000.
936        assert_eq!(spec.resolve(8000), 2000);
937    }
938
939    #[test]
940    fn resolve_percent_within_bounds_takes_neither_clamp() {
941        let spec = BudgetSpec::Percent {
942            percent: 0.10,
943            min: Some(1000),
944            max: Some(50_000),
945        };
946        // 10% of 200k = 20_000, between the floor and cap.
947        assert_eq!(spec.resolve(200_000), 20_000);
948    }
949
950    #[test]
951    fn resolve_percent_floor_wins_when_min_exceeds_max() {
952        let spec = BudgetSpec::Percent {
953            percent: 0.10,
954            min: Some(9000),
955            max: Some(4000),
956        };
957        // 10% of 200k = 20_000 → capped to 4000 → floored up to 9000 (floor wins).
958        assert_eq!(spec.resolve(200_000), 9000);
959    }
960
961    #[test]
962    fn has_percent_budgets_detects_percentage_regions() {
963        let absolute = ContextLayout::new(
964            vec![RegionDefinition::new(
965                "a".to_string(),
966                RegionKind::Pinned,
967                5000,
968            )],
969            5000,
970        );
971        assert!(!absolute.has_percent_budgets());
972
973        let percent = ContextLayout::new(
974            vec![
975                RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000).with_budget(
976                    BudgetSpec::Percent {
977                        percent: 0.05,
978                        min: None,
979                        max: None,
980                    },
981                ),
982            ],
983            5000,
984        );
985        assert!(percent.has_percent_budgets());
986    }
987
988    #[test]
989    fn resolved_is_noop_for_absolute_layout() {
990        let layout = ContextLayout::new(
991            vec![RegionDefinition::new(
992                "a".to_string(),
993                RegionKind::Pinned,
994                5000,
995            )],
996            5000,
997        );
998        let resolved = layout.resolved(1_000_000);
999        assert_eq!(resolved.regions[0].max_tokens, 5000);
1000        // Absolute layout keeps its legacy summed total, not the window.
1001        assert_eq!(resolved.total_budget_tokens, 5000);
1002    }
1003
1004    #[test]
1005    fn resolved_percent_layout_uses_window_as_total() {
1006        let layout = ContextLayout::new(
1007            vec![
1008                RegionDefinition::new("a".to_string(), RegionKind::Pinned, 0).with_budget(
1009                    BudgetSpec::Percent {
1010                        percent: 0.10,
1011                        min: None,
1012                        max: None,
1013                    },
1014                ),
1015            ],
1016            0,
1017        )
1018        .with_eviction_order(vec!["a".to_string()]);
1019        let resolved = layout.resolved(1_000_000);
1020        assert_eq!(resolved.regions[0].max_tokens, 100_000);
1021        assert_eq!(resolved.total_budget_tokens, 1_000_000);
1022        // eviction order carried through.
1023        assert_eq!(resolved.eviction_order, vec!["a".to_string()]);
1024    }
1025
1026    #[test]
1027    fn resolved_compacting_threshold_all_cases() {
1028        // compact_at + explicit threshold cap → min(pct, cap).
1029        let both = RegionDefinition::new(
1030            "c".to_string(),
1031            RegionKind::Compacting {
1032                threshold_tokens: 25_000,
1033            },
1034            0,
1035        )
1036        .with_budget(BudgetSpec::Percent {
1037            percent: 0.20,
1038            min: None,
1039            max: None,
1040        })
1041        .with_compact_at(0.80);
1042        let r = ContextLayout::new(vec![both], 0).resolved(200_000);
1043        // budget = 40_000; 80% = 32_000; capped to 25_000.
1044        assert_eq!(
1045            r.regions[0].kind,
1046            RegionKind::Compacting {
1047                threshold_tokens: 25_000
1048            }
1049        );
1050
1051        // compact_at with no cap (usize::MAX sentinel) → pct only.
1052        let pct_only = RegionDefinition::new(
1053            "c".to_string(),
1054            RegionKind::Compacting {
1055                threshold_tokens: usize::MAX,
1056            },
1057            0,
1058        )
1059        .with_budget(BudgetSpec::Percent {
1060            percent: 0.20,
1061            min: None,
1062            max: None,
1063        })
1064        .with_compact_at(0.80);
1065        let r = ContextLayout::new(vec![pct_only], 0).resolved(200_000);
1066        assert_eq!(
1067            r.regions[0].kind,
1068            RegionKind::Compacting {
1069                threshold_tokens: 32_000
1070            }
1071        );
1072
1073        // compact_at = None → absolute threshold passes through unchanged.
1074        let absolute = RegionDefinition::new(
1075            "c".to_string(),
1076            RegionKind::Compacting {
1077                threshold_tokens: 8000,
1078            },
1079            10_000,
1080        );
1081        let r = ContextLayout::new(vec![absolute], 10_000).resolved(1_000_000);
1082        assert_eq!(
1083            r.regions[0].kind,
1084            RegionKind::Compacting {
1085                threshold_tokens: 8000
1086            }
1087        );
1088    }
1089
1090    #[test]
1091    fn validate_skips_token_checks_for_percent_layouts() {
1092        // A percentage layout whose provisional max_tokens are tiny/zero must not
1093        // trip the fixed-working-budget hard error - that check is deferred to
1094        // post-resolution. Wrap in tracing so no warn-arg lines read uncovered.
1095        let regions = vec![
1096            RegionDefinition::new("big_pinned".to_string(), RegionKind::Pinned, 0).with_budget(
1097                BudgetSpec::Percent {
1098                    percent: 0.95,
1099                    min: None,
1100                    max: None,
1101                },
1102            ),
1103        ];
1104        let layout = ContextLayout::new(regions, 100_000);
1105        with_tracing(|| {
1106            assert!(layout.validate().is_ok());
1107        });
1108    }
1109
1110    #[test]
1111    fn region_definition_default_budget_matches_max_tokens() {
1112        let def = RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000);
1113        assert_eq!(def.budget, BudgetSpec::Absolute(5000));
1114        assert_eq!(def.compact_at, None);
1115    }
1116
1117    #[test]
1118    fn budget_spec_default_is_absolute_zero() {
1119        assert_eq!(BudgetSpec::default(), BudgetSpec::Absolute(0));
1120    }
1121
1122    #[test]
1123    fn test_validate_with_sliding_window_present() {
1124        // A layout that DOES contain a SlidingWindow region exercises the
1125        // has_sliding_window detection returning true, so the "no sliding
1126        // window" warning branch is skipped.
1127        let regions = vec![
1128            RegionDefinition::new("pinned".to_string(), RegionKind::Pinned, 5000),
1129            RegionDefinition::new(
1130                "conv".to_string(),
1131                RegionKind::SlidingWindow {
1132                    max_items: 50,
1133                    eviction_strategy: crate::region::EvictionStrategy::PerItem,
1134                },
1135                5000,
1136            ),
1137        ];
1138        let layout = ContextLayout::new(regions, 20000);
1139        with_tracing(|| {
1140            assert!(layout.validate().is_ok());
1141        });
1142    }
1143}