use crate::error::{Error, Result};
use crate::model::Severity;
use crate::recommendation::Checkpoint;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TelemetryMode {
Off,
#[default]
Aggregate,
Full,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DiscoverObjective {
#[default]
ReviewQueue,
Learner,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AutoApplyGrant {
pub analyzer: String,
pub targets: Vec<String>,
pub max_severity: Severity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceAttribution {
#[default]
Named,
Anonymous,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OutcomeEvalset {
pub hash: String,
pub field: String,
pub higher_is_better: bool,
#[serde(default = "default_horizons")]
pub horizons_ms: Vec<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub checkpoints: Vec<Checkpoint>,
}
fn default_horizons() -> Vec<i64> {
vec![86_400_000, 7 * 86_400_000, 30 * 86_400_000]
}
impl OutcomeEvalset {
pub fn schedule(&self) -> Vec<Checkpoint> {
let mut h: Vec<Checkpoint> = if self.checkpoints.is_empty() {
self.horizons_ms.iter().map(|ms| Checkpoint::AfterMs(*ms)).collect()
} else {
self.checkpoints.clone()
};
h.sort_unstable();
h.dedup();
h
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Cadence {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub every_ms: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub every_grains: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub every_events: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub every_sessions: Option<u64>,
}
impl Cadence {
pub fn is_set(&self) -> bool {
self.every_ms.is_some()
|| self.every_grains.is_some()
|| self.every_events.is_some()
|| self.every_sessions.is_some()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SkillAuthoring {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_min_steps")]
pub min_steps: u32,
}
fn default_true() -> bool {
true
}
fn default_min_steps() -> u32 {
2
}
fn default_min_evidence() -> u32 {
1
}
impl Default for SkillAuthoring {
fn default() -> Self {
SkillAuthoring { enabled: true, min_steps: 2 }
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PlanAuthoring {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_min_steps")]
pub min_nodes: u32,
}
impl Default for PlanAuthoring {
fn default() -> Self {
PlanAuthoring { enabled: true, min_nodes: 2 }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Policy {
#[serde(default)]
pub auto_apply_enabled: bool,
#[serde(default)]
pub auto_apply: Vec<AutoApplyGrant>,
#[serde(default)]
pub deny: Vec<String>,
#[serde(default)]
pub severity_floors: BTreeMap<String, Severity>,
#[serde(default)]
pub telemetry: TelemetryMode,
#[serde(default)]
pub discover_objective: DiscoverObjective,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outcome_evalset: Option<OutcomeEvalset>,
#[serde(default)]
pub evidence_attribution: EvidenceAttribution,
#[serde(default, skip_serializing_if = "is_default_cadence")]
pub cadence: Cadence,
#[serde(default)]
pub skills: SkillAuthoring,
#[serde(default = "default_min_evidence")]
pub min_evidence: u32,
#[serde(default)]
pub plans: PlanAuthoring,
#[serde(default = "default_true")]
pub premise_drift: bool,
}
fn is_default_cadence(c: &Cadence) -> bool {
!c.is_set()
}
impl Default for Policy {
fn default() -> Self {
Policy {
auto_apply_enabled: false,
auto_apply: Vec::new(),
deny: Vec::new(),
severity_floors: BTreeMap::new(),
telemetry: TelemetryMode::default(),
discover_objective: DiscoverObjective::default(),
outcome_evalset: None,
evidence_attribution: EvidenceAttribution::default(),
cadence: Cadence::default(),
skills: SkillAuthoring::default(),
min_evidence: 1,
plans: PlanAuthoring::default(),
premise_drift: true,
}
}
}
impl Policy {
pub fn from_json(s: &str) -> Result<Self> {
serde_json::from_str(s).map_err(|e| Error::InvalidProposal(format!("policy: {e}")))
}
pub fn denies(&self, family: &str) -> bool {
self.deny.iter().any(|d| crate::manifest::analyzer_family(d) == family)
}
pub fn severity_floor(&self, family: &str) -> Option<Severity> {
self.severity_floors
.iter()
.find(|(k, _)| crate::manifest::analyzer_family(k) == family)
.map(|(_, v)| *v)
}
pub fn grants_auto_apply(&self, family: &str, target_class: &str, severity: Severity) -> bool {
if !self.auto_apply_enabled || target_class != "memory" {
return false;
}
self.auto_apply.iter().any(|g| {
crate::manifest::analyzer_family(&g.analyzer) == family
&& g.targets.iter().any(|t| t == target_class)
&& severity <= g.max_severity
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn code_targets_never_auto_apply_even_when_granted() {
let p = Policy::from_json(
r#"{"auto_apply_enabled": true,
"auto_apply": [{"analyzer": "loop.codegen", "targets": ["code", "evalset", "memory"], "max_severity": "high"}]}"#,
)
.unwrap();
assert!(!p.grants_auto_apply("loop.codegen", "code", Severity::Info));
assert!(!p.grants_auto_apply("loop.codegen", "evalset", Severity::Info));
assert!(
p.grants_auto_apply("loop.codegen", "memory", Severity::Low),
"the same grant's memory leg still works — the exclusion is by class"
);
}
#[test]
fn default_policy_grants_nothing() {
let p = Policy::default();
assert!(!p.grants_auto_apply("loop.duplicate_sweep", "memory", Severity::Info));
assert!(!p.denies("loop.staleness"));
assert_eq!(p.telemetry, TelemetryMode::Aggregate);
}
#[test]
fn parses_and_grants() {
let p = Policy::from_json(
r#"{"auto_apply_enabled": true,
"auto_apply": [{"analyzer": "loop.duplicate_sweep", "targets": ["memory"], "max_severity": "low"}],
"deny": ["loop.staleness"],
"severity_floors": {"loop.contradiction_sweep": "high"}}"#,
)
.unwrap();
assert!(p.grants_auto_apply("loop.duplicate_sweep", "memory", Severity::Low));
assert!(!p.grants_auto_apply("loop.duplicate_sweep", "memory", Severity::High), "above max_severity");
assert!(!p.grants_auto_apply("loop.duplicate_sweep", "query", Severity::Low), "query not granted");
assert!(p.denies("loop.staleness"));
assert_eq!(p.severity_floor("loop.contradiction_sweep"), Some(Severity::High));
}
#[test]
fn prompt_and_host_targets_never_granted() {
let p = Policy::from_json(
r#"{"auto_apply_enabled": true,
"auto_apply": [{"analyzer": "x", "targets": ["prompt", "host"], "max_severity": "high"}]}"#,
)
.unwrap();
assert!(!p.grants_auto_apply("x", "prompt", Severity::Info));
assert!(!p.grants_auto_apply("x", "host", Severity::Info));
}
#[test]
fn discover_objective_defaults_to_the_review_queue_rule() {
assert_eq!(Policy::default().discover_objective, DiscoverObjective::ReviewQueue);
let p = Policy::from_json(r#"{"discover_objective": "learner"}"#).unwrap();
assert_eq!(p.discover_objective, DiscoverObjective::Learner);
assert!(
Policy::from_json(r#"{"discover_objective": "eager"}"#).is_err(),
"an unknown objective must not load as the default"
);
}
#[test]
fn outcome_evalset_parses_with_default_horizons() {
let p = Policy::from_json(
r#"{"outcome_evalset": {"hash": "abc123", "field": "exact", "higher_is_better": true}}"#,
)
.unwrap();
let e = p.outcome_evalset.expect("parsed");
assert_eq!((e.hash.as_str(), e.field.as_str(), e.higher_is_better), ("abc123", "exact", true));
assert_eq!(e.horizons_ms, vec![86_400_000, 7 * 86_400_000, 30 * 86_400_000]);
assert!(Policy::default().outcome_evalset.is_none());
assert!(
Policy::from_json(r#"{"outcome_evalset": {"hash": "abc123", "field": "exact"}}"#).is_err(),
"the direction is not optional — a guessed one could revert an improvement"
);
}
#[test]
fn checkpoints_take_the_deployments_unit_and_a_bare_integer_stays_ms() {
let p = Policy::from_json(
r#"{"outcome_evalset": {"hash": "f", "field": "task_score", "higher_is_better": true,
"checkpoints": [{"after_runs": 1}, 3600000, {"after_grains": 50}, {"after_ms": 86400000}]}}"#,
)
.unwrap();
let e = p.outcome_evalset.unwrap();
assert_eq!(
e.schedule(),
vec![
Checkpoint::AfterMs(3_600_000),
Checkpoint::AfterMs(86_400_000),
Checkpoint::AfterRuns(1),
Checkpoint::AfterGrains(50),
],
"sorted, deduplicated, and the bare integer read as milliseconds"
);
let p = Policy::from_json(r#"{"outcome_evalset": {"hash": "f", "field": "x", "higher_is_better": true}}"#).unwrap();
assert_eq!(
p.outcome_evalset.unwrap().schedule(),
vec![
Checkpoint::AfterMs(86_400_000),
Checkpoint::AfterMs(7 * 86_400_000),
Checkpoint::AfterMs(30 * 86_400_000)
]
);
for bad in [
r#"[{"after_turns": 3}]"#,
r#"[{"after_runs": -1}]"#,
r#"["1d"]"#,
r#"[{"after_runs": 1, "after_ms": 2}]"#,
] {
let js = format!(r#"{{"outcome_evalset": {{"hash": "f", "field": "x", "higher_is_better": true, "checkpoints": {bad}}}}}"#);
assert!(Policy::from_json(&js).is_err(), "{bad} must not load");
}
}
#[test]
fn cadence_defaults_to_always_due_and_parses_every_unit() {
let p = Policy::default();
assert!(!p.cadence.is_set());
let p = Policy::from_json(
r#"{"cadence": {"every_ms": 3600000, "every_events": 10, "every_sessions": 1, "every_grains": 50}}"#,
)
.unwrap();
assert!(p.cadence.is_set());
assert_eq!(p.cadence.every_events, Some(10));
assert!(
Policy::from_json(r#"{"cadence": {"every_turns": 10}}"#).is_err(),
"an unknown unit must not load as always-due"
);
assert!(!serde_json::to_string(&Policy::default()).unwrap().contains("cadence"));
}
#[test]
fn skills_default_on_with_two_steps_and_min_evidence_defaults_to_one() {
let p = Policy::default();
assert!(p.skills.enabled);
assert_eq!(p.skills.min_steps, 2);
assert_eq!(p.min_evidence, 1, "one instance may become a rule — today's behaviour");
let p = Policy::from_json(r#"{"skills": {"enabled": false}, "min_evidence": 2}"#).unwrap();
assert!(!p.skills.enabled);
assert_eq!(p.skills.min_steps, 2, "the unset field keeps its default, not zero");
assert_eq!(p.min_evidence, 2);
assert!(Policy::from_json(r#"{"skills": {"auto_apply": true}}"#).is_err(), "no back door");
let round = Policy::from_json(&serde_json::to_string(&Policy::default()).unwrap()).unwrap();
assert_eq!(round.min_evidence, 1);
assert!(round.skills.enabled);
}
#[test]
fn plans_and_premise_drift_default_on_and_are_switchable() {
let p = Policy::default();
assert!(p.plans.enabled);
assert_eq!(p.plans.min_nodes, 2);
assert!(p.premise_drift);
let p = Policy::from_json(r#"{"plans": {"enabled": false}, "premise_drift": false}"#).unwrap();
assert!(!p.plans.enabled);
assert_eq!(p.plans.min_nodes, 2);
assert!(!p.premise_drift);
assert!(Policy::from_json(r#"{"plans": {"auto_apply": true}}"#).is_err(), "no back door");
}
#[test]
fn evidence_attribution_defaults_to_named() {
assert_eq!(Policy::default().evidence_attribution, EvidenceAttribution::Named);
let p = Policy::from_json(r#"{"evidence_attribution": "anonymous"}"#).unwrap();
assert_eq!(p.evidence_attribution, EvidenceAttribution::Anonymous);
assert!(
Policy::from_json(r#"{"evidence_attribution": "redacted"}"#).is_err(),
"an unknown mode must not load as the default"
);
}
#[test]
fn unknown_keys_rejected() {
assert!(Policy::from_json(r#"{"analyzer_cmd": "evil"}"#).is_err());
assert!(Policy::from_json(r#"{"auto_apply_free_text": true}"#).is_err());
}
}