1use crate::analyzer::{AnalyzeCtx, Analyzer};
8use crate::analyzers::bound_evidence;
9use crate::cal;
10use crate::error::Result;
11use crate::manifest::*;
12use crate::model::{normalize_ident, ActionKind, GrainRecord, Severity};
13use crate::recommendation::{MetricSnapshot, Proposal, RecDraft, Summary};
14use serde_json::{json, Map};
15use std::collections::BTreeMap;
16
17const SEEDED_FUNCTIONAL: &[&str] = &[
20 "deploy_target",
21 "lives_in",
22 "reports_to",
23 "status",
24 "tier",
25 "owner",
26 "region",
27 "assigned_to",
28 "primary_email",
29 "current_plan",
30];
31
32pub struct ContradictionSweep {
33 manifest: AnalyzerManifest,
34}
35
36impl ContradictionSweep {
37 pub fn new() -> Self {
38 ContradictionSweep {
39 manifest: AnalyzerManifest {
40 id: "loop.contradiction_sweep/1".into(),
41 title: "Contradiction sweep".into(),
42 description: "Flags conflicting live values under functional relations.".into(),
43 tier: Tier::T0,
44 cadence: CadenceClass::Fast,
45 requires: vec![],
46 target_classes: vec![TargetClass::Memory],
47 auto_apply: AutoApplyClass::Never,
48 trust_class: TrustClass::Builtin,
49 params: vec![ParamSpec::Str {
50 name: "extra_relations".into(),
51 default: String::new(),
52 max_len: 2000,
53 description: "Additional functional (single-valued) relations to check, \
54 comma-separated — e.g. a healthcare deployment adds \
55 \"insurance_plan,prior_auth,next_appt\"."
56 .into(),
57 }],
58 default_on: true,
59 },
60 }
61 }
62}
63
64impl Default for ContradictionSweep {
65 fn default() -> Self {
66 Self::new()
67 }
68}
69
70impl Analyzer for ContradictionSweep {
71 fn manifest(&self) -> &AnalyzerManifest {
72 &self.manifest
73 }
74
75 fn analyze(&self, ctx: &AnalyzeCtx) -> Result<Vec<RecDraft>> {
76 let mut functional: std::collections::BTreeSet<String> =
78 SEEDED_FUNCTIONAL.iter().map(|s| s.to_string()).collect();
79 for r in ctx.params().get_str("extra_relations").split(',') {
80 let r = normalize_ident(r);
81 if !r.is_empty() {
82 functional.insert(r);
83 }
84 }
85
86 let facts = ctx.facts()?;
87 let mut groups: BTreeMap<(String, String, String), Vec<GrainRecord>> = BTreeMap::new();
89 for f in facts {
90 let (Some(s), Some(r)) = (f.fact_subject(), f.fact_relation()) else {
91 continue;
92 };
93 if !functional.contains(&normalize_ident(r)) {
94 continue;
95 }
96 let key = (
97 normalize_ident(&f.namespace),
98 normalize_ident(s),
99 normalize_ident(r),
100 );
101 groups.entry(key).or_default().push(f);
102 }
103
104 let mut drafts = Vec::new();
105 for ((ns, subject, relation), mut members) in groups {
106 let distinct: std::collections::BTreeSet<String> = members
108 .iter()
109 .filter_map(|m| m.fact_object().map(normalize_ident))
110 .collect();
111 if distinct.len() < 2 {
112 continue;
113 }
114 members.sort_by(|a, b| {
116 a.created_at_ms
117 .cmp(&b.created_at_ms)
118 .then(a.hash.cmp(&b.hash))
119 });
120 let latest = members.last().unwrap().clone();
121 let mut latest_fields = Map::new();
122 latest_fields.insert("subject".into(), json!(latest.fact_subject().unwrap_or("")));
123 latest_fields.insert(
124 "relation".into(),
125 json!(latest.fact_relation().unwrap_or("")),
126 );
127 latest_fields.insert("object".into(), json!(latest.fact_object().unwrap_or("")));
128 if !latest.namespace.is_empty() {
132 latest_fields.insert("namespace".into(), json!(latest.namespace));
133 }
134
135 let mut statements = Vec::new();
136 for older in &members[..members.len() - 1] {
137 statements.push(cal::supersede(&older.hash, "fact", &latest_fields));
138 }
139 let evidence = bound_evidence(members.iter().map(|m| m.hash.clone()).collect());
140
141 let mut args = Map::new();
142 args.insert("subject".into(), json!(subject));
143 args.insert("relation".into(), json!(relation));
144 args.insert("count".into(), json!(distinct.len()));
145
146 drafts.push(
147 RecDraft::new(
148 format!("entity:{ns}/{subject}"),
149 ActionKind::FlagContradiction,
150 Summary::new("contradiction.functional", args),
151 Proposal::Cal {
152 cal: cal::batch(&statements),
153 },
154 )
155 .severity(Severity::Medium)
156 .evidence(evidence)
157 .metric(MetricSnapshot {
158 metric: "contradiction_recurrence".into(),
164 baseline: 0.0,
165 unit: "count".into(),
166 n: members.len() as u64,
167 window: "live".into(),
168 subject: Some(subject.clone()),
169 namespace: (!ns.is_empty()).then(|| ns.clone()),
170 relation: Some(relation.clone()),
171 query: format!(
172 "RECALL facts WHERE subject = \"{subject}\" AND relation = \"{relation}\" | COUNT DISTINCT object > 1"
173 ),
174 review_after_ms: 86_400_000,
175 horizons_ms: vec![86_400_000, 7 * 86_400_000, 30 * 86_400_000],
176 higher_is_better: false,
178 }),
179 );
180 }
181 drafts.sort_by(|a, b| a.target_ref.cmp(&b.target_ref));
182 Ok(drafts)
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189 use crate::testkit::TestSubstrate;
190
191 #[test]
192 fn flags_two_live_deploy_targets() {
193 let mut sub = TestSubstrate::new();
194 sub.add_fact("acme", "deploy_target", "us-east-1");
195 sub.add_fact("acme", "deploy_target", "eu-west-1");
196 let drafts = sub.analyze(&ContradictionSweep::new(), 10_000);
197 assert_eq!(drafts.len(), 1);
198 assert_eq!(drafts[0].action_kind, ActionKind::FlagContradiction);
199 }
200
201 #[test]
202 fn extra_relations_extend_the_functional_set() {
203 let mut sub = TestSubstrate::new();
204 sub.add_fact("bob", "insurance_plan", "aetna");
205 sub.add_fact("bob", "insurance_plan", "cigna"); assert!(sub.analyze(&ContradictionSweep::new(), 10_000).is_empty());
208 let drafts = sub.analyze_with(
210 &ContradictionSweep::new(),
211 10_000,
212 &[("extra_relations", serde_json::json!("insurance_plan,prior_auth"))],
213 );
214 assert_eq!(drafts.len(), 1, "the custom functional relation is now checked");
215 }
216
217 #[test]
218 fn ignores_non_functional_relations() {
219 let mut sub = TestSubstrate::new();
220 sub.add_fact("acme", "likes", "pizza");
221 sub.add_fact("acme", "likes", "sushi"); assert!(sub.analyze(&ContradictionSweep::new(), 10_000).is_empty());
223 }
224}