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
167fn 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
208pub 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 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
260pub 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 #[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 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 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 #[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 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); 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); 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 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 #[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 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 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 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 #[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 assert_eq!(files.batch_index, 1);
467 assert_eq!(anchors.batch_index, 1);
468 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 #[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 assert_eq!(bump_verify_runs(cache.path(), "m/other"), 1);
488 }
489}