1use crate::error::{Error, Result};
12use crate::model::{normalize_ident, ActionKind, Origin, Severity};
13use crate::substrate::GrainSpec;
14use serde::{Deserialize, Serialize};
15use serde_json::{Map, Value};
16
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub struct Summary {
22 pub template_id: String,
23 #[serde(default)]
24 pub args: Map<String, Value>,
25}
26
27impl Summary {
28 pub fn new(template_id: impl Into<String>, args: Map<String, Value>) -> Self {
29 Summary {
30 template_id: template_id.into(),
31 args,
32 }
33 }
34
35 pub fn render(&self) -> String {
39 let t = builtin_template(&self.template_id).unwrap_or("{template_id}: {summary}");
40 interpolate(t, &self.args, &self.template_id)
41 }
42}
43
44fn builtin_template(id: &str) -> Option<&'static str> {
47 Some(match id {
48 "duplicate.exact" => "Consolidate {count} exact-duplicate grains for \"{subject}\"",
49 "duplicate.near" => {
50 "Consolidate {count} near-duplicate observations (similarity ≥ {threshold})"
51 }
52 "contradiction.functional" => {
53 "\"{subject}\" holds {count} live values for functional relation \"{relation}\""
54 }
55 "tool_failure.cluster" => {
56 "Tool \"{tool}\" failed {count} times ({rate}% of calls): {signature}"
57 }
58 "staleness.expired" => "Expire \"{subject}\": past its declared valid_to ({age_days}d ago)",
59 "fork.multi_head" => "Entity \"{entity}\" has {count} competing heads",
60 "skill.stall" => {
61 "Skill \"{skill}\" isn't improving: practiced {practice_count}× but proficiency is still {proficiency}"
62 }
63 "goal.stagnation" => "Goal \"{goal}\" is stalled: active {age_days}d with {progress} progress",
64 "cold.grain" => {
65 "Cold memory: \"{subject}\" ({age_days}d old) has never been recalled — retire candidate"
66 }
67 "coverage.gap" => {
68 "Recurring question with no matching memory: \"{query}\" asked {count}× ({empty_rate}% empty)"
69 }
70 "budget.pressure" => {
71 "Assembly budget overflowed on {overflow_rate}% of {samples} recalls — raise the budget or curate memory"
72 }
73 "retention.overdue" => {
74 "Retention: {count} grain(s) in \"{namespace}\" exceed the {max_age_days}d policy (oldest {oldest_days}d); {remaining} more await a later pass"
75 }
76 "outcome.regression" => {
77 "Applied recommendation regressed: {metric} moved {baseline} → {current}"
78 }
79 "run.failures" => {
80 "Workflow {workflow} failed {failed}/{runs} recent runs ({rate}%): {last_error}"
81 }
82 "run.cost" => {
83 "Workflow {workflow} spent ${usd} across {runs} runs (avg ${avg_usd}/run)"
84 }
85 "adapter.candidate" => {
86 "Promote adapter for \"{model}\" (base {base_model}) — gated on its pinned evalset"
87 }
88 "llm.discover" => "{text}",
91 "command.finding" => "{text}",
94 _ => return None,
95 })
96}
97
98fn interpolate(template: &str, args: &Map<String, Value>, template_id: &str) -> String {
99 let mut out = String::with_capacity(template.len());
100 let mut chars = template.chars().peekable();
101 while let Some(c) = chars.next() {
102 if c == '{' {
103 let mut key = String::new();
104 for k in chars.by_ref() {
105 if k == '}' {
106 break;
107 }
108 key.push(k);
109 }
110 if key == "template_id" {
111 out.push_str(template_id);
112 } else {
113 match args.get(&key) {
114 Some(Value::String(s)) => out.push_str(s),
115 Some(v) => out.push_str(&v.to_string()),
116 None => {
117 out.push('{');
119 out.push_str(&key);
120 out.push('}');
121 }
122 }
123 }
124 } else {
125 out.push(c);
126 }
127 }
128 out
129}
130
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133pub struct MetricSnapshot {
134 pub metric: String,
137 pub baseline: f64,
138 pub unit: String,
139 pub n: u64,
140 pub window: String,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub subject: Option<String>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub namespace: Option<String>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub relation: Option<String>,
155 pub query: String,
158 pub review_after_ms: i64,
161 #[serde(default, skip_serializing_if = "Vec::is_empty")]
165 pub horizons_ms: Vec<i64>,
166 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
173 pub higher_is_better: bool,
174}
175
176pub fn is_regression(baseline: f64, current: f64, higher_is_better: bool) -> bool {
183 const EPSILON: f64 = 1e-9;
185 if higher_is_better {
186 current < baseline - EPSILON
187 } else {
188 current > baseline + EPSILON
189 }
190}
191
192impl MetricSnapshot {
193 pub fn horizons(&self) -> Vec<i64> {
196 let mut h = if self.horizons_ms.is_empty() {
197 vec![self.review_after_ms]
198 } else {
199 self.horizons_ms.clone()
200 };
201 h.sort_unstable();
202 h
203 }
204}
205
206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211pub struct OutcomeResult {
212 pub rec_hash: String,
213 pub metric: String,
214 pub baseline: f64,
215 pub current: f64,
216 pub verdict: String,
217 #[serde(default)]
219 pub horizon_ms: i64,
220 pub measured_at_ms: i64,
221}
222
223#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
225#[serde(tag = "proposal", rename_all = "snake_case")]
226pub enum Proposal {
227 Cal { cal: String },
229 Edit {
232 format: String,
233 base_digest: String,
234 diff: String,
235 },
236 Data { data: Map<String, Value> },
238}
239
240#[derive(Debug, Clone, PartialEq)]
244#[non_exhaustive]
245pub struct RecDraft {
246 pub target_ref: String,
247 pub action_kind: ActionKind,
248 pub summary: Summary,
249 pub severity: Severity,
250 pub proposal: Proposal,
251 pub evidence: Vec<String>,
253 pub evidence_query: Option<String>,
254 pub metric: Option<MetricSnapshot>,
255 pub confidence: f64,
256 pub importance: f64,
257 pub evalset_hash: Option<String>,
259}
260
261impl RecDraft {
262 pub fn new(
263 target_ref: impl Into<String>,
264 action_kind: ActionKind,
265 summary: Summary,
266 proposal: Proposal,
267 ) -> Self {
268 RecDraft {
269 target_ref: target_ref.into(),
270 action_kind,
271 summary,
272 severity: Severity::Low,
273 proposal,
274 evidence: Vec::new(),
275 evidence_query: None,
276 metric: None,
277 confidence: 0.8,
278 importance: 0.5,
279 evalset_hash: None,
280 }
281 }
282
283 pub fn severity(mut self, s: Severity) -> Self {
284 self.severity = s;
285 self
286 }
287 pub fn evidence(mut self, hashes: Vec<String>) -> Self {
288 self.evidence = hashes;
289 self
290 }
291 pub fn evidence_query(mut self, q: impl Into<String>) -> Self {
292 self.evidence_query = Some(q.into());
293 self
294 }
295 pub fn metric(mut self, m: MetricSnapshot) -> Self {
296 self.metric = Some(m);
297 self
298 }
299 pub fn confidence(mut self, c: f64) -> Self {
300 self.confidence = c;
301 self
302 }
303 pub fn importance(mut self, i: f64) -> Self {
304 self.importance = i;
305 self
306 }
307 pub fn evalset_hash(mut self, h: impl Into<String>) -> Self {
308 self.evalset_hash = Some(h.into());
309 self
310 }
311}
312
313pub const MAX_EVIDENCE: usize = 64;
315
316pub fn dedup_key(family: &str, target_ref: &str, action: ActionKind) -> String {
319 format!(
321 "{}\u{1f}{}\u{1f}{}",
322 normalize_ident(family),
323 normalize_ident(target_ref),
324 action.as_str()
325 )
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
331#[serde(rename_all = "snake_case")]
332pub enum RecStatus {
333 #[default]
334 Pending,
335 Approved,
336 Rejected,
337 Applied,
338 RolledBack,
339 Expired,
340}
341
342impl RecStatus {
343 pub fn as_str(&self) -> &'static str {
344 match self {
345 RecStatus::Pending => "pending",
346 RecStatus::Approved => "approved",
347 RecStatus::Rejected => "rejected",
348 RecStatus::Applied => "applied",
349 RecStatus::RolledBack => "rolled_back",
350 RecStatus::Expired => "expired",
351 }
352 }
353
354 pub fn can_transition_to(&self, to: RecStatus, by_policy: bool) -> bool {
358 use RecStatus::*;
359 match (self, to) {
360 (Pending, Approved) | (Pending, Rejected) => true,
361 (Pending, Applied) => by_policy, (Approved, Applied) => true,
363 (Applied, RolledBack) => true,
364 (Pending, Expired) | (Approved, Expired) => true,
366 _ => false,
367 }
368 }
369}
370
371#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
373#[serde(rename_all = "lowercase")]
374pub enum ObserverType {
375 Human,
376 Agent,
377 Policy,
378 System,
379}
380
381#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
384pub struct AuditRecord {
385 pub rec_hash: String,
386 #[serde(skip_serializing_if = "Option::is_none")]
387 pub from: Option<RecStatus>,
388 pub to: RecStatus,
389 pub actor: String,
392 pub observer_type: ObserverType,
393 pub because: String,
395 #[serde(skip_serializing_if = "Option::is_none")]
396 pub previous_audit_hash: Option<String>,
397 #[serde(default, skip_serializing_if = "Option::is_none")]
400 pub gating: Option<GatingEvidence>,
401 pub at_ms: i64,
402}
403
404pub const MAX_BECAUSE: usize = 500;
406
407impl AuditRecord {
408 pub fn to_grain_spec(&self, namespace: &str) -> GrainSpec {
411 let mut derived_from = vec![Value::from(self.rec_hash.clone())];
412 if let Some(prev) = &self.previous_audit_hash {
413 derived_from.push(Value::from(prev.clone()));
414 }
415 let mut spec = GrainSpec::new(crate::model::grain_type::OBSERVATION, namespace)
416 .with_field("observation_kind", "loop_audit")
417 .with_field("rec_hash", self.rec_hash.clone())
418 .with_field("to_status", self.to.as_str())
419 .with_field("actor", self.actor.clone())
420 .with_field(
421 "observer_type",
422 serde_json::to_value(self.observer_type).unwrap(),
423 )
424 .with_field("because", self.because.clone())
425 .with_field("at_ms", self.at_ms)
426 .with_field("derived_from", Value::Array(derived_from));
427 if let Some(from) = self.from {
428 spec.fields
429 .insert("from_status".into(), Value::from(from.as_str()));
430 }
431 if let Some(g) = &self.gating {
432 spec.fields
433 .insert("gating_evalset".into(), Value::from(g.evalset_hash.clone()));
434 spec.fields
435 .insert("gating_run_id".into(), Value::from(g.run_id.clone()));
436 spec.fields.insert("gating_passed".into(), Value::from(g.passed));
437 spec.fields.insert("gating_failed".into(), Value::from(g.failed));
438 }
439 spec
440 }
441}
442
443#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
448pub struct Recommendation {
449 #[serde(skip)]
450 pub hash: String,
451 pub analyzer: String,
452 pub params_snapshot: Map<String, Value>,
453 pub origin: Origin,
454 pub target_ref: String,
455 pub action_kind: ActionKind,
456 pub dedup_key: String,
457 pub summary: Summary,
458 pub severity: Severity,
459 #[serde(flatten)]
460 pub proposal: Proposal,
461 pub destructive: bool,
462 pub rollbackable: bool,
463 #[serde(default)]
464 pub evidence: Vec<String>,
465 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub evidence_query: Option<String>,
467 #[serde(default, skip_serializing_if = "Option::is_none")]
468 pub metric: Option<MetricSnapshot>,
469 pub confidence: f64,
470 pub importance: f64,
471 pub created_at_ms: i64,
472 #[serde(default, skip_serializing_if = "Option::is_none")]
476 pub guidance: Option<String>,
477 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub evalset_hash: Option<String>,
484 #[serde(skip)]
485 pub status: RecStatus,
486}
487
488#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
492pub struct GatingEvidence {
493 pub evalset_hash: String,
495 pub run_id: String,
497 pub passed: u64,
498 pub failed: u64,
499}
500
501pub fn validate_code_rules(
511 action: ActionKind,
512 target_class: &str,
513 evalset_hash: Option<&str>,
514) -> Result<()> {
515 let e1 = |why: &str| Err(Error::InvalidRecommendation(format!("Rule E1: {why}")));
516 match (action, target_class) {
517 (ActionKind::CodeRevision, "code") => {
518 if evalset_hash.is_none_or(|h| h.trim().is_empty()) {
519 return e1(
520 "a code_revision must pin the evalset hash it was gated \
521 against (evalset_hash)",
522 );
523 }
524 }
525 (ActionKind::CodeRevision, other) => {
526 return e1(&format!(
527 "code_revision requires a tool: target, got class '{other}'"
528 ));
529 }
530 (ActionKind::AdapterRevision, "model") => {
531 if evalset_hash.is_none_or(|h| h.trim().is_empty()) {
532 return e1(
533 "an adapter_revision must pin the evalset hash it was \
534 gated against (evalset_hash)",
535 );
536 }
537 }
538 (ActionKind::AdapterRevision, other) => {
539 return e1(&format!(
540 "adapter_revision requires a model: target, got class '{other}'"
541 ));
542 }
543 (ActionKind::Revert, "code") => {
548 if evalset_hash.is_some() {
549 return e1("a code revert carries no evalset pin");
550 }
551 }
552 (_, "code") => {
553 return e1("a tool: target requires action_kind code_revision");
554 }
555 (ActionKind::Revert, "model") => {
557 if evalset_hash.is_some() {
558 return e1("an adapter revert carries no evalset pin");
559 }
560 }
561 (_, "model") => {
562 return e1("a model: target requires action_kind adapter_revision");
563 }
564 (_, "evalset") => {
565 if evalset_hash.is_some() {
566 return e1(
567 "an evalset-target recommendation cannot pin an evalset — \
568 the gate cannot gate itself",
569 );
570 }
571 }
572 _ => {
573 if evalset_hash.is_some() {
574 return e1(
575 "evalset_hash is only valid on code_revision or \
576 adapter_revision recommendations",
577 );
578 }
579 }
580 }
581 Ok(())
582}
583
584impl Recommendation {
585 pub fn to_grain_spec(&self, namespace: &str) -> Result<GrainSpec> {
587 let value = serde_json::to_value(self)
588 .map_err(|e| Error::Internal(format!("serialize recommendation: {e}")))?;
589 let obj = value
590 .as_object()
591 .ok_or_else(|| Error::Internal("recommendation did not serialize to object".into()))?
592 .clone();
593 Ok(GrainSpec {
594 grain_type: crate::model::grain_type::RECOMMENDATION.to_string(),
595 namespace: namespace.to_string(),
596 fields: obj,
597 })
598 }
599
600 pub fn from_fields(hash: &str, fields: &Map<String, Value>) -> Result<Self> {
605 let mut rec: Recommendation = serde_json::from_value(Value::Object(fields.clone()))
606 .map_err(|e| Error::InvalidRecommendation(format!("decode {hash}: {e}")))?;
607 rec.hash = hash.to_string();
608 let class = crate::model::TargetRef::parse(&rec.target_ref)
609 .map(|t| t.target_class())
610 .unwrap_or("host");
611 validate_code_rules(rec.action_kind, class, rec.evalset_hash.as_deref())?;
612 Ok(rec)
613 }
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619 use serde_json::json;
620
621 #[test]
622 fn summary_renders_deterministically() {
623 let mut args = Map::new();
624 args.insert("count".into(), json!(3));
625 args.insert("subject".into(), json!("acme"));
626 let s = Summary::new("duplicate.exact", args);
627 assert_eq!(
628 s.render(),
629 "Consolidate 3 exact-duplicate grains for \"acme\""
630 );
631 }
632
633 #[test]
634 fn dedup_key_ignores_content_and_case() {
635 let a = dedup_key(
636 "loop.duplicate_sweep",
637 "entity:NS/John",
638 ActionKind::Consolidate,
639 );
640 let b = dedup_key(
641 "loop.duplicate_sweep",
642 "entity:ns/john",
643 ActionKind::Consolidate,
644 );
645 assert_eq!(a, b, "case-folded to one identity");
646 }
647
648 #[test]
649 fn dedup_key_distinguishes_action() {
650 let a = dedup_key("f", "entity:ns/x", ActionKind::Consolidate);
651 let b = dedup_key("f", "entity:ns/x", ActionKind::FlagContradiction);
652 assert_ne!(a, b);
653 }
654
655 #[test]
656 fn lifecycle_gates_pending_to_applied() {
657 assert!(!RecStatus::Pending.can_transition_to(RecStatus::Applied, false));
658 assert!(RecStatus::Pending.can_transition_to(RecStatus::Applied, true)); assert!(RecStatus::Pending.can_transition_to(RecStatus::Approved, false));
660 assert!(RecStatus::Approved.can_transition_to(RecStatus::Applied, false));
661 assert!(RecStatus::Applied.can_transition_to(RecStatus::RolledBack, false));
662 assert!(!RecStatus::Rejected.can_transition_to(RecStatus::Applied, true));
663 }
664
665 #[test]
666 fn rule_e1_pins_code_and_only_code() {
667 use crate::model::ActionKind as A;
668 assert!(validate_code_rules(A::CodeRevision, "code", Some("abc")).is_ok());
670 assert!(validate_code_rules(A::CodeRevision, "code", None).is_err());
672 assert!(validate_code_rules(A::CodeRevision, "code", Some(" ")).is_err());
673 assert!(validate_code_rules(A::CodeRevision, "memory", Some("abc")).is_err());
675 assert!(validate_code_rules(A::Flag, "code", None).is_err());
677 assert!(validate_code_rules(A::Flag, "evalset", Some("abc")).is_err());
679 assert!(validate_code_rules(A::Flag, "evalset", None).is_ok());
680 assert!(validate_code_rules(A::Consolidate, "memory", Some("abc")).is_err());
682 assert!(validate_code_rules(A::Consolidate, "memory", None).is_ok());
683 }
684
685 #[test]
686 fn rule_e1_pins_adapter_revisions_like_code() {
687 use crate::model::ActionKind as A;
688 assert!(validate_code_rules(A::AdapterRevision, "model", Some("abc")).is_ok());
690 assert!(validate_code_rules(A::AdapterRevision, "model", None).is_err());
692 assert!(validate_code_rules(A::AdapterRevision, "model", Some(" ")).is_err());
693 assert!(validate_code_rules(A::AdapterRevision, "memory", Some("abc")).is_err());
695 assert!(validate_code_rules(A::AdapterRevision, "code", Some("abc")).is_err());
696 assert!(validate_code_rules(A::Flag, "model", None).is_err());
698 assert!(validate_code_rules(A::Revert, "model", None).is_ok());
700 assert!(validate_code_rules(A::Revert, "model", Some("abc")).is_err());
701 }
702
703 #[test]
704 fn from_fields_rechecks_rule_e1() {
705 let mut rec = Recommendation {
708 hash: String::new(),
709 analyzer: "loop.codegen/1".into(),
710 params_snapshot: Map::new(),
711 origin: Origin::Builtin,
712 target_ref: "tool:abc123".into(),
713 action_kind: ActionKind::CodeRevision,
714 dedup_key: "k".into(),
715 summary: Summary::new("command.finding", Map::new()),
716 severity: Severity::Low,
717 proposal: Proposal::Data { data: Map::new() },
718 destructive: false,
719 rollbackable: false,
720 evidence: vec![],
721 evidence_query: None,
722 metric: None,
723 confidence: 0.5,
724 importance: 0.5,
725 created_at_ms: 0,
726 guidance: None,
727 evalset_hash: None, status: RecStatus::Pending,
729 };
730 let spec = rec.to_grain_spec("ns").unwrap();
731 assert!(Recommendation::from_fields("h", &spec.fields).is_err());
732 rec.evalset_hash = Some("es-hash".into());
733 let spec = rec.to_grain_spec("ns").unwrap();
734 let back = Recommendation::from_fields("h", &spec.fields).unwrap();
735 assert_eq!(back.evalset_hash.as_deref(), Some("es-hash"));
736 }
737
738 #[test]
739 fn recommendation_round_trips_through_fields() {
740 let rec = Recommendation {
741 hash: "ignored".into(),
742 analyzer: "loop.staleness/1".into(),
743 params_snapshot: Map::new(),
744 origin: Origin::Builtin,
745 target_ref: "grain:sha256:abc".into(),
746 action_kind: ActionKind::Expire,
747 dedup_key: "k".into(),
748 summary: Summary::new("staleness.expired", Map::new()),
749 severity: Severity::Low,
750 proposal: Proposal::Cal {
751 cal: "FORGET sha256:abc".into(),
752 },
753 destructive: true,
754 rollbackable: false,
755 evidence: vec!["sha256:abc".into()],
756 evidence_query: None,
757 metric: None,
758 confidence: 0.9,
759 importance: 0.4,
760 created_at_ms: 1000,
761 guidance: None,
762 evalset_hash: None,
763 status: RecStatus::Pending,
764 };
765 let spec = rec.to_grain_spec("ns").unwrap();
766 assert!(!spec.fields.contains_key("hash"));
768 assert!(!spec.fields.contains_key("status"));
769 let back = Recommendation::from_fields("realhash", &spec.fields).unwrap();
770 assert_eq!(back.hash, "realhash");
771 assert_eq!(back.analyzer, "loop.staleness/1");
772 assert!(back.destructive);
773 assert!(matches!(back.proposal, Proposal::Cal { .. }));
774 }
775}