1use 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
30pub const ROTATION_UNCOVERED_FILES: &str = "uncovered-files";
34
35pub const ROTATION_ANCHOR_ADJUDICATION: &str = "anchor-adjudication";
40
41#[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
63struct RefinementState {
64 #[serde(default)]
69 verify_runs: u64,
70 #[serde(default)]
73 rotations: BTreeMap<String, RotationCursor>,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct Batch {
79 pub files: Vec<String>,
81 pub rotation: u64,
83 pub batch_index: usize,
85 pub total_batches: usize,
87}
88
89fn 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
98fn 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
123fn 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
153pub 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
167pub 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 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
218pub 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 #[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 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); 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); 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 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 #[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 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 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 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 #[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 assert_eq!(files.batch_index, 1);
376 assert_eq!(anchors.batch_index, 1);
377 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 #[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 assert_eq!(bump_verify_runs(cache.path(), "m/other"), 1);
397 }
398}