1use crate::error::{Error, Result};
13use crate::model::Severity;
14use crate::recommendation::Checkpoint;
15use serde::{Deserialize, Serialize};
16use std::collections::BTreeMap;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum TelemetryMode {
22 Off,
23 #[default]
24 Aggregate,
25 Full,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum DiscoverObjective {
35 #[default]
40 ReviewQueue,
41 Learner,
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct AutoApplyGrant {
54 pub analyzer: String,
57 pub targets: Vec<String>,
60 pub max_severity: Severity,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum EvidenceAttribution {
78 #[default]
80 Named,
81 Anonymous,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum BaselineKind {
103 #[default]
105 NewestBeforeApply,
106 HighWater,
109}
110
111impl BaselineKind {
112 pub fn as_str(self) -> &'static str {
114 match self {
115 BaselineKind::NewestBeforeApply => "newest_before_apply",
116 BaselineKind::HighWater => "high_water",
117 }
118 }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
137#[serde(deny_unknown_fields)]
138pub struct MinEffect {
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub count: Option<f64>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub points: Option<f64>,
143}
144
145impl MinEffect {
146 fn validate(&self) -> Result<()> {
147 let bad = |what: &str| Err(Error::InvalidProposal(format!("policy: outcome_evalset.min_effect: {what}")));
148 match (self.count, self.points) {
149 (None, None) => bad("give {\"count\": n} or {\"points\": p}"),
150 (Some(_), Some(_)) => bad("give count or points, not both"),
151 (Some(v), None) | (None, Some(v)) if !(v.is_finite() && v >= 0.0) => {
152 bad("must be a finite number ≥ 0")
153 }
154 _ => Ok(()),
155 }
156 }
157
158 pub fn resolve(&self, field: &str, total: u64) -> f64 {
161 match (self.count, self.points) {
162 (Some(c), _) => c,
163 (None, Some(p)) => match field {
164 "passed" | "failed" | "total" => p / 100.0 * total as f64,
165 _ => p / 100.0,
166 },
167 (None, None) => 0.0,
168 }
169 }
170}
171
172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
183#[serde(deny_unknown_fields)]
184pub struct CostBound {
185 pub field: String,
186 pub max_increase_ratio: f64,
189}
190
191impl CostBound {
192 fn validate(&self) -> Result<()> {
193 let bad = |what: &str| Err(Error::InvalidProposal(format!("policy: outcome_evalset.cost: {what}")));
194 if self.field.trim().is_empty() {
195 return bad("field must name a cost field (effects, tokens, usd, wall_ms, cost_per_pass, or a harness key)");
196 }
197 if !(self.max_increase_ratio.is_finite() && self.max_increase_ratio > 0.0) {
198 return bad("max_increase_ratio must be a finite number > 0");
199 }
200 Ok(())
201 }
202}
203
204#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
214#[serde(deny_unknown_fields)]
215pub struct OutcomeEvalset {
216 pub hash: String,
218 pub field: String,
221 pub higher_is_better: bool,
225 #[serde(default = "default_horizons")]
228 pub horizons_ms: Vec<i64>,
229 #[serde(default, skip_serializing_if = "Vec::is_empty")]
235 pub checkpoints: Vec<Checkpoint>,
236 #[serde(default)]
240 pub baseline: BaselineKind,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub min_effect: Option<MinEffect>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub cost: Option<CostBound>,
248}
249
250fn default_horizons() -> Vec<i64> {
251 vec![86_400_000, 7 * 86_400_000, 30 * 86_400_000]
252}
253
254impl OutcomeEvalset {
255 pub fn schedule(&self) -> Vec<Checkpoint> {
258 let mut h: Vec<Checkpoint> = if self.checkpoints.is_empty() {
259 self.horizons_ms.iter().map(|ms| Checkpoint::AfterMs(*ms)).collect()
260 } else {
261 self.checkpoints.clone()
262 };
263 h.sort_unstable();
264 h.dedup();
265 h
266 }
267}
268
269#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
286#[serde(deny_unknown_fields)]
287pub struct Cadence {
288 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub every_ms: Option<i64>,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub every_grains: Option<u64>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub every_events: Option<u64>,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub every_sessions: Option<u64>,
302}
303
304impl Cadence {
305 pub fn is_set(&self) -> bool {
307 self.every_ms.is_some()
308 || self.every_grains.is_some()
309 || self.every_events.is_some()
310 || self.every_sessions.is_some()
311 }
312}
313
314#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
328#[serde(deny_unknown_fields)]
329pub struct SkillAuthoring {
330 #[serde(default = "default_true")]
333 pub enabled: bool,
334 #[serde(default = "default_min_steps")]
336 pub min_steps: u32,
337}
338
339pub(crate) fn default_true() -> bool {
340 true
341}
342fn default_min_steps() -> u32 {
343 2
344}
345fn default_min_evidence() -> u32 {
346 1
347}
348
349impl Default for SkillAuthoring {
350 fn default() -> Self {
351 SkillAuthoring { enabled: true, min_steps: 2 }
352 }
353}
354
355#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
368#[serde(deny_unknown_fields)]
369pub struct PlanAuthoring {
370 #[serde(default = "default_true")]
372 pub enabled: bool,
373 #[serde(default = "default_min_steps")]
375 pub min_nodes: u32,
376}
377
378impl Default for PlanAuthoring {
379 fn default() -> Self {
380 PlanAuthoring { enabled: true, min_nodes: 2 }
381 }
382}
383
384#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
392#[serde(rename_all = "snake_case")]
393pub enum NearDuplicateMode {
394 #[default]
395 Flag,
396 Suppress,
397}
398
399#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
409#[serde(deny_unknown_fields)]
410pub struct PlanReplayPolicy {
411 #[serde(default = "default_min_runs")]
414 pub min_runs: u32,
415 #[serde(default = "default_true")]
418 pub require_no_worse: bool,
419 #[serde(default = "default_max_out_of_support")]
423 pub max_out_of_support: f64,
424}
425
426fn default_min_runs() -> u32 {
427 3
428}
429fn default_max_out_of_support() -> f64 {
430 0.5
431}
432
433impl PlanReplayPolicy {
434 fn validate(&self) -> Result<()> {
435 if !(self.max_out_of_support.is_finite() && (0.0..=1.0).contains(&self.max_out_of_support)) {
436 return Err(Error::InvalidProposal(
437 "policy: plan_replay.max_out_of_support must be a fraction in 0..=1".into(),
438 ));
439 }
440 Ok(())
441 }
442
443 pub fn refusal(&self, report: &serde_json::Value) -> Option<String> {
446 let runs = report["totals"]["runs"].as_u64().unwrap_or(0);
447 if runs < u64::from(self.min_runs) {
448 return None;
449 }
450 let oos = report["out_of_support_fraction"].as_f64().unwrap_or(0.0);
451 if oos > self.max_out_of_support {
452 let ids: Vec<&str> = report["runs"]
453 .as_array()
454 .map(|a| {
455 a.iter()
456 .filter(|r| r["verdict"] == "out_of_support")
457 .filter_map(|r| r["run_id"].as_str())
458 .collect()
459 })
460 .unwrap_or_default();
461 return Some(format!(
462 "{:.0}% of {runs} rehearsed runs fall outside the journal's support (limit {:.0}%): {}",
463 oos * 100.0,
464 self.max_out_of_support * 100.0,
465 ids.join(", ")
466 ));
467 }
468 if self.require_no_worse && report["no_worse"].as_bool() != Some(true) {
469 let worse: Vec<String> = report["runs"]
470 .as_array()
471 .map(|a| {
472 a.iter()
473 .filter(|r| r["verdict"] == "worse")
474 .filter_map(|r| {
475 Some(format!(
476 "{} ({} → {})",
477 r["run_id"].as_str()?,
478 r["incumbent_outcome"].as_str().unwrap_or("?"),
479 r["candidate_outcome"].as_str().unwrap_or("?")
480 ))
481 })
482 .collect()
483 })
484 .unwrap_or_default();
485 let scored = runs - report["totals"]["out_of_support"].as_u64().unwrap_or(0);
486 return Some(if worse.is_empty() {
487 format!("no rehearsed run could be scored ({scored} of {runs})")
488 } else {
489 format!("worse than the incumbent on {} of {scored} rehearsed runs: {}", worse.len(), worse.join(", "))
490 });
491 }
492 None
493 }
494}
495
496#[derive(Debug, Clone, Serialize, Deserialize)]
500#[serde(deny_unknown_fields)]
501pub struct Policy {
502 #[serde(default)]
505 pub auto_apply_enabled: bool,
506 #[serde(default)]
508 pub auto_apply: Vec<AutoApplyGrant>,
509 #[serde(default)]
511 pub deny: Vec<String>,
512 #[serde(default)]
515 pub severity_floors: BTreeMap<String, Severity>,
516 #[serde(default)]
517 pub telemetry: TelemetryMode,
518 #[serde(default)]
520 pub discover_objective: DiscoverObjective,
521 #[serde(default, skip_serializing_if = "Option::is_none")]
524 pub outcome_evalset: Option<OutcomeEvalset>,
525 #[serde(default)]
528 pub evidence_attribution: EvidenceAttribution,
529 #[serde(default, skip_serializing_if = "is_default_cadence")]
531 pub cadence: Cadence,
532 #[serde(default)]
534 pub skills: SkillAuthoring,
535 #[serde(default = "default_min_evidence")]
543 pub min_evidence: u32,
544 #[serde(default)]
546 pub plans: PlanAuthoring,
547 #[serde(default = "default_true")]
557 pub premise_drift: bool,
558 #[serde(default = "crate::policy::default_true")]
566 pub premise_drift_open_all: bool,
567 #[serde(default, skip_serializing_if = "is_default_near_duplicate")]
570 pub near_duplicate: NearDuplicateMode,
571 #[serde(default, skip_serializing_if = "Option::is_none")]
573 pub plan_replay: Option<PlanReplayPolicy>,
574}
575
576fn is_default_near_duplicate(m: &NearDuplicateMode) -> bool {
577 *m == NearDuplicateMode::default()
578}
579
580fn is_default_cadence(c: &Cadence) -> bool {
581 !c.is_set()
582}
583
584impl Default for Policy {
585 fn default() -> Self {
586 Policy {
587 auto_apply_enabled: false,
588 auto_apply: Vec::new(),
589 deny: Vec::new(),
590 severity_floors: BTreeMap::new(),
591 telemetry: TelemetryMode::default(),
592 discover_objective: DiscoverObjective::default(),
593 outcome_evalset: None,
594 evidence_attribution: EvidenceAttribution::default(),
595 cadence: Cadence::default(),
596 skills: SkillAuthoring::default(),
597 min_evidence: 1,
598 plans: PlanAuthoring::default(),
599 premise_drift: true,
600 premise_drift_open_all: true,
601 near_duplicate: NearDuplicateMode::default(),
602 plan_replay: None,
603 }
604 }
605}
606
607impl Policy {
608 pub fn from_json(s: &str) -> Result<Self> {
610 let p: Policy =
611 serde_json::from_str(s).map_err(|e| Error::InvalidProposal(format!("policy: {e}")))?;
612 if let Some(m) = p.outcome_evalset.as_ref().and_then(|e| e.min_effect.as_ref()) {
613 m.validate()?;
614 }
615 if let Some(c) = p.outcome_evalset.as_ref().and_then(|e| e.cost.as_ref()) {
616 c.validate()?;
617 }
618 if let Some(r) = p.plan_replay.as_ref() {
619 r.validate()?;
620 }
621 Ok(p)
622 }
623
624 pub fn denies(&self, family: &str) -> bool {
626 self.deny.iter().any(|d| crate::manifest::analyzer_family(d) == family)
627 }
628
629 pub fn severity_floor(&self, family: &str) -> Option<Severity> {
631 self.severity_floors
632 .iter()
633 .find(|(k, _)| crate::manifest::analyzer_family(k) == family)
634 .map(|(_, v)| *v)
635 }
636
637 pub fn grants_auto_apply(&self, family: &str, target_class: &str, severity: Severity) -> bool {
647 if !self.auto_apply_enabled || target_class != "memory" {
648 return false;
649 }
650 self.auto_apply.iter().any(|g| {
651 crate::manifest::analyzer_family(&g.analyzer) == family
652 && g.targets.iter().any(|t| t == target_class)
653 && severity <= g.max_severity
654 })
655 }
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661
662 #[test]
667 fn code_targets_never_auto_apply_even_when_granted() {
668 let p = Policy::from_json(
669 r#"{"auto_apply_enabled": true,
670 "auto_apply": [{"analyzer": "loop.codegen", "targets": ["code", "evalset", "memory"], "max_severity": "high"}]}"#,
671 )
672 .unwrap();
673 assert!(!p.grants_auto_apply("loop.codegen", "code", Severity::Info));
674 assert!(!p.grants_auto_apply("loop.codegen", "evalset", Severity::Info));
675 assert!(
676 p.grants_auto_apply("loop.codegen", "memory", Severity::Low),
677 "the same grant's memory leg still works — the exclusion is by class"
678 );
679 }
680
681 #[test]
682 fn default_policy_grants_nothing() {
683 let p = Policy::default();
684 assert!(!p.grants_auto_apply("loop.duplicate_sweep", "memory", Severity::Info));
685 assert!(!p.denies("loop.staleness"));
686 assert_eq!(p.telemetry, TelemetryMode::Aggregate);
687 }
688
689 #[test]
690 fn parses_and_grants() {
691 let p = Policy::from_json(
692 r#"{"auto_apply_enabled": true,
693 "auto_apply": [{"analyzer": "loop.duplicate_sweep", "targets": ["memory"], "max_severity": "low"}],
694 "deny": ["loop.staleness"],
695 "severity_floors": {"loop.contradiction_sweep": "high"}}"#,
696 )
697 .unwrap();
698 assert!(p.grants_auto_apply("loop.duplicate_sweep", "memory", Severity::Low));
699 assert!(!p.grants_auto_apply("loop.duplicate_sweep", "memory", Severity::High), "above max_severity");
700 assert!(!p.grants_auto_apply("loop.duplicate_sweep", "query", Severity::Low), "query not granted");
701 assert!(p.denies("loop.staleness"));
702 assert_eq!(p.severity_floor("loop.contradiction_sweep"), Some(Severity::High));
703 }
704
705 #[test]
706 fn prompt_and_host_targets_never_granted() {
707 let p = Policy::from_json(
708 r#"{"auto_apply_enabled": true,
709 "auto_apply": [{"analyzer": "x", "targets": ["prompt", "host"], "max_severity": "high"}]}"#,
710 )
711 .unwrap();
712 assert!(!p.grants_auto_apply("x", "prompt", Severity::Info));
713 assert!(!p.grants_auto_apply("x", "host", Severity::Info));
714 }
715
716 #[test]
717 fn discover_objective_defaults_to_the_review_queue_rule() {
718 assert_eq!(Policy::default().discover_objective, DiscoverObjective::ReviewQueue);
719 let p = Policy::from_json(r#"{"discover_objective": "learner"}"#).unwrap();
720 assert_eq!(p.discover_objective, DiscoverObjective::Learner);
721 assert!(
722 Policy::from_json(r#"{"discover_objective": "eager"}"#).is_err(),
723 "an unknown objective must not load as the default"
724 );
725 }
726
727 #[test]
728 fn outcome_evalset_baseline_is_a_named_choice() {
729 let p = Policy::from_json(
730 r#"{"outcome_evalset": {"hash": "f", "field": "passed", "higher_is_better": true, "baseline": "high_water"}}"#,
731 )
732 .unwrap();
733 assert_eq!(p.outcome_evalset.unwrap().baseline, BaselineKind::HighWater);
734 let p = Policy::from_json(r#"{"outcome_evalset": {"hash": "f", "field": "passed", "higher_is_better": true}}"#).unwrap();
736 assert_eq!(p.outcome_evalset.unwrap().baseline, BaselineKind::NewestBeforeApply);
737 let err = Policy::from_json(
740 r#"{"outcome_evalset": {"hash": "f", "field": "passed", "higher_is_better": true, "baseline": "best_ever"}}"#,
741 )
742 .expect_err("unknown baseline kind");
743 let msg = err.to_string();
744 assert!(msg.contains("newest_before_apply") && msg.contains("high_water"), "{msg}");
745 }
746
747 #[test]
748 fn plan_replay_gate_reads_the_report_the_way_the_ticket_says() {
749 let p = Policy::from_json(r#"{"plan_replay": {"min_runs": 3, "require_no_worse": true}}"#).unwrap();
750 let g = p.plan_replay.unwrap();
751 assert_eq!((g.min_runs, g.require_no_worse, g.max_out_of_support), (3, true, 0.5));
752 let report = |runs: u64, worse: u64, oos: u64, no_worse: bool| {
753 let rows: Vec<serde_json::Value> = (0..runs)
754 .map(|i| {
755 let verdict = if i < worse { "worse" } else if i < worse + oos { "out_of_support" } else { "same" };
756 serde_json::json!({"run_id": format!("r{i}"), "verdict": verdict, "incumbent_outcome": "completed", "candidate_outcome": if verdict == "worse" { "failed" } else { "completed" }})
757 })
758 .collect();
759 serde_json::json!({"totals": {"runs": runs, "out_of_support": oos}, "no_worse": no_worse,
760 "out_of_support_fraction": oos as f64 / runs.max(1) as f64, "runs": rows})
761 };
762 assert_eq!(g.refusal(&report(2, 2, 0, false)), None, "under min_runs the gate abstains");
763 assert_eq!(g.refusal(&report(3, 0, 0, true)), None);
764 let why = g.refusal(&report(3, 1, 0, false)).expect("worse is refused");
765 assert!(why.contains("r0") && why.contains("completed → failed"), "{why}");
766 let why = g.refusal(&report(4, 0, 3, false)).expect("out of support beyond the limit is refused");
767 assert!(why.contains("75%") && why.contains("r0, r1, r2"), "{why}");
768 assert!(Policy::from_json(r#"{"plan_replay": {"max_out_of_support": 1.5}}"#).is_err());
769 assert!(Policy::from_json(r#"{"plan_replay": {"min_runs": 3, "strict": true}}"#).is_err());
770 }
771
772 #[test]
773 fn near_duplicate_mode_parses_and_rejects_unknown() {
774 assert_eq!(Policy::default().near_duplicate, NearDuplicateMode::Flag);
775 let p = Policy::from_json(r#"{"near_duplicate": "suppress"}"#).unwrap();
776 assert_eq!(p.near_duplicate, NearDuplicateMode::Suppress);
777 let err = Policy::from_json(r#"{"near_duplicate": "drop"}"#).expect_err("unknown mode");
778 let msg = err.to_string();
779 assert!(msg.contains("flag") && msg.contains("suppress"), "{msg}");
780 }
781
782 #[test]
783 fn cost_bound_needs_a_field_and_a_positive_ratio() {
784 let base = |extra: &str| {
785 format!(r#"{{"outcome_evalset": {{"hash": "f", "field": "passed", "higher_is_better": true, "cost": {extra}}}}}"#)
786 };
787 let c = Policy::from_json(&base(r#"{"field": "tokens", "max_increase_ratio": 1.5}"#))
788 .unwrap().outcome_evalset.unwrap().cost.unwrap();
789 assert_eq!((c.field.as_str(), c.max_increase_ratio), ("tokens", 1.5));
790 for bad in [
791 r#"{"field": "tokens"}"#,
792 r#"{"max_increase_ratio": 1.5}"#,
793 r#"{"field": "", "max_increase_ratio": 1.5}"#,
794 r#"{"field": "tokens", "max_increase_ratio": 0}"#,
795 r#"{"field": "tokens", "max_increase_ratio": -1}"#,
796 r#"{"field": "tokens", "max_increase_ratio": "1.5"}"#,
797 r#"{"field": "tokens", "max_increase_ratio": 1.5, "hard": true}"#,
798 ] {
799 assert!(Policy::from_json(&base(bad)).is_err(), "{bad} must be a policy error");
800 }
801 }
802
803 #[test]
804 fn min_effect_is_one_non_negative_number() {
805 let base = |extra: &str| {
806 format!(r#"{{"outcome_evalset": {{"hash": "f", "field": "passed", "higher_is_better": true, "min_effect": {extra}}}}}"#)
807 };
808 let m = Policy::from_json(&base(r#"{"count": 5}"#)).unwrap().outcome_evalset.unwrap().min_effect.unwrap();
809 assert_eq!(m.resolve("passed", 387), 5.0);
810 let m = Policy::from_json(&base(r#"{"points": 1.0}"#)).unwrap().outcome_evalset.unwrap().min_effect.unwrap();
811 assert_eq!(m.resolve("passed", 200), 2.0, "points scale a count field by the run total");
812 assert!((m.resolve("error_rate", 200) - 0.01).abs() < 1e-12, "a ratio field reads points as a fraction");
813 assert!((m.resolve("category_accuracy", 200) - 0.01).abs() < 1e-12, "a host field is assumed a ratio");
814 for bad in [r#"{"count": -1}"#, r#"{"points": "1"}"#, r#"{}"#, r#"{"count": 1, "points": 1}"#, r#"{"count": null}"#, r#"{"width": 2}"#] {
815 assert!(Policy::from_json(&base(bad)).is_err(), "{bad} must be a policy error");
816 }
817 assert!(Policy::from_json(&base("null")).unwrap().outcome_evalset.unwrap().min_effect.is_none());
819 }
820
821 #[test]
822 fn outcome_evalset_parses_with_default_horizons() {
823 let p = Policy::from_json(
824 r#"{"outcome_evalset": {"hash": "abc123", "field": "exact", "higher_is_better": true}}"#,
825 )
826 .unwrap();
827 let e = p.outcome_evalset.expect("parsed");
828 assert_eq!((e.hash.as_str(), e.field.as_str(), e.higher_is_better), ("abc123", "exact", true));
829 assert_eq!(e.horizons_ms, vec![86_400_000, 7 * 86_400_000, 30 * 86_400_000]);
830 assert!(Policy::default().outcome_evalset.is_none());
831 assert!(
832 Policy::from_json(r#"{"outcome_evalset": {"hash": "abc123", "field": "exact"}}"#).is_err(),
833 "the direction is not optional — a guessed one could revert an improvement"
834 );
835 }
836
837 #[test]
838 fn checkpoints_take_the_deployments_unit_and_a_bare_integer_stays_ms() {
839 let p = Policy::from_json(
840 r#"{"outcome_evalset": {"hash": "f", "field": "task_score", "higher_is_better": true,
841 "checkpoints": [{"after_runs": 1}, 3600000, {"after_grains": 50}, {"after_ms": 86400000}]}}"#,
842 )
843 .unwrap();
844 let e = p.outcome_evalset.unwrap();
845 assert_eq!(
846 e.schedule(),
847 vec![
848 Checkpoint::AfterMs(3_600_000),
849 Checkpoint::AfterMs(86_400_000),
850 Checkpoint::AfterRuns(1),
851 Checkpoint::AfterGrains(50),
852 ],
853 "sorted, deduplicated, and the bare integer read as milliseconds"
854 );
855 let p = Policy::from_json(r#"{"outcome_evalset": {"hash": "f", "field": "x", "higher_is_better": true}}"#).unwrap();
858 assert_eq!(
859 p.outcome_evalset.unwrap().schedule(),
860 vec![
861 Checkpoint::AfterMs(86_400_000),
862 Checkpoint::AfterMs(7 * 86_400_000),
863 Checkpoint::AfterMs(30 * 86_400_000)
864 ]
865 );
866 for bad in [
867 r#"[{"after_turns": 3}]"#,
868 r#"[{"after_runs": -1}]"#,
869 r#"["1d"]"#,
870 r#"[{"after_runs": 1, "after_ms": 2}]"#,
871 ] {
872 let js = format!(r#"{{"outcome_evalset": {{"hash": "f", "field": "x", "higher_is_better": true, "checkpoints": {bad}}}}}"#);
873 assert!(Policy::from_json(&js).is_err(), "{bad} must not load");
874 }
875 }
876
877 #[test]
878 fn cadence_defaults_to_always_due_and_parses_every_unit() {
879 let p = Policy::default();
880 assert!(!p.cadence.is_set());
881 let p = Policy::from_json(
882 r#"{"cadence": {"every_ms": 3600000, "every_events": 10, "every_sessions": 1, "every_grains": 50}}"#,
883 )
884 .unwrap();
885 assert!(p.cadence.is_set());
886 assert_eq!(p.cadence.every_events, Some(10));
887 assert!(
888 Policy::from_json(r#"{"cadence": {"every_turns": 10}}"#).is_err(),
889 "an unknown unit must not load as always-due"
890 );
891 assert!(!serde_json::to_string(&Policy::default()).unwrap().contains("cadence"));
893 }
894
895 #[test]
896 fn skills_default_on_with_two_steps_and_min_evidence_defaults_to_one() {
897 let p = Policy::default();
898 assert!(p.skills.enabled);
899 assert_eq!(p.skills.min_steps, 2);
900 assert_eq!(p.min_evidence, 1, "one instance may become a rule — today's behaviour");
901 let p = Policy::from_json(r#"{"skills": {"enabled": false}, "min_evidence": 2}"#).unwrap();
902 assert!(!p.skills.enabled);
903 assert_eq!(p.skills.min_steps, 2, "the unset field keeps its default, not zero");
904 assert_eq!(p.min_evidence, 2);
905 assert!(Policy::from_json(r#"{"skills": {"auto_apply": true}}"#).is_err(), "no back door");
906 let round = Policy::from_json(&serde_json::to_string(&Policy::default()).unwrap()).unwrap();
908 assert_eq!(round.min_evidence, 1);
909 assert!(round.skills.enabled);
910 }
911
912 #[test]
913 fn plans_and_premise_drift_default_on_and_are_switchable() {
914 let p = Policy::default();
915 assert!(p.plans.enabled);
916 assert_eq!(p.plans.min_nodes, 2);
917 assert!(p.premise_drift);
918 let p = Policy::from_json(r#"{"plans": {"enabled": false}, "premise_drift": false}"#).unwrap();
919 assert!(!p.plans.enabled);
920 assert_eq!(p.plans.min_nodes, 2);
921 assert!(!p.premise_drift);
922 assert!(Policy::from_json(r#"{"plans": {"auto_apply": true}}"#).is_err(), "no back door");
923 }
924
925 #[test]
926 fn evidence_attribution_defaults_to_named() {
927 assert_eq!(Policy::default().evidence_attribution, EvidenceAttribution::Named);
928 let p = Policy::from_json(r#"{"evidence_attribution": "anonymous"}"#).unwrap();
929 assert_eq!(p.evidence_attribution, EvidenceAttribution::Anonymous);
930 assert!(
931 Policy::from_json(r#"{"evidence_attribution": "redacted"}"#).is_err(),
932 "an unknown mode must not load as the default"
933 );
934 }
935
936 #[test]
937 fn unknown_keys_rejected() {
938 assert!(Policy::from_json(r#"{"analyzer_cmd": "evil"}"#).is_err());
940 assert!(Policy::from_json(r#"{"auto_apply_free_text": true}"#).is_err());
941 }
942}