Skip to main content

memstead_base/ingest/
selection.rs

1//! Ingest selection + backoff — pick the next *due* **(binding, operation)
2//! pair** in a `--all` rotation, skipping pairs whose destination is unchanged
3//! and whose sources have not moved. Engine-side generalization of the
4//! plugin's `nextIngest` / `shouldSkip` / backoff state from "next due binding
5//! (build)" to a per-operation rotation.
6//!
7//! **Eligibility** (per pair): an operation participates in the rotation only
8//! when its `operations.<op>` block exists in the binding **and** declares
9//! `trigger: loop` — consent to unattended rotation lives in the declaration.
10//! A one-shot build that already ran stays excluded.
11//!
12//! **Due-checks** (cheap, per pair, before backoff): build is always due
13//! (unchanged semantics — backoff alone decides); sync is due when a source
14//! moved past its `#synced` baseline **or** open findings exist under the
15//! binding's current `(hash(D), source_head)` key; verify is due when a source
16//! moved past its `#verified` baseline (a never-verified source with a live
17//! token counts as moved — the first verify is due). A pair that is not due is
18//! passed over without touching its backoff state.
19//!
20//! The deterministic state (round-robin cursor, per-pair backoff, one-shot
21//! ran-set) lives engine-side under `<workspace>/.memstead.cache/ingest/` —
22//! the same engine-internal bookkeeping location the mtime memo uses. This is
23//! not mem-repo / graph state; selection mutates it as its job. Cursor and
24//! backoff entries are keyed by the **pair id** `<binding>#<op>`; pre-pair
25//! single-key entries (plain binding ids) are **discarded** — the cache is
26//! disposable, and the cost is at most one lost backoff step per binding.
27//!
28//! Backoff shape (mirrors the plugin exactly): a **linear-ramp** per-pair
29//! skip counter. A destination-snapshot change *or* a moved source resets it
30//! to zero and runs; otherwise each unproductive pass grows the cooldown by
31//! one (capped at [`MAX_SKIP_LEVEL`]). A one-shot build never skips. For sync
32//! / verify pairs the moved-source override is **not** applied: the due-check
33//! already encodes source movement, and a productive run mutates the
34//! destination mem, which resets the pair's backoff by itself — so an
35//! un-acted-on brief ramps instead of being re-rendered every pass.
36
37use std::collections::{BTreeMap, BTreeSet};
38use std::path::Path;
39
40use serde::{Deserialize, Serialize};
41
42use crate::Engine;
43use crate::binding::{Binding, BuildMode};
44use crate::pipeline::IngestTrigger;
45use crate::pipeline_store::BindingConfigs;
46
47use super::cursor::{source_moved, source_moved_since};
48use super::findings::current_findings;
49use super::resolve::{ResolvedIngest, resolve_binding_run};
50
51/// The backoff cooldown ceiling — after this many consecutive unproductive
52/// passes the skip count stops growing. Mirrors the plugin's `MAX_SKIP_LEVEL`.
53pub const MAX_SKIP_LEVEL: u32 = 10;
54
55/// One operation of a binding — the second half of a `--all` rotation pair.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
57#[serde(rename_all = "kebab-case")]
58pub enum OperationKind {
59    /// The build operation (grow coverage / one-shot lens).
60    Build,
61    /// The sync operation (the sole maintenance writer).
62    Sync,
63    /// The verify operation (read-only measurement).
64    Verify,
65}
66
67impl OperationKind {
68    /// Every kind, in rotation-sort order (build < sync < verify).
69    pub const ALL: [OperationKind; 3] = [
70        OperationKind::Build,
71        OperationKind::Sync,
72        OperationKind::Verify,
73    ];
74
75    /// Stable wire form (`build` / `sync` / `verify`).
76    pub fn as_wire(&self) -> &'static str {
77        match self {
78            OperationKind::Build => "build",
79            OperationKind::Sync => "sync",
80            OperationKind::Verify => "verify",
81        }
82    }
83}
84
85/// Which operations a `--all` rotation considers. `Only(op)` restricts the
86/// eligible set to that operation's pairs (the CLI default is
87/// `Only(Build)` — byte-stable for the ingest router); [`Self::Any`] rotates
88/// across every eligible pair.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum OperationFilter {
91    /// Rotate over a single operation's pairs.
92    Only(OperationKind),
93    /// Rotate over every eligible (binding, operation) pair.
94    Any,
95}
96
97impl OperationFilter {
98    fn admits(self, op: OperationKind) -> bool {
99        match self {
100            OperationFilter::Only(only) => only == op,
101            OperationFilter::Any => true,
102        }
103    }
104}
105
106/// The cache key of a rotation pair: `<binding>#<op>` (e.g.
107/// `engine/graph#build`). Cursor and backoff state are keyed on this.
108fn pair_key(binding_id: &str, op: OperationKind) -> String {
109    format!("{binding_id}#{}", op.as_wire())
110}
111
112/// Per-ingest destination-snapshot backoff state.
113#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
114pub struct BackoffEntry {
115    /// Passes still to skip before the next run.
116    #[serde(default)]
117    pub skip_remaining: u32,
118    /// Current cooldown level (grows by one per unproductive pass, capped).
119    #[serde(default)]
120    pub skip_level: u32,
121    /// The destination snapshot token this entry was last evaluated against.
122    #[serde(default)]
123    pub snapshot: String,
124}
125
126/// The round-robin cursor — the (binding, operation) pair the last rotation
127/// advanced to, stored as the pair id `<binding>#<op>`. A pre-pair value (a
128/// plain binding id) never matches a pair id, so the first op-aware pass
129/// simply restarts the rotation from the top — the cache is disposable.
130#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
131pub struct Cursor {
132    /// The last pair id the cursor advanced to (`None` before the first pass).
133    #[serde(default)]
134    pub last: Option<String>,
135}
136
137/// Apply the destination-snapshot backoff to `entry`, mutating it, and return
138/// whether to **skip** this pass. `current` is the destination mem's current
139/// snapshot token (empty when none). Mirrors the backoff block of the plugin's
140/// `shouldSkip`:
141///
142///   - destination moved (`snapshot` set and `current` differs) → reset to
143///     zero, store `current`, **run**;
144///   - `skip_remaining > 0` → decrement, **skip**;
145///   - otherwise → if the snapshot is unchanged ramp the level (capped), set
146///     `skip_remaining = skip_level`, store `current`, **run**.
147pub fn apply_backoff(entry: &mut BackoffEntry, current: &str) -> bool {
148    if !entry.snapshot.is_empty() && current != entry.snapshot {
149        entry.skip_remaining = 0;
150        entry.skip_level = 0;
151        entry.snapshot = current.to_string();
152        return false;
153    }
154    if entry.skip_remaining > 0 {
155        entry.skip_remaining -= 1;
156        return true;
157    }
158    if !entry.snapshot.is_empty() && current == entry.snapshot {
159        entry.skip_level = (entry.skip_level + 1).min(MAX_SKIP_LEVEL);
160        entry.skip_remaining = entry.skip_level;
161    }
162    entry.snapshot = current.to_string();
163    false
164}
165
166/// Whether a binding should be skipped this rotation. A one-shot build never
167/// skips (one-shots are excluded from the eligible set once run). Discovery: a
168/// moved source overrides backoff; otherwise the destination-snapshot
169/// [`apply_backoff`].
170pub fn should_skip(
171    mode: BuildMode,
172    source_moved: bool,
173    entry: &mut BackoffEntry,
174    current: &str,
175) -> bool {
176    match mode {
177        BuildMode::OneShot => return false,
178        BuildMode::Discovery => {}
179    }
180    if source_moved {
181        return false;
182    }
183    apply_backoff(entry, current)
184}
185
186// ── state files (engine-internal cache) ─────────────────────────────────────
187
188fn read_json<T: Default + for<'de> Deserialize<'de>>(cache_root: &Path, name: &str) -> T {
189    std::fs::read(cache_root.join(name))
190        .ok()
191        .and_then(|b| serde_json::from_slice(&b).ok())
192        .unwrap_or_default()
193}
194
195fn write_json<T: Serialize>(cache_root: &Path, name: &str, value: &T) {
196    let _ = std::fs::create_dir_all(cache_root);
197    if let Ok(bytes) = serde_json::to_vec(value) {
198        let _ = std::fs::write(cache_root.join(name), bytes);
199    }
200}
201
202/// Read the set of one-shot ingests that have already run.
203fn read_one_shot_runs(cache_root: &Path) -> BTreeSet<String> {
204    let map: BTreeMap<String, bool> = read_json(cache_root, "ingest-one-shot-runs.json");
205    map.into_iter()
206        .filter(|(_, v)| *v)
207        .map(|(k, _)| k)
208        .collect()
209}
210
211/// Select the next *due* ingest (build operation) for a `--all` rotation —
212/// the build-only compatibility form of [`select_next_due_operation`].
213/// Returns the selected binding id, or `None` when nothing is due this pass.
214pub fn select_next_due(
215    engine: &Engine,
216    workspace_root: &Path,
217    configs: &BindingConfigs,
218) -> Option<String> {
219    select_next_due_operation(
220        engine,
221        workspace_root,
222        configs,
223        OperationFilter::Only(OperationKind::Build),
224    )
225    .map(|(name, _)| name)
226}
227
228/// One eligible rotation pair: a resolved binding run plus the operation.
229struct Pair<'a> {
230    /// The pair id `<binding>#<op>` — the cursor/backoff cache key.
231    key: String,
232    /// The resolved run (its `name` is the canonical binding id).
233    ingest: ResolvedIngest,
234    /// The stored binding declaration (the findings due-check needs it).
235    binding: &'a Binding,
236    /// The operation half of the pair.
237    op: OperationKind,
238}
239
240/// Whether an operation block exists on `binding` **and** declares
241/// `trigger: loop` — the pair-eligibility gate. Consent to unattended `--all`
242/// rotation lives in the declaration: a `manual` / `on-event` operation never
243/// rotates, whatever the filter asks for.
244fn declared_for_loop(binding: &Binding, op: OperationKind) -> bool {
245    match op {
246        OperationKind::Build => binding
247            .operations
248            .build
249            .as_ref()
250            .is_some_and(|b| b.trigger == IngestTrigger::Loop),
251        OperationKind::Sync => binding
252            .operations
253            .sync
254            .as_ref()
255            .is_some_and(|s| s.trigger == IngestTrigger::Loop),
256        OperationKind::Verify => binding
257            .operations
258            .verify
259            .as_ref()
260            .is_some_and(|v| v.trigger == IngestTrigger::Loop),
261    }
262}
263
264/// The cheap per-operation due-check, evaluated before backoff. Build is
265/// always due (unchanged semantics — backoff alone decides). Sync is due when
266/// a source moved past its `#synced` baseline or open findings exist under
267/// the binding's current `(hash(D), source_head)` key (the same read the sync
268/// brief consumes — an unreadable findings store contributes nothing here;
269/// the source-moved clause still fires, and the brief render surfaces the
270/// store error). Verify is due when a source moved past its `#verified`
271/// baseline, with a never-verified source counting as moved (the first
272/// verify is due).
273fn operation_due(engine: &Engine, workspace_root: &Path, pair: &Pair<'_>) -> bool {
274    match pair.op {
275        OperationKind::Build => true,
276        OperationKind::Sync => {
277            source_moved(engine, &pair.ingest, workspace_root)
278                || current_findings(engine, workspace_root, pair.binding, &pair.ingest)
279                    .map(|(_key, findings)| !findings.is_empty())
280                    .unwrap_or(false)
281        }
282        OperationKind::Verify => {
283            source_moved_since(engine, &pair.ingest, workspace_root, "verified", true)
284        }
285    }
286}
287
288/// Select the next *due* (binding, operation) pair for a `--all` rotation,
289/// advancing the round-robin cursor and the per-pair backoff state. Returns
290/// the selected binding id and operation, or `None` when nothing eligible is
291/// due (or everything due is backing off) this pass.
292pub fn select_next_due_operation(
293    engine: &Engine,
294    workspace_root: &Path,
295    configs: &BindingConfigs,
296    filter: OperationFilter,
297) -> Option<(String, OperationKind)> {
298    let cache_root = workspace_root.join(".memstead.cache").join("ingest");
299
300    // Eligible = every (resolvable binding, loop-declared operation) pair the
301    // filter admits, minus one-shot builds that already ran. Pair keys derive
302    // from the canonical binding id (`<mem>/<stem>`, D3/D9) — the resolved
303    // run's `name`.
304    let one_shot_ran = read_one_shot_runs(&cache_root);
305    let mut eligible: Vec<Pair<'_>> = Vec::new();
306    for record in &configs.bindings {
307        let binding_id = format!("{}/{}", record.mem, record.name);
308        let Ok(ingest) = resolve_binding_run(&binding_id, &record.config) else {
309            continue;
310        };
311        for op in OperationKind::ALL {
312            if !filter.admits(op) || !declared_for_loop(&record.config, op) {
313                continue;
314            }
315            if op == OperationKind::Build
316                && ingest.mode == BuildMode::OneShot
317                && one_shot_ran.contains(&ingest.name)
318            {
319                continue;
320            }
321            eligible.push(Pair {
322                key: pair_key(&ingest.name, op),
323                ingest: ingest.clone(),
324                binding: &record.config,
325                op,
326            });
327        }
328    }
329    eligible.sort_by(|a, b| a.key.cmp(&b.key));
330    let n = eligible.len();
331    if n == 0 {
332        return None;
333    }
334
335    // Advance the round-robin cursor by one from the last-picked position.
336    let mut cursor: Cursor = read_json(&cache_root, "ingest-cursor.json");
337    let start = cursor
338        .last
339        .as_ref()
340        .and_then(|last| eligible.iter().position(|p| &p.key == last))
341        .map_or(0, |i| (i + 1) % n);
342    cursor.last = Some(eligible[start].key.clone());
343    write_json(&cache_root, "ingest-cursor.json", &cursor);
344
345    // From the start, take the first pair that is due and not backing off.
346    // Pre-pair single-key backoff entries (no `#<op>` suffix) are discarded on
347    // the way through — disposable cache, at most one lost backoff step.
348    let mut backoff: BTreeMap<String, BackoffEntry> = read_json(&cache_root, "ingest-backoff.json");
349    backoff.retain(|k, _| k.contains('#'));
350    let mut selected = None;
351    for offset in 0..n {
352        let pair = &eligible[(start + offset) % n];
353        if !operation_due(engine, workspace_root, pair) {
354            continue;
355        }
356        let current = engine
357            .mem_head_sha(&pair.ingest.destination_mem)
358            .ok()
359            .flatten()
360            .unwrap_or_default();
361        // Build keeps the moved-source backoff override (and the one-shot
362        // never-skips rule). Sync / verify pairs rely on the due-check for
363        // source movement and on the destination-snapshot reset for
364        // productivity, so an un-acted-on brief ramps instead of re-rendering
365        // every pass.
366        let (mode, moved) = match pair.op {
367            OperationKind::Build => (
368                pair.ingest.mode,
369                source_moved(engine, &pair.ingest, workspace_root),
370            ),
371            OperationKind::Sync | OperationKind::Verify => (BuildMode::Discovery, false),
372        };
373        let entry = backoff.entry(pair.key.clone()).or_default();
374        if !should_skip(mode, moved, entry, &current) {
375            selected = Some((pair.ingest.name.clone(), pair.op));
376            break;
377        }
378    }
379    write_json(&cache_root, "ingest-backoff.json", &backoff);
380    selected
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    /// The linear-ramp backoff: first pass at a fresh snapshot runs and stores
388    /// it; a repeat (unchanged) ramps the cooldown and skips it down; a
389    /// destination change resets to zero and runs immediately.
390    #[test]
391    fn backoff_ramps_and_resets() {
392        let mut e = BackoffEntry::default();
393
394        // First evaluation: empty snapshot → runs, stores current.
395        assert!(!apply_backoff(&mut e, "sha1"));
396        assert_eq!(e.snapshot, "sha1");
397        assert_eq!(e.skip_level, 0);
398
399        // Unchanged again → ramp to level 1, skip_remaining 1, runs this pass.
400        assert!(!apply_backoff(&mut e, "sha1"));
401        assert_eq!(e.skip_level, 1);
402        assert_eq!(e.skip_remaining, 1);
403
404        // Next pass: skip_remaining 1 → skip, decrement to 0.
405        assert!(apply_backoff(&mut e, "sha1"));
406        assert_eq!(e.skip_remaining, 0);
407
408        // Next: remaining 0, unchanged → ramp to 2, runs.
409        assert!(!apply_backoff(&mut e, "sha1"));
410        assert_eq!(e.skip_level, 2);
411        assert_eq!(e.skip_remaining, 2);
412
413        // A destination change resets everything and runs immediately.
414        assert!(!apply_backoff(&mut e, "sha2"));
415        assert_eq!(e.skip_level, 0);
416        assert_eq!(e.skip_remaining, 0);
417        assert_eq!(e.snapshot, "sha2");
418    }
419
420    /// The ramp is capped at MAX_SKIP_LEVEL.
421    #[test]
422    fn backoff_caps_at_max_level() {
423        let mut e = BackoffEntry {
424            skip_level: MAX_SKIP_LEVEL,
425            skip_remaining: 0,
426            snapshot: "s".to_string(),
427        };
428        assert!(!apply_backoff(&mut e, "s")); // unchanged, remaining 0 → ramp
429        assert_eq!(e.skip_level, MAX_SKIP_LEVEL, "capped");
430        assert_eq!(e.skip_remaining, MAX_SKIP_LEVEL);
431    }
432
433    /// A one-shot build never skips; a moved source overrides backoff for
434    /// discovery; an unchanged discovery destination backs off.
435    #[test]
436    fn should_skip_honours_mode_and_source_movement() {
437        let mut e = BackoffEntry {
438            skip_remaining: 3,
439            skip_level: 3,
440            snapshot: "s".to_string(),
441        };
442        // A one-shot build never skips, regardless of backoff.
443        assert!(!should_skip(BuildMode::OneShot, false, &mut e.clone(), "s"));
444        // Discovery with a moved source → run (backoff untouched).
445        let mut e2 = e.clone();
446        assert!(!should_skip(BuildMode::Discovery, true, &mut e2, "s"));
447        assert_eq!(e2.skip_remaining, 3, "moved source does not touch backoff");
448        // Discovery, unchanged, cooling down → skip.
449        assert!(should_skip(BuildMode::Discovery, false, &mut e, "s"));
450    }
451
452    // ── op-aware selection (pairs, eligibility, due-checks) ─────────────────
453
454    use crate::binding::{
455        BINDING_VERSION, BuildOperation, Operations, SyncOperation, VerifyOperation, hash_binding,
456    };
457    use crate::pipeline::{MediumType, PatternEntry, PatternMode, Source};
458    use crate::pipeline_store::MemPipelineRecord;
459
460    use super::super::findings::{
461        Finding, FindingClass, FindingKey, FindingTarget, FindingsStore, write_findings_store,
462    };
463
464    fn empty_engine() -> Engine {
465        Engine::from_mounts(Vec::new()).unwrap()
466    }
467
468    fn binding_with(operations: Operations) -> Binding {
469        Binding {
470            version: BINDING_VERSION,
471            intent: None,
472            sources: Vec::new(),
473            reference_mems: Vec::new(),
474            destination_mem: "m".to_string(),
475            deny_paths: Vec::new(),
476            coverage_semantics: None,
477            rules: None,
478            prune: None,
479            operations,
480        }
481    }
482
483    fn build_op(trigger: IngestTrigger) -> BuildOperation {
484        BuildOperation {
485            mode: BuildMode::Discovery,
486            trigger,
487            batch_size: 20,
488            post_actions: None,
489        }
490    }
491
492    fn record(name: &str, config: Binding) -> MemPipelineRecord<Binding> {
493        MemPipelineRecord {
494            mem: "m".to_string(),
495            name: name.to_string(),
496            config,
497        }
498    }
499
500    fn configs_of(bindings: Vec<MemPipelineRecord<Binding>>) -> BindingConfigs {
501        BindingConfigs {
502            bindings,
503            quarantined: Vec::new(),
504        }
505    }
506
507    /// The eligibility gate: a pair rotates only when its operation block
508    /// exists AND declares `trigger: loop`. A manual build, a build-less
509    /// binding's absent block, and a manual sync/verify never rotate.
510    #[test]
511    fn eligibility_requires_block_and_loop_trigger() {
512        let ws = tempfile::tempdir().unwrap();
513        let engine = empty_engine();
514        let configs = configs_of(vec![
515            // build loop → the only eligible pair.
516            record(
517                "a",
518                binding_with(Operations {
519                    build: Some(build_op(IngestTrigger::Loop)),
520                    sync: None,
521                    verify: None,
522                }),
523            ),
524            // build manual → excluded (consent lives in the declaration).
525            record(
526                "b",
527                binding_with(Operations {
528                    build: Some(build_op(IngestTrigger::Manual)),
529                    sync: None,
530                    verify: None,
531                }),
532            ),
533            // no build; sync/verify manual → nothing eligible from it.
534            record(
535                "c",
536                binding_with(Operations {
537                    build: None,
538                    sync: Some(SyncOperation {
539                        trigger: IngestTrigger::Manual,
540                        batch_size: 20,
541                    }),
542                    verify: Some(VerifyOperation {
543                        trigger: IngestTrigger::Manual,
544                        batch_size: 20,
545                        adjudication_cap: 50,
546                        full_resync_every: 20,
547                    }),
548                }),
549            ),
550        ]);
551
552        // Build filter and Any agree: only `m/a`'s build pair rotates.
553        assert_eq!(
554            select_next_due_operation(
555                &engine,
556                ws.path(),
557                &configs,
558                OperationFilter::Only(OperationKind::Build)
559            ),
560            Some(("m/a".to_string(), OperationKind::Build))
561        );
562        assert_eq!(
563            select_next_due_operation(&engine, ws.path(), &configs, OperationFilter::Any),
564            Some(("m/a".to_string(), OperationKind::Build))
565        );
566        // Sync / verify filters: the declared blocks are manual → no pair.
567        assert_eq!(
568            select_next_due_operation(
569                &engine,
570                ws.path(),
571                &configs,
572                OperationFilter::Only(OperationKind::Sync)
573            ),
574            None
575        );
576        assert_eq!(
577            select_next_due_operation(
578                &engine,
579                ws.path(),
580                &configs,
581                OperationFilter::Only(OperationKind::Verify)
582            ),
583            None
584        );
585    }
586
587    /// The sync due-check: a loop-declared sync pair with unmoved sources is
588    /// due only when open findings exist under the binding's current
589    /// `(hash(D), source_head)` key; an empty current batch is not due.
590    #[test]
591    fn sync_pair_due_only_on_open_findings_when_source_unmoved() {
592        let ws = tempfile::tempdir().unwrap();
593        let engine = empty_engine();
594        let binding = binding_with(Operations {
595            build: None,
596            sync: Some(SyncOperation {
597                trigger: IngestTrigger::Loop,
598                batch_size: 20,
599            }),
600            verify: None,
601        });
602        let configs = configs_of(vec![record("s", binding.clone())]);
603
604        // No findings store → not due.
605        assert_eq!(
606            select_next_due_operation(
607                &engine,
608                ws.path(),
609                &configs,
610                OperationFilter::Only(OperationKind::Sync)
611            ),
612            None
613        );
614
615        // The current key for a source-less binding: hash(D) + empty head.
616        let key = FindingKey {
617            binding_hash: hash_binding(&binding),
618            source_head: String::new(),
619        };
620
621        // An empty batch under the current key → still not due.
622        let mut store = FindingsStore {
623            binding: "m/s".to_string(),
624            batches: Vec::new(),
625        };
626        store.record(key.clone(), "0".to_string(), Vec::new());
627        write_findings_store(ws.path(), "m", "s", &store).unwrap();
628        assert_eq!(
629            select_next_due_operation(
630                &engine,
631                ws.path(),
632                &configs,
633                OperationFilter::Only(OperationKind::Sync)
634            ),
635            None
636        );
637
638        // One open finding under the current key → the sync pair is due.
639        store.record(
640            key.clone(),
641            "1".to_string(),
642            vec![Finding {
643                key: key.clone(),
644                facet: "f".to_string(),
645                target: FindingTarget::Artifact {
646                    artifact: "a.rs".to_string(),
647                },
648                class: FindingClass::Uncovered,
649                detail: "no anchor".to_string(),
650                created_at: "1".to_string(),
651            }],
652        );
653        write_findings_store(ws.path(), "m", "s", &store).unwrap();
654        assert_eq!(
655            select_next_due_operation(
656                &engine,
657                ws.path(),
658                &configs,
659                OperationFilter::Only(OperationKind::Sync)
660            ),
661            Some(("m/s".to_string(), OperationKind::Sync))
662        );
663
664        // Findings under a DIFFERENT key (superseded) do not make sync due.
665        let mut stale = FindingsStore {
666            binding: "m/s".to_string(),
667            batches: Vec::new(),
668        };
669        let stale_key = FindingKey {
670            binding_hash: "0000".to_string(),
671            source_head: "old".to_string(),
672        };
673        stale.record(
674            stale_key.clone(),
675            "1".to_string(),
676            vec![Finding {
677                key: stale_key,
678                facet: "f".to_string(),
679                target: FindingTarget::Artifact {
680                    artifact: "a.rs".to_string(),
681                },
682                class: FindingClass::Uncovered,
683                detail: "stale".to_string(),
684                created_at: "1".to_string(),
685            }],
686        );
687        write_findings_store(ws.path(), "m", "s", &stale).unwrap();
688        assert_eq!(
689            select_next_due_operation(
690                &engine,
691                ws.path(),
692                &configs,
693                OperationFilter::Only(OperationKind::Sync)
694            ),
695            None,
696            "superseded findings must not pull a sync into rotation"
697        );
698    }
699
700    /// A binding with a live (mtime) inline source over `ws`, named `f`.
701    fn configs_with_live_source(operations: Operations) -> BindingConfigs {
702        let mut binding = binding_with(operations);
703        binding.sources = vec![Source {
704            name: "f".to_string(),
705            medium_type: MediumType::Filesystem,
706            pointer: String::new(),
707            change_detection: Some("mtime".to_string()),
708            scope: vec![PatternEntry {
709                path: "**/*.rs".to_string(),
710                mode: PatternMode::Allow,
711            }],
712            engagement: None,
713            preparation: None,
714        }];
715        BindingConfigs {
716            bindings: vec![record("v", binding)],
717            quarantined: Vec::new(),
718        }
719    }
720
721    /// The verify due-check: a never-verified binding whose source has a live
722    /// change-detection token is due its first verify; a source with no
723    /// signal (unscoped facet → no token) is not.
724    #[test]
725    fn verify_pair_due_when_never_verified_with_live_token() {
726        let ws = tempfile::tempdir().unwrap();
727        std::fs::write(ws.path().join("a.rs"), "x").unwrap();
728        let engine = empty_engine();
729        let verify_loop = Operations {
730            build: None,
731            sync: None,
732            verify: Some(VerifyOperation {
733                trigger: IngestTrigger::Loop,
734                batch_size: 20,
735                adjudication_cap: 50,
736                full_resync_every: 20,
737            }),
738        };
739
740        // Live token (scoped mtime source), never verified → due.
741        let configs = configs_with_live_source(verify_loop.clone());
742        assert_eq!(
743            select_next_due_operation(
744                &engine,
745                ws.path(),
746                &configs,
747                OperationFilter::Only(OperationKind::Verify)
748            ),
749            Some(("m/v".to_string(), OperationKind::Verify))
750        );
751
752        // No signal (unscoped source → no current token) → not due.
753        let mut no_signal = configs_with_live_source(verify_loop);
754        no_signal.bindings[0].config.sources[0].scope.clear();
755        assert_eq!(
756            select_next_due_operation(
757                &engine,
758                ws.path(),
759                &no_signal,
760                OperationFilter::Only(OperationKind::Verify)
761            ),
762            None
763        );
764    }
765
766    /// `Any` rotates round-robin across (binding, operation) pairs in pair-id
767    /// order, and the cursor is pair-keyed: build and verify pairs alternate.
768    #[test]
769    fn any_filter_rotates_across_pairs() {
770        let ws = tempfile::tempdir().unwrap();
771        std::fs::write(ws.path().join("a.rs"), "x").unwrap();
772        let engine = empty_engine();
773
774        // Two bindings: `m/a` build-loop (no sources) and `m/v` verify-loop
775        // over a live mtime source. Pair order: `m/a#build` < `m/v#verify`.
776        let mut configs = configs_with_live_source(Operations {
777            build: None,
778            sync: None,
779            verify: Some(VerifyOperation {
780                trigger: IngestTrigger::Loop,
781                batch_size: 20,
782                adjudication_cap: 50,
783                full_resync_every: 20,
784            }),
785        });
786        configs.bindings.push(record(
787            "a",
788            binding_with(Operations {
789                build: Some(build_op(IngestTrigger::Loop)),
790                sync: None,
791                verify: None,
792            }),
793        ));
794
795        let next = || {
796            select_next_due_operation(&engine, ws.path(), &configs, OperationFilter::Any).unwrap()
797        };
798        assert_eq!(next(), ("m/a".to_string(), OperationKind::Build));
799        assert_eq!(next(), ("m/v".to_string(), OperationKind::Verify));
800        assert_eq!(next(), ("m/a".to_string(), OperationKind::Build));
801    }
802
803    /// Pre-pair (single-key) backoff entries are discarded, not honoured: a
804    /// legacy `m/a` entry with pending skips does not delay the `m/a#build`
805    /// pair, and the rewritten cache carries only pair-keyed entries.
806    #[test]
807    fn legacy_single_key_backoff_entries_are_discarded() {
808        let ws = tempfile::tempdir().unwrap();
809        let engine = empty_engine();
810        let cache_root = ws.path().join(".memstead.cache").join("ingest");
811        std::fs::create_dir_all(&cache_root).unwrap();
812        let legacy: BTreeMap<String, BackoffEntry> = [(
813            "m/a".to_string(),
814            BackoffEntry {
815                skip_remaining: 5,
816                skip_level: 5,
817                snapshot: "s".to_string(),
818            },
819        )]
820        .into();
821        std::fs::write(
822            cache_root.join("ingest-backoff.json"),
823            serde_json::to_vec(&legacy).unwrap(),
824        )
825        .unwrap();
826
827        let configs = configs_of(vec![record(
828            "a",
829            binding_with(Operations {
830                build: Some(build_op(IngestTrigger::Loop)),
831                sync: None,
832                verify: None,
833            }),
834        )]);
835        assert_eq!(
836            select_next_due_operation(
837                &engine,
838                ws.path(),
839                &configs,
840                OperationFilter::Only(OperationKind::Build)
841            ),
842            Some(("m/a".to_string(), OperationKind::Build)),
843            "a legacy entry's pending skips are discarded, not honoured"
844        );
845
846        let rewritten: BTreeMap<String, BackoffEntry> =
847            serde_json::from_slice(&std::fs::read(cache_root.join("ingest-backoff.json")).unwrap())
848                .unwrap();
849        assert!(!rewritten.contains_key("m/a"), "legacy key pruned");
850        assert!(rewritten.contains_key("m/a#build"), "pair key written");
851    }
852}