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(¤t_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(¤t_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}