Skip to main content

harn_vm/orchestration/
compaction_policy_registry.rs

1//! Per-session compaction policy declarations.
2//!
3//! `harn-serve` consumers (TUI, IDE, cloud) used to maintain their own
4//! "when do I compact?" logic alongside the engine. This registry lifts
5//! that decision into the runtime so any caller can `compaction_policy(...)`
6//! once and then `compaction_check(session_id)` / `compaction_run(...)`
7//! on every turn without re-encoding thresholds at the call site.
8//!
9//! Policies are thread-local because `agent_sessions` is. A `None` lookup
10//! falls back to the default policy registered with the empty session key.
11//! Policies are *additive* — registering one replaces any prior entry for
12//! the same session id.
13
14use std::cell::RefCell;
15use std::collections::BTreeMap;
16use std::thread_local;
17
18use serde::{Deserialize, Serialize};
19
20use super::{compact_strategy_name, parse_compact_strategy, CompactStrategy, CompactionPolicy};
21use crate::value::VmValue;
22
23/// Default ratio of the model's context window at which compaction fires
24/// when `max_tokens` isn't set explicitly. Matches the TUI default
25/// (`BURIN_TUI_COMPACTION_THRESHOLD_RATIO`) so lifting policy into harn
26/// doesn't shift the firing point for existing surfaces.
27pub const DEFAULT_SAFETY_RATIO: f64 = 0.7;
28
29/// User-facing strategy names. These map to engine [`CompactStrategy`]s
30/// through [`CompactionPolicyDeclaration::engine_strategy`] so the policy
31/// surface can stay stable while the underlying engine evolves.
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum PolicyStrategy {
34    /// LLM summarization of archived messages.
35    Summarize,
36    /// LLM summarization, with a deterministic truncate fallback on error.
37    SummarizeThenPrune,
38    /// Keep the head and tail verbatim; compact only the middle.
39    /// Maps to the engine truncate strategy with both `keep_first` and
40    /// `keep_last` set.
41    HeadAndTail,
42    /// Rolling window: drop everything older than `keep_last`. Maps to the
43    /// engine truncate strategy with `keep_first = 0`.
44    Window,
45    /// Deterministic observation masking — keeps short results verbatim,
46    /// masks long tool outputs. Cheapest, no LLM round-trip.
47    ObservationMask,
48    /// Caller-supplied closure decides the summary.
49    Custom,
50}
51
52impl PolicyStrategy {
53    pub fn as_str(self) -> &'static str {
54        match self {
55            Self::Summarize => "summarize",
56            Self::SummarizeThenPrune => "summarize-then-prune",
57            Self::HeadAndTail => "head+tail",
58            Self::Window => "window",
59            Self::ObservationMask => "observation_mask",
60            Self::Custom => "custom",
61        }
62    }
63
64    /// Translate a policy-level strategy name into the engine strategy
65    /// that backs it. Accepts both the policy-level aliases
66    /// (`summarize`, `summarize-then-prune`, ...) and the raw engine
67    /// names (`llm`, `truncate`, `observation_mask`, `custom`) so callers
68    /// can mix vocabularies during the policy migration.
69    pub fn parse(value: &str) -> Result<Self, String> {
70        match value.trim() {
71            "summarize" | "llm" => Ok(Self::Summarize),
72            "summarize-then-prune" | "summarize_then_prune" => Ok(Self::SummarizeThenPrune),
73            "head+tail" | "head-tail" | "head_tail" => Ok(Self::HeadAndTail),
74            "window" | "truncate" => Ok(Self::Window),
75            "observation_mask" | "observation-mask" | "mask" => Ok(Self::ObservationMask),
76            "custom" => Ok(Self::Custom),
77            other => Err(format!(
78                "unknown compaction policy strategy '{other}' (expected one of: summarize, \
79                 summarize-then-prune, head+tail, window, observation_mask, custom)"
80            )),
81        }
82    }
83
84    /// The engine strategy used for the primary compaction run.
85    pub fn engine_strategy(self) -> CompactStrategy {
86        match self {
87            Self::Summarize | Self::SummarizeThenPrune => CompactStrategy::Llm,
88            Self::HeadAndTail | Self::Window => CompactStrategy::Truncate,
89            Self::ObservationMask => CompactStrategy::ObservationMask,
90            Self::Custom => CompactStrategy::Custom,
91        }
92    }
93
94    /// Engine fallback used when the primary strategy fails. Currently
95    /// only `summarize-then-prune` declares one — everything else returns
96    /// `None`, deferring to the engine's per-call decision.
97    pub fn engine_fallback(self) -> Option<CompactStrategy> {
98        match self {
99            Self::SummarizeThenPrune => Some(CompactStrategy::Truncate),
100            _ => None,
101        }
102    }
103}
104
105/// Declared inputs that drive the `compaction_check` decision and the
106/// downstream `compaction_run` call. Values are normalized at registration
107/// time so consumers can rely on every threshold being either populated
108/// or explicitly `None`.
109#[derive(Clone, Debug)]
110pub struct CompactionPolicyDeclaration {
111    pub strategy: PolicyStrategy,
112    /// Hard cap on estimated tokens before `compaction_check` returns
113    /// `compact_now`. `None` means tokens alone never trigger.
114    pub max_tokens: Option<usize>,
115    /// Hard cap on message count. `None` means message count alone never
116    /// triggers.
117    pub max_turns: Option<usize>,
118    /// When `context_window` is set, `compaction_check` fires when
119    /// estimated tokens exceed `context_window * safety_ratio`. Both must
120    /// be set for the ratio rule to apply.
121    pub context_window: Option<usize>,
122    pub safety_ratio: f64,
123    /// How many recent messages to keep verbatim during compaction.
124    pub keep_last: usize,
125    /// How many initial messages to keep verbatim during compaction.
126    pub keep_first: usize,
127    /// Token budget passed through to the engine for tier-2 compaction
128    /// (used by `summarize-then-prune` to escalate from LLM to truncate
129    /// when the LLM result still exceeds the cap).
130    pub hard_limit_tokens: Option<usize>,
131    /// Per-tool-result microcompaction threshold. `None` keeps the engine
132    /// default.
133    pub tool_output_max_chars: Option<usize>,
134    /// Closure invoked when `strategy` is `custom`.
135    pub summarize_fn: Option<VmValue>,
136    /// Optional prompt template path used when the engine selects LLM
137    /// summarization.
138    pub summarize_prompt: Option<String>,
139    /// Author/scope/preserve/drop directives that the engine threads
140    /// through the LLM compaction prompt and persisted metadata.
141    pub instructions: CompactionPolicy,
142}
143
144impl Default for CompactionPolicyDeclaration {
145    fn default() -> Self {
146        Self {
147            strategy: PolicyStrategy::SummarizeThenPrune,
148            max_tokens: None,
149            max_turns: None,
150            context_window: None,
151            safety_ratio: DEFAULT_SAFETY_RATIO,
152            keep_last: 12,
153            keep_first: 0,
154            hard_limit_tokens: None,
155            tool_output_max_chars: None,
156            summarize_fn: None,
157            summarize_prompt: None,
158            instructions: CompactionPolicy::default(),
159        }
160    }
161}
162
163impl CompactionPolicyDeclaration {
164    /// Token budget at which the policy considers a session "full".
165    /// Resolves the most restrictive of `max_tokens` and
166    /// `context_window * safety_ratio`; returns `None` when neither rule
167    /// is configured.
168    pub fn token_threshold(&self) -> Option<usize> {
169        let ratio_threshold = self.context_window.map(|window| {
170            let raw = (window as f64) * self.safety_ratio;
171            if raw.is_finite() && raw > 0.0 {
172                raw.floor() as usize
173            } else {
174                window
175            }
176        });
177        match (self.max_tokens, ratio_threshold) {
178            (Some(a), Some(b)) => Some(a.min(b)),
179            (Some(a), None) => Some(a),
180            (None, Some(b)) => Some(b),
181            (None, None) => None,
182        }
183    }
184
185    /// Decision metadata describing every triggered threshold. Returned
186    /// as part of the [`CompactionDecision`] so callers can log what
187    /// pushed them over the line.
188    pub fn evaluate(&self, estimated_tokens: usize, message_count: usize) -> EvaluationContext {
189        let token_threshold = self.token_threshold();
190        let token_trigger = token_threshold.is_some_and(|cap| estimated_tokens > cap);
191        let turn_trigger = self
192            .max_turns
193            .is_some_and(|cap| cap > 0 && message_count > cap);
194        EvaluationContext {
195            token_threshold,
196            token_trigger,
197            turn_trigger,
198            estimated_tokens,
199            message_count,
200            strategy: self.strategy,
201        }
202    }
203
204    /// JSON snapshot for telemetry payloads.
205    pub fn to_json(&self) -> serde_json::Value {
206        let mut map = serde_json::Map::new();
207        map.insert(
208            "strategy".to_string(),
209            serde_json::Value::String(self.strategy.as_str().to_string()),
210        );
211        map.insert(
212            "engine_strategy".to_string(),
213            serde_json::Value::String(
214                compact_strategy_name(&self.strategy.engine_strategy()).to_string(),
215            ),
216        );
217        if let Some(value) = self.max_tokens {
218            map.insert("max_tokens".to_string(), serde_json::json!(value));
219        }
220        if let Some(value) = self.max_turns {
221            map.insert("max_turns".to_string(), serde_json::json!(value));
222        }
223        if let Some(value) = self.context_window {
224            map.insert("context_window".to_string(), serde_json::json!(value));
225        }
226        map.insert(
227            "safety_ratio".to_string(),
228            serde_json::json!(self.safety_ratio),
229        );
230        map.insert("keep_last".to_string(), serde_json::json!(self.keep_last));
231        if self.keep_first > 0 {
232            map.insert("keep_first".to_string(), serde_json::json!(self.keep_first));
233        }
234        if let Some(value) = self.hard_limit_tokens {
235            map.insert("hard_limit_tokens".to_string(), serde_json::json!(value));
236        }
237        if let Some(value) = self.tool_output_max_chars {
238            map.insert(
239                "tool_output_max_chars".to_string(),
240                serde_json::json!(value),
241            );
242        }
243        if let Some(threshold) = self.token_threshold() {
244            map.insert("token_threshold".to_string(), serde_json::json!(threshold));
245        }
246        if let Some(policy_json) = self.instructions.metadata_json() {
247            map.insert("instructions".to_string(), policy_json);
248        }
249        serde_json::Value::Object(map)
250    }
251}
252
253/// Outcome of an [`CompactionPolicyDeclaration::evaluate`] pass, used by
254/// `compaction_check` to assemble the user-facing decision dict.
255#[derive(Clone, Debug)]
256pub struct EvaluationContext {
257    pub token_threshold: Option<usize>,
258    pub token_trigger: bool,
259    pub turn_trigger: bool,
260    pub estimated_tokens: usize,
261    pub message_count: usize,
262    pub strategy: PolicyStrategy,
263}
264
265impl EvaluationContext {
266    pub fn fires(&self) -> bool {
267        self.token_trigger || self.turn_trigger
268    }
269
270    pub fn trigger_label(&self) -> &'static str {
271        match (self.token_trigger, self.turn_trigger) {
272            (true, true) => "tokens_and_turns",
273            (true, false) => "tokens",
274            (false, true) => "turns",
275            (false, false) => "manual",
276        }
277    }
278}
279
280/// Symbolic action returned from [`compaction_check`]. Mirrors the spec
281/// triad — `compact_now | defer | abandon`.
282#[derive(Clone, Copy, Debug, PartialEq, Eq)]
283pub enum CompactionAction {
284    CompactNow,
285    Defer,
286    Abandon,
287}
288
289impl CompactionAction {
290    pub fn as_str(self) -> &'static str {
291        match self {
292            Self::CompactNow => "compact_now",
293            Self::Defer => "defer",
294            Self::Abandon => "abandon",
295        }
296    }
297}
298
299/// Structured outcome of `compaction_check`. Lowered to a Harn dict by
300/// the builtin layer; kept typed at the Rust boundary so downstream
301/// consumers (replay, telemetry) can pattern match without re-parsing
302/// strings.
303#[derive(Clone, Debug, Serialize, Deserialize)]
304pub struct CompactionDecision {
305    pub action: String,
306    pub session_id: String,
307    pub estimated_tokens: usize,
308    pub message_count: usize,
309    pub trigger: String,
310    pub strategy: String,
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub token_threshold: Option<usize>,
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub turn_threshold: Option<usize>,
315    /// Engine strategy that `compaction_run` would invoke for this
316    /// decision. Distinct from `strategy` (the user-facing label) so
317    /// hosts can render either.
318    pub engine_strategy: String,
319    /// `true` when no policy was registered and the registry returned
320    /// the default policy.
321    pub policy_inherited: bool,
322}
323
324/// Key used for the "default policy" entry in the registry. Empty so it
325/// can't collide with a real session id.
326const DEFAULT_POLICY_KEY: &str = "";
327
328thread_local! {
329    static POLICIES: RefCell<BTreeMap<String, CompactionPolicyDeclaration>> =
330        const { RefCell::new(BTreeMap::new()) };
331}
332
333/// Register or replace the policy keyed by `session_id`. Pass an empty
334/// `session_id` to set the default policy used when no per-session entry
335/// is found.
336pub fn set_policy(session_id: &str, policy: CompactionPolicyDeclaration) {
337    POLICIES.with(|cell| {
338        cell.borrow_mut().insert(session_id.to_string(), policy);
339    });
340}
341
342/// Remove the policy keyed by `session_id`. Returns the prior value when
343/// one was present.
344pub fn clear_policy(session_id: &str) -> Option<CompactionPolicyDeclaration> {
345    POLICIES.with(|cell| cell.borrow_mut().remove(session_id))
346}
347
348/// Fetch the active policy for a session. Falls back to the
349/// default-key entry when no per-session policy is registered.
350pub fn policy_for(session_id: &str) -> Option<(CompactionPolicyDeclaration, bool)> {
351    POLICIES.with(|cell| {
352        let borrow = cell.borrow();
353        if let Some(policy) = borrow.get(session_id) {
354            return Some((policy.clone(), false));
355        }
356        borrow
357            .get(DEFAULT_POLICY_KEY)
358            .map(|policy| (policy.clone(), true))
359    })
360}
361
362/// Clear every entry. Used by `reset_stdlib_state` so cross-test
363/// pollution doesn't leak prior policies into later runs.
364pub fn reset_registry() {
365    POLICIES.with(|cell| cell.borrow_mut().clear());
366}
367
368/// Compile a [`CompactionPolicyDeclaration`] into an
369/// [`super::AutoCompactConfig`] for the engine. Encapsulates the
370/// policy-name → engine-strategy mapping so each call site stays small.
371pub fn to_auto_compact_config(policy: &CompactionPolicyDeclaration) -> super::AutoCompactConfig {
372    let engine_strategy = policy.strategy.engine_strategy();
373    let mut cfg = super::AutoCompactConfig {
374        keep_last: policy.keep_last,
375        keep_first: policy.keep_first,
376        compact_strategy: engine_strategy.clone(),
377        hard_limit_strategy: engine_strategy,
378        fallback_strategy: policy.strategy.engine_fallback(),
379        summarize_prompt: policy.summarize_prompt.clone(),
380        custom_compactor: policy.summarize_fn.clone(),
381        policy: policy.instructions.clone(),
382        policy_strategy: policy.strategy.as_str().to_string(),
383        ..Default::default()
384    };
385    if let Some(threshold) = policy.token_threshold() {
386        cfg.token_threshold = threshold;
387    } else {
388        cfg.token_threshold = 0;
389    }
390    // `hard_limit_tokens` is the *escalation* cap that switches engine
391    // tier-2 on when tier-1's summary still exceeds the budget. Honor it
392    // only when explicitly declared so the typical single-tier policy
393    // never accidentally re-invokes the strategy on its own output.
394    cfg.hard_limit_tokens = policy.hard_limit_tokens;
395    if let Some(value) = policy.tool_output_max_chars {
396        cfg.tool_output_max_chars = value;
397    }
398    cfg
399}
400
401/// Round-trip parse a policy from a Harn dict. Returns `Err` on shape
402/// mismatch so the builtin layer can surface a meaningful error.
403pub fn parse_policy_dict(
404    builtin: &str,
405    dict: &BTreeMap<String, VmValue>,
406) -> Result<CompactionPolicyDeclaration, String> {
407    let mut policy = CompactionPolicyDeclaration::default();
408    if let Some(value) = dict.get("strategy") {
409        match value {
410            VmValue::String(text) => {
411                policy.strategy =
412                    PolicyStrategy::parse(text).map_err(|e| format!("{builtin}: {e}"))?;
413            }
414            VmValue::Nil => {}
415            other => {
416                return Err(format!(
417                    "{builtin}: `strategy` must be a string, got {}",
418                    other.type_name()
419                ));
420            }
421        }
422    }
423    if let Some(value) = optional_usize(dict, "max_tokens", builtin)? {
424        policy.max_tokens = Some(value);
425    }
426    if let Some(value) = optional_usize(dict, "max_turns", builtin)? {
427        policy.max_turns = Some(value);
428    }
429    if let Some(value) = optional_usize(dict, "context_window", builtin)? {
430        policy.context_window = Some(value);
431    }
432    if let Some(value) = optional_f64(dict, "safety_ratio", builtin)? {
433        if !(0.0..=1.0).contains(&value) {
434            return Err(format!(
435                "{builtin}: `safety_ratio` must be between 0.0 and 1.0, got {value}"
436            ));
437        }
438        policy.safety_ratio = value;
439    }
440    if let Some(value) = optional_usize(dict, "keep_last", builtin)? {
441        policy.keep_last = value;
442    }
443    if let Some(value) = optional_usize(dict, "keep_first", builtin)? {
444        policy.keep_first = value;
445    }
446    if let Some(value) = optional_usize(dict, "hard_limit_tokens", builtin)? {
447        policy.hard_limit_tokens = Some(value);
448    }
449    if let Some(value) = optional_usize(dict, "tool_output_max_chars", builtin)? {
450        policy.tool_output_max_chars = Some(value);
451    }
452    if let Some(value) = dict.get("summarize_fn") {
453        match value {
454            VmValue::Closure(_) => {
455                policy.summarize_fn = Some(value.clone());
456            }
457            VmValue::Nil => {}
458            other => {
459                return Err(format!(
460                    "{builtin}: `summarize_fn` must be a closure, got {}",
461                    other.type_name()
462                ));
463            }
464        }
465    }
466    if let Some(value) = dict.get("summarize_prompt") {
467        match value {
468            VmValue::String(text) => {
469                let trimmed = text.trim();
470                if !trimmed.is_empty() {
471                    policy.summarize_prompt = Some(trimmed.to_string());
472                }
473            }
474            VmValue::Nil => {}
475            other => {
476                return Err(format!(
477                    "{builtin}: `summarize_prompt` must be a string, got {}",
478                    other.type_name()
479                ));
480            }
481        }
482    }
483
484    // Engine-level instructions (`policy`, `instructions`, `scope`,
485    // `preserve`, `drop`, `extend_default_instructions`, `author`) live
486    // on a nested dict but also accept top-level keys for ergonomics.
487    policy.instructions = super::parse_compaction_policy_options(Some(dict), builtin)
488        .map_err(|error| format!("{builtin}: {}", display_vm_error(&error)))?;
489
490    if matches!(policy.strategy, PolicyStrategy::Custom) && policy.summarize_fn.is_none() {
491        return Err(format!(
492            "{builtin}: `summarize_fn` is required when strategy is 'custom'"
493        ));
494    }
495    if matches!(policy.strategy, PolicyStrategy::SummarizeThenPrune)
496        && parse_compact_strategy("truncate").is_err()
497    {
498        // Defensive sanity check — fallback string must remain a known engine
499        // strategy. The parser already accepts "truncate", so this guards the
500        // future where someone renames the engine variant.
501        return Err(format!(
502            "{builtin}: summarize-then-prune fallback 'truncate' is no longer a known engine strategy"
503        ));
504    }
505    Ok(policy)
506}
507
508fn display_vm_error(error: &crate::value::VmError) -> String {
509    match error {
510        crate::value::VmError::Runtime(message) => message.clone(),
511        other => format!("{other:?}"),
512    }
513}
514
515fn optional_usize(
516    dict: &BTreeMap<String, VmValue>,
517    key: &str,
518    builtin: &str,
519) -> Result<Option<usize>, String> {
520    match dict.get(key) {
521        None | Some(VmValue::Nil) => Ok(None),
522        Some(VmValue::Int(value)) => {
523            if *value < 0 {
524                return Err(format!("{builtin}: `{key}` must be >= 0, got {value}"));
525            }
526            Ok(Some(*value as usize))
527        }
528        Some(other) => Err(format!(
529            "{builtin}: `{key}` must be an int, got {}",
530            other.type_name()
531        )),
532    }
533}
534
535fn optional_f64(
536    dict: &BTreeMap<String, VmValue>,
537    key: &str,
538    builtin: &str,
539) -> Result<Option<f64>, String> {
540    match dict.get(key) {
541        None | Some(VmValue::Nil) => Ok(None),
542        Some(VmValue::Float(value)) => Ok(Some(*value)),
543        Some(VmValue::Int(value)) => Ok(Some(*value as f64)),
544        Some(other) => Err(format!(
545            "{builtin}: `{key}` must be a number, got {}",
546            other.type_name()
547        )),
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn safety_ratio_picks_more_restrictive_cap() {
557        let policy = CompactionPolicyDeclaration {
558            max_tokens: Some(40_000),
559            context_window: Some(100_000),
560            safety_ratio: 0.5,
561            ..Default::default()
562        };
563        // ratio = 50k, max_tokens = 40k → 40k wins.
564        assert_eq!(policy.token_threshold(), Some(40_000));
565    }
566
567    #[test]
568    fn ratio_only_when_window_set() {
569        let policy = CompactionPolicyDeclaration {
570            context_window: Some(120_000),
571            safety_ratio: 0.7,
572            ..Default::default()
573        };
574        assert_eq!(policy.token_threshold(), Some(84_000));
575    }
576
577    #[test]
578    fn evaluate_marks_token_trigger() {
579        let policy = CompactionPolicyDeclaration {
580            max_tokens: Some(10_000),
581            ..Default::default()
582        };
583        let ctx = policy.evaluate(12_000, 5);
584        assert!(ctx.token_trigger);
585        assert!(ctx.fires());
586        assert_eq!(ctx.trigger_label(), "tokens");
587    }
588
589    #[test]
590    fn evaluate_marks_turn_trigger() {
591        let policy = CompactionPolicyDeclaration {
592            max_turns: Some(20),
593            ..Default::default()
594        };
595        let ctx = policy.evaluate(0, 25);
596        assert!(ctx.turn_trigger);
597        assert_eq!(ctx.trigger_label(), "turns");
598    }
599
600    #[test]
601    fn defer_when_no_thresholds_configured() {
602        let policy = CompactionPolicyDeclaration::default();
603        let ctx = policy.evaluate(1_000_000, 1_000_000);
604        assert!(!ctx.fires());
605    }
606
607    #[test]
608    fn default_policy_falls_back_to_session_lookup() {
609        reset_registry();
610        let default = CompactionPolicyDeclaration {
611            max_tokens: Some(50_000),
612            ..Default::default()
613        };
614        set_policy(DEFAULT_POLICY_KEY, default);
615        let (resolved, inherited) =
616            policy_for("session-without-explicit-policy").expect("default policy resolved");
617        assert!(inherited);
618        assert_eq!(resolved.max_tokens, Some(50_000));
619        reset_registry();
620    }
621
622    #[test]
623    fn session_specific_policy_takes_precedence() {
624        reset_registry();
625        set_policy(
626            "",
627            CompactionPolicyDeclaration {
628                max_tokens: Some(50_000),
629                ..Default::default()
630            },
631        );
632        set_policy(
633            "session-a",
634            CompactionPolicyDeclaration {
635                max_tokens: Some(80_000),
636                ..Default::default()
637            },
638        );
639        let (resolved, inherited) = policy_for("session-a").expect("session policy resolved");
640        assert!(!inherited);
641        assert_eq!(resolved.max_tokens, Some(80_000));
642        reset_registry();
643    }
644
645    #[test]
646    fn strategy_aliases_round_trip() {
647        assert_eq!(
648            PolicyStrategy::parse("summarize")
649                .unwrap()
650                .engine_strategy(),
651            CompactStrategy::Llm
652        );
653        assert_eq!(
654            PolicyStrategy::parse("summarize-then-prune")
655                .unwrap()
656                .engine_fallback(),
657            Some(CompactStrategy::Truncate)
658        );
659        assert_eq!(
660            PolicyStrategy::parse("window").unwrap().engine_strategy(),
661            CompactStrategy::Truncate
662        );
663        assert_eq!(
664            PolicyStrategy::parse("head+tail")
665                .unwrap()
666                .engine_strategy(),
667            CompactStrategy::Truncate
668        );
669        assert_eq!(
670            PolicyStrategy::parse("observation_mask")
671                .unwrap()
672                .engine_strategy(),
673            CompactStrategy::ObservationMask
674        );
675        assert!(PolicyStrategy::parse("unknown").is_err());
676    }
677}