1use crate::error::{Error, Result};
16use crate::model::{normalize_ident, ActionKind, Origin, Severity};
17use crate::substrate::GrainSpec;
18use serde::{Deserialize, Serialize};
19use serde_json::{Map, Value};
20
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct Summary {
26 pub template_id: String,
27 #[serde(default)]
28 pub args: Map<String, Value>,
29}
30
31impl Summary {
32 pub fn new(template_id: impl Into<String>, args: Map<String, Value>) -> Self {
33 Summary {
34 template_id: template_id.into(),
35 args,
36 }
37 }
38
39 pub fn render(&self) -> String {
43 let t = builtin_template(&self.template_id).unwrap_or("{template_id}: {summary}");
44 interpolate(t, &self.args, &self.template_id)
45 }
46}
47
48fn builtin_template(id: &str) -> Option<&'static str> {
51 Some(match id {
52 "duplicate.exact" => "Consolidate {count} exact-duplicate grains for \"{subject}\"",
53 "duplicate.near" => {
54 "Consolidate {count} near-duplicate observations (similarity ≥ {threshold})"
55 }
56 "contradiction.functional" => {
57 "\"{subject}\" holds {count} live values for functional relation \"{relation}\""
58 }
59 "tool_failure.cluster" => {
60 "Tool \"{tool}\" failed {count} times ({rate}% of the calls that could \
61 fail this way): {signature}"
62 }
63 "staleness.expired" => "Expire \"{subject}\": past its declared valid_to ({age_days}d ago)",
64 "fork.multi_head" => "Entity \"{entity}\" has {count} competing heads",
65 "skill.stall" => {
66 "Skill \"{skill}\" isn't improving: practiced {practice_count}× but proficiency is still {proficiency}"
67 }
68 "goal.stagnation" => "Goal \"{goal}\" is stalled: active {age_days}d with {progress} progress",
69 "cold.grain" => {
70 "Cold memory: \"{subject}\" ({age_days}d old) has never been recalled — retire candidate"
71 }
72 "coverage.gap" => {
73 "Recurring question with no matching memory: \"{query}\" asked {count}× ({empty_rate}% empty)"
74 }
75 "budget.pressure" => {
76 "Assembly budget overflowed on {overflow_rate}% of {samples} recalls — raise the budget or curate memory"
77 }
78 "retention.overdue" => {
79 "Retention: {count} grain(s) in \"{namespace}\" exceed the {max_age_days}d policy (oldest {oldest_days}d); {remaining} more await a later pass"
80 }
81 "outcome.regression" => {
82 "Applied recommendation regressed: {metric} moved {baseline} → {current}"
83 }
84 "outcome.regression_high_water" => {
88 "Applied recommendation regressed against the best run before the apply ({baseline_run}): {metric} moved {baseline} → {current}"
89 }
90 "outcome.regression_costlier" => {
93 "Applied recommendation regressed: {metric} moved {baseline} → {current}, and {cost_field} rose {cost_baseline} → {cost_current} (bound ×{cost_ratio})"
94 }
95 "outcome.regression_high_water_costlier" => {
96 "Applied recommendation regressed against the best run before the apply ({baseline_run}): {metric} moved {baseline} → {current}, and {cost_field} rose {cost_baseline} → {cost_current} (bound ×{cost_ratio})"
97 }
98 "outcome.held_costlier" => {
101 "Applied recommendation held ({metric} {baseline} → {current}) but {cost_field} rose {cost_baseline} → {cost_current}, past ×{cost_ratio} of the baseline run ({baseline_run} → {current_run}) — a cost/quality trade to decide"
102 }
103 "outcome.premise_drift" => {
104 "{current} of the grains this recommendation cited have since been superseded by a different value or retracted — its premise moved; revert it"
105 }
106 "run.failures" => {
107 "Workflow {workflow} failed {failed}/{runs} recent runs ({rate}%): {last_error}"
108 }
109 "run.cost" => {
110 "Workflow {workflow} spent ${usd} across {runs} runs (avg ${avg_usd}/run)"
111 }
112 "run.context_pressure" => {
113 "Workflow {workflow} summarized its own transcript {folds} time(s) across {runs} runs (avg {avg_folds}/run) — its nodes are outgrowing the model's window"
114 }
115 "adapter.candidate" => {
116 "Promote adapter for \"{model}\" (base {base_model}) — gated on its pinned evalset"
117 }
118 "llm.discover" => "{text}",
121 "llm.lesson" => "{text} — record lesson: \"{lesson}\"",
124 "llm.lesson_near_duplicate" => {
128 "{text} — record lesson: \"{lesson}\" — NEAR-DUPLICATE of {near_count} live lesson(s) on this entity (best match {near_score} by {near_method}, {near_hash})"
129 }
130 "llm.consolidation" => {
133 "{text} — consolidate {count} lessons into one: \"{lesson}\""
134 }
135 "lesson.pile" => {
136 "{count} live lessons on \"{subject}\" exceed the budget of {max_active} — every rule competes for the model's attention; consolidate or retire: {members}"
137 }
138 "llm.fact" => "{text} — record fact: {relation} = \"{object}\"",
142 "llm.skill" => "{text} — record skill: \"{name}\" ({steps} steps)",
143 "llm.plan" => "{text} — record plan: \"{name}\" ({nodes} steps, {edges} edges)",
144 "llm.query_revision" => "{text} — redefine \"{name}\" as: {body}",
145 "llm.plan_revision" => "{text} — revise plan {plan}: {edits}",
146 "llm.plan_revision_refused" => {
149 "{text} — plan revision of {plan} ({edits}) NOT applicable: rehearsed against the plan's journaled runs and {reason}"
150 }
151 "llm.code_revision" => {
152 "{text} — new source for tool \"{tool}\" ({bytes} bytes), pending its evalset gate"
153 }
154 "command.finding" => "{text}",
157 _ => return None,
158 })
159}
160
161fn interpolate(template: &str, args: &Map<String, Value>, template_id: &str) -> String {
162 let mut out = String::with_capacity(template.len());
163 let mut chars = template.chars().peekable();
164 while let Some(c) = chars.next() {
165 if c == '{' {
166 let mut key = String::new();
167 for k in chars.by_ref() {
168 if k == '}' {
169 break;
170 }
171 key.push(k);
172 }
173 if key == "template_id" {
174 out.push_str(template_id);
175 } else {
176 match args.get(&key) {
177 Some(Value::String(s)) => out.push_str(s),
178 Some(v) => out.push_str(&v.to_string()),
179 None => {
180 out.push('{');
182 out.push_str(&key);
183 out.push('}');
184 }
185 }
186 }
187 } else {
188 out.push(c);
189 }
190 }
191 out
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
211pub enum Checkpoint {
212 AfterMs(i64),
214 AfterRuns(u32),
217 AfterGrains(u32),
219}
220
221impl Checkpoint {
222 pub fn label(&self) -> String {
224 match self {
225 Checkpoint::AfterMs(ms) if ms % 86_400_000 == 0 => format!("{}d", ms / 86_400_000),
226 Checkpoint::AfterMs(ms) if ms % 3_600_000 == 0 => format!("{}h", ms / 3_600_000),
227 Checkpoint::AfterMs(ms) => format!("{ms}ms"),
228 Checkpoint::AfterRuns(n) => format!("{n} run{}", if *n == 1 { "" } else { "s" }),
229 Checkpoint::AfterGrains(n) => format!("{n} grain{}", if *n == 1 { "" } else { "s" }),
230 }
231 }
232
233 pub fn as_ms(&self) -> Option<i64> {
236 match self {
237 Checkpoint::AfterMs(ms) => Some(*ms),
238 _ => None,
239 }
240 }
241}
242
243impl Serialize for Checkpoint {
244 fn serialize<S: serde::Serializer>(&self, ser: S) -> std::result::Result<S::Ok, S::Error> {
245 use serde::ser::SerializeMap;
246 match self {
247 Checkpoint::AfterMs(ms) => ser.serialize_i64(*ms),
249 Checkpoint::AfterRuns(n) => {
250 let mut m = ser.serialize_map(Some(1))?;
251 m.serialize_entry("after_runs", n)?;
252 m.end()
253 }
254 Checkpoint::AfterGrains(n) => {
255 let mut m = ser.serialize_map(Some(1))?;
256 m.serialize_entry("after_grains", n)?;
257 m.end()
258 }
259 }
260 }
261}
262
263impl<'de> Deserialize<'de> for Checkpoint {
264 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> std::result::Result<Self, D::Error> {
265 use serde::de::Error as _;
266 let v = Value::deserialize(de)?;
267 match &v {
268 Value::Number(n) => n
269 .as_i64()
270 .filter(|ms| *ms >= 0)
271 .map(Checkpoint::AfterMs)
272 .ok_or_else(|| D::Error::custom("checkpoint: a bare number is non-negative milliseconds")),
273 Value::Object(m) if m.len() == 1 => {
274 let (k, val) = m.iter().next().unwrap();
275 let n = val.as_u64().ok_or_else(|| {
276 D::Error::custom(format!("checkpoint: {k} takes a non-negative integer"))
277 })?;
278 match k.as_str() {
279 "after_ms" => i64::try_from(n)
280 .map(Checkpoint::AfterMs)
281 .map_err(|_| D::Error::custom("checkpoint: after_ms out of range")),
282 "after_runs" => u32::try_from(n)
283 .map(Checkpoint::AfterRuns)
284 .map_err(|_| D::Error::custom("checkpoint: after_runs out of range")),
285 "after_grains" => u32::try_from(n)
286 .map(Checkpoint::AfterGrains)
287 .map_err(|_| D::Error::custom("checkpoint: after_grains out of range")),
288 other => Err(D::Error::custom(format!(
289 "checkpoint: unknown unit {other:?} (expected after_ms, after_runs or after_grains)"
290 ))),
291 }
292 }
293 _ => Err(D::Error::custom(
294 "checkpoint: expected milliseconds or {\"after_ms\"|\"after_runs\"|\"after_grains\": n}",
295 )),
296 }
297 }
298}
299
300#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
302pub struct MetricSnapshot {
303 pub metric: String,
306 pub baseline: f64,
307 pub unit: String,
308 pub n: u64,
309 pub window: String,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub subject: Option<String>,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub namespace: Option<String>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub relation: Option<String>,
324 pub query: String,
327 pub review_after_ms: i64,
330 #[serde(default, skip_serializing_if = "Vec::is_empty")]
334 pub horizons_ms: Vec<i64>,
335 #[serde(default, skip_serializing_if = "Vec::is_empty")]
339 pub checkpoints: Vec<Checkpoint>,
340 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
347 pub higher_is_better: bool,
348}
349
350pub fn is_regression(baseline: f64, current: f64, higher_is_better: bool, tolerance: f64) -> bool {
367 const EPSILON: f64 = 1e-9;
368 let tolerance = if tolerance.is_finite() && tolerance > 0.0 { tolerance } else { 0.0 };
369 if higher_is_better {
370 current < baseline - tolerance - EPSILON
371 } else {
372 current > baseline + tolerance + EPSILON
373 }
374}
375
376impl MetricSnapshot {
377 pub fn schedule(&self) -> Vec<Checkpoint> {
382 let mut h: Vec<Checkpoint> = if !self.checkpoints.is_empty() {
383 self.checkpoints.clone()
384 } else if !self.horizons_ms.is_empty() {
385 self.horizons_ms.iter().map(|ms| Checkpoint::AfterMs(*ms)).collect()
386 } else {
387 vec![Checkpoint::AfterMs(self.review_after_ms)]
388 };
389 h.sort_unstable();
390 h.dedup();
391 h
392 }
393
394 pub fn horizons(&self) -> Vec<i64> {
397 self.schedule().iter().filter_map(Checkpoint::as_ms).collect()
398 }
399}
400
401fn is_zero(x: &f64) -> bool {
402 *x == 0.0
403}
404
405#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
411pub struct CostRead {
412 pub field: String,
413 pub max_increase_ratio: f64,
414 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub baseline: Option<f64>,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub current: Option<f64>,
418 pub status: String,
419}
420
421impl CostRead {
422 pub fn breached(&self) -> bool {
423 self.status == "breached"
424 }
425}
426
427#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
432pub struct OutcomeResult {
433 pub rec_hash: String,
434 pub metric: String,
435 pub baseline: f64,
436 pub current: f64,
437 pub verdict: String,
438 #[serde(default, skip_serializing_if = "String::is_empty")]
444 pub baseline_kind: String,
445 #[serde(default, skip_serializing_if = "Option::is_none")]
447 pub baseline_run_id: Option<String>,
448 #[serde(default, skip_serializing_if = "Option::is_none")]
453 pub best_before: Option<f64>,
454 #[serde(default, skip_serializing_if = "is_zero")]
459 pub tolerance: f64,
460 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub current_run_id: Option<String>,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
467 pub cost: Option<CostRead>,
468 #[serde(default)]
471 pub horizon_ms: i64,
472 #[serde(default, skip_serializing_if = "Option::is_none")]
476 pub checkpoint: Option<Checkpoint>,
477 pub measured_at_ms: i64,
478}
479
480#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
482#[serde(tag = "proposal", rename_all = "snake_case")]
483pub enum Proposal {
484 Cal { cal: String },
486 Edit {
489 format: String,
490 base_digest: String,
491 diff: String,
492 },
493 Data { data: Map<String, Value> },
495}
496
497#[derive(Debug, Clone, PartialEq)]
501#[non_exhaustive]
502pub struct RecDraft {
503 pub target_ref: String,
504 pub action_kind: ActionKind,
505 pub summary: Summary,
506 pub severity: Severity,
507 pub proposal: Proposal,
508 pub evidence: Vec<String>,
510 pub evidence_query: Option<String>,
511 pub metric: Option<MetricSnapshot>,
512 pub confidence: f64,
513 pub importance: f64,
514 pub evalset_hash: Option<String>,
516}
517
518impl RecDraft {
519 pub fn new(
520 target_ref: impl Into<String>,
521 action_kind: ActionKind,
522 summary: Summary,
523 proposal: Proposal,
524 ) -> Self {
525 RecDraft {
526 target_ref: target_ref.into(),
527 action_kind,
528 summary,
529 severity: Severity::Low,
530 proposal,
531 evidence: Vec::new(),
532 evidence_query: None,
533 metric: None,
534 confidence: 0.8,
535 importance: 0.5,
536 evalset_hash: None,
537 }
538 }
539
540 pub fn severity(mut self, s: Severity) -> Self {
541 self.severity = s;
542 self
543 }
544 pub fn evidence(mut self, hashes: Vec<String>) -> Self {
545 self.evidence = hashes;
546 self
547 }
548 pub fn evidence_query(mut self, q: impl Into<String>) -> Self {
549 self.evidence_query = Some(q.into());
550 self
551 }
552 pub fn metric(mut self, m: MetricSnapshot) -> Self {
553 self.metric = Some(m);
554 self
555 }
556 pub fn confidence(mut self, c: f64) -> Self {
557 self.confidence = c;
558 self
559 }
560 pub fn importance(mut self, i: f64) -> Self {
561 self.importance = i;
562 self
563 }
564 pub fn evalset_hash(mut self, h: impl Into<String>) -> Self {
565 self.evalset_hash = Some(h.into());
566 self
567 }
568}
569
570pub const MAX_EVIDENCE: usize = 64;
572
573pub fn dedup_key(family: &str, target_ref: &str, action: ActionKind) -> String {
576 format!(
578 "{}\u{1f}{}\u{1f}{}",
579 normalize_ident(family),
580 normalize_ident(target_ref),
581 action.as_str()
582 )
583}
584
585pub fn authored_dedup_key(
595 family: &str,
596 target_ref: &str,
597 action: ActionKind,
598 content: &str,
599) -> String {
600 format!(
601 "{}\u{1f}{}",
602 dedup_key(family, target_ref, action),
603 content_fingerprint(content)
604 )
605}
606
607pub fn revert_dedup_key(family: &str, target_ref: &str, revert_of: &str) -> String {
615 format!(
616 "{}\u{1f}{}",
617 dedup_key(family, target_ref, ActionKind::Revert),
618 normalize_ident(revert_of)
619 )
620}
621
622pub fn content_fingerprint(content: &str) -> String {
627 let mut normalized = String::with_capacity(content.len());
628 let mut pending_space = false;
629 for c in content.chars() {
630 if c.is_alphanumeric() {
631 if pending_space && !normalized.is_empty() {
632 normalized.push(' ');
633 }
634 pending_space = false;
635 normalized.extend(c.to_lowercase());
636 } else if c.is_whitespace() || !c.is_alphanumeric() {
637 pending_space = true;
638 }
639 }
640 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
641 for b in normalized.as_bytes() {
642 h ^= u64::from(*b);
643 h = h.wrapping_mul(0x0000_0100_0000_01b3);
644 }
645 format!("{h:016x}")
646}
647
648#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
651#[serde(rename_all = "snake_case")]
652pub enum RecStatus {
653 #[default]
654 Pending,
655 Approved,
656 Rejected,
657 Applied,
658 RolledBack,
659 Expired,
660}
661
662impl RecStatus {
663 pub fn as_str(&self) -> &'static str {
664 match self {
665 RecStatus::Pending => "pending",
666 RecStatus::Approved => "approved",
667 RecStatus::Rejected => "rejected",
668 RecStatus::Applied => "applied",
669 RecStatus::RolledBack => "rolled_back",
670 RecStatus::Expired => "expired",
671 }
672 }
673
674 pub fn can_transition_to(&self, to: RecStatus, by_policy: bool) -> bool {
678 use RecStatus::*;
679 match (self, to) {
680 (Pending, Approved) | (Pending, Rejected) => true,
681 (Pending, Applied) => by_policy, (Approved, Applied) => true,
683 (Applied, RolledBack) => true,
684 (Pending, Expired) | (Approved, Expired) => true,
686 _ => false,
687 }
688 }
689}
690
691#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
693#[serde(rename_all = "lowercase")]
694pub enum ObserverType {
695 Human,
696 Agent,
697 Policy,
698 System,
699}
700
701#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
704pub struct AuditRecord {
705 pub rec_hash: String,
706 #[serde(skip_serializing_if = "Option::is_none")]
707 pub from: Option<RecStatus>,
708 pub to: RecStatus,
709 pub actor: String,
712 pub observer_type: ObserverType,
713 pub because: String,
715 #[serde(skip_serializing_if = "Option::is_none")]
716 pub previous_audit_hash: Option<String>,
717 #[serde(default, skip_serializing_if = "Option::is_none")]
720 pub gating: Option<GatingEvidence>,
721 pub at_ms: i64,
722}
723
724pub const MAX_BECAUSE: usize = 500;
726
727impl AuditRecord {
728 pub fn to_grain_spec(&self, namespace: &str) -> GrainSpec {
731 let mut derived_from = vec![Value::from(self.rec_hash.clone())];
732 if let Some(prev) = &self.previous_audit_hash {
733 derived_from.push(Value::from(prev.clone()));
734 }
735 let mut spec = GrainSpec::new(crate::model::grain_type::OBSERVATION, namespace)
736 .with_field("observation_kind", "loop_audit")
737 .with_field("rec_hash", self.rec_hash.clone())
738 .with_field("to_status", self.to.as_str())
739 .with_field("actor", self.actor.clone())
740 .with_field(
741 "observer_type",
742 serde_json::to_value(self.observer_type).unwrap(),
743 )
744 .with_field("because", self.because.clone())
745 .with_field("at_ms", self.at_ms)
746 .with_field("derived_from", Value::Array(derived_from));
747 if let Some(from) = self.from {
748 spec.fields
749 .insert("from_status".into(), Value::from(from.as_str()));
750 }
751 if let Some(g) = &self.gating {
752 spec.fields
753 .insert("gating_evalset".into(), Value::from(g.evalset_hash.clone()));
754 spec.fields
755 .insert("gating_run_id".into(), Value::from(g.run_id.clone()));
756 spec.fields.insert("gating_passed".into(), Value::from(g.passed));
757 spec.fields.insert("gating_failed".into(), Value::from(g.failed));
758 }
759 spec
760 }
761}
762
763#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
771pub struct NearDuplicate {
772 pub hash: String,
773 pub score: f64,
774 pub method: String,
775}
776
777#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
778pub struct Recommendation {
779 #[serde(skip)]
780 pub hash: String,
781 pub analyzer: String,
782 pub params_snapshot: Map<String, Value>,
783 pub origin: Origin,
784 pub target_ref: String,
785 pub action_kind: ActionKind,
786 pub dedup_key: String,
787 pub summary: Summary,
788 pub severity: Severity,
789 #[serde(flatten)]
790 pub proposal: Proposal,
791 pub destructive: bool,
792 pub rollbackable: bool,
793 #[serde(default)]
794 pub evidence: Vec<String>,
795 #[serde(default, skip_serializing_if = "Option::is_none")]
796 pub evidence_query: Option<String>,
797 #[serde(default, skip_serializing_if = "Option::is_none")]
798 pub metric: Option<MetricSnapshot>,
799 pub confidence: f64,
800 pub importance: f64,
801 pub created_at_ms: i64,
802 #[serde(default, skip_serializing_if = "Option::is_none")]
806 pub guidance: Option<String>,
807 #[serde(default, skip_serializing_if = "Option::is_none")]
813 pub evalset_hash: Option<String>,
814 #[serde(default, skip_serializing_if = "Vec::is_empty")]
818 pub near_duplicate_of: Vec<NearDuplicate>,
819 #[serde(default, skip_serializing_if = "Option::is_none")]
826 pub replay: Option<Value>,
827 #[serde(skip)]
828 pub status: RecStatus,
829}
830
831#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
835pub struct GatingEvidence {
836 pub evalset_hash: String,
838 pub run_id: String,
840 pub passed: u64,
841 pub failed: u64,
842}
843
844pub fn validate_code_rules(
854 action: ActionKind,
855 target_class: &str,
856 evalset_hash: Option<&str>,
857) -> Result<()> {
858 let e1 = |why: &str| Err(Error::InvalidRecommendation(format!("Rule E1: {why}")));
859 match (action, target_class) {
860 (ActionKind::CodeRevision, "code") => {
861 if evalset_hash.is_none_or(|h| h.trim().is_empty()) {
862 return e1(
863 "a code_revision must pin the evalset hash it was gated \
864 against (evalset_hash)",
865 );
866 }
867 }
868 (ActionKind::CodeRevision, other) => {
869 return e1(&format!(
870 "code_revision requires a tool: target, got class '{other}'"
871 ));
872 }
873 (ActionKind::AdapterRevision, "model") => {
874 if evalset_hash.is_none_or(|h| h.trim().is_empty()) {
875 return e1(
876 "an adapter_revision must pin the evalset hash it was \
877 gated against (evalset_hash)",
878 );
879 }
880 }
881 (ActionKind::AdapterRevision, other) => {
882 return e1(&format!(
883 "adapter_revision requires a model: target, got class '{other}'"
884 ));
885 }
886 (ActionKind::Revert, "code") => {
891 if evalset_hash.is_some() {
892 return e1("a code revert carries no evalset pin");
893 }
894 }
895 (_, "code") => {
896 return e1("a tool: target requires action_kind code_revision");
897 }
898 (ActionKind::Revert, "model") => {
900 if evalset_hash.is_some() {
901 return e1("an adapter revert carries no evalset pin");
902 }
903 }
904 (_, "model") => {
905 return e1("a model: target requires action_kind adapter_revision");
906 }
907 (_, "evalset") => {
908 if evalset_hash.is_some() {
909 return e1(
910 "an evalset-target recommendation cannot pin an evalset — \
911 the gate cannot gate itself",
912 );
913 }
914 }
915 _ => {
916 if evalset_hash.is_some() {
917 return e1(
918 "evalset_hash is only valid on code_revision or \
919 adapter_revision recommendations",
920 );
921 }
922 }
923 }
924 Ok(())
925}
926
927impl Recommendation {
928 pub fn to_grain_spec(&self, namespace: &str) -> Result<GrainSpec> {
930 let value = serde_json::to_value(self)
931 .map_err(|e| Error::Internal(format!("serialize recommendation: {e}")))?;
932 let obj = value
933 .as_object()
934 .ok_or_else(|| Error::Internal("recommendation did not serialize to object".into()))?
935 .clone();
936 Ok(GrainSpec {
937 grain_type: crate::model::grain_type::RECOMMENDATION.to_string(),
938 namespace: namespace.to_string(),
939 fields: obj,
940 })
941 }
942
943 pub fn from_fields(hash: &str, fields: &Map<String, Value>) -> Result<Self> {
948 let mut rec: Recommendation = serde_json::from_value(Value::Object(fields.clone()))
949 .map_err(|e| Error::InvalidRecommendation(format!("decode {hash}: {e}")))?;
950 rec.hash = hash.to_string();
951 let class = crate::model::TargetRef::parse(&rec.target_ref)
952 .map(|t| t.target_class())
953 .unwrap_or("host");
954 validate_code_rules(rec.action_kind, class, rec.evalset_hash.as_deref())?;
955 Ok(rec)
956 }
957}
958
959#[cfg(test)]
960mod tests {
961 #[test]
966 fn the_regression_rule_holds_at_the_tolerance_and_regresses_past_it() {
967 use super::is_regression;
968 assert!(!is_regression(100.0, 0.0, true, 100.0), "a 100-point drop under a 100-point tolerance is accepted");
970 assert!(is_regression(100.0, 0.0, true, 99.9));
971 assert!(!is_regression(92.0, 91.5, true, 1.0));
972 assert!(is_regression(92.0, 90.5, true, 1.0));
973 assert!(!is_regression(359.0, 355.0, true, 5.0));
975 assert!(!is_regression(359.0, 354.0, true, 5.0), "exactly at the floor holds");
976 assert!(is_regression(359.0, 353.0, true, 5.0));
977 assert!(!is_regression(28.0, 33.0, false, 5.0));
978 assert!(is_regression(28.0, 34.0, false, 5.0));
979 assert!(is_regression(359.0, 355.0, true, 0.0));
981 assert!(is_regression(359.0, 355.0, true, -5.0));
982 assert!(is_regression(359.0, 355.0, true, f64::NAN));
983 assert!(!is_regression(1.0, 1.0, true, 0.0), "equal is never a regression");
984 }
985
986 use super::*;
987 use serde_json::json;
988
989 #[test]
990 fn summary_renders_deterministically() {
991 let mut args = Map::new();
992 args.insert("count".into(), json!(3));
993 args.insert("subject".into(), json!("acme"));
994 let s = Summary::new("duplicate.exact", args);
995 assert_eq!(
996 s.render(),
997 "Consolidate 3 exact-duplicate grains for \"acme\""
998 );
999 }
1000
1001 #[test]
1002 fn dedup_key_ignores_content_and_case() {
1003 let a = dedup_key(
1004 "loop.duplicate_sweep",
1005 "entity:NS/John",
1006 ActionKind::Consolidate,
1007 );
1008 let b = dedup_key(
1009 "loop.duplicate_sweep",
1010 "entity:ns/john",
1011 ActionKind::Consolidate,
1012 );
1013 assert_eq!(a, b, "case-folded to one identity");
1014 }
1015
1016 #[test]
1017 fn authored_dedup_key_distinguishes_content_but_not_wording_noise() {
1018 let a = authored_dedup_key("llm", "entity:ns/x", ActionKind::Record, "Record the vendor name.");
1019 let same = authored_dedup_key("llm", "entity:NS/X", ActionKind::Record, " record THE vendor name ");
1020 let other = authored_dedup_key("llm", "entity:ns/x", ActionKind::Record, "Record the amount.");
1021 assert_eq!(a, same, "case, punctuation and spacing are not a new lesson");
1022 assert_ne!(a, other, "a different lesson on the same entity is a different finding");
1023 assert!(a.starts_with(&dedup_key("llm", "entity:ns/x", ActionKind::Record)));
1024 assert_eq!(content_fingerprint("A b"), content_fingerprint("a-b"));
1025 assert_ne!(content_fingerprint("ab"), content_fingerprint("a b"));
1026 }
1027
1028 #[test]
1029 fn a_revert_is_keyed_by_what_it_reverts() {
1030 let a = revert_dedup_key("loop.outcome_review", "entity:ns/capture", "aaaa");
1031 let b = revert_dedup_key("loop.outcome_review", "entity:ns/capture", "bbbb");
1032 let a2 = revert_dedup_key("loop.outcome_review", "entity:NS/Capture", "AAAA");
1033 assert_ne!(a, b, "two reverts on one target are two findings");
1034 assert_eq!(a, a2, "the same revert, case-folded, is one");
1035 assert!(a.starts_with(&dedup_key("loop.outcome_review", "entity:ns/capture", ActionKind::Revert)));
1036 }
1037
1038 #[test]
1039 fn dedup_key_distinguishes_action() {
1040 let a = dedup_key("f", "entity:ns/x", ActionKind::Consolidate);
1041 let b = dedup_key("f", "entity:ns/x", ActionKind::FlagContradiction);
1042 assert_ne!(a, b);
1043 }
1044
1045 #[test]
1046 fn lifecycle_gates_pending_to_applied() {
1047 assert!(!RecStatus::Pending.can_transition_to(RecStatus::Applied, false));
1048 assert!(RecStatus::Pending.can_transition_to(RecStatus::Applied, true)); assert!(RecStatus::Pending.can_transition_to(RecStatus::Approved, false));
1050 assert!(RecStatus::Approved.can_transition_to(RecStatus::Applied, false));
1051 assert!(RecStatus::Applied.can_transition_to(RecStatus::RolledBack, false));
1052 assert!(!RecStatus::Rejected.can_transition_to(RecStatus::Applied, true));
1053 }
1054
1055 #[test]
1056 fn rule_e1_pins_code_and_only_code() {
1057 use crate::model::ActionKind as A;
1058 assert!(validate_code_rules(A::CodeRevision, "code", Some("abc")).is_ok());
1060 assert!(validate_code_rules(A::CodeRevision, "code", None).is_err());
1062 assert!(validate_code_rules(A::CodeRevision, "code", Some(" ")).is_err());
1063 assert!(validate_code_rules(A::CodeRevision, "memory", Some("abc")).is_err());
1065 assert!(validate_code_rules(A::Flag, "code", None).is_err());
1067 assert!(validate_code_rules(A::Flag, "evalset", Some("abc")).is_err());
1069 assert!(validate_code_rules(A::Flag, "evalset", None).is_ok());
1070 assert!(validate_code_rules(A::Consolidate, "memory", Some("abc")).is_err());
1072 assert!(validate_code_rules(A::Consolidate, "memory", None).is_ok());
1073 }
1074
1075 #[test]
1076 fn rule_e1_pins_adapter_revisions_like_code() {
1077 use crate::model::ActionKind as A;
1078 assert!(validate_code_rules(A::AdapterRevision, "model", Some("abc")).is_ok());
1080 assert!(validate_code_rules(A::AdapterRevision, "model", None).is_err());
1082 assert!(validate_code_rules(A::AdapterRevision, "model", Some(" ")).is_err());
1083 assert!(validate_code_rules(A::AdapterRevision, "memory", Some("abc")).is_err());
1085 assert!(validate_code_rules(A::AdapterRevision, "code", Some("abc")).is_err());
1086 assert!(validate_code_rules(A::Flag, "model", None).is_err());
1088 assert!(validate_code_rules(A::Revert, "model", None).is_ok());
1090 assert!(validate_code_rules(A::Revert, "model", Some("abc")).is_err());
1091 }
1092
1093 #[test]
1094 fn from_fields_rechecks_rule_e1() {
1095 let mut rec = Recommendation {
1098 hash: String::new(),
1099 analyzer: "loop.codegen/1".into(),
1100 params_snapshot: Map::new(),
1101 origin: Origin::Builtin,
1102 target_ref: "tool:abc123".into(),
1103 action_kind: ActionKind::CodeRevision,
1104 dedup_key: "k".into(),
1105 summary: Summary::new("command.finding", Map::new()),
1106 severity: Severity::Low,
1107 proposal: Proposal::Data { data: Map::new() },
1108 destructive: false,
1109 rollbackable: false,
1110 evidence: vec![],
1111 evidence_query: None,
1112 metric: None,
1113 confidence: 0.5,
1114 importance: 0.5,
1115 created_at_ms: 0,
1116 guidance: None,
1117 evalset_hash: None, near_duplicate_of: Vec::new(),
1119 replay: None,
1120 status: RecStatus::Pending,
1121 };
1122 let spec = rec.to_grain_spec("ns").unwrap();
1123 assert!(Recommendation::from_fields("h", &spec.fields).is_err());
1124 rec.evalset_hash = Some("es-hash".into());
1125 let spec = rec.to_grain_spec("ns").unwrap();
1126 let back = Recommendation::from_fields("h", &spec.fields).unwrap();
1127 assert_eq!(back.evalset_hash.as_deref(), Some("es-hash"));
1128 }
1129
1130 #[test]
1131 fn recommendation_round_trips_through_fields() {
1132 let rec = Recommendation {
1133 hash: "ignored".into(),
1134 analyzer: "loop.staleness/1".into(),
1135 params_snapshot: Map::new(),
1136 origin: Origin::Builtin,
1137 target_ref: "grain:sha256:abc".into(),
1138 action_kind: ActionKind::Expire,
1139 dedup_key: "k".into(),
1140 summary: Summary::new("staleness.expired", Map::new()),
1141 severity: Severity::Low,
1142 proposal: Proposal::Cal {
1143 cal: "FORGET sha256:abc".into(),
1144 },
1145 destructive: true,
1146 rollbackable: false,
1147 evidence: vec!["sha256:abc".into()],
1148 evidence_query: None,
1149 metric: None,
1150 confidence: 0.9,
1151 importance: 0.4,
1152 created_at_ms: 1000,
1153 guidance: None,
1154 evalset_hash: None,
1155 near_duplicate_of: Vec::new(),
1156 replay: None,
1157 status: RecStatus::Pending,
1158 };
1159 let spec = rec.to_grain_spec("ns").unwrap();
1160 assert!(!spec.fields.contains_key("hash"));
1162 assert!(!spec.fields.contains_key("status"));
1163 let back = Recommendation::from_fields("realhash", &spec.fields).unwrap();
1164 assert_eq!(back.hash, "realhash");
1165 assert_eq!(back.analyzer, "loop.staleness/1");
1166 assert!(back.destructive);
1167 assert!(matches!(back.proposal, Proposal::Cal { .. }));
1168 }
1169}