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 Withdrawn,
670}
671
672impl RecStatus {
673 pub fn as_str(&self) -> &'static str {
674 match self {
675 RecStatus::Pending => "pending",
676 RecStatus::Approved => "approved",
677 RecStatus::Rejected => "rejected",
678 RecStatus::Applied => "applied",
679 RecStatus::RolledBack => "rolled_back",
680 RecStatus::Expired => "expired",
681 RecStatus::Withdrawn => "withdrawn",
682 }
683 }
684
685 pub fn can_transition_to(&self, to: RecStatus, by_policy: bool) -> bool {
689 use RecStatus::*;
690 match (self, to) {
691 (Pending, Approved) | (Pending, Rejected) => true,
692 (Pending, Applied) => by_policy, (Approved, Applied) => true,
694 (Applied, RolledBack) => true,
695 (Pending, Expired) | (Approved, Expired) => true,
697 (Pending, Withdrawn) | (Approved, Withdrawn) => true,
702 _ => false,
703 }
704 }
705}
706
707#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
709#[serde(rename_all = "lowercase")]
710pub enum ObserverType {
711 Human,
712 Agent,
713 Policy,
714 System,
715}
716
717#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
720pub struct AuditRecord {
721 pub rec_hash: String,
722 #[serde(skip_serializing_if = "Option::is_none")]
723 pub from: Option<RecStatus>,
724 pub to: RecStatus,
725 pub actor: String,
728 pub observer_type: ObserverType,
729 pub because: String,
731 #[serde(skip_serializing_if = "Option::is_none")]
732 pub previous_audit_hash: Option<String>,
733 #[serde(default, skip_serializing_if = "Option::is_none")]
736 pub gating: Option<GatingEvidence>,
737 pub at_ms: i64,
738}
739
740pub const MAX_BECAUSE: usize = 500;
742
743impl AuditRecord {
744 pub fn to_grain_spec(&self, namespace: &str) -> GrainSpec {
747 let mut derived_from = vec![Value::from(self.rec_hash.clone())];
748 if let Some(prev) = &self.previous_audit_hash {
749 derived_from.push(Value::from(prev.clone()));
750 }
751 let mut spec = GrainSpec::new(crate::model::grain_type::OBSERVATION, namespace)
752 .with_field("observation_kind", "loop_audit")
753 .with_field("rec_hash", self.rec_hash.clone())
754 .with_field("to_status", self.to.as_str())
755 .with_field("actor", self.actor.clone())
756 .with_field(
757 "observer_type",
758 serde_json::to_value(self.observer_type).unwrap(),
759 )
760 .with_field("because", self.because.clone())
761 .with_field("at_ms", self.at_ms)
762 .with_field("derived_from", Value::Array(derived_from));
763 if let Some(from) = self.from {
764 spec.fields
765 .insert("from_status".into(), Value::from(from.as_str()));
766 }
767 if let Some(g) = &self.gating {
768 spec.fields
769 .insert("gating_evalset".into(), Value::from(g.evalset_hash.clone()));
770 spec.fields
771 .insert("gating_run_id".into(), Value::from(g.run_id.clone()));
772 spec.fields.insert("gating_passed".into(), Value::from(g.passed));
773 spec.fields.insert("gating_failed".into(), Value::from(g.failed));
774 }
775 spec
776 }
777}
778
779#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
787pub struct NearDuplicate {
788 pub hash: String,
789 pub score: f64,
790 pub method: String,
791}
792
793#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
794pub struct Recommendation {
795 #[serde(skip)]
796 pub hash: String,
797 pub analyzer: String,
798 pub params_snapshot: Map<String, Value>,
799 pub origin: Origin,
800 pub target_ref: String,
801 pub action_kind: ActionKind,
802 pub dedup_key: String,
803 pub summary: Summary,
804 pub severity: Severity,
805 #[serde(flatten)]
806 pub proposal: Proposal,
807 pub destructive: bool,
808 pub rollbackable: bool,
809 #[serde(default)]
810 pub evidence: Vec<String>,
811 #[serde(default, skip_serializing_if = "Option::is_none")]
812 pub evidence_query: Option<String>,
813 #[serde(default, skip_serializing_if = "Option::is_none")]
814 pub metric: Option<MetricSnapshot>,
815 pub confidence: f64,
816 pub importance: f64,
817 pub created_at_ms: i64,
818 #[serde(default, skip_serializing_if = "Option::is_none")]
822 pub guidance: Option<String>,
823 #[serde(default, skip_serializing_if = "Option::is_none")]
829 pub evalset_hash: Option<String>,
830 #[serde(default, skip_serializing_if = "Vec::is_empty")]
834 pub near_duplicate_of: Vec<NearDuplicate>,
835 #[serde(default, skip_serializing_if = "Option::is_none")]
842 pub replay: Option<Value>,
843 #[serde(default, skip_serializing_if = "Vec::is_empty")]
859 pub scope: Vec<String>,
860 #[serde(skip)]
861 pub status: RecStatus,
862}
863
864pub fn normalize_scope(namespaces: &[String]) -> Vec<String> {
868 let mut out: Vec<String> = namespaces
869 .iter()
870 .map(|s| s.trim().to_string())
871 .filter(|s| !s.is_empty())
872 .collect();
873 out.sort();
874 out.dedup();
875 out
876}
877
878#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
882pub struct GatingEvidence {
883 pub evalset_hash: String,
885 pub run_id: String,
887 pub passed: u64,
888 pub failed: u64,
889}
890
891pub fn validate_code_rules(
901 action: ActionKind,
902 target_class: &str,
903 evalset_hash: Option<&str>,
904) -> Result<()> {
905 let e1 = |why: &str| Err(Error::InvalidRecommendation(format!("Rule E1: {why}")));
906 match (action, target_class) {
907 (ActionKind::CodeRevision, "code") => {
908 if evalset_hash.is_none_or(|h| h.trim().is_empty()) {
909 return e1(
910 "a code_revision must pin the evalset hash it was gated \
911 against (evalset_hash)",
912 );
913 }
914 }
915 (ActionKind::CodeRevision, other) => {
916 return e1(&format!(
917 "code_revision requires a tool: target, got class '{other}'"
918 ));
919 }
920 (ActionKind::AdapterRevision, "model") => {
921 if evalset_hash.is_none_or(|h| h.trim().is_empty()) {
922 return e1(
923 "an adapter_revision must pin the evalset hash it was \
924 gated against (evalset_hash)",
925 );
926 }
927 }
928 (ActionKind::AdapterRevision, other) => {
929 return e1(&format!(
930 "adapter_revision requires a model: target, got class '{other}'"
931 ));
932 }
933 (ActionKind::Revert, "code") => {
938 if evalset_hash.is_some() {
939 return e1("a code revert carries no evalset pin");
940 }
941 }
942 (_, "code") => {
943 return e1("a tool: target requires action_kind code_revision");
944 }
945 (ActionKind::Revert, "model") => {
947 if evalset_hash.is_some() {
948 return e1("an adapter revert carries no evalset pin");
949 }
950 }
951 (_, "model") => {
952 return e1("a model: target requires action_kind adapter_revision");
953 }
954 (_, "evalset") => {
955 if evalset_hash.is_some() {
956 return e1(
957 "an evalset-target recommendation cannot pin an evalset — \
958 the gate cannot gate itself",
959 );
960 }
961 }
962 _ => {
963 if evalset_hash.is_some() {
964 return e1(
965 "evalset_hash is only valid on code_revision or \
966 adapter_revision recommendations",
967 );
968 }
969 }
970 }
971 Ok(())
972}
973
974impl Recommendation {
975 pub fn to_grain_spec(&self, namespace: &str) -> Result<GrainSpec> {
977 let value = serde_json::to_value(self)
978 .map_err(|e| Error::Internal(format!("serialize recommendation: {e}")))?;
979 let obj = value
980 .as_object()
981 .ok_or_else(|| Error::Internal("recommendation did not serialize to object".into()))?
982 .clone();
983 Ok(GrainSpec {
984 grain_type: crate::model::grain_type::RECOMMENDATION.to_string(),
985 namespace: namespace.to_string(),
986 fields: obj,
987 })
988 }
989
990 pub fn from_fields(hash: &str, fields: &Map<String, Value>) -> Result<Self> {
995 let mut rec: Recommendation = serde_json::from_value(Value::Object(fields.clone()))
996 .map_err(|e| Error::InvalidRecommendation(format!("decode {hash}: {e}")))?;
997 rec.hash = hash.to_string();
998 let class = crate::model::TargetRef::parse(&rec.target_ref)
999 .map(|t| t.target_class())
1000 .unwrap_or("host");
1001 validate_code_rules(rec.action_kind, class, rec.evalset_hash.as_deref())?;
1002 Ok(rec)
1003 }
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008 #[test]
1013 fn the_regression_rule_holds_at_the_tolerance_and_regresses_past_it() {
1014 use super::is_regression;
1015 assert!(!is_regression(100.0, 0.0, true, 100.0), "a 100-point drop under a 100-point tolerance is accepted");
1017 assert!(is_regression(100.0, 0.0, true, 99.9));
1018 assert!(!is_regression(92.0, 91.5, true, 1.0));
1019 assert!(is_regression(92.0, 90.5, true, 1.0));
1020 assert!(!is_regression(359.0, 355.0, true, 5.0));
1022 assert!(!is_regression(359.0, 354.0, true, 5.0), "exactly at the floor holds");
1023 assert!(is_regression(359.0, 353.0, true, 5.0));
1024 assert!(!is_regression(28.0, 33.0, false, 5.0));
1025 assert!(is_regression(28.0, 34.0, false, 5.0));
1026 assert!(is_regression(359.0, 355.0, true, 0.0));
1028 assert!(is_regression(359.0, 355.0, true, -5.0));
1029 assert!(is_regression(359.0, 355.0, true, f64::NAN));
1030 assert!(!is_regression(1.0, 1.0, true, 0.0), "equal is never a regression");
1031 }
1032
1033 use super::*;
1034 use serde_json::json;
1035
1036 #[test]
1037 fn summary_renders_deterministically() {
1038 let mut args = Map::new();
1039 args.insert("count".into(), json!(3));
1040 args.insert("subject".into(), json!("acme"));
1041 let s = Summary::new("duplicate.exact", args);
1042 assert_eq!(
1043 s.render(),
1044 "Consolidate 3 exact-duplicate grains for \"acme\""
1045 );
1046 }
1047
1048 #[test]
1049 fn dedup_key_ignores_content_and_case() {
1050 let a = dedup_key(
1051 "loop.duplicate_sweep",
1052 "entity:NS/John",
1053 ActionKind::Consolidate,
1054 );
1055 let b = dedup_key(
1056 "loop.duplicate_sweep",
1057 "entity:ns/john",
1058 ActionKind::Consolidate,
1059 );
1060 assert_eq!(a, b, "case-folded to one identity");
1061 }
1062
1063 #[test]
1064 fn authored_dedup_key_distinguishes_content_but_not_wording_noise() {
1065 let a = authored_dedup_key("llm", "entity:ns/x", ActionKind::Record, "Record the vendor name.");
1066 let same = authored_dedup_key("llm", "entity:NS/X", ActionKind::Record, " record THE vendor name ");
1067 let other = authored_dedup_key("llm", "entity:ns/x", ActionKind::Record, "Record the amount.");
1068 assert_eq!(a, same, "case, punctuation and spacing are not a new lesson");
1069 assert_ne!(a, other, "a different lesson on the same entity is a different finding");
1070 assert!(a.starts_with(&dedup_key("llm", "entity:ns/x", ActionKind::Record)));
1071 assert_eq!(content_fingerprint("A b"), content_fingerprint("a-b"));
1072 assert_ne!(content_fingerprint("ab"), content_fingerprint("a b"));
1073 }
1074
1075 #[test]
1076 fn a_revert_is_keyed_by_what_it_reverts() {
1077 let a = revert_dedup_key("loop.outcome_review", "entity:ns/capture", "aaaa");
1078 let b = revert_dedup_key("loop.outcome_review", "entity:ns/capture", "bbbb");
1079 let a2 = revert_dedup_key("loop.outcome_review", "entity:NS/Capture", "AAAA");
1080 assert_ne!(a, b, "two reverts on one target are two findings");
1081 assert_eq!(a, a2, "the same revert, case-folded, is one");
1082 assert!(a.starts_with(&dedup_key("loop.outcome_review", "entity:ns/capture", ActionKind::Revert)));
1083 }
1084
1085 #[test]
1086 fn dedup_key_distinguishes_action() {
1087 let a = dedup_key("f", "entity:ns/x", ActionKind::Consolidate);
1088 let b = dedup_key("f", "entity:ns/x", ActionKind::FlagContradiction);
1089 assert_ne!(a, b);
1090 }
1091
1092 #[test]
1093 fn lifecycle_gates_pending_to_applied() {
1094 assert!(!RecStatus::Pending.can_transition_to(RecStatus::Applied, false));
1095 assert!(RecStatus::Pending.can_transition_to(RecStatus::Applied, true)); assert!(RecStatus::Pending.can_transition_to(RecStatus::Approved, false));
1097 assert!(RecStatus::Approved.can_transition_to(RecStatus::Applied, false));
1098 assert!(RecStatus::Applied.can_transition_to(RecStatus::RolledBack, false));
1099 assert!(!RecStatus::Rejected.can_transition_to(RecStatus::Applied, true));
1100 }
1101
1102 #[test]
1103 fn rule_e1_pins_code_and_only_code() {
1104 use crate::model::ActionKind as A;
1105 assert!(validate_code_rules(A::CodeRevision, "code", Some("abc")).is_ok());
1107 assert!(validate_code_rules(A::CodeRevision, "code", None).is_err());
1109 assert!(validate_code_rules(A::CodeRevision, "code", Some(" ")).is_err());
1110 assert!(validate_code_rules(A::CodeRevision, "memory", Some("abc")).is_err());
1112 assert!(validate_code_rules(A::Flag, "code", None).is_err());
1114 assert!(validate_code_rules(A::Flag, "evalset", Some("abc")).is_err());
1116 assert!(validate_code_rules(A::Flag, "evalset", None).is_ok());
1117 assert!(validate_code_rules(A::Consolidate, "memory", Some("abc")).is_err());
1119 assert!(validate_code_rules(A::Consolidate, "memory", None).is_ok());
1120 }
1121
1122 #[test]
1123 fn rule_e1_pins_adapter_revisions_like_code() {
1124 use crate::model::ActionKind as A;
1125 assert!(validate_code_rules(A::AdapterRevision, "model", Some("abc")).is_ok());
1127 assert!(validate_code_rules(A::AdapterRevision, "model", None).is_err());
1129 assert!(validate_code_rules(A::AdapterRevision, "model", Some(" ")).is_err());
1130 assert!(validate_code_rules(A::AdapterRevision, "memory", Some("abc")).is_err());
1132 assert!(validate_code_rules(A::AdapterRevision, "code", Some("abc")).is_err());
1133 assert!(validate_code_rules(A::Flag, "model", None).is_err());
1135 assert!(validate_code_rules(A::Revert, "model", None).is_ok());
1137 assert!(validate_code_rules(A::Revert, "model", Some("abc")).is_err());
1138 }
1139
1140 #[test]
1141 fn from_fields_rechecks_rule_e1() {
1142 let mut rec = Recommendation {
1145 hash: String::new(),
1146 analyzer: "loop.codegen/1".into(),
1147 params_snapshot: Map::new(),
1148 origin: Origin::Builtin,
1149 target_ref: "tool:abc123".into(),
1150 action_kind: ActionKind::CodeRevision,
1151 dedup_key: "k".into(),
1152 summary: Summary::new("command.finding", Map::new()),
1153 severity: Severity::Low,
1154 proposal: Proposal::Data { data: Map::new() },
1155 destructive: false,
1156 rollbackable: false,
1157 evidence: vec![],
1158 evidence_query: None,
1159 metric: None,
1160 confidence: 0.5,
1161 importance: 0.5,
1162 created_at_ms: 0,
1163 guidance: None,
1164 evalset_hash: None, near_duplicate_of: Vec::new(),
1166 replay: None,
1167 status: RecStatus::Pending,
1168 scope: Vec::new(),
1169 };
1170 let spec = rec.to_grain_spec("ns").unwrap();
1171 assert!(Recommendation::from_fields("h", &spec.fields).is_err());
1172 rec.evalset_hash = Some("es-hash".into());
1173 let spec = rec.to_grain_spec("ns").unwrap();
1174 let back = Recommendation::from_fields("h", &spec.fields).unwrap();
1175 assert_eq!(back.evalset_hash.as_deref(), Some("es-hash"));
1176 }
1177
1178 #[test]
1179 fn recommendation_round_trips_through_fields() {
1180 let rec = Recommendation {
1181 hash: "ignored".into(),
1182 analyzer: "loop.staleness/1".into(),
1183 params_snapshot: Map::new(),
1184 origin: Origin::Builtin,
1185 target_ref: "grain:sha256:abc".into(),
1186 action_kind: ActionKind::Expire,
1187 dedup_key: "k".into(),
1188 summary: Summary::new("staleness.expired", Map::new()),
1189 severity: Severity::Low,
1190 proposal: Proposal::Cal {
1191 cal: "FORGET sha256:abc".into(),
1192 },
1193 destructive: true,
1194 rollbackable: false,
1195 evidence: vec!["sha256:abc".into()],
1196 evidence_query: None,
1197 metric: None,
1198 confidence: 0.9,
1199 importance: 0.4,
1200 created_at_ms: 1000,
1201 guidance: None,
1202 evalset_hash: None,
1203 near_duplicate_of: Vec::new(),
1204 replay: None,
1205 status: RecStatus::Pending,
1206 scope: Vec::new(),
1207 };
1208 let spec = rec.to_grain_spec("ns").unwrap();
1209 assert!(!spec.fields.contains_key("hash"));
1211 assert!(!spec.fields.contains_key("status"));
1212 let back = Recommendation::from_fields("realhash", &spec.fields).unwrap();
1213 assert_eq!(back.hash, "realhash");
1214 assert_eq!(back.analyzer, "loop.staleness/1");
1215 assert!(back.destructive);
1216 assert!(matches!(back.proposal, Proposal::Cal { .. }));
1217 }
1218}