use crate::analyzer::{AnalyzeCtx, Analyzer};
use crate::decide::{Ask, DECIDE_MIN_P, QUESTIONS_PER_REQUEST};
use crate::analyzers::bound_evidence;
use crate::cal;
use crate::error::Result;
use crate::manifest::*;
use crate::model::{normalize_ident, ActionKind, GrainRecord, Severity};
use crate::recommendation::{MetricSnapshot, Proposal, RecDraft, Summary};
use serde_json::{json, Map, Value};
use std::collections::BTreeMap;
const SEEDED_FUNCTIONAL: &[&str] = &[
"deploy_target",
"lives_in",
"reports_to",
"status",
"tier",
"owner",
"region",
"assigned_to",
"primary_email",
"current_plan",
];
pub struct ContradictionSweep {
manifest: AnalyzerManifest,
}
impl ContradictionSweep {
pub fn new() -> Self {
ContradictionSweep {
manifest: AnalyzerManifest {
id: "loop.contradiction_sweep/1".into(),
title: "Contradiction sweep".into(),
description: "Flags conflicting live values under functional relations.".into(),
tier: Tier::T0,
cadence: CadenceClass::Fast,
requires: vec![],
target_classes: vec![TargetClass::Memory],
auto_apply: AutoApplyClass::Never,
trust_class: TrustClass::Builtin,
params: vec![ParamSpec::Str {
name: "extra_relations".into(),
default: String::new(),
max_len: 2000,
description: "Additional functional (single-valued) relations to check, \
comma-separated — e.g. a healthcare deployment adds \
\"insurance_plan,prior_auth,next_appt\"."
.into(),
}],
default_on: true,
},
}
}
}
impl Default for ContradictionSweep {
fn default() -> Self {
Self::new()
}
}
impl Analyzer for ContradictionSweep {
fn manifest(&self) -> &AnalyzerManifest {
&self.manifest
}
fn analyze(&self, ctx: &AnalyzeCtx) -> Result<Vec<RecDraft>> {
let mut functional: std::collections::BTreeSet<String> =
SEEDED_FUNCTIONAL.iter().map(|s| s.to_string()).collect();
for r in ctx.params().get_str("extra_relations").split(',') {
let r = normalize_ident(r);
if !r.is_empty() {
functional.insert(r);
}
}
let facts = ctx.facts()?;
let mut groups: BTreeMap<(String, String, String), Vec<GrainRecord>> = BTreeMap::new();
let mut other: BTreeMap<(String, String, String), Vec<GrainRecord>> = BTreeMap::new();
for f in facts {
let (Some(s), Some(r)) = (f.fact_subject(), f.fact_relation()) else {
continue;
};
let key = (
normalize_ident(&f.namespace),
normalize_ident(s),
normalize_ident(r),
);
if !functional.contains(&key.2) {
other.entry(key).or_default().push(f);
continue;
}
groups.entry(key).or_default().push(f);
}
let mut drafts = Vec::new();
for ((ns, subject, relation), mut members) in groups {
let distinct: std::collections::BTreeSet<String> = members
.iter()
.filter_map(|m| m.fact_object().map(normalize_ident))
.collect();
if distinct.len() < 2 {
continue;
}
members.sort_by(|a, b| {
a.created_at_ms
.cmp(&b.created_at_ms)
.then(a.hash.cmp(&b.hash))
});
let latest = members.last().unwrap().clone();
let mut latest_fields = Map::new();
latest_fields.insert("subject".into(), json!(latest.fact_subject().unwrap_or("")));
latest_fields.insert(
"relation".into(),
json!(latest.fact_relation().unwrap_or("")),
);
latest_fields.insert("object".into(), json!(latest.fact_object().unwrap_or("")));
if !latest.namespace.is_empty() {
latest_fields.insert("namespace".into(), json!(latest.namespace));
}
let mut statements = Vec::new();
for older in &members[..members.len() - 1] {
statements.push(cal::supersede(&older.hash, "fact", &latest_fields));
}
let evidence = bound_evidence(members.iter().map(|m| m.hash.clone()).collect());
let mut args = Map::new();
args.insert("subject".into(), json!(subject));
args.insert("relation".into(), json!(relation));
args.insert("count".into(), json!(distinct.len()));
drafts.push(
RecDraft::new(
format!("entity:{ns}/{subject}"),
ActionKind::FlagContradiction,
Summary::new("contradiction.functional", args),
Proposal::Cal {
cal: cal::batch(&statements),
},
)
.severity(Severity::Medium)
.evidence(evidence)
.metric(MetricSnapshot {
metric: "contradiction_recurrence".into(),
baseline: 0.0,
unit: "count".into(),
n: members.len() as u64,
window: "live".into(),
subject: Some(subject.clone()),
namespace: (!ns.is_empty()).then(|| ns.clone()),
relation: Some(relation.clone()),
query: format!(
"RECALL facts WHERE subject = \"{subject}\" AND relation = \"{relation}\" | COUNT DISTINCT object > 1"
),
review_after_ms: 86_400_000,
horizons_ms: vec![86_400_000, 7 * 86_400_000, 30 * 86_400_000],
checkpoints: Vec::new(),
higher_is_better: false,
}),
);
}
drafts.extend(judged(ctx, other));
drafts.sort_by(|a, b| a.target_ref.cmp(&b.target_ref));
Ok(drafts)
}
}
fn supersede_older(members: &[GrainRecord], latest: &GrainRecord) -> String {
let mut latest_fields = Map::new();
latest_fields.insert("subject".into(), json!(latest.fact_subject().unwrap_or("")));
latest_fields.insert("relation".into(), json!(latest.fact_relation().unwrap_or("")));
latest_fields.insert("object".into(), json!(latest.fact_object().unwrap_or("")));
if !latest.namespace.is_empty() {
latest_fields.insert("namespace".into(), json!(latest.namespace));
}
let statements: Vec<String> = members
.iter()
.filter(|m| m.hash != latest.hash)
.map(|older| cal::supersede(&older.hash, "fact", &latest_fields))
.collect();
cal::batch(&statements)
}
fn fact_text(f: &GrainRecord) -> String {
format!(
"{} {} {}",
f.fact_subject().unwrap_or(""),
f.fact_relation().unwrap_or(""),
f.fact_object().unwrap_or("")
)
}
fn oldest_first(a: &GrainRecord, b: &GrainRecord) -> std::cmp::Ordering {
a.created_at_ms.cmp(&b.created_at_ms).then(a.hash.cmp(&b.hash))
}
fn judged(
ctx: &AnalyzeCtx,
other: BTreeMap<(String, String, String), Vec<GrainRecord>>,
) -> Vec<RecDraft> {
let Some(d) = ctx.decider() else {
return Vec::new();
};
if !d.calibrated() {
return Vec::new();
}
struct Pair {
group: usize,
a: GrainRecord,
b: GrainRecord,
}
let groups: Vec<((String, String, String), Vec<GrainRecord>)> = other.into_iter().collect();
let mut pairs: Vec<Pair> = Vec::new();
'groups: for (g, (_, members)) in groups.iter().enumerate() {
let mut by_object: BTreeMap<String, GrainRecord> = BTreeMap::new();
for m in members {
let Some(o) = m.fact_object() else { continue };
let slot = by_object.entry(normalize_ident(o)).or_insert_with(|| m.clone());
if oldest_first(slot, m).is_lt() {
*slot = m.clone();
}
}
if by_object.len() < 2 {
continue;
}
let reps: Vec<&GrainRecord> = by_object.values().collect();
for i in 0..reps.len() {
for j in (i + 1)..reps.len() {
if pairs.len() >= d.pair_cap() {
break 'groups;
}
pairs.push(Pair { group: g, a: reps[i].clone(), b: reps[j].clone() });
}
}
}
let backend = d.describe();
type Conflict = (BTreeMap<String, GrainRecord>, BTreeMap<String, f64>, crate::decide::Answered);
let mut conflicts: BTreeMap<usize, Conflict> = BTreeMap::new();
for chunk in pairs.chunks(QUESTIONS_PER_REQUEST) {
let mut state = Map::new();
let mut asks = Vec::new();
for (n, p) in chunk.iter().enumerate() {
let id = format!("p{n}");
state.insert(id.clone(), json!({"a": fact_text(&p.a), "b": fact_text(&p.b)}));
asks.push(Ask::Noul {
instructions: format!(
"Can statements \"a\" and \"b\" of pair \"{id}\" (in state.pairs) both be true at the same time?"
),
id,
});
}
let Ok(a) = d.ask(json!({ "pairs": Value::Object(state) }), &asks) else {
continue;
};
if !a.calibrated {
continue;
}
for (n, p) in chunk.iter().enumerate() {
let Some(&both) = a.noul.get(&format!("p{n}")) else { continue };
let p_no = 1.0 - both;
if p_no < DECIDE_MIN_P {
continue;
}
let entry = conflicts
.entry(p.group)
.or_insert_with(|| (BTreeMap::new(), BTreeMap::new(), a.clone()));
entry.0.insert(p.a.hash.clone(), p.a.clone());
entry.0.insert(p.b.hash.clone(), p.b.clone());
entry.1.insert(
format!(
"not_both:{}|{}",
p.a.fact_object().unwrap_or(""),
p.b.fact_object().unwrap_or("")
),
p_no,
);
}
}
let mut drafts = Vec::new();
for (g, (involved, ps, answered)) in conflicts {
let ((ns, subject, relation), _) = &groups[g];
let mut members: Vec<GrainRecord> = involved.into_values().collect();
members.sort_by(oldest_first);
let latest = members.last().expect("a conflicting pair has two members").clone();
let evidence = bound_evidence(members.iter().map(|m| m.hash.clone()).collect());
let p_max = ps.values().copied().fold(0.0_f64, f64::max);
let mut args = Map::new();
args.insert("subject".into(), json!(subject));
args.insert("relation".into(), json!(relation));
args.insert("count".into(), json!(members.len()));
args.insert("p".into(), json!((p_max * 1000.0).round() / 1000.0));
drafts.push(
RecDraft::new(
format!("entity:{ns}/{subject}"),
ActionKind::FlagContradiction,
Summary::new("contradiction.judged", args),
Proposal::Cal {
cal: supersede_older(&members, &latest),
},
)
.severity(Severity::Medium)
.evidence(evidence)
.judged_by(answered.judged_by(&backend, "contradiction", ps)),
);
}
drafts
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testkit::TestSubstrate;
#[test]
fn flags_two_live_deploy_targets() {
let mut sub = TestSubstrate::new();
sub.add_fact("acme", "deploy_target", "us-east-1");
sub.add_fact("acme", "deploy_target", "eu-west-1");
let drafts = sub.analyze(&ContradictionSweep::new(), 10_000);
assert_eq!(drafts.len(), 1);
assert_eq!(drafts[0].action_kind, ActionKind::FlagContradiction);
}
#[test]
fn extra_relations_extend_the_functional_set() {
let mut sub = TestSubstrate::new();
sub.add_fact("bob", "insurance_plan", "aetna");
sub.add_fact("bob", "insurance_plan", "cigna"); assert!(sub.analyze(&ContradictionSweep::new(), 10_000).is_empty());
let drafts = sub.analyze_with(
&ContradictionSweep::new(),
10_000,
&[("extra_relations", serde_json::json!("insurance_plan,prior_auth"))],
);
assert_eq!(drafts.len(), 1, "the custom functional relation is now checked");
}
#[test]
fn ignores_non_functional_relations() {
let mut sub = TestSubstrate::new();
sub.add_fact("acme", "likes", "pizza");
sub.add_fact("acme", "likes", "sushi"); assert!(sub.analyze(&ContradictionSweep::new(), 10_000).is_empty());
}
}