Skip to main content

memnite_core/
conflicts.rs

1use std::collections::{BTreeMap, HashSet};
2
3use crate::event::{Event, EventKind, MemoryData};
4
5/// A cross-writer lost-field conflict: a value some writer set for `field` of
6/// `memory_id` did not survive into the projected winner (it lost to a different
7/// writer). Heuristic — lamport is a total order, not causal — so this can
8/// over-report a deliberately-superseded edit. Advisory; never blocks.
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct Conflict {
11    pub memory_id: String,
12    pub field: String,
13    pub lost_writer: String,
14    pub lost_lamport: u64,
15    pub winning_writer: String,
16    pub winning_lamport: u64,
17}
18
19const FIELDS: [&str; 8] = [
20    "title",
21    "body",
22    "mem_type",
23    "scope",
24    "project",
25    "topic_key",
26    "anchors",
27    "tags",
28];
29
30fn writer(ev: &Event) -> String {
31    format!("{}@{}", ev.engine, ev.machine)
32}
33
34fn content_data(ev: &Event) -> bool {
35    matches!(
36        ev.kind,
37        EventKind::MemoryAdded(_) | EventKind::MemoryUpdated(_) | EventKind::MemoryPatched(_)
38    )
39}
40
41/// Per-field view of one content event: for each of the 8 fields, whether the
42/// event touched it and (if so) its string value. `MemoryAdded`/`MemoryUpdated`
43/// touch every field (full snapshot). `MemoryPatched` touches only `Some` fields.
44fn touched_values(ev: &Event) -> [(bool, String); 8] {
45    match &ev.kind {
46        EventKind::MemoryAdded(d) | EventKind::MemoryUpdated(d) => {
47            let v = full_field_values(d);
48            std::array::from_fn(|i| (true, v[i].clone()))
49        }
50        EventKind::MemoryPatched(p) => [
51            opt(&p.title),
52            opt(&p.body),
53            opt(&p.mem_type),
54            p.scope
55                .as_ref()
56                .map_or((false, String::new()), |s| (true, format!("{s:?}"))),
57            opt(&p.project),
58            p.topic_key
59                .as_ref()
60                .map_or((false, String::new()), |s| (true, format!("{:?}", Some(s)))),
61            p.anchors
62                .as_ref()
63                .map_or((false, String::new()), |a| (true, format!("{a:?}"))),
64            p.tags
65                .as_ref()
66                .map_or((false, String::new()), |t| (true, format!("{t:?}"))),
67        ],
68        _ => std::array::from_fn(|_| (false, String::new())),
69    }
70}
71
72fn opt(v: &Option<String>) -> (bool, String) {
73    match v {
74        Some(s) => (true, s.clone()),
75        None => (false, String::new()),
76    }
77}
78
79/// Full field values for a whole-snapshot event (index matches `FIELDS`).
80fn full_field_values(d: &MemoryData) -> [String; 8] {
81    [
82        d.title.clone(),
83        d.body.clone(),
84        d.mem_type.clone(),
85        format!("{:?}", d.scope),
86        d.project.clone(),
87        format!("{:?}", d.topic_key),
88        format!("{:?}", d.anchors),
89        format!("{:?}", d.tags),
90    ]
91}
92
93/// Detect cross-writer lost-field conflicts across the event log. Pure, total,
94/// deterministic (memory_ids sorted; fields in fixed order). Skips memories with
95/// a terminal `MemoryDeleted`.
96pub fn detect_conflicts(events: &[Event]) -> Vec<Conflict> {
97    let deleted: HashSet<&str> = events
98        .iter()
99        .filter(|e| matches!(e.kind, EventKind::MemoryDeleted))
100        .map(|e| e.memory_id.as_str())
101        .collect();
102
103    let mut by_mem: BTreeMap<&str, Vec<&Event>> = BTreeMap::new();
104    for ev in events {
105        if content_data(ev) && !deleted.contains(ev.memory_id.as_str()) {
106            by_mem.entry(ev.memory_id.as_str()).or_default().push(ev);
107        }
108    }
109
110    let mut out = Vec::new();
111    for (mem_id, mut evs) in by_mem {
112        if evs.len() < 2 {
113            continue;
114        }
115        evs.sort_by(|a, b| {
116            a.lamport
117                .cmp(&b.lamport)
118                .then_with(|| a.event_id.cmp(&b.event_id))
119        });
120
121        let tvals: Vec<[(bool, String); 8]> = evs.iter().map(|e| touched_values(e)).collect();
122
123        for fi in 0..FIELDS.len() {
124            // Walk in order, carrying the last-seen value of this field. A setter
125            // is an event that TOUCHED the field AND changed its value. The first
126            // observed value seeds `prev` as a baseline (not a competing setter).
127            // The field's winner is the LAST event that touched it (which may not
128            // be the global last event, since a delta can touch other fields).
129            let mut prev: Option<String> = None;
130            let mut setters: Vec<(&Event, String)> = Vec::new();
131            let mut field_winner: Option<&Event> = None;
132            for (i, ev_i) in evs.iter().enumerate() {
133                let (touched, val) = &tvals[i][fi];
134                if *touched {
135                    let changed = prev.as_deref() != Some(val.as_str());
136                    if changed && prev.is_some() {
137                        setters.push((ev_i, val.clone()));
138                    }
139                    prev = Some(val.clone());
140                    field_winner = Some(ev_i);
141                }
142            }
143            let winner_val = prev.unwrap_or_default();
144            let winner_ev = match field_winner {
145                Some(e) => e,
146                None => continue,
147            };
148            let winner_writer = writer(winner_ev);
149
150            let distinct: HashSet<String> = setters.iter().map(|(e, _)| writer(e)).collect();
151            if distinct.len() < 2 {
152                continue;
153            }
154            let lost = setters
155                .iter()
156                .rev()
157                .find(|(e, v)| writer(e) != winner_writer && *v != winner_val);
158            if let Some((lose_ev, _)) = lost {
159                out.push(Conflict {
160                    memory_id: mem_id.to_string(),
161                    field: FIELDS[fi].to_string(),
162                    lost_writer: writer(lose_ev),
163                    lost_lamport: lose_ev.lamport,
164                    winning_writer: winner_writer.clone(),
165                    winning_lamport: winner_ev.lamport,
166                });
167            }
168        }
169    }
170    out
171}