Skip to main content

oxibrain_core/
sync.rs

1//! Vault sync classification — a pure decision function (P9).
2//!
3//! Given scanned markdown files and the ledger's known note content hashes per
4//! source path, decide per file whether sync must ingest it (`New`), skip it
5//! (`Unchanged` — an episode with this exact content already exists), or ingest
6//! it as a new version (`Modified` — the path is known with different content).
7//!
8//! The store fetches the `KnownNotes` map; this module only decides. Modified
9//! files append a new episode; the previous episode remains (append-only
10//! ledger, P1). Stale claims surface via `contradictions` and are removed with
11//! `retract` — sync itself never retracts.
12
13use crate::types::ContentHash;
14use oxibrain_ports::Timestamp;
15use std::collections::{HashMap, HashSet};
16
17/// A scanned candidate file for sync.
18///
19/// `path` is relative to the sync root with forward slashes (stable across
20/// machines and working directories). `modified` is the file's mtime and
21/// becomes the episode's `occurred_at`, so episode ids are stable across
22/// re-syncs of an unchanged tree.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct SyncFile {
25    pub path: String,
26    pub content_hash: ContentHash,
27    pub modified: Timestamp,
28}
29
30/// What sync must do with one scanned file.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum SyncAction {
33    /// No live episode for this path — ingest.
34    New(SyncFile),
35    /// An episode with this exact content exists — skip (idempotent no-op).
36    Unchanged(String),
37    /// The path is known with different content — ingest as a new episode.
38    /// The previous episode remains (P1); see the module docs.
39    Modified(SyncFile),
40}
41
42/// Known note content hashes per source path, as read from the ledger
43/// (`store::ledger::note_hashes_by_path`).
44pub type KnownNotes = HashMap<String, HashSet<ContentHash>>;
45
46/// Classify scanned files against known notes.
47///
48/// Pure and total: every input file appears in exactly one output action
49/// (conservation), and the output preserves the input order (callers pass the
50/// scan's deterministic path order).
51pub fn classify(files: Vec<SyncFile>, known: &KnownNotes) -> Vec<SyncAction> {
52    files
53        .into_iter()
54        .map(|f| match known.get(&f.path) {
55            // An empty set means no live episode for the path (e.g. all
56            // versions redacted) — same decision as an unknown path.
57            Some(hashes) if !hashes.is_empty() => {
58                if hashes.contains(&f.content_hash) {
59                    SyncAction::Unchanged(f.path)
60                } else {
61                    SyncAction::Modified(f)
62                }
63            }
64            _ => SyncAction::New(f),
65        })
66        .collect()
67}
68
69/// State of the latest event-path episode for a locator.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct LocatorState {
72    /// The occurrence_id of the most recent episode for this locator.
73    pub latest_occurrence_id: String,
74    /// The content hash of that episode.
75    pub latest_content_hash: ContentHash,
76}
77
78/// Classify scanned files using event-identity state, falling back to legacy
79/// content-hash knowledge for locators not yet on the event path.
80///
81/// Precedence: event_states > legacy > New.
82/// Pure and total: every input file appears in exactly one output action.
83pub fn classify_event(
84    files: Vec<SyncFile>,
85    legacy: &KnownNotes,
86    event_states: &HashMap<String, LocatorState>,
87) -> Vec<SyncAction> {
88    files
89        .into_iter()
90        .map(|f| {
91            if let Some(state) = event_states.get(&f.path) {
92                if state.latest_content_hash == f.content_hash {
93                    SyncAction::Unchanged(f.path)
94                } else {
95                    SyncAction::Modified(f)
96                }
97            } else if let Some(hashes) = legacy.get(&f.path) {
98                if !hashes.is_empty() && hashes.contains(&f.content_hash) {
99                    SyncAction::Unchanged(f.path)
100                } else {
101                    SyncAction::Modified(f)
102                }
103            } else {
104                SyncAction::New(f)
105            }
106        })
107        .collect()
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::content_hash;
114    use proptest::prelude::*;
115
116    fn file(path: &str, content: &str, t: i64) -> SyncFile {
117        SyncFile {
118            path: path.into(),
119            content_hash: content_hash(content),
120            modified: Timestamp(t),
121        }
122    }
123
124    fn known(entries: &[(&str, &[&str])]) -> KnownNotes {
125        entries
126            .iter()
127            .map(|(path, contents)| {
128                (
129                    (*path).to_string(),
130                    contents.iter().map(|c| content_hash(c)).collect(),
131                )
132            })
133            .collect()
134    }
135
136    #[test]
137    fn unknown_path_is_new() {
138        let actions = classify(vec![file("a.md", "hello", 1)], &known(&[]));
139        assert_eq!(actions, vec![SyncAction::New(file("a.md", "hello", 1))]);
140    }
141
142    #[test]
143    fn matching_hash_is_unchanged() {
144        let actions = classify(
145            vec![file("a.md", "hello", 2)],
146            &known(&[("a.md", &["hello"])]),
147        );
148        assert_eq!(actions, vec![SyncAction::Unchanged("a.md".into())]);
149    }
150
151    #[test]
152    fn mismatched_hash_is_modified() {
153        let actions = classify(
154            vec![file("a.md", "hello v2", 2)],
155            &known(&[("a.md", &["hello"])]),
156        );
157        assert_eq!(
158            actions,
159            vec![SyncAction::Modified(file("a.md", "hello v2", 2))]
160        );
161    }
162
163    #[test]
164    fn any_prior_version_hash_counts_as_unchanged() {
165        // The path has two versions ingested already; current content matches
166        // the older one exactly — still a no-op (content identity, not recency).
167        let actions = classify(
168            vec![file("a.md", "hello", 3)],
169            &known(&[("a.md", &["hello", "hello v2"])]),
170        );
171        assert_eq!(actions, vec![SyncAction::Unchanged("a.md".into())]);
172    }
173
174    #[test]
175    fn empty_known_entry_is_new() {
176        // A path with no live episodes (e.g. all redacted) is New, not Modified.
177        let mut map = KnownNotes::new();
178        map.insert("a.md".into(), HashSet::new());
179        let actions = classify(vec![file("a.md", "hello", 1)], &map);
180        assert_eq!(actions, vec![SyncAction::New(file("a.md", "hello", 1))]);
181    }
182
183    #[test]
184    fn output_preserves_input_order() {
185        let files = vec![
186            file("b.md", "b", 1),
187            file("a.md", "a", 1),
188            file("c.md", "c", 1),
189        ];
190        let actions = classify(files, &known(&[]));
191        let paths: Vec<&str> = actions
192            .iter()
193            .map(|a| match a {
194                SyncAction::New(f) | SyncAction::Modified(f) => f.path.as_str(),
195                SyncAction::Unchanged(p) => p.as_str(),
196            })
197            .collect();
198        assert_eq!(paths, vec!["b.md", "a.md", "c.md"]);
199    }
200
201    proptest! {
202        #![proptest_config(ProptestConfig::with_cases(64))]
203
204        /// Conservation and classification law: for arbitrary inputs, every
205        /// file lands in exactly one action, and the action agrees with the
206        /// known map per the module rules.
207        #[test]
208        fn classify_is_total_and_correct(
209            files in proptest::collection::vec(
210                (".*a?b?c?[0-9]{0,3}\\.md", ".*", 0i64..1000),
211                0..16
212            ),
213            known in proptest::collection::vec(
214                (".*a?b?c?[0-9]{0,3}\\.md", proptest::collection::vec(".*", 0..3)),
215                0..8
216            ),
217        ) {
218            let sync_files: Vec<SyncFile> = files
219                .iter()
220                .map(|(p, c, t)| file(p, c, *t))
221                .collect();
222            let mut map = KnownNotes::new();
223            for (p, cs) in &known {
224                let set: HashSet<ContentHash> = cs.iter().map(|c| content_hash(c)).collect();
225                map.insert(p.clone(), set);
226            }
227            let actions = classify(sync_files.clone(), &map);
228
229            // Conservation: one action per input, same order.
230            assert_eq!(actions.len(), sync_files.len());
231            for (f, a) in sync_files.iter().zip(&actions) {
232                let got_path = match a {
233                    SyncAction::New(sf) | SyncAction::Modified(sf) => &sf.path,
234                    SyncAction::Unchanged(p) => p,
235                };
236                assert_eq!(got_path, &f.path);
237                // Classification law.
238                let entry = map.get(&f.path);
239                let expect_unchanged = entry.is_some_and(|s| s.contains(&f.content_hash));
240                let expect_new = entry.is_none_or(|s| s.is_empty());
241                match a {
242                    SyncAction::Unchanged(_) => assert!(expect_unchanged),
243                    SyncAction::New(_) => assert!(!expect_unchanged && expect_new),
244                    SyncAction::Modified(_) => assert!(!expect_unchanged && !expect_new),
245                }
246            }
247        }
248    }
249
250    use super::LocatorState;
251
252    fn locator_state(occ: &str, content: &str) -> LocatorState {
253        LocatorState {
254            latest_occurrence_id: occ.into(),
255            latest_content_hash: content_hash(content),
256        }
257    }
258
259    #[test]
260    fn classify_event_new_when_no_state() {
261        let files = vec![file("a.md", "hello", 1)];
262        let actions = classify_event(files, &KnownNotes::new(), &HashMap::new());
263        assert_eq!(actions, vec![SyncAction::New(file("a.md", "hello", 1))]);
264    }
265
266    #[test]
267    fn classify_event_unchanged_when_event_hash_matches() {
268        let states = HashMap::from([("a.md".to_string(), locator_state("occ1", "hello"))]);
269        let files = vec![file("a.md", "hello", 1)];
270        let actions = classify_event(files, &KnownNotes::new(), &states);
271        assert_eq!(actions, vec![SyncAction::Unchanged("a.md".into())]);
272    }
273
274    #[test]
275    fn classify_event_modified_when_event_hash_differs() {
276        let states = HashMap::from([("a.md".to_string(), locator_state("occ1", "old"))]);
277        let files = vec![file("a.md", "new", 2)];
278        let actions = classify_event(files, &KnownNotes::new(), &states);
279        assert_eq!(actions, vec![SyncAction::Modified(file("a.md", "new", 2))]);
280    }
281
282    #[test]
283    fn classify_event_unchanged_via_legacy_hash() {
284        // No event-path state, but legacy hash matches → Unchanged.
285        let mut legacy = KnownNotes::new();
286        legacy.insert("a.md".into(), HashSet::from([content_hash("hello")]));
287        let files = vec![file("a.md", "hello", 1)];
288        let actions = classify_event(files, &legacy, &HashMap::new());
289        assert_eq!(actions, vec![SyncAction::Unchanged("a.md".into())]);
290    }
291
292    #[test]
293    fn classify_event_modified_via_legacy_mismatch() {
294        // Legacy knows the path but content changed → Modified (first event-path ingest).
295        let mut legacy = KnownNotes::new();
296        legacy.insert("a.md".into(), HashSet::from([content_hash("old")]));
297        let files = vec![file("a.md", "new", 2)];
298        let actions = classify_event(files, &legacy, &HashMap::new());
299        assert_eq!(actions, vec![SyncAction::Modified(file("a.md", "new", 2))]);
300    }
301
302    #[test]
303    fn classify_event_event_state_takes_precedence_over_legacy() {
304        // Event state says "old", legacy says "hello" — event state wins.
305        let states = HashMap::from([("a.md".to_string(), locator_state("occ1", "old"))]);
306        let mut legacy = KnownNotes::new();
307        legacy.insert("a.md".into(), HashSet::from([content_hash("hello")]));
308        let files = vec![file("a.md", "hello", 1)];
309        let actions = classify_event(files, &legacy, &states);
310        // Event state hash != file hash → Modified, even though legacy matches.
311        assert_eq!(
312            actions,
313            vec![SyncAction::Modified(file("a.md", "hello", 1))]
314        );
315    }
316}