Skip to main content

lean_ctx_protocol/
context_policy_evidence.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Neutral, content-free evidence about how context representation strategies
3//! performed on completed tasks.
4//!
5//! The Engine observes and aggregates; it does not decide. A consumer that
6//! learns a policy from this evidence lives outside this crate. Records carry
7//! bounded enums and counts only — never prompts, source text, paths or
8//! repository names. Anything the Engine did not measure is marked
9//! `Unmeasured`, never reported as zero.
10
11use serde::{Deserialize, Serialize};
12
13use crate::common::ValidationError;
14use crate::context_gateway::{QualityRecoveryV1, RetentionCountsV1};
15
16pub const CONTEXT_POLICY_EVIDENCE_VERSION: u32 = 1;
17/// Upper bound on records in one evidence document.
18pub const MAX_EVIDENCE_RECORDS: usize = 4_096;
19
20/// The task class the planner assigned.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum WorkloadTaskClassV1 {
24    BugFix,
25    Refactor,
26    TestAddition,
27    Documentation,
28    Investigation,
29}
30
31/// Dominant language of the planned file context, by extension.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum WorkloadLanguageV1 {
35    Rust,
36    Python,
37    TypeScript,
38    JavaScript,
39    Go,
40    Java,
41    C,
42    Cpp,
43    CSharp,
44    Swift,
45    Kotlin,
46    Ruby,
47    Php,
48    Shell,
49    Other,
50    /// No file object was planned.
51    None,
52}
53
54/// Planned context budget, bucketed.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum WorkloadSizeV1 {
58    /// ≤ 2k tokens.
59    Tiny,
60    /// ≤ 8k tokens.
61    Small,
62    /// ≤ 32k tokens.
63    Medium,
64    /// ≤ 128k tokens.
65    Large,
66    VeryLarge,
67}
68
69impl WorkloadSizeV1 {
70    #[must_use]
71    pub fn from_budget_tokens(tokens: u64) -> Self {
72        match tokens {
73            0..=2_048 => Self::Tiny,
74            2_049..=8_192 => Self::Small,
75            8_193..=32_768 => Self::Medium,
76            32_769..=131_072 => Self::Large,
77            _ => Self::VeryLarge,
78        }
79    }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct WorkloadV1 {
85    pub task_class: WorkloadTaskClassV1,
86    pub language: WorkloadLanguageV1,
87    pub size: WorkloadSizeV1,
88}
89
90/// Context quality measured on the task's deliveries, summed.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case", tag = "state", deny_unknown_fields)]
93pub enum QualityEvidenceV1 {
94    Measured {
95        /// Critical probes only: losing an important fact is not a quality failure.
96        retention: RetentionCountsV1,
97        recovery: QualityRecoveryV1,
98    },
99    Unmeasured,
100}
101
102/// Whether a strategy change was checked for security regressions. The Engine
103/// cannot attribute one today, so it reports `Unmeasured`; a consumer must treat
104/// that as ineligible for promotion, never as "no regression".
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case", tag = "state", deny_unknown_fields)]
107pub enum SecurityEvidenceV1 {
108    Measured { regressions: u64 },
109    Unmeasured,
110}
111
112/// The read strategy the planner chose, as a closed set. Configured mode
113/// strings are free text and could name a project; anything outside the known
114/// modes is reported as `Other`, never passed through.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum ReadStrategyV1 {
118    Full,
119    Map,
120    Signatures,
121    Aggressive,
122    Entropy,
123    Task,
124    Reference,
125    Diff,
126    Lines,
127    Auto,
128    Other,
129}
130
131impl ReadStrategyV1 {
132    /// Classify an Engine read mode; `lines:N-M` is `Lines`.
133    #[must_use]
134    pub fn from_mode(mode: &str) -> Self {
135        match mode {
136            "full" => Self::Full,
137            "map" => Self::Map,
138            "signatures" => Self::Signatures,
139            "aggressive" => Self::Aggressive,
140            "entropy" => Self::Entropy,
141            "task" => Self::Task,
142            "reference" => Self::Reference,
143            "diff" => Self::Diff,
144            "auto" => Self::Auto,
145            _ if mode == "lines" || mode.starts_with("lines:") => Self::Lines,
146            _ => Self::Other,
147        }
148    }
149
150    #[must_use]
151    pub fn as_str(self) -> &'static str {
152        match self {
153            Self::Full => "full",
154            Self::Map => "map",
155            Self::Signatures => "signatures",
156            Self::Aggressive => "aggressive",
157            Self::Entropy => "entropy",
158            Self::Task => "task",
159            Self::Reference => "reference",
160            Self::Diff => "diff",
161            Self::Lines => "lines",
162            Self::Auto => "auto",
163            Self::Other => "other",
164        }
165    }
166}
167
168#[allow(clippy::trivially_copy_pass_by_ref)] // serde's skip_serializing_if signature
169fn is_zero(value: &u64) -> bool {
170    *value == 0
171}
172
173/// Outcomes of one read strategy on one workload, on one UTC day.
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(deny_unknown_fields)]
176pub struct StrategyOutcomeRecordV1 {
177    pub workload: WorkloadV1,
178    pub strategy: ReadStrategyV1,
179    /// Days since the Unix epoch (UTC) on which the outcomes were observed,
180    /// so a consumer can weight evidence by its real age.
181    pub observed_day: u64,
182    /// Tasks with a terminal outcome; Unknown outcomes are never counted.
183    pub samples: u64,
184    pub accepted: u64,
185    pub rejected: u64,
186    /// Tasks where the user explicitly overrode the planner's mode.
187    pub explicit_overrides: u64,
188    /// Tasks whose deliveries were all verified; only these contribute tokens.
189    pub token_samples: u64,
190    /// Tasks whose runtime signals are fully attributed; the counts below are
191    /// tasks among them with at least one such event. Zero counts are left
192    /// out on the wire, so consumers of the first schema keep parsing.
193    #[serde(default, skip_serializing_if = "is_zero")]
194    pub signal_samples: u64,
195    /// Full re-read of a file read compressed in the task.
196    #[serde(default, skip_serializing_if = "is_zero")]
197    pub bounce_tasks: u64,
198    /// Expansion of a file handle read compressed in the task.
199    #[serde(default, skip_serializing_if = "is_zero")]
200    pub expand_tasks: u64,
201    /// Failed edit after a compressed read in the task.
202    #[serde(default, skip_serializing_if = "is_zero")]
203    pub edit_failure_tasks: u64,
204    pub tokens_original: u64,
205    pub tokens_delivered: u64,
206    pub quality: QualityEvidenceV1,
207    pub security: SecurityEvidenceV1,
208}
209
210impl StrategyOutcomeRecordV1 {
211    pub fn validate(&self) -> Result<(), ValidationError> {
212        if self.samples == 0 {
213            return Err(ValidationError::new("record without samples"));
214        }
215        if self.accepted.checked_add(self.rejected) != Some(self.samples) {
216            return Err(ValidationError::new(
217                "accepted + rejected must equal samples",
218            ));
219        }
220        if self.explicit_overrides > self.samples {
221            return Err(ValidationError::new("more overrides than samples"));
222        }
223        if self.token_samples > self.samples {
224            return Err(ValidationError::new("more token samples than samples"));
225        }
226        if self.signal_samples > self.samples
227            || [
228                self.bounce_tasks,
229                self.expand_tasks,
230                self.edit_failure_tasks,
231            ]
232            .iter()
233            .any(|count| *count > self.signal_samples)
234        {
235            return Err(ValidationError::new(
236                "signal counts exceed their attributed tasks",
237            ));
238        }
239        if self.token_samples == 0 && (self.tokens_original > 0 || self.tokens_delivered > 0) {
240            return Err(ValidationError::new("tokens without token samples"));
241        }
242        if self.tokens_delivered > self.tokens_original {
243            return Err(ValidationError::new("delivered tokens exceed original"));
244        }
245        if let QualityEvidenceV1::Measured { recovery, .. } = &self.quality
246            && (recovery.handles_verified > recovery.handles_emitted
247                || recovery.failures > recovery.handles_emitted
248                || recovery.critical_failures > recovery.failures)
249        {
250            return Err(ValidationError::new("impossible recovery counts"));
251        }
252        Ok(())
253    }
254
255    fn key(&self) -> (&WorkloadV1, ReadStrategyV1, u64) {
256        (&self.workload, self.strategy, self.observed_day)
257    }
258}
259
260/// Verdict of a paired task evaluation (`lean-ctx eval frontier`).
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
262#[serde(rename_all = "snake_case")]
263pub enum EvaluationVerdictV1 {
264    Improved,
265    NonInferior,
266    Regressed,
267    Underpowered,
268}
269
270/// A paired task evaluation of one read strategy against the unranked raw
271/// baseline on a declared suite. Suite-level, not per workload; no suite name
272/// or task content. Deltas are in thousandths of the suite's score scale.
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274#[serde(deny_unknown_fields)]
275pub struct StrategyEvaluationV1 {
276    pub strategy: ReadStrategyV1,
277    pub evidence_tier: crate::context_gateway::QualityEvidenceTierV1,
278    pub verdict: EvaluationVerdictV1,
279    pub pairs: u64,
280    pub powered: bool,
281    pub delta_milli: i64,
282    pub ci_low_milli: i64,
283    pub ci_high_milli: i64,
284    pub margin_milli: i64,
285}
286
287impl StrategyEvaluationV1 {
288    pub fn validate(&self) -> Result<(), ValidationError> {
289        if self.strategy == ReadStrategyV1::Other {
290            return Err(ValidationError::new("an evaluation names a known strategy"));
291        }
292        if self.ci_low_milli > self.ci_high_milli || self.margin_milli < 0 {
293            return Err(ValidationError::new("impossible evaluation interval"));
294        }
295        if self.powered && self.pairs == 0 {
296            return Err(ValidationError::new("a powered evaluation has pairs"));
297        }
298        Ok(())
299    }
300}
301
302/// Aggregated evidence of one scope (tenant/project), sorted by
303/// (workload, strategy, day) so equal evidence serializes to equal bytes.
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
305#[serde(deny_unknown_fields)]
306pub struct ContextPolicyEvidenceV1 {
307    pub schema_version: u32,
308    pub records: Vec<StrategyOutcomeRecordV1>,
309    /// The newest task evaluation per strategy, sorted by strategy. Absent
310    /// when none was run; an absent evaluation is never a passing one.
311    /// Evaluations run on a declared suite, not in a scope: the same
312    /// machine-wide results are attached to every scope's evidence.
313    #[serde(default, skip_serializing_if = "Vec::is_empty")]
314    pub evaluations: Vec<StrategyEvaluationV1>,
315}
316
317impl ContextPolicyEvidenceV1 {
318    /// The evidence in the first schema's shape (no runtime signals, no
319    /// evaluations), for consumers that reject unknown fields.
320    #[must_use]
321    pub fn without_v1_1_fields(&self) -> Self {
322        let mut legacy = self.clone();
323        legacy.evaluations.clear();
324        for record in &mut legacy.records {
325            record.signal_samples = 0;
326            record.bounce_tasks = 0;
327            record.expand_tasks = 0;
328            record.edit_failure_tasks = 0;
329        }
330        legacy
331    }
332
333    pub fn validate(&self) -> Result<(), ValidationError> {
334        if self.schema_version != CONTEXT_POLICY_EVIDENCE_VERSION {
335            return Err(ValidationError::new("unsupported evidence version"));
336        }
337        if self.records.len() > MAX_EVIDENCE_RECORDS {
338            return Err(ValidationError::new("too many evidence records"));
339        }
340        for record in &self.records {
341            record.validate()?;
342        }
343        if !self
344            .records
345            .windows(2)
346            .all(|pair| pair[0].key() < pair[1].key())
347        {
348            return Err(ValidationError::new(
349                "records must be strictly sorted by workload, strategy and day",
350            ));
351        }
352        for evaluation in &self.evaluations {
353            evaluation.validate()?;
354        }
355        if !self
356            .evaluations
357            .windows(2)
358            .all(|pair| pair[0].strategy < pair[1].strategy)
359        {
360            return Err(ValidationError::new(
361                "evaluations must be one per strategy, sorted",
362            ));
363        }
364        Ok(())
365    }
366}
367
368/// Schema version of [`ContextPolicyV1`].
369pub const CONTEXT_POLICY_VERSION: u32 = 1;
370/// Upper bound on entries in one policy.
371pub const MAX_POLICY_ENTRIES: usize = 4_096;
372
373/// One workload's read strategy in a promoted policy.
374#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
375#[serde(deny_unknown_fields)]
376pub struct ContextPolicyEntryV1 {
377    pub workload: WorkloadV1,
378    pub strategy: ReadStrategyV1,
379}
380
381/// A promoted read-strategy policy in the Engine's vocabulary. Whoever
382/// learned it keeps its own artifact; `learner_digest` names that artifact
383/// so a later rollback can be traced to it. Entries are sorted by workload,
384/// one per workload, and only name strategies the Engine can apply.
385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
386#[serde(deny_unknown_fields)]
387pub struct ContextPolicyV1 {
388    pub schema_version: u32,
389    pub version: u64,
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub parent_version: Option<u64>,
392    pub entries: Vec<ContextPolicyEntryV1>,
393    /// Lowercase hex digest of the learner's artifact (opaque to the Engine).
394    pub learner_digest: String,
395}
396
397impl ContextPolicyV1 {
398    pub fn validate(&self) -> Result<(), ValidationError> {
399        if self.schema_version != CONTEXT_POLICY_VERSION {
400            return Err(ValidationError::new("unsupported policy version"));
401        }
402        if self.entries.len() > MAX_POLICY_ENTRIES {
403            return Err(ValidationError::new("too many policy entries"));
404        }
405        if self
406            .parent_version
407            .is_some_and(|parent| parent >= self.version)
408        {
409            return Err(ValidationError::new(
410                "a policy must be newer than its parent",
411            ));
412        }
413        if self.entries.iter().any(|entry| {
414            matches!(
415                entry.strategy,
416                ReadStrategyV1::Other | ReadStrategyV1::Auto | ReadStrategyV1::Lines
417            )
418        }) {
419            return Err(ValidationError::new(
420                "a policy names only strategies the planner can apply",
421            ));
422        }
423        if !self
424            .entries
425            .windows(2)
426            .all(|pair| pair[0].workload < pair[1].workload)
427        {
428            return Err(ValidationError::new(
429                "policy entries must be one per workload, sorted",
430            ));
431        }
432        if self.learner_digest.len() != 64
433            || !self
434                .learner_digest
435                .bytes()
436                .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
437        {
438            return Err(ValidationError::new("invalid learner digest"));
439        }
440        Ok(())
441    }
442
443    /// The strategy this policy assigns to `workload`, if any.
444    #[must_use]
445    pub fn strategy_for(&self, workload: &WorkloadV1) -> Option<ReadStrategyV1> {
446        self.entries
447            .binary_search_by(|entry| entry.workload.cmp(workload))
448            .ok()
449            .map(|index| self.entries[index].strategy)
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    fn record(strategy: ReadStrategyV1, day: u64) -> StrategyOutcomeRecordV1 {
458        StrategyOutcomeRecordV1 {
459            workload: WorkloadV1 {
460                task_class: WorkloadTaskClassV1::BugFix,
461                language: WorkloadLanguageV1::Rust,
462                size: WorkloadSizeV1::Tiny,
463            },
464            strategy,
465            observed_day: day,
466            samples: 3,
467            accepted: 2,
468            rejected: 1,
469            explicit_overrides: 0,
470            token_samples: 3,
471            signal_samples: 0,
472            bounce_tasks: 0,
473            expand_tasks: 0,
474            edit_failure_tasks: 0,
475            tokens_original: 900,
476            tokens_delivered: 300,
477            quality: QualityEvidenceV1::Unmeasured,
478            security: SecurityEvidenceV1::Unmeasured,
479        }
480    }
481
482    #[test]
483    fn budget_buckets_have_inclusive_upper_bounds() {
484        assert_eq!(WorkloadSizeV1::from_budget_tokens(0), WorkloadSizeV1::Tiny);
485        assert_eq!(
486            WorkloadSizeV1::from_budget_tokens(2_048),
487            WorkloadSizeV1::Tiny
488        );
489        assert_eq!(
490            WorkloadSizeV1::from_budget_tokens(2_049),
491            WorkloadSizeV1::Small
492        );
493        assert_eq!(
494            WorkloadSizeV1::from_budget_tokens(131_072),
495            WorkloadSizeV1::Large
496        );
497        assert_eq!(
498            WorkloadSizeV1::from_budget_tokens(131_073),
499            WorkloadSizeV1::VeryLarge
500        );
501    }
502
503    #[test]
504    fn modes_map_to_a_closed_set_and_never_pass_text_through() {
505        assert_eq!(ReadStrategyV1::from_mode("map"), ReadStrategyV1::Map);
506        assert_eq!(
507            ReadStrategyV1::from_mode("lines:10-20"),
508            ReadStrategyV1::Lines
509        );
510        assert_eq!(
511            ReadStrategyV1::from_mode("internal-payments"),
512            ReadStrategyV1::Other
513        );
514        let json = serde_json::to_string(&ReadStrategyV1::from_mode("internal-payments"))
515            .expect("serializes");
516        assert_eq!(json, r#""other""#);
517        for strategy in [
518            ReadStrategyV1::Full,
519            ReadStrategyV1::Lines,
520            ReadStrategyV1::Other,
521        ] {
522            assert_eq!(
523                serde_json::to_value(strategy).expect("serializes"),
524                strategy.as_str()
525            );
526        }
527    }
528
529    #[test]
530    fn evidence_must_be_consistent_and_sorted() {
531        let mut evidence = ContextPolicyEvidenceV1 {
532            schema_version: CONTEXT_POLICY_EVIDENCE_VERSION,
533            records: vec![
534                record(ReadStrategyV1::Full, 20_000),
535                record(ReadStrategyV1::Full, 20_001),
536                record(ReadStrategyV1::Map, 19_000),
537            ],
538            evaluations: Vec::new(),
539        };
540        let evaluation = |strategy| StrategyEvaluationV1 {
541            strategy,
542            evidence_tier: crate::context_gateway::QualityEvidenceTierV1::LiveTaskEvaluation,
543            verdict: EvaluationVerdictV1::NonInferior,
544            pairs: 30,
545            powered: true,
546            delta_milli: 5,
547            ci_low_milli: -10,
548            ci_high_milli: 20,
549            margin_milli: 30,
550        };
551        evidence.evaluations = vec![
552            evaluation(ReadStrategyV1::Full),
553            evaluation(ReadStrategyV1::Map),
554        ];
555        assert!(evidence.validate().is_ok());
556        evidence.evaluations.swap(0, 1);
557        assert!(evidence.validate().is_err(), "evaluations unsorted");
558        evidence.evaluations = vec![evaluation(ReadStrategyV1::Other)];
559        assert!(
560            evidence.validate().is_err(),
561            "an unknown strategy is no evaluation"
562        );
563        evidence.evaluations.clear();
564        assert!(evidence.validate().is_ok());
565        evidence.records.swap(0, 1);
566        assert!(evidence.validate().is_err(), "days unsorted");
567        evidence.records.swap(0, 1);
568        evidence.records[0].rejected = 2;
569        assert!(evidence.validate().is_err(), "outcomes do not add up");
570        evidence.records[0].rejected = 1;
571        evidence.records[0].token_samples = 0;
572        assert!(evidence.validate().is_err(), "tokens without token samples");
573        evidence.records[0].token_samples = 4;
574        assert!(
575            evidence.validate().is_err(),
576            "more token samples than samples"
577        );
578        evidence.records[0].token_samples = 3;
579        evidence.records[0].quality = QualityEvidenceV1::Measured {
580            retention: RetentionCountsV1 {
581                retained: 1,
582                recoverable: 0,
583                lost: 0,
584            },
585            recovery: QualityRecoveryV1 {
586                handles_emitted: 1,
587                handles_verified: 1,
588                failures: 0,
589                critical_failures: 1,
590            },
591        };
592        assert!(evidence.validate().is_err(), "impossible recovery counts");
593    }
594
595    #[test]
596    fn unmeasured_is_explicit_on_the_wire() {
597        let json = serde_json::to_string(&record(ReadStrategyV1::Map, 1)).expect("round trip");
598        assert!(
599            json.contains(r#""quality":{"state":"unmeasured"}"#),
600            "{json}"
601        );
602        assert!(
603            json.contains(r#""security":{"state":"unmeasured"}"#),
604            "{json}"
605        );
606        let back: StrategyOutcomeRecordV1 = serde_json::from_str(&json).expect("round trip");
607        assert_eq!(back, record(ReadStrategyV1::Map, 1));
608
609        // Zero signal counts stay off the wire, so first-schema consumers
610        // (which reject unknown fields) keep parsing; non-zero ones appear.
611        assert!(!json.contains("signal_samples"), "{json}");
612        let mut signalled = record(ReadStrategyV1::Map, 1);
613        signalled.signal_samples = 2;
614        signalled.bounce_tasks = 1;
615        let evidence = ContextPolicyEvidenceV1 {
616            schema_version: CONTEXT_POLICY_EVIDENCE_VERSION,
617            records: vec![signalled],
618            evaluations: vec![StrategyEvaluationV1 {
619                strategy: ReadStrategyV1::Map,
620                evidence_tier: crate::context_gateway::QualityEvidenceTierV1::LiveTaskEvaluation,
621                verdict: EvaluationVerdictV1::NonInferior,
622                pairs: 10,
623                powered: true,
624                delta_milli: 0,
625                ci_low_milli: -5,
626                ci_high_milli: 5,
627                margin_milli: 30,
628            }],
629        };
630        let current = serde_json::to_string(&evidence).expect("json");
631        assert!(current.contains("\"bounce_tasks\":1") && current.contains("evaluations"));
632        let legacy = serde_json::to_string(&evidence.without_v1_1_fields()).expect("json");
633        assert!(
634            !legacy.contains("signal_samples")
635                && !legacy.contains("bounce_tasks")
636                && !legacy.contains("evaluations"),
637            "{legacy}"
638        );
639    }
640
641    #[test]
642    fn policies_name_only_applicable_strategies_once_per_workload() {
643        let workload = |size| WorkloadV1 {
644            task_class: WorkloadTaskClassV1::BugFix,
645            language: WorkloadLanguageV1::Rust,
646            size,
647        };
648        let mut policy = ContextPolicyV1 {
649            schema_version: CONTEXT_POLICY_VERSION,
650            version: 2,
651            parent_version: Some(1),
652            entries: vec![
653                ContextPolicyEntryV1 {
654                    workload: workload(WorkloadSizeV1::Tiny),
655                    strategy: ReadStrategyV1::Full,
656                },
657                ContextPolicyEntryV1 {
658                    workload: workload(WorkloadSizeV1::Large),
659                    strategy: ReadStrategyV1::Map,
660                },
661            ],
662            learner_digest: "a".repeat(64),
663        };
664        policy.validate().expect("valid policy");
665        assert_eq!(
666            policy.strategy_for(&workload(WorkloadSizeV1::Large)),
667            Some(ReadStrategyV1::Map)
668        );
669        assert_eq!(policy.strategy_for(&workload(WorkloadSizeV1::Medium)), None);
670
671        let mut invalid = policy.clone();
672        invalid.entries[1].strategy = ReadStrategyV1::Auto;
673        assert!(
674            invalid.validate().is_err(),
675            "auto is not a learned strategy"
676        );
677        let mut invalid = policy.clone();
678        invalid.entries.swap(0, 1);
679        assert!(invalid.validate().is_err(), "unsorted entries");
680        let mut invalid = policy.clone();
681        invalid.parent_version = Some(2);
682        assert!(invalid.validate().is_err(), "parent not older");
683        policy.learner_digest = "A".repeat(64);
684        assert!(policy.validate().is_err(), "uppercase digest");
685    }
686}