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/// Advance one **named** rotation over an arbitrary item set — the generalized
168/// rotation core the verify samplers (D2) repurpose. `items` is the full set to
169/// cover (sorted + de-duplicated by the caller for determinism); `rotation_key`
170/// namespaces this rotation within the binding's state file so independent
171/// samples rotate on their own cursor. One rotation walks the whole set once in
172/// a reproducibly-shuffled order before reshuffling for the next; same persisted
173/// state → same sequence. `None` when `items` is empty.
174pub fn next_rotation_batch(
175    cache_root: &Path,
176    binding_name: &str,
177    rotation_key: &str,
178    items: Vec<String>,
179    batch_size: usize,
180) -> Option<Batch> {
181    let batch_size = batch_size.max(1);
182    if items.is_empty() {
183        return None;
184    }
185
186    let mut state = load_state(cache_root, binding_name).unwrap_or_default();
187    let mut cursor = state.rotations.remove(rotation_key).unwrap_or_default();
188    if cursor.order.is_empty() || cursor.cursor >= cursor.order.len() {
189        // New rotation: bump the counter (only after a completed prior rotation)
190        // and reshuffle the whole set.
191        let rotation = cursor.rotation + u64::from(!cursor.order.is_empty());
192        let mut order = items;
193        shuffle(&mut order, rotation);
194        cursor = RotationCursor {
195            rotation,
196            cursor: 0,
197            order,
198        };
199    }
200
201    let end = (cursor.cursor + batch_size).min(cursor.order.len());
202    let files = cursor.order[cursor.cursor..end].to_vec();
203    let batch_index = cursor.cursor / batch_size + 1;
204    let total_batches = cursor.order.len().div_ceil(batch_size);
205    cursor.cursor += files.len();
206    let rotation = cursor.rotation;
207    state.rotations.insert(rotation_key.to_string(), cursor);
208    save_state(cache_root, binding_name, &state);
209
210    Some(Batch {
211        files,
212        rotation,
213        batch_index,
214        total_batches,
215    })
216}
217
218/// Advance the uncovered-artifact file sample (D2) — the retained rotation over
219/// a source facet's enumerated files, one `batch_size` window at a time. A thin
220/// wrapper over [`next_rotation_batch`] keyed [`ROTATION_UNCOVERED_FILES`].
221/// `None` when the binding has no source files (e.g. a non-enumerable medium).
222pub fn next_batch(
223    engine: &Engine,
224    resolved: &ResolvedIngest,
225    workspace_root: &Path,
226    cache_root: &Path,
227    batch_size: usize,
228) -> Option<Batch> {
229    let all_files = enumerate_source_files(engine, resolved, workspace_root);
230    next_rotation_batch(
231        cache_root,
232        &resolved.name,
233        ROTATION_UNCOVERED_FILES,
234        all_files,
235        batch_size,
236    )
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::binding::BuildMode;
243    use crate::ingest::resolve::Source;
244    use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
245
246    fn resolved(name: &str, batch_size: u32) -> ResolvedIngest {
247        ResolvedIngest {
248            name: name.to_string(),
249            mode: BuildMode::Discovery,
250            trigger: IngestTrigger::Loop,
251            batch_size,
252            deny_paths: vec![],
253            projection_ref: format!("{name}/p"),
254            projection_mem: name.to_string(),
255            projection_name: "p".to_string(),
256            intent: None,
257            sources: vec![ResolvedSource::Primary(Source {
258                name: "f".to_string(),
259                medium_type: MediumType::Codebase,
260                pointer: String::new(),
261                change_detection: None,
262                scope: vec![PatternEntry {
263                    path: "**/*.rs".to_string(),
264                    mode: PatternMode::Allow,
265                }],
266                engagement: None,
267                preparation: None,
268            })],
269            destination_mem: name.to_string(),
270            rules: None,
271            post_actions: None,
272        }
273    }
274
275    /// Batching walks the shuffled file set across a rotation, then reshuffles a
276    /// new rotation once exhausted.
277    #[test]
278    fn next_batch_walks_a_rotation_then_starts_a_new_one() {
279        let ws = tempfile::tempdir().unwrap();
280        let cache = tempfile::tempdir().unwrap();
281        let root = ws.path();
282        for i in 0..5 {
283            std::fs::write(root.join(format!("f{i}.rs")), "").unwrap();
284        }
285        let r = resolved("ref", 2);
286        // A path-medium rotation needs no mounted mems; the engine is only
287        // consulted for graph sources.
288        let engine = Engine::from_mounts(Vec::new()).unwrap();
289
290        let b1 = next_batch(&engine, &r, root, cache.path(), 2).unwrap();
291        assert_eq!(b1.rotation, 0);
292        assert_eq!(b1.batch_index, 1);
293        assert_eq!(b1.total_batches, 3); // ceil(5/2)
294        assert_eq!(b1.files.len(), 2);
295
296        let b2 = next_batch(&engine, &r, root, cache.path(), 2).unwrap();
297        assert_eq!(b2.batch_index, 2);
298        let b3 = next_batch(&engine, &r, root, cache.path(), 2).unwrap();
299        assert_eq!(b3.batch_index, 3);
300        assert_eq!(b3.files.len(), 1); // remainder
301
302        // Rotation exhausted → next batch starts rotation 1.
303        let b4 = next_batch(&engine, &r, root, cache.path(), 2).unwrap();
304        assert_eq!(b4.rotation, 1);
305        assert_eq!(b4.batch_index, 1);
306
307        // Every file appears exactly once across a rotation.
308        let mut seen: Vec<String> = [b1.files, b2.files, b3.files].concat();
309        seen.sort();
310        seen.dedup();
311        assert_eq!(seen.len(), 5, "the rotation covers all files");
312    }
313
314    /// D2 — a named rotation over an arbitrary item set is deterministic
315    /// (same persisted state → same sequence), covers the whole set over a full
316    /// rotation, and reshuffles the next rotation into a different order.
317    #[test]
318    fn named_rotation_is_deterministic_and_covers_the_whole_set() {
319        let cache = tempfile::tempdir().unwrap();
320        let items: Vec<String> = (0..6).map(|i| format!("id{i}")).collect();
321        let key = ROTATION_ANCHOR_ADJUDICATION;
322
323        // Walk a full rotation of batch 2 → three windows covering all six ids.
324        let mut covered: Vec<String> = Vec::new();
325        let mut order_r0: Vec<String> = Vec::new();
326        for i in 0..3 {
327            let b = next_rotation_batch(cache.path(), "m/b", key, items.clone(), 2).unwrap();
328            assert_eq!(b.rotation, 0);
329            assert_eq!(b.batch_index, i + 1);
330            assert_eq!(b.total_batches, 3);
331            covered.extend(b.files.clone());
332            order_r0.extend(b.files);
333        }
334        let mut uniq = covered.clone();
335        uniq.sort();
336        uniq.dedup();
337        assert_eq!(uniq.len(), 6, "one rotation covers the whole set");
338
339        // Next rotation reshuffles (different order, same coverage).
340        let b = next_rotation_batch(cache.path(), "m/b", key, items.clone(), 2).unwrap();
341        assert_eq!(
342            b.rotation, 1,
343            "a new rotation starts once the prior is done"
344        );
345
346        // Reproducibility: re-running from a fresh cache with the same seed
347        // (rotation 0) yields the identical first-rotation order.
348        let cache2 = tempfile::tempdir().unwrap();
349        let mut order_repro: Vec<String> = Vec::new();
350        for _ in 0..3 {
351            let b = next_rotation_batch(cache2.path(), "m/b", key, items.clone(), 2).unwrap();
352            order_repro.extend(b.files);
353        }
354        assert_eq!(order_r0, order_repro, "same seed/state → same sequence");
355    }
356
357    /// D2 — two named rotations under one binding advance on independent cursors:
358    /// walking one does not consume the other.
359    #[test]
360    fn named_rotations_are_independent() {
361        let cache = tempfile::tempdir().unwrap();
362        let a: Vec<String> = (0..4).map(|i| format!("a{i}")).collect();
363        let files =
364            next_rotation_batch(cache.path(), "m/b", ROTATION_UNCOVERED_FILES, a.clone(), 2)
365                .unwrap();
366        let anchors = next_rotation_batch(
367            cache.path(),
368            "m/b",
369            ROTATION_ANCHOR_ADJUDICATION,
370            a.clone(),
371            2,
372        )
373        .unwrap();
374        // Both are the first window of their own rotation.
375        assert_eq!(files.batch_index, 1);
376        assert_eq!(anchors.batch_index, 1);
377        // Advancing the file rotation again does not touch the anchor cursor.
378        let files2 =
379            next_rotation_batch(cache.path(), "m/b", ROTATION_UNCOVERED_FILES, a.clone(), 2)
380                .unwrap();
381        assert_eq!(files2.batch_index, 2);
382        let anchors_again =
383            next_rotation_batch(cache.path(), "m/b", ROTATION_ANCHOR_ADJUDICATION, a, 2).unwrap();
384        assert_eq!(anchors_again.batch_index, 2, "anchor cursor is independent");
385    }
386
387    /// D3 — the verify-run counter ticks every call and persists across a fresh
388    /// load (the level-trigger clock survives process restarts).
389    #[test]
390    fn verify_run_counter_ticks_and_persists() {
391        let cache = tempfile::tempdir().unwrap();
392        assert_eq!(bump_verify_runs(cache.path(), "m/b"), 1);
393        assert_eq!(bump_verify_runs(cache.path(), "m/b"), 2);
394        assert_eq!(bump_verify_runs(cache.path(), "m/b"), 3);
395        // A different binding has its own counter.
396        assert_eq!(bump_verify_runs(cache.path(), "m/other"), 1);
397    }
398}