Skip to main content

areev_loop/analyzers/
contradiction_sweep.rs

1//! Contradiction sweep (T0). Flags subjects holding two or more live objects
2//! under a *functional* relation (one that should be single-valued). Ships with
3//! a seeded functional-relation list so it fires on day one; the from-file
4//! learner (single-valued for ≥80% of subjects) is deferred. Resolving a
5//! contradiction is a judgment call, so it never auto-applies.
6
7use 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
17/// Relations that are single-valued by convention (a subset of the built-in
18/// `mg:` vocabulary plus common agent relations).
19const 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        // Seeded functional relations + any host-supplied domain relations.
77        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        // (ns, subject, relation) → live facts, only for functional relations.
88        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            // Distinct live objects?
107            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            // Resolve-to-latest: keep the newest, supersede the older values.
115            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            // The resolution supersedes older values with a NEW grain built
129            // from these fields — carry the namespace or the winning value
130            // would migrate to the store default namespace.
131            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                    // After resolving to the latest value, does the subject
159                    // again hold ≥2 live values under this functional
160                    // relation? Baseline 0 = one live value; any excess at a
161                    // checkpoint is a regression → outcome review proposes a
162                    // revert for human judgment.
163                    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                    checkpoints: Vec::new(),
177                    // A count of excess live values: fewer is better.
178                    higher_is_better: false,
179                }),
180            );
181        }
182        drafts.sort_by(|a, b| a.target_ref.cmp(&b.target_ref));
183        Ok(drafts)
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::testkit::TestSubstrate;
191
192    #[test]
193    fn flags_two_live_deploy_targets() {
194        let mut sub = TestSubstrate::new();
195        sub.add_fact("acme", "deploy_target", "us-east-1");
196        sub.add_fact("acme", "deploy_target", "eu-west-1");
197        let drafts = sub.analyze(&ContradictionSweep::new(), 10_000);
198        assert_eq!(drafts.len(), 1);
199        assert_eq!(drafts[0].action_kind, ActionKind::FlagContradiction);
200    }
201
202    #[test]
203    fn extra_relations_extend_the_functional_set() {
204        let mut sub = TestSubstrate::new();
205        sub.add_fact("bob", "insurance_plan", "aetna");
206        sub.add_fact("bob", "insurance_plan", "cigna"); // not in the seeded list
207        // Without the param, insurance_plan isn't treated as functional.
208        assert!(sub.analyze(&ContradictionSweep::new(), 10_000).is_empty());
209        // A healthcare deployment adds it.
210        let drafts = sub.analyze_with(
211            &ContradictionSweep::new(),
212            10_000,
213            &[("extra_relations", serde_json::json!("insurance_plan,prior_auth"))],
214        );
215        assert_eq!(drafts.len(), 1, "the custom functional relation is now checked");
216    }
217
218    #[test]
219    fn ignores_non_functional_relations() {
220        let mut sub = TestSubstrate::new();
221        sub.add_fact("acme", "likes", "pizza");
222        sub.add_fact("acme", "likes", "sushi"); // multi-valued relation — fine
223        assert!(sub.analyze(&ContradictionSweep::new(), 10_000).is_empty());
224    }
225}