Skip to main content

memstead_base/ingest/
slice.rs

1//! Changed-slice computation — the deterministic core of the ingest
2//! source-cursor: given a source's stored baseline and its current state,
3//! classify the pass as reseed / unchanged / changed, and for a changed
4//! pass compute the added / modified / deleted [`Slice`].
5//!
6//! This is the engine-side port of the plugin's per-facet
7//! `computeSourceCursor` return contract (`inject.mjs`). It covers the two
8//! strategies whose slice computation is pure once the inputs are in hand:
9//!
10//!   - **graph** — map a source mem's [`ChangeEnvelope`]s into a slice of
11//!     entity ids ([`graph_changes_to_slice`] / [`graph_slice_outcome`]).
12//!   - **mtime** — classify against a stored digest token and diff the
13//!     stat maps ([`mtime_slice_outcome`], over the [`super::change_detection`]
14//!     primitives).
15//!
16//! The **git** strategy's slice (parse `git diff --name-status`, build
17//! facet-scope pathspecs) lands with its subprocess glue and the path
18//! normalization it needs — kept together rather than split here.
19//!
20//! Load-bearing invariant preserved from the plugin: the new baseline
21//! `token` is only ever *returned* here, never written. It is recorded by the
22//! engine's `set_mem_sync_state` writer when `projection advance` completes a
23//! full pass (D7), so an aborted pass leaves the baseline untouched and the next
24//! run re-presents the identical slice.
25
26use serde::{Deserialize, Serialize};
27
28use crate::ops::ChangeEnvelope;
29
30use super::change_detection::{
31    Digest, StatDiff, StatMap, diff_stat_maps, digest_stat_map, digests_equal, parse_digest_token,
32    serialize_digest_token,
33};
34
35/// The classified set of changed source artifacts in one pass. For the git
36/// and mtime strategies the entries are workspace-relative paths; for the
37/// graph strategy they are entity ids. Each class is kept sorted.
38///
39/// Serde-serializable so the [`super::advance`] durable store can freeze a
40/// presented slice to `.memstead/state/advance/` and re-present its remainder
41/// across process restarts (D7).
42#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
43pub struct Slice {
44    /// Newly present artifacts.
45    pub added: Vec<String>,
46    /// Artifacts present before and after, but changed.
47    pub modified: Vec<String>,
48    /// Artifacts that vanished (the cheapest, highest-signal drift).
49    pub deleted: Vec<String>,
50}
51
52impl Slice {
53    fn sort(&mut self) {
54        self.added.sort();
55        self.modified.sort();
56        self.deleted.sort();
57    }
58}
59
60impl From<StatDiff> for Slice {
61    fn from(d: StatDiff) -> Self {
62        Slice {
63            added: d.added,
64            modified: d.modified,
65            deleted: d.deleted,
66        }
67    }
68}
69
70/// Why a source produced no usable change signal — the classified reason a
71/// [`SliceOutcome::NoSignal`] carries so the brief can render it
72/// *distinguishably* from a genuinely-unchanged source (which stays silent).
73/// Every variant is a rendered state; none is dropped silently.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum NoSignalReason {
76    /// The facet has no allow patterns — it is **unscoped**, so no file-tree
77    /// strategy will diff or enumerate the whole medium. A typed refusal,
78    /// uniform across git / mtime / refinement. A facet that truly wants the
79    /// whole medium writes `**/*`.
80    Unscoped,
81    /// Change detection is declared `none` — the source is inert by design
82    /// ([`super::resolve::ChangeStrategy::None`]), re-roamed whole with no
83    /// slice.
84    DetectionNone,
85    /// A git strategy could not read a signal: no work tree over the medium
86    /// pointer, `HEAD` unreadable, the stored baseline is unknown (gc'd /
87    /// rewritten / out-of-repo pathspec), or the diff subprocess failed.
88    GitUnavailable,
89    /// A graph strategy could not read a signal: the source mem has no
90    /// snapshot token, is unknown to the engine, or its change history could
91    /// not be fetched against the stored baseline.
92    GraphSnapshotMissing,
93}
94
95/// The outcome of computing a source's changed slice against its baseline —
96/// the engine-side shape of the plugin's per-facet `computeSourceCursor`
97/// return. The `token` in `Reseed` / `Unchanged` / `Changed` is the new
98/// baseline the agent records **only after a full pass** (never written
99/// here). `NoSignal` carries a [`NoSignalReason`] and renders in the brief; it
100/// advances no baseline (a whole re-roam or a typed refusal, per reason).
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum SliceOutcome {
103    /// No usable change signal, classified by [`NoSignalReason`] — rendered in
104    /// the brief, never silently dropped. Advances no baseline.
105    NoSignal {
106        /// Why detection produced no signal.
107        reason: NoSignalReason,
108    },
109    /// No prior baseline — seed at the current token, present no slice.
110    Reseed {
111        /// The token to seed the baseline at.
112        token: String,
113    },
114    /// Baseline equals the current token — nothing moved.
115    Unchanged {
116        /// The (unchanged) current token.
117        token: String,
118    },
119    /// The source moved: the changed slice plus the new baseline token.
120    Changed {
121        /// The new baseline token to record after a full pass.
122        token: String,
123        /// The changed artifacts.
124        slice: Slice,
125        /// The precise slice was unavailable (mtime memo miss) and this is
126        /// a full-scan stand-in — detection still fired, precision was lost.
127        degraded: bool,
128    },
129}
130
131/// A git commit id / graph snapshot token: 7–64 hex characters. Mirrors the
132/// plugin's `isGitToken`, distinguishing a usable baseline from a foreign
133/// token (an mtime digest JSON, an empty string, junk).
134pub fn is_git_token(s: &str) -> bool {
135    (7..=64).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_hexdigit())
136}
137
138/// Map a graph mem's change envelopes into a [`Slice`] of entity ids:
139/// `Removed` → deleted; `Renamed` → the new id added **and** the old id
140/// deleted; `Added` → added; `Updated` → modified.
141pub fn graph_changes_to_slice(changes: &[ChangeEnvelope]) -> Slice {
142    let mut slice = Slice::default();
143    for change in changes {
144        match change {
145            ChangeEnvelope::Added { id, .. } => slice.added.push(id.as_ref().to_string()),
146            ChangeEnvelope::Removed { id, .. } => slice.deleted.push(id.as_ref().to_string()),
147            ChangeEnvelope::Renamed { from_id, to_id, .. } => {
148                slice.added.push(to_id.as_ref().to_string());
149                slice.deleted.push(from_id.as_ref().to_string());
150            }
151            ChangeEnvelope::Updated { id, .. } => slice.modified.push(id.as_ref().to_string()),
152        }
153    }
154    slice.sort();
155    slice
156}
157
158/// Classify a graph source against its baseline. `baseline` is the token
159/// stored for this `(ingest, facet)`; `current` is the source mem's current
160/// snapshot token; `changes` are the source mem's changes since `baseline`
161/// (only consulted when the source actually moved). Mirrors the plugin's
162/// `computeGraphSlice`:
163///
164///   - `current` not a usable snapshot token → [`NoSignal`] (degrade).
165///   - `baseline` absent / not a usable token → [`Reseed`] at `current`.
166///   - `baseline == current` → [`Unchanged`].
167///   - otherwise → [`Changed`] with the mapped `changes`.
168///
169/// [`NoSignal`]: SliceOutcome::NoSignal
170/// [`Reseed`]: SliceOutcome::Reseed
171/// [`Unchanged`]: SliceOutcome::Unchanged
172/// [`Changed`]: SliceOutcome::Changed
173pub fn graph_slice_outcome(
174    baseline: Option<&str>,
175    current: &str,
176    changes: &[ChangeEnvelope],
177) -> SliceOutcome {
178    if !is_git_token(current) {
179        return SliceOutcome::NoSignal {
180            reason: NoSignalReason::GraphSnapshotMissing,
181        };
182    }
183    match baseline {
184        Some(b) if is_git_token(b) => {
185            if b == current {
186                SliceOutcome::Unchanged {
187                    token: current.to_string(),
188                }
189            } else {
190                SliceOutcome::Changed {
191                    token: current.to_string(),
192                    slice: graph_changes_to_slice(changes),
193                    degraded: false,
194                }
195            }
196        }
197        _ => SliceOutcome::Reseed {
198            token: current.to_string(),
199        },
200    }
201}
202
203/// Classify an mtime source against its baseline digest token. `baseline` is
204/// the token stored for this `(ingest, facet)`; `current_map` is the freshly
205/// stat'd map; `prev_map` is the memo of the baseline's stat map, if the
206/// skill cache still holds it. Mirrors the plugin's mtime branch of
207/// `computeSourceCursor`:
208///
209///   - `baseline` not a parseable digest token → [`Reseed`] at the current
210///     digest.
211///   - baseline digest equals the current digest → [`Unchanged`].
212///   - otherwise → [`Changed`]: the precise [`diff_stat_maps`] when `prev_map`
213///     is present, or a **degraded** full scan (every current file as added)
214///     on a memo miss — detection still fired from the digest.
215///
216/// [`Reseed`]: SliceOutcome::Reseed
217/// [`Unchanged`]: SliceOutcome::Unchanged
218/// [`Changed`]: SliceOutcome::Changed
219pub fn mtime_slice_outcome(
220    baseline: Option<&str>,
221    prev_map: Option<&StatMap>,
222    current_map: &StatMap,
223) -> SliceOutcome {
224    let current_digest: Digest = digest_stat_map(current_map);
225    let token = serialize_digest_token(&current_digest);
226
227    let baseline_digest = baseline.and_then(parse_digest_token);
228    let Some(baseline_digest) = baseline_digest else {
229        return SliceOutcome::Reseed { token };
230    };
231
232    if digests_equal(Some(&baseline_digest), Some(&current_digest)) {
233        return SliceOutcome::Unchanged { token };
234    }
235
236    match prev_map {
237        Some(prev) => SliceOutcome::Changed {
238            token,
239            slice: diff_stat_maps(prev, current_map).into(),
240            degraded: false,
241        },
242        None => {
243            // Memo miss: the digest proved the source moved, but the precise
244            // slice is gone — present every current file as added (one-tick
245            // full scan), flagged degraded.
246            let mut added: Vec<String> = current_map.keys().cloned().collect();
247            added.sort();
248            SliceOutcome::Changed {
249                token,
250                slice: Slice {
251                    added,
252                    modified: Vec::new(),
253                    deleted: Vec::new(),
254                },
255                degraded: true,
256            }
257        }
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::entity::EntityId;
265
266    fn added(mem: &str, slug: &str) -> ChangeEnvelope {
267        ChangeEnvelope::Added {
268            id: EntityId::new(mem, slug),
269            title: None,
270            entity_type: None,
271        }
272    }
273
274    fn updated(mem: &str, slug: &str) -> ChangeEnvelope {
275        ChangeEnvelope::Updated {
276            id: EntityId::new(mem, slug),
277            title: None,
278            entity_type: None,
279        }
280    }
281
282    fn removed(mem: &str, slug: &str) -> ChangeEnvelope {
283        ChangeEnvelope::Removed {
284            id: EntityId::new(mem, slug),
285            title: None,
286            entity_type: None,
287        }
288    }
289
290    fn renamed(mem: &str, from: &str, to: &str) -> ChangeEnvelope {
291        ChangeEnvelope::Renamed {
292            from_id: EntityId::new(mem, from),
293            to_id: EntityId::new(mem, to),
294            title: None,
295            entity_type: None,
296        }
297    }
298
299    fn entry(mtime: i64, size: u64) -> super::super::change_detection::StatEntry {
300        super::super::change_detection::StatEntry { mtime, size }
301    }
302
303    fn stat_map(pairs: &[(&str, i64, u64)]) -> StatMap {
304        pairs
305            .iter()
306            .map(|(k, m, s)| ((*k).to_string(), entry(*m, *s)))
307            .collect()
308    }
309
310    /// A 7–64 hex string is a git token; too short, too long, and non-hex
311    /// (including an mtime digest JSON) are not.
312    #[test]
313    fn is_git_token_recognizes_hex_shas() {
314        assert!(is_git_token("a1b2c3d")); // 7 hex
315        assert!(is_git_token(&"a".repeat(40))); // canonical sha1 length
316        assert!(is_git_token("ABCDEF0")); // case-insensitive
317        assert!(!is_git_token("a1b2c3")); // 6 — too short
318        assert!(!is_git_token(&"a".repeat(65))); // too long
319        assert!(!is_git_token("not-hex")); // non-hex
320        assert!(!is_git_token(
321            r#"{"v":1,"count":2,"watermark":9,"aggregate":"x"}"#
322        ));
323        assert!(!is_git_token(""));
324    }
325
326    /// The graph mapping routes each action: removed → deleted, added →
327    /// added, updated → modified, and a rename → new id added + old deleted.
328    #[test]
329    fn graph_mapping_routes_each_action() {
330        let changes = vec![
331            added("m", "new-a"),
332            updated("m", "changed-b"),
333            removed("m", "gone-c"),
334            renamed("m", "old-d", "new-d"),
335        ];
336        let slice = graph_changes_to_slice(&changes);
337        assert_eq!(
338            slice.added,
339            vec![
340                EntityId::new("m", "new-a").as_ref().to_string(),
341                EntityId::new("m", "new-d").as_ref().to_string(),
342            ]
343        );
344        assert_eq!(
345            slice.modified,
346            vec![EntityId::new("m", "changed-b").as_ref().to_string()]
347        );
348        assert_eq!(
349            slice.deleted,
350            vec![
351                EntityId::new("m", "gone-c").as_ref().to_string(),
352                EntityId::new("m", "old-d").as_ref().to_string(),
353            ]
354        );
355    }
356
357    /// The graph outcome contract: invalid current → NoSignal; absent/foreign
358    /// baseline → Reseed; equal → Unchanged; moved → Changed with the slice.
359    #[test]
360    fn graph_outcome_classifies_against_baseline() {
361        let cur = "a".repeat(40);
362
363        // current not a snapshot token → degrade.
364        assert_eq!(
365            graph_slice_outcome(Some(&cur), "not-a-sha", &[]),
366            SliceOutcome::NoSignal {
367                reason: NoSignalReason::GraphSnapshotMissing
368            }
369        );
370
371        // no baseline → reseed at current.
372        assert_eq!(
373            graph_slice_outcome(None, &cur, &[]),
374            SliceOutcome::Reseed { token: cur.clone() }
375        );
376        // foreign baseline token (mtime digest) → reseed.
377        assert_eq!(
378            graph_slice_outcome(Some("{\"v\":1}"), &cur, &[]),
379            SliceOutcome::Reseed { token: cur.clone() }
380        );
381
382        // baseline == current → unchanged.
383        assert_eq!(
384            graph_slice_outcome(Some(&cur), &cur, &[]),
385            SliceOutcome::Unchanged { token: cur.clone() }
386        );
387
388        // moved → changed with the mapped slice.
389        let base = "b".repeat(40);
390        match graph_slice_outcome(Some(&base), &cur, &[added("m", "x")]) {
391            SliceOutcome::Changed {
392                token,
393                slice,
394                degraded,
395            } => {
396                assert_eq!(token, cur);
397                assert!(!degraded);
398                assert_eq!(
399                    slice.added,
400                    vec![EntityId::new("m", "x").as_ref().to_string()]
401                );
402            }
403            other => panic!("expected Changed, got {other:?}"),
404        }
405    }
406
407    /// The mtime outcome: unparseable baseline → reseed; equal digest →
408    /// unchanged; moved with a memo → precise diff; moved without a memo →
409    /// a degraded full scan.
410    #[test]
411    fn mtime_outcome_classifies_and_degrades() {
412        let now = stat_map(&[("a.rs", 100, 10), ("b.rs", 200, 20)]);
413        let token_now = serialize_digest_token(&digest_stat_map(&now));
414
415        // No baseline → reseed at the current digest token.
416        assert_eq!(
417            mtime_slice_outcome(None, None, &now),
418            SliceOutcome::Reseed {
419                token: token_now.clone()
420            }
421        );
422        // A git-sha baseline is not a digest token → reseed.
423        assert_eq!(
424            mtime_slice_outcome(Some(&"a".repeat(40)), None, &now),
425            SliceOutcome::Reseed {
426                token: token_now.clone()
427            }
428        );
429
430        // Baseline digest equals current → unchanged.
431        assert_eq!(
432            mtime_slice_outcome(Some(&token_now), None, &now),
433            SliceOutcome::Unchanged {
434                token: token_now.clone()
435            }
436        );
437
438        // Moved, with the prior map in the memo → precise diff.
439        let prev = stat_map(&[("a.rs", 100, 10), ("gone.rs", 5, 5)]);
440        let prev_token = serialize_digest_token(&digest_stat_map(&prev));
441        match mtime_slice_outcome(Some(&prev_token), Some(&prev), &now) {
442            SliceOutcome::Changed {
443                token,
444                slice,
445                degraded,
446            } => {
447                assert_eq!(token, token_now);
448                assert!(!degraded);
449                assert_eq!(slice.added, vec!["b.rs"]);
450                assert_eq!(slice.deleted, vec!["gone.rs"]);
451                assert!(slice.modified.is_empty());
452            }
453            other => panic!("expected precise Changed, got {other:?}"),
454        }
455
456        // Moved, memo miss → degraded full scan (every current file added).
457        match mtime_slice_outcome(Some(&prev_token), None, &now) {
458            SliceOutcome::Changed {
459                token,
460                slice,
461                degraded,
462            } => {
463                assert_eq!(token, token_now);
464                assert!(degraded, "memo miss is a degraded full scan");
465                assert_eq!(slice.added, vec!["a.rs", "b.rs"]);
466                assert!(slice.modified.is_empty() && slice.deleted.is_empty());
467            }
468            other => panic!("expected degraded Changed, got {other:?}"),
469        }
470    }
471}