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 "llm.discover" => "{text}",
88 "command.finding" => "{text}",
91 _ => return None,
92 })
93}
94
95fn interpolate(template: &str, args: &Map<String, Value>, template_id: &str) -> String {
96 let mut out = String::with_capacity(template.len());
97 let mut chars = template.chars().peekable();
98 while let Some(c) = chars.next() {
99 if c == '{' {
100 let mut key = String::new();
101 for k in chars.by_ref() {
102 if k == '}' {
103 break;
104 }
105 key.push(k);
106 }
107 if key == "template_id" {
108 out.push_str(template_id);
109 } else {
110 match args.get(&key) {
111 Some(Value::String(s)) => out.push_str(s),
112 Some(v) => out.push_str(&v.to_string()),
113 None => {
114 out.push('{');
116 out.push_str(&key);
117 out.push('}');
118 }
119 }
120 }
121 } else {
122 out.push(c);
123 }
124 }
125 out
126}
127
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130pub struct MetricSnapshot {
131 pub metric: String,
134 pub baseline: f64,
135 pub unit: String,
136 pub n: u64,
137 pub window: String,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub subject: Option<String>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub namespace: Option<String>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub relation: Option<String>,
152 pub query: String,
155 pub review_after_ms: i64,
158 #[serde(default, skip_serializing_if = "Vec::is_empty")]
162 pub horizons_ms: Vec<i64>,
163}
164
165impl MetricSnapshot {
166 pub fn horizons(&self) -> Vec<i64> {
169 let mut h = if self.horizons_ms.is_empty() {
170 vec![self.review_after_ms]
171 } else {
172 self.horizons_ms.clone()
173 };
174 h.sort_unstable();
175 h
176 }
177}
178
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184pub struct OutcomeResult {
185 pub rec_hash: String,
186 pub metric: String,
187 pub baseline: f64,
188 pub current: f64,
189 pub verdict: String,
190 #[serde(default)]
192 pub horizon_ms: i64,
193 pub measured_at_ms: i64,
194}
195
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
198#[serde(tag = "proposal", rename_all = "snake_case")]
199pub enum Proposal {
200 Cal { cal: String },
202 Edit {
205 format: String,
206 base_digest: String,
207 diff: String,
208 },
209 Data { data: Map<String, Value> },
211}
212
213#[derive(Debug, Clone, PartialEq)]
217#[non_exhaustive]
218pub struct RecDraft {
219 pub target_ref: String,
220 pub action_kind: ActionKind,
221 pub summary: Summary,
222 pub severity: Severity,
223 pub proposal: Proposal,
224 pub evidence: Vec<String>,
226 pub evidence_query: Option<String>,
227 pub metric: Option<MetricSnapshot>,
228 pub confidence: f64,
229 pub importance: f64,
230 pub evalset_hash: Option<String>,
232}
233
234impl RecDraft {
235 pub fn new(
236 target_ref: impl Into<String>,
237 action_kind: ActionKind,
238 summary: Summary,
239 proposal: Proposal,
240 ) -> Self {
241 RecDraft {
242 target_ref: target_ref.into(),
243 action_kind,
244 summary,
245 severity: Severity::Low,
246 proposal,
247 evidence: Vec::new(),
248 evidence_query: None,
249 metric: None,
250 confidence: 0.8,
251 importance: 0.5,
252 evalset_hash: None,
253 }
254 }
255
256 pub fn severity(mut self, s: Severity) -> Self {
257 self.severity = s;
258 self
259 }
260 pub fn evidence(mut self, hashes: Vec<String>) -> Self {
261 self.evidence = hashes;
262 self
263 }
264 pub fn evidence_query(mut self, q: impl Into<String>) -> Self {
265 self.evidence_query = Some(q.into());
266 self
267 }
268 pub fn metric(mut self, m: MetricSnapshot) -> Self {
269 self.metric = Some(m);
270 self
271 }
272 pub fn confidence(mut self, c: f64) -> Self {
273 self.confidence = c;
274 self
275 }
276 pub fn importance(mut self, i: f64) -> Self {
277 self.importance = i;
278 self
279 }
280 pub fn evalset_hash(mut self, h: impl Into<String>) -> Self {
281 self.evalset_hash = Some(h.into());
282 self
283 }
284}
285
286pub const MAX_EVIDENCE: usize = 64;
288
289pub fn dedup_key(family: &str, target_ref: &str, action: ActionKind) -> String {
292 format!(
294 "{}\u{1f}{}\u{1f}{}",
295 normalize_ident(family),
296 normalize_ident(target_ref),
297 action.as_str()
298 )
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
304#[serde(rename_all = "snake_case")]
305pub enum RecStatus {
306 #[default]
307 Pending,
308 Approved,
309 Rejected,
310 Applied,
311 RolledBack,
312 Expired,
313}
314
315impl RecStatus {
316 pub fn as_str(&self) -> &'static str {
317 match self {
318 RecStatus::Pending => "pending",
319 RecStatus::Approved => "approved",
320 RecStatus::Rejected => "rejected",
321 RecStatus::Applied => "applied",
322 RecStatus::RolledBack => "rolled_back",
323 RecStatus::Expired => "expired",
324 }
325 }
326
327 pub fn can_transition_to(&self, to: RecStatus, by_policy: bool) -> bool {
331 use RecStatus::*;
332 match (self, to) {
333 (Pending, Approved) | (Pending, Rejected) => true,
334 (Pending, Applied) => by_policy, (Approved, Applied) => true,
336 (Applied, RolledBack) => true,
337 (Pending, Expired) | (Approved, Expired) => true,
339 _ => false,
340 }
341 }
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
346#[serde(rename_all = "lowercase")]
347pub enum ObserverType {
348 Human,
349 Agent,
350 Policy,
351 System,
352}
353
354#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
357pub struct AuditRecord {
358 pub rec_hash: String,
359 #[serde(skip_serializing_if = "Option::is_none")]
360 pub from: Option<RecStatus>,
361 pub to: RecStatus,
362 pub actor: String,
365 pub observer_type: ObserverType,
366 pub because: String,
368 #[serde(skip_serializing_if = "Option::is_none")]
369 pub previous_audit_hash: Option<String>,
370 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub gating: Option<GatingEvidence>,
374 pub at_ms: i64,
375}
376
377pub const MAX_BECAUSE: usize = 500;
379
380impl AuditRecord {
381 pub fn to_grain_spec(&self, namespace: &str) -> GrainSpec {
384 let mut derived_from = vec![Value::from(self.rec_hash.clone())];
385 if let Some(prev) = &self.previous_audit_hash {
386 derived_from.push(Value::from(prev.clone()));
387 }
388 let mut spec = GrainSpec::new(crate::model::grain_type::OBSERVATION, namespace)
389 .with_field("observation_kind", "loop_audit")
390 .with_field("rec_hash", self.rec_hash.clone())
391 .with_field("to_status", self.to.as_str())
392 .with_field("actor", self.actor.clone())
393 .with_field(
394 "observer_type",
395 serde_json::to_value(self.observer_type).unwrap(),
396 )
397 .with_field("because", self.because.clone())
398 .with_field("at_ms", self.at_ms)
399 .with_field("derived_from", Value::Array(derived_from));
400 if let Some(from) = self.from {
401 spec.fields
402 .insert("from_status".into(), Value::from(from.as_str()));
403 }
404 if let Some(g) = &self.gating {
405 spec.fields
406 .insert("gating_evalset".into(), Value::from(g.evalset_hash.clone()));
407 spec.fields
408 .insert("gating_run_id".into(), Value::from(g.run_id.clone()));
409 spec.fields.insert("gating_passed".into(), Value::from(g.passed));
410 spec.fields.insert("gating_failed".into(), Value::from(g.failed));
411 }
412 spec
413 }
414}
415
416#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
421pub struct Recommendation {
422 #[serde(skip)]
423 pub hash: String,
424 pub analyzer: String,
425 pub params_snapshot: Map<String, Value>,
426 pub origin: Origin,
427 pub target_ref: String,
428 pub action_kind: ActionKind,
429 pub dedup_key: String,
430 pub summary: Summary,
431 pub severity: Severity,
432 #[serde(flatten)]
433 pub proposal: Proposal,
434 pub destructive: bool,
435 pub rollbackable: bool,
436 #[serde(default)]
437 pub evidence: Vec<String>,
438 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub evidence_query: Option<String>,
440 #[serde(default, skip_serializing_if = "Option::is_none")]
441 pub metric: Option<MetricSnapshot>,
442 pub confidence: f64,
443 pub importance: f64,
444 pub created_at_ms: i64,
445 #[serde(default, skip_serializing_if = "Option::is_none")]
449 pub guidance: Option<String>,
450 #[serde(default, skip_serializing_if = "Option::is_none")]
456 pub evalset_hash: Option<String>,
457 #[serde(skip)]
458 pub status: RecStatus,
459}
460
461#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
465pub struct GatingEvidence {
466 pub evalset_hash: String,
468 pub run_id: String,
470 pub passed: u64,
471 pub failed: u64,
472}
473
474pub fn validate_code_rules(
482 action: ActionKind,
483 target_class: &str,
484 evalset_hash: Option<&str>,
485) -> Result<()> {
486 let e1 = |why: &str| Err(Error::InvalidRecommendation(format!("Rule E1: {why}")));
487 match (action, target_class) {
488 (ActionKind::CodeRevision, "code") => {
489 if evalset_hash.is_none_or(|h| h.trim().is_empty()) {
490 return e1(
491 "a code_revision must pin the evalset hash it was gated \
492 against (evalset_hash)",
493 );
494 }
495 }
496 (ActionKind::CodeRevision, other) => {
497 return e1(&format!(
498 "code_revision requires a tool: target, got class '{other}'"
499 ));
500 }
501 (ActionKind::Revert, "code") => {
506 if evalset_hash.is_some() {
507 return e1("a code revert carries no evalset pin");
508 }
509 }
510 (_, "code") => {
511 return e1("a tool: target requires action_kind code_revision");
512 }
513 (_, "evalset") => {
514 if evalset_hash.is_some() {
515 return e1(
516 "an evalset-target recommendation cannot pin an evalset — \
517 the gate cannot gate itself",
518 );
519 }
520 }
521 _ => {
522 if evalset_hash.is_some() {
523 return e1("evalset_hash is only valid on code_revision recommendations");
524 }
525 }
526 }
527 Ok(())
528}
529
530impl Recommendation {
531 pub fn to_grain_spec(&self, namespace: &str) -> Result<GrainSpec> {
533 let value = serde_json::to_value(self)
534 .map_err(|e| Error::Internal(format!("serialize recommendation: {e}")))?;
535 let obj = value
536 .as_object()
537 .ok_or_else(|| Error::Internal("recommendation did not serialize to object".into()))?
538 .clone();
539 Ok(GrainSpec {
540 grain_type: crate::model::grain_type::RECOMMENDATION.to_string(),
541 namespace: namespace.to_string(),
542 fields: obj,
543 })
544 }
545
546 pub fn from_fields(hash: &str, fields: &Map<String, Value>) -> Result<Self> {
551 let mut rec: Recommendation = serde_json::from_value(Value::Object(fields.clone()))
552 .map_err(|e| Error::InvalidRecommendation(format!("decode {hash}: {e}")))?;
553 rec.hash = hash.to_string();
554 let class = crate::model::TargetRef::parse(&rec.target_ref)
555 .map(|t| t.target_class())
556 .unwrap_or("host");
557 validate_code_rules(rec.action_kind, class, rec.evalset_hash.as_deref())?;
558 Ok(rec)
559 }
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565 use serde_json::json;
566
567 #[test]
568 fn summary_renders_deterministically() {
569 let mut args = Map::new();
570 args.insert("count".into(), json!(3));
571 args.insert("subject".into(), json!("acme"));
572 let s = Summary::new("duplicate.exact", args);
573 assert_eq!(
574 s.render(),
575 "Consolidate 3 exact-duplicate grains for \"acme\""
576 );
577 }
578
579 #[test]
580 fn dedup_key_ignores_content_and_case() {
581 let a = dedup_key(
582 "loop.duplicate_sweep",
583 "entity:NS/John",
584 ActionKind::Consolidate,
585 );
586 let b = dedup_key(
587 "loop.duplicate_sweep",
588 "entity:ns/john",
589 ActionKind::Consolidate,
590 );
591 assert_eq!(a, b, "case-folded to one identity");
592 }
593
594 #[test]
595 fn dedup_key_distinguishes_action() {
596 let a = dedup_key("f", "entity:ns/x", ActionKind::Consolidate);
597 let b = dedup_key("f", "entity:ns/x", ActionKind::FlagContradiction);
598 assert_ne!(a, b);
599 }
600
601 #[test]
602 fn lifecycle_gates_pending_to_applied() {
603 assert!(!RecStatus::Pending.can_transition_to(RecStatus::Applied, false));
604 assert!(RecStatus::Pending.can_transition_to(RecStatus::Applied, true)); assert!(RecStatus::Pending.can_transition_to(RecStatus::Approved, false));
606 assert!(RecStatus::Approved.can_transition_to(RecStatus::Applied, false));
607 assert!(RecStatus::Applied.can_transition_to(RecStatus::RolledBack, false));
608 assert!(!RecStatus::Rejected.can_transition_to(RecStatus::Applied, true));
609 }
610
611 #[test]
612 fn rule_e1_pins_code_and_only_code() {
613 use crate::model::ActionKind as A;
614 assert!(validate_code_rules(A::CodeRevision, "code", Some("abc")).is_ok());
616 assert!(validate_code_rules(A::CodeRevision, "code", None).is_err());
618 assert!(validate_code_rules(A::CodeRevision, "code", Some(" ")).is_err());
619 assert!(validate_code_rules(A::CodeRevision, "memory", Some("abc")).is_err());
621 assert!(validate_code_rules(A::Flag, "code", None).is_err());
623 assert!(validate_code_rules(A::Flag, "evalset", Some("abc")).is_err());
625 assert!(validate_code_rules(A::Flag, "evalset", None).is_ok());
626 assert!(validate_code_rules(A::Consolidate, "memory", Some("abc")).is_err());
628 assert!(validate_code_rules(A::Consolidate, "memory", None).is_ok());
629 }
630
631 #[test]
632 fn from_fields_rechecks_rule_e1() {
633 let mut rec = Recommendation {
636 hash: String::new(),
637 analyzer: "loop.codegen/1".into(),
638 params_snapshot: Map::new(),
639 origin: Origin::Builtin,
640 target_ref: "tool:abc123".into(),
641 action_kind: ActionKind::CodeRevision,
642 dedup_key: "k".into(),
643 summary: Summary::new("command.finding", Map::new()),
644 severity: Severity::Low,
645 proposal: Proposal::Data { data: Map::new() },
646 destructive: false,
647 rollbackable: false,
648 evidence: vec![],
649 evidence_query: None,
650 metric: None,
651 confidence: 0.5,
652 importance: 0.5,
653 created_at_ms: 0,
654 guidance: None,
655 evalset_hash: None, status: RecStatus::Pending,
657 };
658 let spec = rec.to_grain_spec("ns").unwrap();
659 assert!(Recommendation::from_fields("h", &spec.fields).is_err());
660 rec.evalset_hash = Some("es-hash".into());
661 let spec = rec.to_grain_spec("ns").unwrap();
662 let back = Recommendation::from_fields("h", &spec.fields).unwrap();
663 assert_eq!(back.evalset_hash.as_deref(), Some("es-hash"));
664 }
665
666 #[test]
667 fn recommendation_round_trips_through_fields() {
668 let rec = Recommendation {
669 hash: "ignored".into(),
670 analyzer: "loop.staleness/1".into(),
671 params_snapshot: Map::new(),
672 origin: Origin::Builtin,
673 target_ref: "grain:sha256:abc".into(),
674 action_kind: ActionKind::Expire,
675 dedup_key: "k".into(),
676 summary: Summary::new("staleness.expired", Map::new()),
677 severity: Severity::Low,
678 proposal: Proposal::Cal {
679 cal: "FORGET sha256:abc".into(),
680 },
681 destructive: true,
682 rollbackable: false,
683 evidence: vec!["sha256:abc".into()],
684 evidence_query: None,
685 metric: None,
686 confidence: 0.9,
687 importance: 0.4,
688 created_at_ms: 1000,
689 guidance: None,
690 evalset_hash: None,
691 status: RecStatus::Pending,
692 };
693 let spec = rec.to_grain_spec("ns").unwrap();
694 assert!(!spec.fields.contains_key("hash"));
696 assert!(!spec.fields.contains_key("status"));
697 let back = Recommendation::from_fields("realhash", &spec.fields).unwrap();
698 assert_eq!(back.hash, "realhash");
699 assert_eq!(back.analyzer, "loop.staleness/1");
700 assert!(back.destructive);
701 assert!(matches!(back.proposal, Proposal::Cal { .. }));
702 }
703}