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
339fn 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, skip_serializing_if = "is_default_near_duplicate")]
561 pub near_duplicate: NearDuplicateMode,
562 #[serde(default, skip_serializing_if = "Option::is_none")]
564 pub plan_replay: Option<PlanReplayPolicy>,
565}
566
567fn is_default_near_duplicate(m: &NearDuplicateMode) -> bool {
568 *m == NearDuplicateMode::default()
569}
570
571fn is_default_cadence(c: &Cadence) -> bool {
572 !c.is_set()
573}
574
575impl Default for Policy {
576 fn default() -> Self {
577 Policy {
578 auto_apply_enabled: false,
579 auto_apply: Vec::new(),
580 deny: Vec::new(),
581 severity_floors: BTreeMap::new(),
582 telemetry: TelemetryMode::default(),
583 discover_objective: DiscoverObjective::default(),
584 outcome_evalset: None,
585 evidence_attribution: EvidenceAttribution::default(),
586 cadence: Cadence::default(),
587 skills: SkillAuthoring::default(),
588 min_evidence: 1,
589 plans: PlanAuthoring::default(),
590 premise_drift: true,
591 near_duplicate: NearDuplicateMode::default(),
592 plan_replay: None,
593 }
594 }
595}
596
597impl Policy {
598 pub fn from_json(s: &str) -> Result<Self> {
600 let p: Policy =
601 serde_json::from_str(s).map_err(|e| Error::InvalidProposal(format!("policy: {e}")))?;
602 if let Some(m) = p.outcome_evalset.as_ref().and_then(|e| e.min_effect.as_ref()) {
603 m.validate()?;
604 }
605 if let Some(c) = p.outcome_evalset.as_ref().and_then(|e| e.cost.as_ref()) {
606 c.validate()?;
607 }
608 if let Some(r) = p.plan_replay.as_ref() {
609 r.validate()?;
610 }
611 Ok(p)
612 }
613
614 pub fn denies(&self, family: &str) -> bool {
616 self.deny.iter().any(|d| crate::manifest::analyzer_family(d) == family)
617 }
618
619 pub fn severity_floor(&self, family: &str) -> Option<Severity> {
621 self.severity_floors
622 .iter()
623 .find(|(k, _)| crate::manifest::analyzer_family(k) == family)
624 .map(|(_, v)| *v)
625 }
626
627 pub fn grants_auto_apply(&self, family: &str, target_class: &str, severity: Severity) -> bool {
637 if !self.auto_apply_enabled || target_class != "memory" {
638 return false;
639 }
640 self.auto_apply.iter().any(|g| {
641 crate::manifest::analyzer_family(&g.analyzer) == family
642 && g.targets.iter().any(|t| t == target_class)
643 && severity <= g.max_severity
644 })
645 }
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651
652 #[test]
657 fn code_targets_never_auto_apply_even_when_granted() {
658 let p = Policy::from_json(
659 r#"{"auto_apply_enabled": true,
660 "auto_apply": [{"analyzer": "loop.codegen", "targets": ["code", "evalset", "memory"], "max_severity": "high"}]}"#,
661 )
662 .unwrap();
663 assert!(!p.grants_auto_apply("loop.codegen", "code", Severity::Info));
664 assert!(!p.grants_auto_apply("loop.codegen", "evalset", Severity::Info));
665 assert!(
666 p.grants_auto_apply("loop.codegen", "memory", Severity::Low),
667 "the same grant's memory leg still works — the exclusion is by class"
668 );
669 }
670
671 #[test]
672 fn default_policy_grants_nothing() {
673 let p = Policy::default();
674 assert!(!p.grants_auto_apply("loop.duplicate_sweep", "memory", Severity::Info));
675 assert!(!p.denies("loop.staleness"));
676 assert_eq!(p.telemetry, TelemetryMode::Aggregate);
677 }
678
679 #[test]
680 fn parses_and_grants() {
681 let p = Policy::from_json(
682 r#"{"auto_apply_enabled": true,
683 "auto_apply": [{"analyzer": "loop.duplicate_sweep", "targets": ["memory"], "max_severity": "low"}],
684 "deny": ["loop.staleness"],
685 "severity_floors": {"loop.contradiction_sweep": "high"}}"#,
686 )
687 .unwrap();
688 assert!(p.grants_auto_apply("loop.duplicate_sweep", "memory", Severity::Low));
689 assert!(!p.grants_auto_apply("loop.duplicate_sweep", "memory", Severity::High), "above max_severity");
690 assert!(!p.grants_auto_apply("loop.duplicate_sweep", "query", Severity::Low), "query not granted");
691 assert!(p.denies("loop.staleness"));
692 assert_eq!(p.severity_floor("loop.contradiction_sweep"), Some(Severity::High));
693 }
694
695 #[test]
696 fn prompt_and_host_targets_never_granted() {
697 let p = Policy::from_json(
698 r#"{"auto_apply_enabled": true,
699 "auto_apply": [{"analyzer": "x", "targets": ["prompt", "host"], "max_severity": "high"}]}"#,
700 )
701 .unwrap();
702 assert!(!p.grants_auto_apply("x", "prompt", Severity::Info));
703 assert!(!p.grants_auto_apply("x", "host", Severity::Info));
704 }
705
706 #[test]
707 fn discover_objective_defaults_to_the_review_queue_rule() {
708 assert_eq!(Policy::default().discover_objective, DiscoverObjective::ReviewQueue);
709 let p = Policy::from_json(r#"{"discover_objective": "learner"}"#).unwrap();
710 assert_eq!(p.discover_objective, DiscoverObjective::Learner);
711 assert!(
712 Policy::from_json(r#"{"discover_objective": "eager"}"#).is_err(),
713 "an unknown objective must not load as the default"
714 );
715 }
716
717 #[test]
718 fn outcome_evalset_baseline_is_a_named_choice() {
719 let p = Policy::from_json(
720 r#"{"outcome_evalset": {"hash": "f", "field": "passed", "higher_is_better": true, "baseline": "high_water"}}"#,
721 )
722 .unwrap();
723 assert_eq!(p.outcome_evalset.unwrap().baseline, BaselineKind::HighWater);
724 let p = Policy::from_json(r#"{"outcome_evalset": {"hash": "f", "field": "passed", "higher_is_better": true}}"#).unwrap();
726 assert_eq!(p.outcome_evalset.unwrap().baseline, BaselineKind::NewestBeforeApply);
727 let err = Policy::from_json(
730 r#"{"outcome_evalset": {"hash": "f", "field": "passed", "higher_is_better": true, "baseline": "best_ever"}}"#,
731 )
732 .expect_err("unknown baseline kind");
733 let msg = err.to_string();
734 assert!(msg.contains("newest_before_apply") && msg.contains("high_water"), "{msg}");
735 }
736
737 #[test]
738 fn plan_replay_gate_reads_the_report_the_way_the_ticket_says() {
739 let p = Policy::from_json(r#"{"plan_replay": {"min_runs": 3, "require_no_worse": true}}"#).unwrap();
740 let g = p.plan_replay.unwrap();
741 assert_eq!((g.min_runs, g.require_no_worse, g.max_out_of_support), (3, true, 0.5));
742 let report = |runs: u64, worse: u64, oos: u64, no_worse: bool| {
743 let rows: Vec<serde_json::Value> = (0..runs)
744 .map(|i| {
745 let verdict = if i < worse { "worse" } else if i < worse + oos { "out_of_support" } else { "same" };
746 serde_json::json!({"run_id": format!("r{i}"), "verdict": verdict, "incumbent_outcome": "completed", "candidate_outcome": if verdict == "worse" { "failed" } else { "completed" }})
747 })
748 .collect();
749 serde_json::json!({"totals": {"runs": runs, "out_of_support": oos}, "no_worse": no_worse,
750 "out_of_support_fraction": oos as f64 / runs.max(1) as f64, "runs": rows})
751 };
752 assert_eq!(g.refusal(&report(2, 2, 0, false)), None, "under min_runs the gate abstains");
753 assert_eq!(g.refusal(&report(3, 0, 0, true)), None);
754 let why = g.refusal(&report(3, 1, 0, false)).expect("worse is refused");
755 assert!(why.contains("r0") && why.contains("completed → failed"), "{why}");
756 let why = g.refusal(&report(4, 0, 3, false)).expect("out of support beyond the limit is refused");
757 assert!(why.contains("75%") && why.contains("r0, r1, r2"), "{why}");
758 assert!(Policy::from_json(r#"{"plan_replay": {"max_out_of_support": 1.5}}"#).is_err());
759 assert!(Policy::from_json(r#"{"plan_replay": {"min_runs": 3, "strict": true}}"#).is_err());
760 }
761
762 #[test]
763 fn near_duplicate_mode_parses_and_rejects_unknown() {
764 assert_eq!(Policy::default().near_duplicate, NearDuplicateMode::Flag);
765 let p = Policy::from_json(r#"{"near_duplicate": "suppress"}"#).unwrap();
766 assert_eq!(p.near_duplicate, NearDuplicateMode::Suppress);
767 let err = Policy::from_json(r#"{"near_duplicate": "drop"}"#).expect_err("unknown mode");
768 let msg = err.to_string();
769 assert!(msg.contains("flag") && msg.contains("suppress"), "{msg}");
770 }
771
772 #[test]
773 fn cost_bound_needs_a_field_and_a_positive_ratio() {
774 let base = |extra: &str| {
775 format!(r#"{{"outcome_evalset": {{"hash": "f", "field": "passed", "higher_is_better": true, "cost": {extra}}}}}"#)
776 };
777 let c = Policy::from_json(&base(r#"{"field": "tokens", "max_increase_ratio": 1.5}"#))
778 .unwrap().outcome_evalset.unwrap().cost.unwrap();
779 assert_eq!((c.field.as_str(), c.max_increase_ratio), ("tokens", 1.5));
780 for bad in [
781 r#"{"field": "tokens"}"#,
782 r#"{"max_increase_ratio": 1.5}"#,
783 r#"{"field": "", "max_increase_ratio": 1.5}"#,
784 r#"{"field": "tokens", "max_increase_ratio": 0}"#,
785 r#"{"field": "tokens", "max_increase_ratio": -1}"#,
786 r#"{"field": "tokens", "max_increase_ratio": "1.5"}"#,
787 r#"{"field": "tokens", "max_increase_ratio": 1.5, "hard": true}"#,
788 ] {
789 assert!(Policy::from_json(&base(bad)).is_err(), "{bad} must be a policy error");
790 }
791 }
792
793 #[test]
794 fn min_effect_is_one_non_negative_number() {
795 let base = |extra: &str| {
796 format!(r#"{{"outcome_evalset": {{"hash": "f", "field": "passed", "higher_is_better": true, "min_effect": {extra}}}}}"#)
797 };
798 let m = Policy::from_json(&base(r#"{"count": 5}"#)).unwrap().outcome_evalset.unwrap().min_effect.unwrap();
799 assert_eq!(m.resolve("passed", 387), 5.0);
800 let m = Policy::from_json(&base(r#"{"points": 1.0}"#)).unwrap().outcome_evalset.unwrap().min_effect.unwrap();
801 assert_eq!(m.resolve("passed", 200), 2.0, "points scale a count field by the run total");
802 assert!((m.resolve("error_rate", 200) - 0.01).abs() < 1e-12, "a ratio field reads points as a fraction");
803 assert!((m.resolve("category_accuracy", 200) - 0.01).abs() < 1e-12, "a host field is assumed a ratio");
804 for bad in [r#"{"count": -1}"#, r#"{"points": "1"}"#, r#"{}"#, r#"{"count": 1, "points": 1}"#, r#"{"count": null}"#, r#"{"width": 2}"#] {
805 assert!(Policy::from_json(&base(bad)).is_err(), "{bad} must be a policy error");
806 }
807 assert!(Policy::from_json(&base("null")).unwrap().outcome_evalset.unwrap().min_effect.is_none());
809 }
810
811 #[test]
812 fn outcome_evalset_parses_with_default_horizons() {
813 let p = Policy::from_json(
814 r#"{"outcome_evalset": {"hash": "abc123", "field": "exact", "higher_is_better": true}}"#,
815 )
816 .unwrap();
817 let e = p.outcome_evalset.expect("parsed");
818 assert_eq!((e.hash.as_str(), e.field.as_str(), e.higher_is_better), ("abc123", "exact", true));
819 assert_eq!(e.horizons_ms, vec![86_400_000, 7 * 86_400_000, 30 * 86_400_000]);
820 assert!(Policy::default().outcome_evalset.is_none());
821 assert!(
822 Policy::from_json(r#"{"outcome_evalset": {"hash": "abc123", "field": "exact"}}"#).is_err(),
823 "the direction is not optional — a guessed one could revert an improvement"
824 );
825 }
826
827 #[test]
828 fn checkpoints_take_the_deployments_unit_and_a_bare_integer_stays_ms() {
829 let p = Policy::from_json(
830 r#"{"outcome_evalset": {"hash": "f", "field": "task_score", "higher_is_better": true,
831 "checkpoints": [{"after_runs": 1}, 3600000, {"after_grains": 50}, {"after_ms": 86400000}]}}"#,
832 )
833 .unwrap();
834 let e = p.outcome_evalset.unwrap();
835 assert_eq!(
836 e.schedule(),
837 vec![
838 Checkpoint::AfterMs(3_600_000),
839 Checkpoint::AfterMs(86_400_000),
840 Checkpoint::AfterRuns(1),
841 Checkpoint::AfterGrains(50),
842 ],
843 "sorted, deduplicated, and the bare integer read as milliseconds"
844 );
845 let p = Policy::from_json(r#"{"outcome_evalset": {"hash": "f", "field": "x", "higher_is_better": true}}"#).unwrap();
848 assert_eq!(
849 p.outcome_evalset.unwrap().schedule(),
850 vec![
851 Checkpoint::AfterMs(86_400_000),
852 Checkpoint::AfterMs(7 * 86_400_000),
853 Checkpoint::AfterMs(30 * 86_400_000)
854 ]
855 );
856 for bad in [
857 r#"[{"after_turns": 3}]"#,
858 r#"[{"after_runs": -1}]"#,
859 r#"["1d"]"#,
860 r#"[{"after_runs": 1, "after_ms": 2}]"#,
861 ] {
862 let js = format!(r#"{{"outcome_evalset": {{"hash": "f", "field": "x", "higher_is_better": true, "checkpoints": {bad}}}}}"#);
863 assert!(Policy::from_json(&js).is_err(), "{bad} must not load");
864 }
865 }
866
867 #[test]
868 fn cadence_defaults_to_always_due_and_parses_every_unit() {
869 let p = Policy::default();
870 assert!(!p.cadence.is_set());
871 let p = Policy::from_json(
872 r#"{"cadence": {"every_ms": 3600000, "every_events": 10, "every_sessions": 1, "every_grains": 50}}"#,
873 )
874 .unwrap();
875 assert!(p.cadence.is_set());
876 assert_eq!(p.cadence.every_events, Some(10));
877 assert!(
878 Policy::from_json(r#"{"cadence": {"every_turns": 10}}"#).is_err(),
879 "an unknown unit must not load as always-due"
880 );
881 assert!(!serde_json::to_string(&Policy::default()).unwrap().contains("cadence"));
883 }
884
885 #[test]
886 fn skills_default_on_with_two_steps_and_min_evidence_defaults_to_one() {
887 let p = Policy::default();
888 assert!(p.skills.enabled);
889 assert_eq!(p.skills.min_steps, 2);
890 assert_eq!(p.min_evidence, 1, "one instance may become a rule — today's behaviour");
891 let p = Policy::from_json(r#"{"skills": {"enabled": false}, "min_evidence": 2}"#).unwrap();
892 assert!(!p.skills.enabled);
893 assert_eq!(p.skills.min_steps, 2, "the unset field keeps its default, not zero");
894 assert_eq!(p.min_evidence, 2);
895 assert!(Policy::from_json(r#"{"skills": {"auto_apply": true}}"#).is_err(), "no back door");
896 let round = Policy::from_json(&serde_json::to_string(&Policy::default()).unwrap()).unwrap();
898 assert_eq!(round.min_evidence, 1);
899 assert!(round.skills.enabled);
900 }
901
902 #[test]
903 fn plans_and_premise_drift_default_on_and_are_switchable() {
904 let p = Policy::default();
905 assert!(p.plans.enabled);
906 assert_eq!(p.plans.min_nodes, 2);
907 assert!(p.premise_drift);
908 let p = Policy::from_json(r#"{"plans": {"enabled": false}, "premise_drift": false}"#).unwrap();
909 assert!(!p.plans.enabled);
910 assert_eq!(p.plans.min_nodes, 2);
911 assert!(!p.premise_drift);
912 assert!(Policy::from_json(r#"{"plans": {"auto_apply": true}}"#).is_err(), "no back door");
913 }
914
915 #[test]
916 fn evidence_attribution_defaults_to_named() {
917 assert_eq!(Policy::default().evidence_attribution, EvidenceAttribution::Named);
918 let p = Policy::from_json(r#"{"evidence_attribution": "anonymous"}"#).unwrap();
919 assert_eq!(p.evidence_attribution, EvidenceAttribution::Anonymous);
920 assert!(
921 Policy::from_json(r#"{"evidence_attribution": "redacted"}"#).is_err(),
922 "an unknown mode must not load as the default"
923 );
924 }
925
926 #[test]
927 fn unknown_keys_rejected() {
928 assert!(Policy::from_json(r#"{"analyzer_cmd": "evil"}"#).is_err());
930 assert!(Policy::from_json(r#"{"auto_apply_free_text": true}"#).is_err());
931 }
932}