Skip to main content

memstead_base/ingest/
refinement.rs

1//! Rotation / batch-order scheduling — the deterministic substrate the verify
2//! sampler (E3b) reuses. The refinement-as-writer *brief* (the scout/writer
3//! two-phase flow and its temp findings file) is **deleted** (D1/D9/AC10):
4//! `refinement` mode is gone from the vocabulary and no renderer remains. What
5//! survives, unrendered, is the rotation machinery — a `batch_size`-at-a-time
6//! walk over a source facet's files in a reproducibly-shuffled order that
7//! resets each rotation.
8//!
9//! One rotation of [`next_batch`] walks the source files in `batch_size`
10//! batches, covering the whole set once before reshuffling for the next
11//! rotation. Deterministic state (rotation, cursor, shuffled file order) lives
12//! engine-side under `<workspace>/.memstead.cache/ingest/refinement/` — the
13//! same engine-internal cache the mtime memo and backoff use.
14//!
15//! **Port note.** The original plugin shuffled the file order with
16//! `Math.random`; this uses a small rotation-seeded PRNG so the order still
17//! varies across rotations but is reproducible (no `rand` dependency). The
18//! behaviour preserved is "each rotation covers the whole source set in a
19//! different batch order"; the exact permutation is not load-bearing.
20
21use std::collections::BTreeMap;
22use std::path::{Path, PathBuf};
23
24use serde::{Deserialize, Serialize};
25
26use super::cursor::enumerate_source_artifacts;
27use super::resolve::{ResolvedIngest, ResolvedSource};
28use crate::Engine;
29
30/// The rotation key the verify uncovered-artifact sampler walks under. Named so
31/// independent verify samples (uncovered files, anchor spot-checks) each get
32/// their own rotation cursor within one binding's state without interfering.
33pub const ROTATION_UNCOVERED_FILES: &str = "uncovered-files";
34
35/// The rotation key the verify anchor-adjudication sampler walks under (D2) — a
36/// distinct cursor from [`ROTATION_UNCOVERED_FILES`], so the cap-sized
37/// adjudication window rotates over the anchor set independently of the
38/// uncovered-file sample.
39pub const ROTATION_ANCHOR_ADJUDICATION: &str = "anchor-adjudication";
40
41/// One named rotation's cursor over a set: the shuffled item order plus its
42/// rotation counter and position. One rotation covers the whole set once before
43/// reshuffling.
44#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45struct RotationCursor {
46    #[serde(default)]
47    rotation: u64,
48    #[serde(default)]
49    cursor: usize,
50    #[serde(default)]
51    order: Vec<String>,
52}
53
54/// Per-binding verify-scheduling state (persisted as JSON under the engine cache
55/// tier). Holds the level-trigger run clock (`verify_runs`, for `full_resync_every`,
56/// D3) and the set of named rotation cursors the verify samplers walk (D2).
57///
58/// The prior flat single-rotation shape (a bare `rotation`/`cursor`/`file_order`
59/// triple) is superseded by `rotations`; because this lives under the recomputable
60/// `.memstead.cache/` tier, a state file in the old shape simply fails to parse
61/// and reseeds — no migration needed.
62#[derive(Debug, Clone, Default, Serialize, Deserialize)]
63struct RefinementState {
64    /// The verify-run counter — the `full_resync_every` level-trigger clock (D3).
65    /// Ticks every verify run, including a run whose source enumerates to nothing
66    /// (a non-enumerable medium), so the schedule refuses *on time* rather than
67    /// silently never firing.
68    #[serde(default)]
69    verify_runs: u64,
70    /// Named rotation cursors, keyed by sample kind
71    /// ([`ROTATION_UNCOVERED_FILES`], [`ROTATION_ANCHOR_ADJUDICATION`]).
72    #[serde(default)]
73    rotations: BTreeMap<String, RotationCursor>,
74}
75
76/// One batch: the files to review plus its position in the rotation.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct Batch {
79    /// The files this batch reviews (a `batch_size` slice of the rotation).
80    pub files: Vec<String>,
81    /// The current rotation number.
82    pub rotation: u64,
83    /// This batch's 1-based index within the rotation.
84    pub batch_index: usize,
85    /// The total number of batches in the rotation.
86    pub total_batches: usize,
87}
88
89/// The `<workspace>/.memstead.cache/ingest/refinement/` directory.
90fn refinement_dir(cache_root: &Path) -> PathBuf {
91    cache_root.join("refinement")
92}
93
94fn state_path(cache_root: &Path, binding_name: &str) -> PathBuf {
95    refinement_dir(cache_root).join(format!("{binding_name}.json"))
96}
97
98/// Enumerate the union of every source facet's files (sorted, de-duplicated).
99/// Each facet's enumeration applies the binding's `deny_paths` (the same
100/// strategy-invariant deny set the git and mtime slices honour), so a denied
101/// file never lands in a batch.
102fn enumerate_source_files(
103    engine: &Engine,
104    resolved: &ResolvedIngest,
105    workspace_root: &Path,
106) -> Vec<String> {
107    let mut files: Vec<String> = Vec::new();
108    for source in &resolved.sources {
109        if let ResolvedSource::Primary(p) = source {
110            files.extend(enumerate_source_artifacts(
111                engine,
112                p,
113                &resolved.deny_paths,
114                workspace_root,
115            ));
116        }
117    }
118    files.sort();
119    files.dedup();
120    files
121}
122
123/// A small rotation-seeded Fisher-Yates shuffle — reproducible, dependency-free.
124fn shuffle(files: &mut [String], seed: u64) {
125    let mut state = seed
126        .wrapping_mul(6_364_136_223_846_793_005)
127        .wrapping_add(1_442_695_040_888_963_407);
128    for i in (1..files.len()).rev() {
129        state = state
130            .wrapping_mul(6_364_136_223_846_793_005)
131            .wrapping_add(1_442_695_040_888_963_407);
132        let j = ((state >> 33) as usize) % (i + 1);
133        files.swap(i, j);
134    }
135}
136
137fn load_state(cache_root: &Path, binding_name: &str) -> Option<RefinementState> {
138    let bytes = std::fs::read(state_path(cache_root, binding_name)).ok()?;
139    serde_json::from_slice(&bytes).ok()
140}
141
142fn save_state(cache_root: &Path, binding_name: &str, state: &RefinementState) {
143    let path = state_path(cache_root, binding_name);
144    if let Some(parent) = path.parent() {
145        let _ = std::fs::create_dir_all(parent);
146    }
147    if let Ok(mut bytes) = serde_json::to_vec_pretty(state) {
148        bytes.push(b'\n');
149        let _ = std::fs::write(path, bytes);
150    }
151}
152
153/// Increment and return the persisted verify-run counter for a binding — the
154/// level-trigger clock the `full_resync_every` schedule reads (D3). Independent
155/// of the rotation cursors: it ticks every verify run, including a run whose
156/// source enumerates to nothing (a non-enumerable medium), so the schedule can
157/// **refuse on time** rather than silently never firing. Returns the new
158/// (post-increment, 1-based) run count.
159pub fn bump_verify_runs(cache_root: &Path, binding_name: &str) -> u64 {
160    let mut state = load_state(cache_root, binding_name).unwrap_or_default();
161    state.verify_runs = state.verify_runs.saturating_add(1);
162    let n = state.verify_runs;
163    save_state(cache_root, binding_name, &state);
164    n
165}
166
167/// Bring a rotation in flight into agreement with the item set it is asked
168/// to walk **this** call. The cursor persists a shuffled order across runs;
169/// until 2026-09-02 that order was consulted as-is mid-rotation, so an item
170/// that had left the set (a source file a fresh `deny_paths` entry now
171/// excludes) kept being served for the rest of the rotation and every sampled
172/// verify recorded an `uncovered` finding for a file the binding denied
173/// (backlog, the apparatus files of 2026-09-01). The rule now: an item no
174/// longer in the set leaves the order (the position shifts with it), and an
175/// item newly in the set joins at the end of the current rotation in a
176/// reproducible order, so the rotation still covers the whole set once
177/// before reshuffling and the next window never names a departed item. A
178/// rotation that has not started, or is exhausted, is left to the reshuffle.
179fn reconcile_order(cursor: &mut RotationCursor, items: &[String]) {
180    if cursor.order.is_empty() || cursor.cursor >= cursor.order.len() {
181        return;
182    }
183    let current: std::collections::BTreeSet<&str> = items.iter().map(String::as_str).collect();
184    let mut kept: Vec<String> = Vec::with_capacity(cursor.order.len());
185    let mut position = 0usize;
186    for (i, item) in cursor.order.iter().enumerate() {
187        if current.contains(item.as_str()) {
188            if i < cursor.cursor {
189                position += 1;
190            }
191            kept.push(item.clone());
192        }
193    }
194    let present: std::collections::BTreeSet<&str> = kept.iter().map(String::as_str).collect();
195    let mut arrivals: Vec<String> = items
196        .iter()
197        .filter(|i| !present.contains(i.as_str()))
198        .cloned()
199        .collect();
200    if !arrivals.is_empty() {
201        shuffle(&mut arrivals, cursor.rotation);
202        kept.extend(arrivals);
203    }
204    cursor.order = kept;
205    cursor.cursor = position;
206}
207
208/// Advance one **named** rotation over an arbitrary item set — the generalized
209/// rotation core the verify samplers (D2) repurpose. `items` is the full set to
210/// cover (sorted + de-duplicated by the caller for determinism); `rotation_key`
211/// namespaces this rotation within the binding's state file so independent
212/// samples rotate on their own cursor. One rotation walks the whole set once in
213/// a reproducibly-shuffled order before reshuffling for the next; same persisted
214/// state → same sequence. `None` when `items` is empty.
215pub fn next_rotation_batch(
216    cache_root: &Path,
217    binding_name: &str,
218    rotation_key: &str,
219    items: Vec<String>,
220    batch_size: usize,
221) -> Option<Batch> {
222    let batch_size = batch_size.max(1);
223    if items.is_empty() {
224        return None;
225    }
226
227    let mut state = load_state(cache_root, binding_name).unwrap_or_default();
228    let mut cursor = state.rotations.remove(rotation_key).unwrap_or_default();
229    reconcile_order(&mut cursor, &items);
230    if cursor.order.is_empty() || cursor.cursor >= cursor.order.len() {
231        // New rotation: bump the counter (only after a completed prior rotation)
232        // and reshuffle the whole set.
233        let rotation = cursor.rotation + u64::from(!cursor.order.is_empty());
234        let mut order = items;
235        shuffle(&mut order, rotation);
236        cursor = RotationCursor {
237            rotation,
238            cursor: 0,
239            order,
240        };
241    }
242
243    let end = (cursor.cursor + batch_size).min(cursor.order.len());
244    let files = cursor.order[cursor.cursor..end].to_vec();
245    let batch_index = cursor.cursor / batch_size + 1;
246    let total_batches = cursor.order.len().div_ceil(batch_size);
247    cursor.cursor += files.len();
248    let rotation = cursor.rotation;
249    state.rotations.insert(rotation_key.to_string(), cursor);
250    save_state(cache_root, binding_name, &state);
251
252    Some(Batch {
253        files,
254        rotation,
255        batch_index,
256        total_batches,
257    })
258}
259
260/// Advance the uncovered-artifact file sample (D2) — the retained rotation over
261/// a source facet's enumerated files, one `batch_size` window at a time. A thin
262/// wrapper over [`next_rotation_batch`] keyed [`ROTATION_UNCOVERED_FILES`].
263/// `None` when the binding has no source files (e.g. a non-enumerable medium).
264pub fn next_batch(
265    engine: &Engine,
266    resolved: &ResolvedIngest,
267    workspace_root: &Path,
268    cache_root: &Path,
269    batch_size: usize,
270) -> Option<Batch> {
271    let all_files = enumerate_source_files(engine, resolved, workspace_root);
272    next_rotation_batch(
273        cache_root,
274        &resolved.name,
275        ROTATION_UNCOVERED_FILES,
276        all_files,
277        batch_size,
278    )
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use crate::binding::BuildMode;
285    use crate::ingest::resolve::Source;
286    use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
287
288    fn resolved(name: &str, batch_size: u32) -> ResolvedIngest {
289        ResolvedIngest {
290            name: name.to_string(),
291            mode: BuildMode::Discovery,
292            trigger: IngestTrigger::Loop,
293            batch_size,
294            deny_paths: vec![],
295            projection_ref: format!("{name}/p"),
296            projection_mem: name.to_string(),
297            projection_name: "p".to_string(),
298            intent: None,
299            sources: vec![ResolvedSource::Primary(Source {
300                name: "f".to_string(),
301                medium_type: MediumType::Codebase,
302                pointer: String::new(),
303                change_detection: None,
304                scope: vec![PatternEntry {
305                    path: "**/*.rs".to_string(),
306                    mode: PatternMode::Allow,
307                }],
308                engagement: None,
309                preparation: None,
310            })],
311            destination_mem: name.to_string(),
312            rules: None,
313            post_actions: None,
314        }
315    }
316
317    /// A3 AC1, the scheduler half: an item that leaves the set mid-rotation
318    /// is never served again, an item that joins is served before the
319    /// rotation ends, and the rotation still completes once over the set.
320    #[test]
321    fn rotation_in_flight_follows_the_item_set() {
322        let cache = tempfile::tempdir().unwrap();
323        let key = "k";
324        let all: Vec<String> = (0..10).map(|i| format!("f{i}")).collect();
325        let first = next_rotation_batch(cache.path(), "m/b", key, all.clone(), 3).unwrap();
326        assert_eq!(first.rotation, 0);
327        assert_eq!(first.files.len(), 3);
328
329        // Deny half the set: no later window of this rotation names a
330        // denied item, and the rotation walks exactly the surviving ones.
331        let kept: Vec<String> = all.iter().filter(|f| f.as_str() > "f4").cloned().collect();
332        let mut served: Vec<String> = Vec::new();
333        for _ in 0..4 {
334            let b = next_rotation_batch(cache.path(), "m/b", key, kept.clone(), 3).unwrap();
335            if b.rotation != 0 {
336                break;
337            }
338            served.extend(b.files);
339        }
340        assert!(
341            served.iter().all(|f| kept.contains(f)),
342            "a denied item was served: {served:?}"
343        );
344        let already: std::collections::BTreeSet<&String> = first.files.iter().collect();
345        for f in &kept {
346            assert!(
347                served.contains(f) || already.contains(f),
348                "{f} was never served in rotation 0: served {served:?}, first {:?}",
349                first.files
350            );
351        }
352
353        // Lift the deny: the returning items are served within the next
354        // rotation's worth of windows (they join the rotation in flight, or
355        // the reshuffle that follows an exhausted one picks them up).
356        let mut seen: Vec<String> = Vec::new();
357        for _ in 0..8 {
358            let b = next_rotation_batch(cache.path(), "m/b", key, all.clone(), 3).unwrap();
359            seen.extend(b.files);
360        }
361        for f in all.iter().filter(|f| f.as_str() <= "f4") {
362            assert!(seen.contains(f), "returning item {f} not served: {seen:?}");
363        }
364    }
365
366    /// Batching walks the shuffled file set across a rotation, then reshuffles a
367    /// new rotation once exhausted.
368    #[test]
369    fn next_batch_walks_a_rotation_then_starts_a_new_one() {
370        let ws = tempfile::tempdir().unwrap();
371        let cache = tempfile::tempdir().unwrap();
372        let root = ws.path();
373        for i in 0..5 {
374            std::fs::write(root.join(format!("f{i}.rs")), "").unwrap();
375        }
376        let r = resolved("ref", 2);
377        // A path-medium rotation needs no mounted mems; the engine is only
378        // consulted for graph sources.
379        let engine = Engine::from_mounts(Vec::new()).unwrap();
380
381        let b1 = next_batch(&engine, &r, root, cache.path(), 2).unwrap();
382        assert_eq!(b1.rotation, 0);
383        assert_eq!(b1.batch_index, 1);
384        assert_eq!(b1.total_batches, 3); // ceil(5/2)
385        assert_eq!(b1.files.len(), 2);
386
387        let b2 = next_batch(&engine, &r, root, cache.path(), 2).unwrap();
388        assert_eq!(b2.batch_index, 2);
389        let b3 = next_batch(&engine, &r, root, cache.path(), 2).unwrap();
390        assert_eq!(b3.batch_index, 3);
391        assert_eq!(b3.files.len(), 1); // remainder
392
393        // Rotation exhausted → next batch starts rotation 1.
394        let b4 = next_batch(&engine, &r, root, cache.path(), 2).unwrap();
395        assert_eq!(b4.rotation, 1);
396        assert_eq!(b4.batch_index, 1);
397
398        // Every file appears exactly once across a rotation.
399        let mut seen: Vec<String> = [b1.files, b2.files, b3.files].concat();
400        seen.sort();
401        seen.dedup();
402        assert_eq!(seen.len(), 5, "the rotation covers all files");
403    }
404
405    /// D2 — a named rotation over an arbitrary item set is deterministic
406    /// (same persisted state → same sequence), covers the whole set over a full
407    /// rotation, and reshuffles the next rotation into a different order.
408    #[test]
409    fn named_rotation_is_deterministic_and_covers_the_whole_set() {
410        let cache = tempfile::tempdir().unwrap();
411        let items: Vec<String> = (0..6).map(|i| format!("id{i}")).collect();
412        let key = ROTATION_ANCHOR_ADJUDICATION;
413
414        // Walk a full rotation of batch 2 → three windows covering all six ids.
415        let mut covered: Vec<String> = Vec::new();
416        let mut order_r0: Vec<String> = Vec::new();
417        for i in 0..3 {
418            let b = next_rotation_batch(cache.path(), "m/b", key, items.clone(), 2).unwrap();
419            assert_eq!(b.rotation, 0);
420            assert_eq!(b.batch_index, i + 1);
421            assert_eq!(b.total_batches, 3);
422            covered.extend(b.files.clone());
423            order_r0.extend(b.files);
424        }
425        let mut uniq = covered.clone();
426        uniq.sort();
427        uniq.dedup();
428        assert_eq!(uniq.len(), 6, "one rotation covers the whole set");
429
430        // Next rotation reshuffles (different order, same coverage).
431        let b = next_rotation_batch(cache.path(), "m/b", key, items.clone(), 2).unwrap();
432        assert_eq!(
433            b.rotation, 1,
434            "a new rotation starts once the prior is done"
435        );
436
437        // Reproducibility: re-running from a fresh cache with the same seed
438        // (rotation 0) yields the identical first-rotation order.
439        let cache2 = tempfile::tempdir().unwrap();
440        let mut order_repro: Vec<String> = Vec::new();
441        for _ in 0..3 {
442            let b = next_rotation_batch(cache2.path(), "m/b", key, items.clone(), 2).unwrap();
443            order_repro.extend(b.files);
444        }
445        assert_eq!(order_r0, order_repro, "same seed/state → same sequence");
446    }
447
448    /// D2 — two named rotations under one binding advance on independent cursors:
449    /// walking one does not consume the other.
450    #[test]
451    fn named_rotations_are_independent() {
452        let cache = tempfile::tempdir().unwrap();
453        let a: Vec<String> = (0..4).map(|i| format!("a{i}")).collect();
454        let files =
455            next_rotation_batch(cache.path(), "m/b", ROTATION_UNCOVERED_FILES, a.clone(), 2)
456                .unwrap();
457        let anchors = next_rotation_batch(
458            cache.path(),
459            "m/b",
460            ROTATION_ANCHOR_ADJUDICATION,
461            a.clone(),
462            2,
463        )
464        .unwrap();
465        // Both are the first window of their own rotation.
466        assert_eq!(files.batch_index, 1);
467        assert_eq!(anchors.batch_index, 1);
468        // Advancing the file rotation again does not touch the anchor cursor.
469        let files2 =
470            next_rotation_batch(cache.path(), "m/b", ROTATION_UNCOVERED_FILES, a.clone(), 2)
471                .unwrap();
472        assert_eq!(files2.batch_index, 2);
473        let anchors_again =
474            next_rotation_batch(cache.path(), "m/b", ROTATION_ANCHOR_ADJUDICATION, a, 2).unwrap();
475        assert_eq!(anchors_again.batch_index, 2, "anchor cursor is independent");
476    }
477
478    /// D3 — the verify-run counter ticks every call and persists across a fresh
479    /// load (the level-trigger clock survives process restarts).
480    #[test]
481    fn verify_run_counter_ticks_and_persists() {
482        let cache = tempfile::tempdir().unwrap();
483        assert_eq!(bump_verify_runs(cache.path(), "m/b"), 1);
484        assert_eq!(bump_verify_runs(cache.path(), "m/b"), 2);
485        assert_eq!(bump_verify_runs(cache.path(), "m/b"), 3);
486        // A different binding has its own counter.
487        assert_eq!(bump_verify_runs(cache.path(), "m/other"), 1);
488    }
489}