Skip to main content

areev_loop/analyzers/
fork_surfacing.rs

1//! Fork surfacing (T0; requires the `forks` capability). Entities with more
2//! than one live head, ranked, with a proposed merge. When the substrate does
3//! not provide forks the analyzer yields nothing and the manifest's
4//! `requires: [forks]` drives the activation-ladder message (ยง8) โ€” never a
5//! silent pretend-success.
6
7use crate::analyzer::{AnalyzeCtx, Analyzer};
8use crate::analyzers::bound_evidence;
9use crate::cal;
10use crate::error::Result;
11use crate::manifest::*;
12use crate::model::{ActionKind, Severity};
13use crate::recommendation::{Proposal, RecDraft, Summary};
14use serde_json::{json, Map};
15
16pub struct ForkSurfacing {
17    manifest: AnalyzerManifest,
18}
19
20impl ForkSurfacing {
21    pub fn new() -> Self {
22        ForkSurfacing {
23            manifest: AnalyzerManifest {
24                id: "loop.fork_surfacing/1".into(),
25                title: "Fork surfacing".into(),
26                description: "Surfaces entities with multiple live heads and proposes a merge."
27                    .into(),
28                tier: Tier::T0,
29                cadence: CadenceClass::Fast,
30                requires: vec![Capability::Forks],
31                target_classes: vec![TargetClass::Memory],
32                // Merging heads supersedes the non-primary head(s), which can
33                // drop their distinct content โ€” a lossy choice a human must
34                // make. Never auto-apply, regardless of policy.
35                auto_apply: AutoApplyClass::Never,
36                trust_class: TrustClass::Builtin,
37                params: vec![],
38                default_on: true,
39            },
40        }
41    }
42}
43
44impl Default for ForkSurfacing {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49
50impl Analyzer for ForkSurfacing {
51    fn manifest(&self) -> &AnalyzerManifest {
52        &self.manifest
53    }
54
55    fn analyze(&self, ctx: &AnalyzeCtx) -> Result<Vec<RecDraft>> {
56        // Degrade cleanly when the substrate can't track forks.
57        if !ctx.capabilities().forks {
58            return Ok(vec![]);
59        }
60        let mut groups = ctx.heads()?;
61        groups.sort_by(|a, b| a.entity.cmp(&b.entity));
62
63        let mut drafts = Vec::new();
64        for group in groups {
65            if group.heads.len() < 2 {
66                continue;
67            }
68            let mut heads = group.heads.clone();
69            heads.sort(); // deterministic primary = smallest hash
70            let primary = heads[0].clone();
71
72            // Merge: supersede the secondary heads into the primary.
73            let mut statements = Vec::new();
74            for secondary in &heads[1..] {
75                let mut fields = Map::new();
76                fields.insert("merge_into".into(), json!(primary));
77                statements.push(cal::supersede(secondary, "state", &fields));
78            }
79            let evidence = bound_evidence(heads.clone());
80
81            let mut args = Map::new();
82            args.insert("entity".into(), json!(group.entity));
83            args.insert("count".into(), json!(heads.len()));
84
85            drafts.push(
86                RecDraft::new(
87                    format!("entity:{}", group.entity),
88                    ActionKind::MergeHeads,
89                    Summary::new("fork.multi_head", args),
90                    Proposal::Cal {
91                        cal: cal::batch(&statements),
92                    },
93                )
94                .severity(Severity::Medium)
95                .evidence(evidence),
96            );
97        }
98        Ok(drafts)
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::testkit::TestSubstrate;
106
107    #[test]
108    fn surfaces_multi_head_entity() {
109        let mut sub = TestSubstrate::new();
110        sub.add_fork("caller/john", &["ref-a", "ref-b"]);
111        let drafts = sub.analyze(&ForkSurfacing::new(), 10_000);
112        assert_eq!(drafts.len(), 1);
113        assert_eq!(drafts[0].action_kind, ActionKind::MergeHeads);
114        assert_eq!(drafts[0].evidence.len(), 2);
115    }
116
117    #[test]
118    fn degrades_when_forks_unavailable() {
119        let sub = TestSubstrate::new(); // forks capability off by default
120        assert!(sub.analyze(&ForkSurfacing::new(), 10_000).is_empty());
121    }
122}