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_facet_files;
27use super::resolve::{ResolvedIngest, ResolvedSource};
28
29/// The rotation key the verify uncovered-artifact sampler walks under. Named so
30/// independent verify samples (uncovered files, anchor spot-checks) each get
31/// their own rotation cursor within one binding's state without interfering.
32pub const ROTATION_UNCOVERED_FILES: &str = "uncovered-files";
33
34/// The rotation key the verify anchor-adjudication sampler walks under (D2) — a
35/// distinct cursor from [`ROTATION_UNCOVERED_FILES`], so the cap-sized
36/// adjudication window rotates over the anchor set independently of the
37/// uncovered-file sample.
38pub const ROTATION_ANCHOR_ADJUDICATION: &str = "anchor-adjudication";
39
40/// One named rotation's cursor over a set: the shuffled item order plus its
41/// rotation counter and position. One rotation covers the whole set once before
42/// reshuffling.
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44struct RotationCursor {
45    #[serde(default)]
46    rotation: u64,
47    #[serde(default)]
48    cursor: usize,
49    #[serde(default)]
50    order: Vec<String>,
51}
52
53/// Per-binding verify-scheduling state (persisted as JSON under the engine cache
54/// tier). Holds the level-trigger run clock (`verify_runs`, for `full_resync_every`,
55/// D3) and the set of named rotation cursors the verify samplers walk (D2).
56///
57/// The prior flat single-rotation shape (a bare `rotation`/`cursor`/`file_order`
58/// triple) is superseded by `rotations`; because this lives under the recomputable
59/// `.memstead.cache/` tier, a state file in the old shape simply fails to parse
60/// and reseeds — no migration needed.
61#[derive(Debug, Clone, Default, Serialize, Deserialize)]
62struct RefinementState {
63    /// The verify-run counter — the `full_resync_every` level-trigger clock (D3).
64    /// Ticks every verify run, including a run whose source enumerates to nothing
65    /// (a non-enumerable medium), so the schedule refuses *on time* rather than
66    /// silently never firing.
67    #[serde(default)]
68    verify_runs: u64,
69    /// Named rotation cursors, keyed by sample kind
70    /// ([`ROTATION_UNCOVERED_FILES`], [`ROTATION_ANCHOR_ADJUDICATION`]).
71    #[serde(default)]
72    rotations: BTreeMap<String, RotationCursor>,
73}
74
75/// One batch: the files to review plus its position in the rotation.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct Batch {
78    /// The files this batch reviews (a `batch_size` slice of the rotation).
79    pub files: Vec<String>,
80    /// The current rotation number.
81    pub rotation: u64,
82    /// This batch's 1-based index within the rotation.
83    pub batch_index: usize,
84    /// The total number of batches in the rotation.
85    pub total_batches: usize,
86}
87
88/// The `<workspace>/.memstead.cache/ingest/refinement/` directory.
89fn refinement_dir(cache_root: &Path) -> PathBuf {
90    cache_root.join("refinement")
91}
92
93fn state_path(cache_root: &Path, binding_name: &str) -> PathBuf {
94    refinement_dir(cache_root).join(format!("{binding_name}.json"))
95}
96
97/// Enumerate the union of every source facet's files (sorted, de-duplicated).
98/// Each facet's enumeration applies the binding's `deny_paths` (the same
99/// strategy-invariant deny set the git and mtime slices honour), so a denied
100/// file never lands in a batch.
101fn enumerate_source_files(resolved: &ResolvedIngest, workspace_root: &Path) -> Vec<String> {
102    let mut files: Vec<String> = Vec::new();
103    for source in &resolved.sources {
104        if let ResolvedSource::Primary(p) = source {
105            files.extend(enumerate_facet_files(
106                p,
107                &resolved.deny_paths,
108                workspace_root,
109            ));
110        }
111    }
112    files.sort();
113    files.dedup();
114    files
115}
116
117/// A small rotation-seeded Fisher-Yates shuffle — reproducible, dependency-free.
118fn shuffle(files: &mut [String], seed: u64) {
119    let mut state = seed
120        .wrapping_mul(6_364_136_223_846_793_005)
121        .wrapping_add(1_442_695_040_888_963_407);
122    for i in (1..files.len()).rev() {
123        state = state
124            .wrapping_mul(6_364_136_223_846_793_005)
125            .wrapping_add(1_442_695_040_888_963_407);
126        let j = ((state >> 33) as usize) % (i + 1);
127        files.swap(i, j);
128    }
129}
130
131fn load_state(cache_root: &Path, binding_name: &str) -> Option<RefinementState> {
132    let bytes = std::fs::read(state_path(cache_root, binding_name)).ok()?;
133    serde_json::from_slice(&bytes).ok()
134}
135
136fn save_state(cache_root: &Path, binding_name: &str, state: &RefinementState) {
137    let path = state_path(cache_root, binding_name);
138    if let Some(parent) = path.parent() {
139        let _ = std::fs::create_dir_all(parent);
140    }
141    if let Ok(mut bytes) = serde_json::to_vec_pretty(state) {
142        bytes.push(b'\n');
143        let _ = std::fs::write(path, bytes);
144    }
145}
146
147/// Increment and return the persisted verify-run counter for a binding — the
148/// level-trigger clock the `full_resync_every` schedule reads (D3). Independent
149/// of the rotation cursors: it ticks every verify run, including a run whose
150/// source enumerates to nothing (a non-enumerable medium), so the schedule can
151/// **refuse on time** rather than silently never firing. Returns the new
152/// (post-increment, 1-based) run count.
153pub fn bump_verify_runs(cache_root: &Path, binding_name: &str) -> u64 {
154    let mut state = load_state(cache_root, binding_name).unwrap_or_default();
155    state.verify_runs = state.verify_runs.saturating_add(1);
156    let n = state.verify_runs;
157    save_state(cache_root, binding_name, &state);
158    n
159}
160
161/// Advance one **named** rotation over an arbitrary item set — the generalized
162/// rotation core the verify samplers (D2) repurpose. `items` is the full set to
163/// cover (sorted + de-duplicated by the caller for determinism); `rotation_key`
164/// namespaces this rotation within the binding's state file so independent
165/// samples rotate on their own cursor. One rotation walks the whole set once in
166/// a reproducibly-shuffled order before reshuffling for the next; same persisted
167/// state → same sequence. `None` when `items` is empty.
168pub fn next_rotation_batch(
169    cache_root: &Path,
170    binding_name: &str,
171    rotation_key: &str,
172    items: Vec<String>,
173    batch_size: usize,
174) -> Option<Batch> {
175    let batch_size = batch_size.max(1);
176    if items.is_empty() {
177        return None;
178    }
179
180    let mut state = load_state(cache_root, binding_name).unwrap_or_default();
181    let mut cursor = state.rotations.remove(rotation_key).unwrap_or_default();
182    if cursor.order.is_empty() || cursor.cursor >= cursor.order.len() {
183        // New rotation: bump the counter (only after a completed prior rotation)
184        // and reshuffle the whole set.
185        let rotation = cursor.rotation + u64::from(!cursor.order.is_empty());
186        let mut order = items;
187        shuffle(&mut order, rotation);
188        cursor = RotationCursor {
189            rotation,
190            cursor: 0,
191            order,
192        };
193    }
194
195    let end = (cursor.cursor + batch_size).min(cursor.order.len());
196    let files = cursor.order[cursor.cursor..end].to_vec();
197    let batch_index = cursor.cursor / batch_size + 1;
198    let total_batches = cursor.order.len().div_ceil(batch_size);
199    cursor.cursor += files.len();
200    let rotation = cursor.rotation;
201    state.rotations.insert(rotation_key.to_string(), cursor);
202    save_state(cache_root, binding_name, &state);
203
204    Some(Batch {
205        files,
206        rotation,
207        batch_index,
208        total_batches,
209    })
210}
211
212/// Advance the uncovered-artifact file sample (D2) — the retained rotation over
213/// a source facet's enumerated files, one `batch_size` window at a time. A thin
214/// wrapper over [`next_rotation_batch`] keyed [`ROTATION_UNCOVERED_FILES`].
215/// `None` when the binding has no source files (e.g. a non-enumerable medium).
216pub fn next_batch(
217    resolved: &ResolvedIngest,
218    workspace_root: &Path,
219    cache_root: &Path,
220    batch_size: usize,
221) -> Option<Batch> {
222    let all_files = enumerate_source_files(resolved, workspace_root);
223    next_rotation_batch(
224        cache_root,
225        &resolved.name,
226        ROTATION_UNCOVERED_FILES,
227        all_files,
228        batch_size,
229    )
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::binding::BuildMode;
236    use crate::ingest::resolve::Source;
237    use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
238
239    fn resolved(name: &str, batch_size: u32) -> ResolvedIngest {
240        ResolvedIngest {
241            name: name.to_string(),
242            mode: BuildMode::Discovery,
243            trigger: IngestTrigger::Loop,
244            batch_size,
245            deny_paths: vec![],
246            projection_ref: format!("{name}/p"),
247            projection_mem: name.to_string(),
248            projection_name: "p".to_string(),
249            intent: None,
250            sources: vec![ResolvedSource::Primary(Source {
251                name: "f".to_string(),
252                medium_type: MediumType::Codebase,
253                pointer: String::new(),
254                change_detection: None,
255                scope: vec![PatternEntry {
256                    path: "**/*.rs".to_string(),
257                    mode: PatternMode::Allow,
258                }],
259                engagement: None,
260                preparation: None,
261            })],
262            destination_mem: name.to_string(),
263            rules: None,
264            post_actions: None,
265        }
266    }
267
268    /// Batching walks the shuffled file set across a rotation, then reshuffles a
269    /// new rotation once exhausted.
270    #[test]
271    fn next_batch_walks_a_rotation_then_starts_a_new_one() {
272        let ws = tempfile::tempdir().unwrap();
273        let cache = tempfile::tempdir().unwrap();
274        let root = ws.path();
275        for i in 0..5 {
276            std::fs::write(root.join(format!("f{i}.rs")), "").unwrap();
277        }
278        let r = resolved("ref", 2);
279
280        let b1 = next_batch(&r, root, cache.path(), 2).unwrap();
281        assert_eq!(b1.rotation, 0);
282        assert_eq!(b1.batch_index, 1);
283        assert_eq!(b1.total_batches, 3); // ceil(5/2)
284        assert_eq!(b1.files.len(), 2);
285
286        let b2 = next_batch(&r, root, cache.path(), 2).unwrap();
287        assert_eq!(b2.batch_index, 2);
288        let b3 = next_batch(&r, root, cache.path(), 2).unwrap();
289        assert_eq!(b3.batch_index, 3);
290        assert_eq!(b3.files.len(), 1); // remainder
291
292        // Rotation exhausted → next batch starts rotation 1.
293        let b4 = next_batch(&r, root, cache.path(), 2).unwrap();
294        assert_eq!(b4.rotation, 1);
295        assert_eq!(b4.batch_index, 1);
296
297        // Every file appears exactly once across a rotation.
298        let mut seen: Vec<String> = [b1.files, b2.files, b3.files].concat();
299        seen.sort();
300        seen.dedup();
301        assert_eq!(seen.len(), 5, "the rotation covers all files");
302    }
303
304    /// D2 — a named rotation over an arbitrary item set is deterministic
305    /// (same persisted state → same sequence), covers the whole set over a full
306    /// rotation, and reshuffles the next rotation into a different order.
307    #[test]
308    fn named_rotation_is_deterministic_and_covers_the_whole_set() {
309        let cache = tempfile::tempdir().unwrap();
310        let items: Vec<String> = (0..6).map(|i| format!("id{i}")).collect();
311        let key = ROTATION_ANCHOR_ADJUDICATION;
312
313        // Walk a full rotation of batch 2 → three windows covering all six ids.
314        let mut covered: Vec<String> = Vec::new();
315        let mut order_r0: Vec<String> = Vec::new();
316        for i in 0..3 {
317            let b = next_rotation_batch(cache.path(), "m/b", key, items.clone(), 2).unwrap();
318            assert_eq!(b.rotation, 0);
319            assert_eq!(b.batch_index, i + 1);
320            assert_eq!(b.total_batches, 3);
321            covered.extend(b.files.clone());
322            order_r0.extend(b.files);
323        }
324        let mut uniq = covered.clone();
325        uniq.sort();
326        uniq.dedup();
327        assert_eq!(uniq.len(), 6, "one rotation covers the whole set");
328
329        // Next rotation reshuffles (different order, same coverage).
330        let b = next_rotation_batch(cache.path(), "m/b", key, items.clone(), 2).unwrap();
331        assert_eq!(
332            b.rotation, 1,
333            "a new rotation starts once the prior is done"
334        );
335
336        // Reproducibility: re-running from a fresh cache with the same seed
337        // (rotation 0) yields the identical first-rotation order.
338        let cache2 = tempfile::tempdir().unwrap();
339        let mut order_repro: Vec<String> = Vec::new();
340        for _ in 0..3 {
341            let b = next_rotation_batch(cache2.path(), "m/b", key, items.clone(), 2).unwrap();
342            order_repro.extend(b.files);
343        }
344        assert_eq!(order_r0, order_repro, "same seed/state → same sequence");
345    }
346
347    /// D2 — two named rotations under one binding advance on independent cursors:
348    /// walking one does not consume the other.
349    #[test]
350    fn named_rotations_are_independent() {
351        let cache = tempfile::tempdir().unwrap();
352        let a: Vec<String> = (0..4).map(|i| format!("a{i}")).collect();
353        let files =
354            next_rotation_batch(cache.path(), "m/b", ROTATION_UNCOVERED_FILES, a.clone(), 2)
355                .unwrap();
356        let anchors = next_rotation_batch(
357            cache.path(),
358            "m/b",
359            ROTATION_ANCHOR_ADJUDICATION,
360            a.clone(),
361            2,
362        )
363        .unwrap();
364        // Both are the first window of their own rotation.
365        assert_eq!(files.batch_index, 1);
366        assert_eq!(anchors.batch_index, 1);
367        // Advancing the file rotation again does not touch the anchor cursor.
368        let files2 =
369            next_rotation_batch(cache.path(), "m/b", ROTATION_UNCOVERED_FILES, a.clone(), 2)
370                .unwrap();
371        assert_eq!(files2.batch_index, 2);
372        let anchors_again =
373            next_rotation_batch(cache.path(), "m/b", ROTATION_ANCHOR_ADJUDICATION, a, 2).unwrap();
374        assert_eq!(anchors_again.batch_index, 2, "anchor cursor is independent");
375    }
376
377    /// D3 — the verify-run counter ticks every call and persists across a fresh
378    /// load (the level-trigger clock survives process restarts).
379    #[test]
380    fn verify_run_counter_ticks_and_persists() {
381        let cache = tempfile::tempdir().unwrap();
382        assert_eq!(bump_verify_runs(cache.path(), "m/b"), 1);
383        assert_eq!(bump_verify_runs(cache.path(), "m/b"), 2);
384        assert_eq!(bump_verify_runs(cache.path(), "m/b"), 3);
385        // A different binding has its own counter.
386        assert_eq!(bump_verify_runs(cache.path(), "m/other"), 1);
387    }
388}