use crate::error::{Error, Result};
use crate::model::{normalize_ident, ActionKind, Origin, Severity};
use crate::substrate::GrainSpec;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Summary {
pub template_id: String,
#[serde(default)]
pub args: Map<String, Value>,
}
impl Summary {
pub fn new(template_id: impl Into<String>, args: Map<String, Value>) -> Self {
Summary {
template_id: template_id.into(),
args,
}
}
pub fn render(&self) -> String {
let t = builtin_template(&self.template_id).unwrap_or("{template_id}: {summary}");
interpolate(t, &self.args, &self.template_id)
}
}
fn builtin_template(id: &str) -> Option<&'static str> {
Some(match id {
"duplicate.exact" => "Consolidate {count} exact-duplicate grains for \"{subject}\"",
"duplicate.near" => {
"Consolidate {count} near-duplicate observations (similarity ≥ {threshold})"
}
"contradiction.functional" => {
"\"{subject}\" holds {count} live values for functional relation \"{relation}\""
}
"tool_failure.cluster" => {
"Tool \"{tool}\" failed {count} times ({rate}% of the calls that could \
fail this way): {signature}"
}
"staleness.expired" => "Expire \"{subject}\": past its declared valid_to ({age_days}d ago)",
"fork.multi_head" => "Entity \"{entity}\" has {count} competing heads",
"skill.stall" => {
"Skill \"{skill}\" isn't improving: practiced {practice_count}× but proficiency is still {proficiency}"
}
"goal.stagnation" => "Goal \"{goal}\" is stalled: active {age_days}d with {progress} progress",
"cold.grain" => {
"Cold memory: \"{subject}\" ({age_days}d old) has never been recalled — retire candidate"
}
"coverage.gap" => {
"Recurring question with no matching memory: \"{query}\" asked {count}× ({empty_rate}% empty)"
}
"budget.pressure" => {
"Assembly budget overflowed on {overflow_rate}% of {samples} recalls — raise the budget or curate memory"
}
"retention.overdue" => {
"Retention: {count} grain(s) in \"{namespace}\" exceed the {max_age_days}d policy (oldest {oldest_days}d); {remaining} more await a later pass"
}
"outcome.regression" => {
"Applied recommendation regressed: {metric} moved {baseline} → {current}"
}
"outcome.premise_drift" => {
"{current} of the grains this recommendation cited have since been superseded by a different value or retracted — its premise moved; revert it"
}
"run.failures" => {
"Workflow {workflow} failed {failed}/{runs} recent runs ({rate}%): {last_error}"
}
"run.cost" => {
"Workflow {workflow} spent ${usd} across {runs} runs (avg ${avg_usd}/run)"
}
"adapter.candidate" => {
"Promote adapter for \"{model}\" (base {base_model}) — gated on its pinned evalset"
}
"llm.discover" => "{text}",
"llm.lesson" => "{text} — record lesson: \"{lesson}\"",
"llm.fact" => "{text} — record fact: {relation} = \"{object}\"",
"llm.skill" => "{text} — record skill: \"{name}\" ({steps} steps)",
"llm.plan" => "{text} — record plan: \"{name}\" ({nodes} steps, {edges} edges)",
"llm.query_revision" => "{text} — redefine \"{name}\" as: {body}",
"llm.plan_revision" => "{text} — revise plan {plan}: {edits}",
"llm.code_revision" => {
"{text} — new source for tool \"{tool}\" ({bytes} bytes), pending its evalset gate"
}
"command.finding" => "{text}",
_ => return None,
})
}
fn interpolate(template: &str, args: &Map<String, Value>, template_id: &str) -> String {
let mut out = String::with_capacity(template.len());
let mut chars = template.chars().peekable();
while let Some(c) = chars.next() {
if c == '{' {
let mut key = String::new();
for k in chars.by_ref() {
if k == '}' {
break;
}
key.push(k);
}
if key == "template_id" {
out.push_str(template_id);
} else {
match args.get(&key) {
Some(Value::String(s)) => out.push_str(s),
Some(v) => out.push_str(&v.to_string()),
None => {
out.push('{');
out.push_str(&key);
out.push('}');
}
}
}
} else {
out.push(c);
}
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Checkpoint {
AfterMs(i64),
AfterRuns(u32),
AfterGrains(u32),
}
impl Checkpoint {
pub fn label(&self) -> String {
match self {
Checkpoint::AfterMs(ms) if ms % 86_400_000 == 0 => format!("{}d", ms / 86_400_000),
Checkpoint::AfterMs(ms) if ms % 3_600_000 == 0 => format!("{}h", ms / 3_600_000),
Checkpoint::AfterMs(ms) => format!("{ms}ms"),
Checkpoint::AfterRuns(n) => format!("{n} run{}", if *n == 1 { "" } else { "s" }),
Checkpoint::AfterGrains(n) => format!("{n} grain{}", if *n == 1 { "" } else { "s" }),
}
}
pub fn as_ms(&self) -> Option<i64> {
match self {
Checkpoint::AfterMs(ms) => Some(*ms),
_ => None,
}
}
}
impl Serialize for Checkpoint {
fn serialize<S: serde::Serializer>(&self, ser: S) -> std::result::Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
match self {
Checkpoint::AfterMs(ms) => ser.serialize_i64(*ms),
Checkpoint::AfterRuns(n) => {
let mut m = ser.serialize_map(Some(1))?;
m.serialize_entry("after_runs", n)?;
m.end()
}
Checkpoint::AfterGrains(n) => {
let mut m = ser.serialize_map(Some(1))?;
m.serialize_entry("after_grains", n)?;
m.end()
}
}
}
}
impl<'de> Deserialize<'de> for Checkpoint {
fn deserialize<D: serde::Deserializer<'de>>(de: D) -> std::result::Result<Self, D::Error> {
use serde::de::Error as _;
let v = Value::deserialize(de)?;
match &v {
Value::Number(n) => n
.as_i64()
.filter(|ms| *ms >= 0)
.map(Checkpoint::AfterMs)
.ok_or_else(|| D::Error::custom("checkpoint: a bare number is non-negative milliseconds")),
Value::Object(m) if m.len() == 1 => {
let (k, val) = m.iter().next().unwrap();
let n = val.as_u64().ok_or_else(|| {
D::Error::custom(format!("checkpoint: {k} takes a non-negative integer"))
})?;
match k.as_str() {
"after_ms" => i64::try_from(n)
.map(Checkpoint::AfterMs)
.map_err(|_| D::Error::custom("checkpoint: after_ms out of range")),
"after_runs" => u32::try_from(n)
.map(Checkpoint::AfterRuns)
.map_err(|_| D::Error::custom("checkpoint: after_runs out of range")),
"after_grains" => u32::try_from(n)
.map(Checkpoint::AfterGrains)
.map_err(|_| D::Error::custom("checkpoint: after_grains out of range")),
other => Err(D::Error::custom(format!(
"checkpoint: unknown unit {other:?} (expected after_ms, after_runs or after_grains)"
))),
}
}
_ => Err(D::Error::custom(
"checkpoint: expected milliseconds or {\"after_ms\"|\"after_runs\"|\"after_grains\": n}",
)),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MetricSnapshot {
pub metric: String,
pub baseline: f64,
pub unit: String,
pub n: u64,
pub window: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relation: Option<String>,
pub query: String,
pub review_after_ms: i64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub horizons_ms: Vec<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub checkpoints: Vec<Checkpoint>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub higher_is_better: bool,
}
pub fn is_regression(baseline: f64, current: f64, higher_is_better: bool) -> bool {
const EPSILON: f64 = 1e-9;
if higher_is_better {
current < baseline - EPSILON
} else {
current > baseline + EPSILON
}
}
impl MetricSnapshot {
pub fn schedule(&self) -> Vec<Checkpoint> {
let mut h: Vec<Checkpoint> = if !self.checkpoints.is_empty() {
self.checkpoints.clone()
} else if !self.horizons_ms.is_empty() {
self.horizons_ms.iter().map(|ms| Checkpoint::AfterMs(*ms)).collect()
} else {
vec![Checkpoint::AfterMs(self.review_after_ms)]
};
h.sort_unstable();
h.dedup();
h
}
pub fn horizons(&self) -> Vec<i64> {
self.schedule().iter().filter_map(Checkpoint::as_ms).collect()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OutcomeResult {
pub rec_hash: String,
pub metric: String,
pub baseline: f64,
pub current: f64,
pub verdict: String,
#[serde(default)]
pub horizon_ms: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checkpoint: Option<Checkpoint>,
pub measured_at_ms: i64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "proposal", rename_all = "snake_case")]
pub enum Proposal {
Cal { cal: String },
Edit {
format: String,
base_digest: String,
diff: String,
},
Data { data: Map<String, Value> },
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct RecDraft {
pub target_ref: String,
pub action_kind: ActionKind,
pub summary: Summary,
pub severity: Severity,
pub proposal: Proposal,
pub evidence: Vec<String>,
pub evidence_query: Option<String>,
pub metric: Option<MetricSnapshot>,
pub confidence: f64,
pub importance: f64,
pub evalset_hash: Option<String>,
}
impl RecDraft {
pub fn new(
target_ref: impl Into<String>,
action_kind: ActionKind,
summary: Summary,
proposal: Proposal,
) -> Self {
RecDraft {
target_ref: target_ref.into(),
action_kind,
summary,
severity: Severity::Low,
proposal,
evidence: Vec::new(),
evidence_query: None,
metric: None,
confidence: 0.8,
importance: 0.5,
evalset_hash: None,
}
}
pub fn severity(mut self, s: Severity) -> Self {
self.severity = s;
self
}
pub fn evidence(mut self, hashes: Vec<String>) -> Self {
self.evidence = hashes;
self
}
pub fn evidence_query(mut self, q: impl Into<String>) -> Self {
self.evidence_query = Some(q.into());
self
}
pub fn metric(mut self, m: MetricSnapshot) -> Self {
self.metric = Some(m);
self
}
pub fn confidence(mut self, c: f64) -> Self {
self.confidence = c;
self
}
pub fn importance(mut self, i: f64) -> Self {
self.importance = i;
self
}
pub fn evalset_hash(mut self, h: impl Into<String>) -> Self {
self.evalset_hash = Some(h.into());
self
}
}
pub const MAX_EVIDENCE: usize = 64;
pub fn dedup_key(family: &str, target_ref: &str, action: ActionKind) -> String {
format!(
"{}\u{1f}{}\u{1f}{}",
normalize_ident(family),
normalize_ident(target_ref),
action.as_str()
)
}
pub fn authored_dedup_key(
family: &str,
target_ref: &str,
action: ActionKind,
content: &str,
) -> String {
format!(
"{}\u{1f}{}",
dedup_key(family, target_ref, action),
content_fingerprint(content)
)
}
pub fn revert_dedup_key(family: &str, target_ref: &str, revert_of: &str) -> String {
format!(
"{}\u{1f}{}",
dedup_key(family, target_ref, ActionKind::Revert),
normalize_ident(revert_of)
)
}
pub fn content_fingerprint(content: &str) -> String {
let mut normalized = String::with_capacity(content.len());
let mut pending_space = false;
for c in content.chars() {
if c.is_alphanumeric() {
if pending_space && !normalized.is_empty() {
normalized.push(' ');
}
pending_space = false;
normalized.extend(c.to_lowercase());
} else if c.is_whitespace() || !c.is_alphanumeric() {
pending_space = true;
}
}
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in normalized.as_bytes() {
h ^= u64::from(*b);
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{h:016x}")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RecStatus {
#[default]
Pending,
Approved,
Rejected,
Applied,
RolledBack,
Expired,
}
impl RecStatus {
pub fn as_str(&self) -> &'static str {
match self {
RecStatus::Pending => "pending",
RecStatus::Approved => "approved",
RecStatus::Rejected => "rejected",
RecStatus::Applied => "applied",
RecStatus::RolledBack => "rolled_back",
RecStatus::Expired => "expired",
}
}
pub fn can_transition_to(&self, to: RecStatus, by_policy: bool) -> bool {
use RecStatus::*;
match (self, to) {
(Pending, Approved) | (Pending, Rejected) => true,
(Pending, Applied) => by_policy, (Approved, Applied) => true,
(Applied, RolledBack) => true,
(Pending, Expired) | (Approved, Expired) => true,
_ => false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ObserverType {
Human,
Agent,
Policy,
System,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AuditRecord {
pub rec_hash: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub from: Option<RecStatus>,
pub to: RecStatus,
pub actor: String,
pub observer_type: ObserverType,
pub because: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_audit_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gating: Option<GatingEvidence>,
pub at_ms: i64,
}
pub const MAX_BECAUSE: usize = 500;
impl AuditRecord {
pub fn to_grain_spec(&self, namespace: &str) -> GrainSpec {
let mut derived_from = vec![Value::from(self.rec_hash.clone())];
if let Some(prev) = &self.previous_audit_hash {
derived_from.push(Value::from(prev.clone()));
}
let mut spec = GrainSpec::new(crate::model::grain_type::OBSERVATION, namespace)
.with_field("observation_kind", "loop_audit")
.with_field("rec_hash", self.rec_hash.clone())
.with_field("to_status", self.to.as_str())
.with_field("actor", self.actor.clone())
.with_field(
"observer_type",
serde_json::to_value(self.observer_type).unwrap(),
)
.with_field("because", self.because.clone())
.with_field("at_ms", self.at_ms)
.with_field("derived_from", Value::Array(derived_from));
if let Some(from) = self.from {
spec.fields
.insert("from_status".into(), Value::from(from.as_str()));
}
if let Some(g) = &self.gating {
spec.fields
.insert("gating_evalset".into(), Value::from(g.evalset_hash.clone()));
spec.fields
.insert("gating_run_id".into(), Value::from(g.run_id.clone()));
spec.fields.insert("gating_passed".into(), Value::from(g.passed));
spec.fields.insert("gating_failed".into(), Value::from(g.failed));
}
spec
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Recommendation {
#[serde(skip)]
pub hash: String,
pub analyzer: String,
pub params_snapshot: Map<String, Value>,
pub origin: Origin,
pub target_ref: String,
pub action_kind: ActionKind,
pub dedup_key: String,
pub summary: Summary,
pub severity: Severity,
#[serde(flatten)]
pub proposal: Proposal,
pub destructive: bool,
pub rollbackable: bool,
#[serde(default)]
pub evidence: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evidence_query: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metric: Option<MetricSnapshot>,
pub confidence: f64,
pub importance: f64,
pub created_at_ms: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub guidance: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evalset_hash: Option<String>,
#[serde(skip)]
pub status: RecStatus,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GatingEvidence {
pub evalset_hash: String,
pub run_id: String,
pub passed: u64,
pub failed: u64,
}
pub fn validate_code_rules(
action: ActionKind,
target_class: &str,
evalset_hash: Option<&str>,
) -> Result<()> {
let e1 = |why: &str| Err(Error::InvalidRecommendation(format!("Rule E1: {why}")));
match (action, target_class) {
(ActionKind::CodeRevision, "code") => {
if evalset_hash.is_none_or(|h| h.trim().is_empty()) {
return e1(
"a code_revision must pin the evalset hash it was gated \
against (evalset_hash)",
);
}
}
(ActionKind::CodeRevision, other) => {
return e1(&format!(
"code_revision requires a tool: target, got class '{other}'"
));
}
(ActionKind::AdapterRevision, "model") => {
if evalset_hash.is_none_or(|h| h.trim().is_empty()) {
return e1(
"an adapter_revision must pin the evalset hash it was \
gated against (evalset_hash)",
);
}
}
(ActionKind::AdapterRevision, other) => {
return e1(&format!(
"adapter_revision requires a model: target, got class '{other}'"
));
}
(ActionKind::Revert, "code") => {
if evalset_hash.is_some() {
return e1("a code revert carries no evalset pin");
}
}
(_, "code") => {
return e1("a tool: target requires action_kind code_revision");
}
(ActionKind::Revert, "model") => {
if evalset_hash.is_some() {
return e1("an adapter revert carries no evalset pin");
}
}
(_, "model") => {
return e1("a model: target requires action_kind adapter_revision");
}
(_, "evalset") => {
if evalset_hash.is_some() {
return e1(
"an evalset-target recommendation cannot pin an evalset — \
the gate cannot gate itself",
);
}
}
_ => {
if evalset_hash.is_some() {
return e1(
"evalset_hash is only valid on code_revision or \
adapter_revision recommendations",
);
}
}
}
Ok(())
}
impl Recommendation {
pub fn to_grain_spec(&self, namespace: &str) -> Result<GrainSpec> {
let value = serde_json::to_value(self)
.map_err(|e| Error::Internal(format!("serialize recommendation: {e}")))?;
let obj = value
.as_object()
.ok_or_else(|| Error::Internal("recommendation did not serialize to object".into()))?
.clone();
Ok(GrainSpec {
grain_type: crate::model::grain_type::RECOMMENDATION.to_string(),
namespace: namespace.to_string(),
fields: obj,
})
}
pub fn from_fields(hash: &str, fields: &Map<String, Value>) -> Result<Self> {
let mut rec: Recommendation = serde_json::from_value(Value::Object(fields.clone()))
.map_err(|e| Error::InvalidRecommendation(format!("decode {hash}: {e}")))?;
rec.hash = hash.to_string();
let class = crate::model::TargetRef::parse(&rec.target_ref)
.map(|t| t.target_class())
.unwrap_or("host");
validate_code_rules(rec.action_kind, class, rec.evalset_hash.as_deref())?;
Ok(rec)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn summary_renders_deterministically() {
let mut args = Map::new();
args.insert("count".into(), json!(3));
args.insert("subject".into(), json!("acme"));
let s = Summary::new("duplicate.exact", args);
assert_eq!(
s.render(),
"Consolidate 3 exact-duplicate grains for \"acme\""
);
}
#[test]
fn dedup_key_ignores_content_and_case() {
let a = dedup_key(
"loop.duplicate_sweep",
"entity:NS/John",
ActionKind::Consolidate,
);
let b = dedup_key(
"loop.duplicate_sweep",
"entity:ns/john",
ActionKind::Consolidate,
);
assert_eq!(a, b, "case-folded to one identity");
}
#[test]
fn authored_dedup_key_distinguishes_content_but_not_wording_noise() {
let a = authored_dedup_key("llm", "entity:ns/x", ActionKind::Record, "Record the vendor name.");
let same = authored_dedup_key("llm", "entity:NS/X", ActionKind::Record, " record THE vendor name ");
let other = authored_dedup_key("llm", "entity:ns/x", ActionKind::Record, "Record the amount.");
assert_eq!(a, same, "case, punctuation and spacing are not a new lesson");
assert_ne!(a, other, "a different lesson on the same entity is a different finding");
assert!(a.starts_with(&dedup_key("llm", "entity:ns/x", ActionKind::Record)));
assert_eq!(content_fingerprint("A b"), content_fingerprint("a-b"));
assert_ne!(content_fingerprint("ab"), content_fingerprint("a b"));
}
#[test]
fn a_revert_is_keyed_by_what_it_reverts() {
let a = revert_dedup_key("loop.outcome_review", "entity:ns/capture", "aaaa");
let b = revert_dedup_key("loop.outcome_review", "entity:ns/capture", "bbbb");
let a2 = revert_dedup_key("loop.outcome_review", "entity:NS/Capture", "AAAA");
assert_ne!(a, b, "two reverts on one target are two findings");
assert_eq!(a, a2, "the same revert, case-folded, is one");
assert!(a.starts_with(&dedup_key("loop.outcome_review", "entity:ns/capture", ActionKind::Revert)));
}
#[test]
fn dedup_key_distinguishes_action() {
let a = dedup_key("f", "entity:ns/x", ActionKind::Consolidate);
let b = dedup_key("f", "entity:ns/x", ActionKind::FlagContradiction);
assert_ne!(a, b);
}
#[test]
fn lifecycle_gates_pending_to_applied() {
assert!(!RecStatus::Pending.can_transition_to(RecStatus::Applied, false));
assert!(RecStatus::Pending.can_transition_to(RecStatus::Applied, true)); assert!(RecStatus::Pending.can_transition_to(RecStatus::Approved, false));
assert!(RecStatus::Approved.can_transition_to(RecStatus::Applied, false));
assert!(RecStatus::Applied.can_transition_to(RecStatus::RolledBack, false));
assert!(!RecStatus::Rejected.can_transition_to(RecStatus::Applied, true));
}
#[test]
fn rule_e1_pins_code_and_only_code() {
use crate::model::ActionKind as A;
assert!(validate_code_rules(A::CodeRevision, "code", Some("abc")).is_ok());
assert!(validate_code_rules(A::CodeRevision, "code", None).is_err());
assert!(validate_code_rules(A::CodeRevision, "code", Some(" ")).is_err());
assert!(validate_code_rules(A::CodeRevision, "memory", Some("abc")).is_err());
assert!(validate_code_rules(A::Flag, "code", None).is_err());
assert!(validate_code_rules(A::Flag, "evalset", Some("abc")).is_err());
assert!(validate_code_rules(A::Flag, "evalset", None).is_ok());
assert!(validate_code_rules(A::Consolidate, "memory", Some("abc")).is_err());
assert!(validate_code_rules(A::Consolidate, "memory", None).is_ok());
}
#[test]
fn rule_e1_pins_adapter_revisions_like_code() {
use crate::model::ActionKind as A;
assert!(validate_code_rules(A::AdapterRevision, "model", Some("abc")).is_ok());
assert!(validate_code_rules(A::AdapterRevision, "model", None).is_err());
assert!(validate_code_rules(A::AdapterRevision, "model", Some(" ")).is_err());
assert!(validate_code_rules(A::AdapterRevision, "memory", Some("abc")).is_err());
assert!(validate_code_rules(A::AdapterRevision, "code", Some("abc")).is_err());
assert!(validate_code_rules(A::Flag, "model", None).is_err());
assert!(validate_code_rules(A::Revert, "model", None).is_ok());
assert!(validate_code_rules(A::Revert, "model", Some("abc")).is_err());
}
#[test]
fn from_fields_rechecks_rule_e1() {
let mut rec = Recommendation {
hash: String::new(),
analyzer: "loop.codegen/1".into(),
params_snapshot: Map::new(),
origin: Origin::Builtin,
target_ref: "tool:abc123".into(),
action_kind: ActionKind::CodeRevision,
dedup_key: "k".into(),
summary: Summary::new("command.finding", Map::new()),
severity: Severity::Low,
proposal: Proposal::Data { data: Map::new() },
destructive: false,
rollbackable: false,
evidence: vec![],
evidence_query: None,
metric: None,
confidence: 0.5,
importance: 0.5,
created_at_ms: 0,
guidance: None,
evalset_hash: None, status: RecStatus::Pending,
};
let spec = rec.to_grain_spec("ns").unwrap();
assert!(Recommendation::from_fields("h", &spec.fields).is_err());
rec.evalset_hash = Some("es-hash".into());
let spec = rec.to_grain_spec("ns").unwrap();
let back = Recommendation::from_fields("h", &spec.fields).unwrap();
assert_eq!(back.evalset_hash.as_deref(), Some("es-hash"));
}
#[test]
fn recommendation_round_trips_through_fields() {
let rec = Recommendation {
hash: "ignored".into(),
analyzer: "loop.staleness/1".into(),
params_snapshot: Map::new(),
origin: Origin::Builtin,
target_ref: "grain:sha256:abc".into(),
action_kind: ActionKind::Expire,
dedup_key: "k".into(),
summary: Summary::new("staleness.expired", Map::new()),
severity: Severity::Low,
proposal: Proposal::Cal {
cal: "FORGET sha256:abc".into(),
},
destructive: true,
rollbackable: false,
evidence: vec!["sha256:abc".into()],
evidence_query: None,
metric: None,
confidence: 0.9,
importance: 0.4,
created_at_ms: 1000,
guidance: None,
evalset_hash: None,
status: RecStatus::Pending,
};
let spec = rec.to_grain_spec("ns").unwrap();
assert!(!spec.fields.contains_key("hash"));
assert!(!spec.fields.contains_key("status"));
let back = Recommendation::from_fields("realhash", &spec.fields).unwrap();
assert_eq!(back.hash, "realhash");
assert_eq!(back.analyzer, "loop.staleness/1");
assert!(back.destructive);
assert!(matches!(back.proposal, Proposal::Cal { .. }));
}
}