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 (measurement — writes no entity).
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/// Non-mutating twin of [`apply_backoff`]: the same skip decision without
167/// touching the entry. A destination that moved since the stored snapshot
168/// always runs; otherwise a positive `skip_remaining` skips. Peek selection
169/// uses this so a render predicts exactly what a consuming selection would
170/// pick, while mutating nothing.
171fn would_skip_backoff(entry: &BackoffEntry, current: &str) -> bool {
172    if !entry.snapshot.is_empty() && current != entry.snapshot {
173        return false;
174    }
175    entry.skip_remaining > 0
176}
177
178/// Non-mutating twin of [`should_skip`], for peek selection.
179fn would_skip(mode: BuildMode, source_moved: bool, entry: &BackoffEntry, current: &str) -> bool {
180    match mode {
181        BuildMode::OneShot => return false,
182        BuildMode::Discovery => {}
183    }
184    if source_moved {
185        return false;
186    }
187    would_skip_backoff(entry, current)
188}
189
190/// Whether a binding should be skipped this rotation. A one-shot build never
191/// skips (one-shots are excluded from the eligible set once run). Discovery: a
192/// moved source overrides backoff; otherwise the destination-snapshot
193/// [`apply_backoff`].
194pub fn should_skip(
195    mode: BuildMode,
196    source_moved: bool,
197    entry: &mut BackoffEntry,
198    current: &str,
199) -> bool {
200    match mode {
201        BuildMode::OneShot => return false,
202        BuildMode::Discovery => {}
203    }
204    if source_moved {
205        return false;
206    }
207    apply_backoff(entry, current)
208}
209
210// ── state files (engine-internal cache) ─────────────────────────────────────
211
212fn read_json<T: Default + for<'de> Deserialize<'de>>(cache_root: &Path, name: &str) -> T {
213    std::fs::read(cache_root.join(name))
214        .ok()
215        .and_then(|b| serde_json::from_slice(&b).ok())
216        .unwrap_or_default()
217}
218
219fn write_json<T: Serialize>(cache_root: &Path, name: &str, value: &T) {
220    let _ = std::fs::create_dir_all(cache_root);
221    if let Ok(bytes) = serde_json::to_vec(value) {
222        let _ = std::fs::write(cache_root.join(name), bytes);
223    }
224}
225
226/// Read the set of one-shot ingests that have already run.
227fn read_one_shot_runs(cache_root: &Path) -> BTreeSet<String> {
228    let map: BTreeMap<String, bool> = read_json(cache_root, "ingest-one-shot-runs.json");
229    map.into_iter()
230        .filter(|(_, v)| *v)
231        .map(|(k, _)| k)
232        .collect()
233}
234
235/// Select the next *due* ingest (build operation) for a `--all` rotation —
236/// the build-only compatibility form of [`select_next_due_operation`].
237/// Returns the selected binding id, or `None` when nothing is due this pass.
238pub fn select_next_due(
239    engine: &Engine,
240    workspace_root: &Path,
241    configs: &BindingConfigs,
242) -> Option<String> {
243    select_next_due_operation(
244        engine,
245        workspace_root,
246        configs,
247        OperationFilter::Only(OperationKind::Build),
248        true,
249    )
250    .map(|(name, _)| name)
251}
252
253/// The (binding, operation) pairs a `--all` rotation with `filter` will NEVER
254/// rotate because the binding does not declare the operation with
255/// `trigger: loop` — consent lives in the declaration. Exposed so the CLI can
256/// say so explicitly instead of the rotation dropping them silently (a loop
257/// operator deserves to see WHY a binding never comes up).
258pub fn not_loop_declared(
259    configs: &BindingConfigs,
260    filter: OperationFilter,
261) -> Vec<(String, OperationKind)> {
262    let mut out = Vec::new();
263    for record in &configs.bindings {
264        let binding_id = format!("{}/{}", record.mem, record.name);
265        for op in OperationKind::ALL {
266            if filter.admits(op) && !declared_for_loop(&record.config, op) {
267                out.push((binding_id.clone(), op));
268            }
269        }
270    }
271    out.sort();
272    out
273}
274
275/// One eligible rotation pair: a resolved binding run plus the operation.
276struct Pair<'a> {
277    /// The pair id `<binding>#<op>` — the cursor/backoff cache key.
278    key: String,
279    /// The resolved run (its `name` is the canonical binding id).
280    ingest: ResolvedIngest,
281    /// The stored binding declaration (the findings due-check needs it).
282    binding: &'a Binding,
283    /// The operation half of the pair.
284    op: OperationKind,
285}
286
287/// Whether an operation block exists on `binding` **and** declares
288/// `trigger: loop` — the pair-eligibility gate. Consent to unattended `--all`
289/// rotation lives in the declaration: a `manual` / `on-event` operation never
290/// rotates, whatever the filter asks for.
291fn declared_for_loop(binding: &Binding, op: OperationKind) -> bool {
292    match op {
293        OperationKind::Build => binding
294            .operations
295            .build
296            .as_ref()
297            .is_some_and(|b| b.trigger == IngestTrigger::Loop),
298        OperationKind::Sync => binding
299            .operations
300            .sync
301            .as_ref()
302            .is_some_and(|s| s.trigger == IngestTrigger::Loop),
303        OperationKind::Verify => binding
304            .operations
305            .verify
306            .as_ref()
307            .is_some_and(|v| v.trigger == IngestTrigger::Loop),
308    }
309}
310
311/// The cheap per-operation due-check, evaluated before backoff. Build is
312/// always due (unchanged semantics — backoff alone decides). Sync is due when
313/// a source moved past its `#synced` baseline or open findings exist under
314/// the binding's current `(hash(D), source_head)` key (the same read the sync
315/// brief consumes — an unreadable findings store contributes nothing here;
316/// the source-moved clause still fires, and the brief render surfaces the
317/// store error). Verify is due when a source moved past its `#verified`
318/// baseline, with a never-verified source counting as moved (the first
319/// verify is due).
320fn operation_due(engine: &Engine, workspace_root: &Path, pair: &Pair<'_>) -> bool {
321    match pair.op {
322        OperationKind::Build => true,
323        OperationKind::Sync => {
324            source_moved(engine, &pair.ingest, workspace_root)
325                || current_findings(engine, workspace_root, pair.binding, &pair.ingest)
326                    .map(|(_key, findings)| !findings.is_empty())
327                    .unwrap_or(false)
328        }
329        OperationKind::Verify => {
330            source_moved_since(engine, &pair.ingest, workspace_root, "verified", true)
331        }
332    }
333}
334
335/// Select the next *due* (binding, operation) pair for a `--all` rotation,
336/// advancing the round-robin cursor and the per-pair backoff state. Returns
337/// the selected binding id and operation, or `None` when nothing eligible is
338/// due (or everything due is backing off) this pass.
339/// `consume: false` is the PEEK form — decision 12 (backlog-sweep plan 03):
340/// rendering a brief is a read, so a plain `--all` render must leave cursor
341/// and backoff byte-identical, however often it runs. `consume: true` is the
342/// loop driver taking the rotation slot it is about to act on — the one
343/// place scheduler state advances.
344pub fn select_next_due_operation(
345    engine: &Engine,
346    workspace_root: &Path,
347    configs: &BindingConfigs,
348    filter: OperationFilter,
349    consume: bool,
350) -> Option<(String, OperationKind)> {
351    let cache_root = workspace_root.join(".memstead.cache").join("ingest");
352
353    // Eligible = every (resolvable binding, loop-declared operation) pair the
354    // filter admits, minus one-shot builds that already ran. Pair keys derive
355    // from the canonical binding id (`<mem>/<stem>`, D3/D9) — the resolved
356    // run's `name`.
357    let one_shot_ran = read_one_shot_runs(&cache_root);
358    let mut eligible: Vec<Pair<'_>> = Vec::new();
359    for record in &configs.bindings {
360        let binding_id = format!("{}/{}", record.mem, record.name);
361        let Ok(ingest) = resolve_binding_run(&binding_id, &record.config) else {
362            continue;
363        };
364        for op in OperationKind::ALL {
365            if !filter.admits(op) || !declared_for_loop(&record.config, op) {
366                continue;
367            }
368            if op == OperationKind::Build
369                && ingest.mode == BuildMode::OneShot
370                && one_shot_ran.contains(&ingest.name)
371            {
372                continue;
373            }
374            eligible.push(Pair {
375                key: pair_key(&ingest.name, op),
376                ingest: ingest.clone(),
377                binding: &record.config,
378                op,
379            });
380        }
381    }
382    eligible.sort_by(|a, b| a.key.cmp(&b.key));
383    let n = eligible.len();
384    if n == 0 {
385        return None;
386    }
387
388    // Advance the round-robin cursor by one from the last-picked position.
389    let mut cursor: Cursor = read_json(&cache_root, "ingest-cursor.json");
390    let start = cursor
391        .last
392        .as_ref()
393        .and_then(|last| eligible.iter().position(|p| &p.key == last))
394        .map_or(0, |i| (i + 1) % n);
395    if consume {
396        cursor.last = Some(eligible[start].key.clone());
397        write_json(&cache_root, "ingest-cursor.json", &cursor);
398    }
399
400    // From the start, take the first pair that is due and not backing off.
401    // Pre-pair single-key backoff entries (no `#<op>` suffix) are discarded on
402    // the way through — disposable cache, at most one lost backoff step.
403    let mut backoff: BTreeMap<String, BackoffEntry> = read_json(&cache_root, "ingest-backoff.json");
404    backoff.retain(|k, _| k.contains('#'));
405    let mut selected = None;
406    for offset in 0..n {
407        let pair = &eligible[(start + offset) % n];
408        if !operation_due(engine, workspace_root, pair) {
409            continue;
410        }
411        let current = engine
412            .mem_head_sha(&pair.ingest.destination_mem)
413            .ok()
414            .flatten()
415            .unwrap_or_default();
416        // Build keeps the moved-source backoff override (and the one-shot
417        // never-skips rule). Sync / verify pairs rely on the due-check for
418        // source movement and on the destination-snapshot reset for
419        // productivity, so an un-acted-on brief ramps instead of re-rendering
420        // every pass.
421        let (mode, moved) = match pair.op {
422            OperationKind::Build => (
423                pair.ingest.mode,
424                source_moved(engine, &pair.ingest, workspace_root),
425            ),
426            OperationKind::Sync | OperationKind::Verify => (BuildMode::Discovery, false),
427        };
428        if consume {
429            let entry = backoff.entry(pair.key.clone()).or_default();
430            if !should_skip(mode, moved, entry, &current) {
431                selected = Some((pair.ingest.name.clone(), pair.op));
432                break;
433            }
434        } else {
435            let entry = backoff.get(&pair.key).cloned().unwrap_or_default();
436            if !would_skip(mode, moved, &entry, &current) {
437                selected = Some((pair.ingest.name.clone(), pair.op));
438                break;
439            }
440        }
441    }
442    if consume {
443        write_json(&cache_root, "ingest-backoff.json", &backoff);
444    }
445    selected
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    /// The linear-ramp backoff: first pass at a fresh snapshot runs and stores
453    /// it; a repeat (unchanged) ramps the cooldown and skips it down; a
454    /// destination change resets to zero and runs immediately.
455    #[test]
456    fn backoff_ramps_and_resets() {
457        let mut e = BackoffEntry::default();
458
459        // First evaluation: empty snapshot → runs, stores current.
460        assert!(!apply_backoff(&mut e, "sha1"));
461        assert_eq!(e.snapshot, "sha1");
462        assert_eq!(e.skip_level, 0);
463
464        // Unchanged again → ramp to level 1, skip_remaining 1, runs this pass.
465        assert!(!apply_backoff(&mut e, "sha1"));
466        assert_eq!(e.skip_level, 1);
467        assert_eq!(e.skip_remaining, 1);
468
469        // Next pass: skip_remaining 1 → skip, decrement to 0.
470        assert!(apply_backoff(&mut e, "sha1"));
471        assert_eq!(e.skip_remaining, 0);
472
473        // Next: remaining 0, unchanged → ramp to 2, runs.
474        assert!(!apply_backoff(&mut e, "sha1"));
475        assert_eq!(e.skip_level, 2);
476        assert_eq!(e.skip_remaining, 2);
477
478        // A destination change resets everything and runs immediately.
479        assert!(!apply_backoff(&mut e, "sha2"));
480        assert_eq!(e.skip_level, 0);
481        assert_eq!(e.skip_remaining, 0);
482        assert_eq!(e.snapshot, "sha2");
483    }
484
485    /// The ramp is capped at MAX_SKIP_LEVEL.
486    #[test]
487    fn backoff_caps_at_max_level() {
488        let mut e = BackoffEntry {
489            skip_level: MAX_SKIP_LEVEL,
490            skip_remaining: 0,
491            snapshot: "s".to_string(),
492        };
493        assert!(!apply_backoff(&mut e, "s")); // unchanged, remaining 0 → ramp
494        assert_eq!(e.skip_level, MAX_SKIP_LEVEL, "capped");
495        assert_eq!(e.skip_remaining, MAX_SKIP_LEVEL);
496    }
497
498    /// A one-shot build never skips; a moved source overrides backoff for
499    /// discovery; an unchanged discovery destination backs off.
500    #[test]
501    fn should_skip_honours_mode_and_source_movement() {
502        let mut e = BackoffEntry {
503            skip_remaining: 3,
504            skip_level: 3,
505            snapshot: "s".to_string(),
506        };
507        // A one-shot build never skips, regardless of backoff.
508        assert!(!should_skip(BuildMode::OneShot, false, &mut e.clone(), "s"));
509        // Discovery with a moved source → run (backoff untouched).
510        let mut e2 = e.clone();
511        assert!(!should_skip(BuildMode::Discovery, true, &mut e2, "s"));
512        assert_eq!(e2.skip_remaining, 3, "moved source does not touch backoff");
513        // Discovery, unchanged, cooling down → skip.
514        assert!(should_skip(BuildMode::Discovery, false, &mut e, "s"));
515    }
516
517    // ── op-aware selection (pairs, eligibility, due-checks) ─────────────────
518
519    use crate::binding::{
520        BINDING_VERSION, BuildOperation, Operations, SyncOperation, VerifyOperation, hash_binding,
521    };
522    use crate::pipeline::{MediumType, PatternEntry, PatternMode, Source};
523    use crate::pipeline_store::MemPipelineRecord;
524
525    use super::super::findings::{
526        Finding, FindingClass, FindingKey, FindingTarget, FindingsStore, write_findings_store,
527    };
528
529    fn empty_engine() -> Engine {
530        Engine::from_mounts(Vec::new()).unwrap()
531    }
532
533    fn binding_with(operations: Operations) -> Binding {
534        Binding {
535            version: BINDING_VERSION,
536            intent: None,
537            sources: Vec::new(),
538            reference_mems: Vec::new(),
539            destination_mem: "m".to_string(),
540            deny_paths: Vec::new(),
541            coverage_semantics: None,
542            rules: None,
543            prune: None,
544            operations,
545        }
546    }
547
548    fn build_op(trigger: IngestTrigger) -> BuildOperation {
549        BuildOperation {
550            mode: BuildMode::Discovery,
551            trigger,
552            batch_size: 20,
553            post_actions: None,
554        }
555    }
556
557    fn record(name: &str, config: Binding) -> MemPipelineRecord<Binding> {
558        MemPipelineRecord {
559            mem: "m".to_string(),
560            name: name.to_string(),
561            config,
562        }
563    }
564
565    fn configs_of(bindings: Vec<MemPipelineRecord<Binding>>) -> BindingConfigs {
566        BindingConfigs {
567            bindings,
568            quarantined: Vec::new(),
569        }
570    }
571
572    /// The eligibility gate: a pair rotates only when its operation block
573    /// exists AND declares `trigger: loop`. A manual build, a build-less
574    /// binding's absent block, and a manual sync/verify never rotate.
575    #[test]
576    fn eligibility_requires_block_and_loop_trigger() {
577        let ws = tempfile::tempdir().unwrap();
578        let engine = empty_engine();
579        let configs = configs_of(vec![
580            // build loop → the only eligible pair.
581            record(
582                "a",
583                binding_with(Operations {
584                    build: Some(build_op(IngestTrigger::Loop)),
585                    sync: None,
586                    verify: None,
587                }),
588            ),
589            // build manual → excluded (consent lives in the declaration).
590            record(
591                "b",
592                binding_with(Operations {
593                    build: Some(build_op(IngestTrigger::Manual)),
594                    sync: None,
595                    verify: None,
596                }),
597            ),
598            // no build; sync/verify manual → nothing eligible from it.
599            record(
600                "c",
601                binding_with(Operations {
602                    build: None,
603                    sync: Some(SyncOperation {
604                        trigger: IngestTrigger::Manual,
605                        batch_size: 20,
606                    }),
607                    verify: Some(VerifyOperation {
608                        trigger: IngestTrigger::Manual,
609                        batch_size: 20,
610                        adjudication_cap: 50,
611                        full_resync_every: 20,
612                    }),
613                }),
614            ),
615        ]);
616
617        // Build filter and Any agree: only `m/a`'s build pair rotates.
618        assert_eq!(
619            select_next_due_operation(
620                &engine,
621                ws.path(),
622                &configs,
623                OperationFilter::Only(OperationKind::Build),
624                true
625            ),
626            Some(("m/a".to_string(), OperationKind::Build))
627        );
628        assert_eq!(
629            select_next_due_operation(&engine, ws.path(), &configs, OperationFilter::Any, true),
630            Some(("m/a".to_string(), OperationKind::Build))
631        );
632        // Sync / verify filters: the declared blocks are manual → no pair.
633        assert_eq!(
634            select_next_due_operation(
635                &engine,
636                ws.path(),
637                &configs,
638                OperationFilter::Only(OperationKind::Sync),
639                true
640            ),
641            None
642        );
643        assert_eq!(
644            select_next_due_operation(
645                &engine,
646                ws.path(),
647                &configs,
648                OperationFilter::Only(OperationKind::Verify),
649                true
650            ),
651            None
652        );
653    }
654
655    /// The sync due-check: a loop-declared sync pair with unmoved sources is
656    /// due only when open findings exist under the binding's current
657    /// `(hash(D), source_head)` key; an empty current batch is not due.
658    #[test]
659    fn sync_pair_due_only_on_open_findings_when_source_unmoved() {
660        let ws = tempfile::tempdir().unwrap();
661        let engine = empty_engine();
662        let binding = binding_with(Operations {
663            build: None,
664            sync: Some(SyncOperation {
665                trigger: IngestTrigger::Loop,
666                batch_size: 20,
667            }),
668            verify: None,
669        });
670        let configs = configs_of(vec![record("s", binding.clone())]);
671
672        // No findings store → not due.
673        assert_eq!(
674            select_next_due_operation(
675                &engine,
676                ws.path(),
677                &configs,
678                OperationFilter::Only(OperationKind::Sync),
679                true
680            ),
681            None
682        );
683
684        // The current key for a source-less binding: hash(D) + empty head.
685        let key = FindingKey {
686            binding_hash: hash_binding(&binding),
687            source_head: String::new(),
688        };
689
690        // An empty batch under the current key → still not due.
691        let mut store = FindingsStore {
692            binding: "m/s".to_string(),
693            batches: Vec::new(),
694        };
695        store.record(key.clone(), "0".to_string(), Vec::new());
696        write_findings_store(ws.path(), "m", "s", &store).unwrap();
697        assert_eq!(
698            select_next_due_operation(
699                &engine,
700                ws.path(),
701                &configs,
702                OperationFilter::Only(OperationKind::Sync),
703                true
704            ),
705            None
706        );
707
708        // One open finding under the current key → the sync pair is due.
709        store.record(
710            key.clone(),
711            "1".to_string(),
712            vec![Finding {
713                key: key.clone(),
714                facet: "f".to_string(),
715                target: FindingTarget::Artifact {
716                    artifact: "a.rs".to_string(),
717                },
718                class: FindingClass::Uncovered,
719                detail: "no anchor".to_string(),
720                created_at: "1".to_string(),
721            }],
722        );
723        write_findings_store(ws.path(), "m", "s", &store).unwrap();
724        assert_eq!(
725            select_next_due_operation(
726                &engine,
727                ws.path(),
728                &configs,
729                OperationFilter::Only(OperationKind::Sync),
730                true
731            ),
732            Some(("m/s".to_string(), OperationKind::Sync))
733        );
734
735        // Findings under a DIFFERENT key (superseded) do not make sync due.
736        let mut stale = FindingsStore {
737            binding: "m/s".to_string(),
738            batches: Vec::new(),
739        };
740        let stale_key = FindingKey {
741            binding_hash: "0000".to_string(),
742            source_head: "old".to_string(),
743        };
744        stale.record(
745            stale_key.clone(),
746            "1".to_string(),
747            vec![Finding {
748                key: stale_key,
749                facet: "f".to_string(),
750                target: FindingTarget::Artifact {
751                    artifact: "a.rs".to_string(),
752                },
753                class: FindingClass::Uncovered,
754                detail: "stale".to_string(),
755                created_at: "1".to_string(),
756            }],
757        );
758        write_findings_store(ws.path(), "m", "s", &stale).unwrap();
759        assert_eq!(
760            select_next_due_operation(
761                &engine,
762                ws.path(),
763                &configs,
764                OperationFilter::Only(OperationKind::Sync),
765                true
766            ),
767            None,
768            "superseded findings must not pull a sync into rotation"
769        );
770    }
771
772    /// A binding with a live (mtime) inline source over `ws`, named `f`.
773    fn configs_with_live_source(operations: Operations) -> BindingConfigs {
774        let mut binding = binding_with(operations);
775        binding.sources = vec![Source {
776            name: "f".to_string(),
777            medium_type: MediumType::Filesystem,
778            pointer: String::new(),
779            change_detection: Some("mtime".to_string()),
780            scope: vec![PatternEntry {
781                path: "**/*.rs".to_string(),
782                mode: PatternMode::Allow,
783            }],
784            engagement: None,
785            preparation: None,
786        }];
787        BindingConfigs {
788            bindings: vec![record("v", binding)],
789            quarantined: Vec::new(),
790        }
791    }
792
793    /// The verify due-check: a never-verified binding whose source has a live
794    /// change-detection token is due its first verify; a source with no
795    /// signal (unscoped facet → no token) is not.
796    #[test]
797    fn verify_pair_due_when_never_verified_with_live_token() {
798        let ws = tempfile::tempdir().unwrap();
799        std::fs::write(ws.path().join("a.rs"), "x").unwrap();
800        let engine = empty_engine();
801        let verify_loop = Operations {
802            build: None,
803            sync: None,
804            verify: Some(VerifyOperation {
805                trigger: IngestTrigger::Loop,
806                batch_size: 20,
807                adjudication_cap: 50,
808                full_resync_every: 20,
809            }),
810        };
811
812        // Live token (scoped mtime source), never verified → due.
813        let configs = configs_with_live_source(verify_loop.clone());
814        assert_eq!(
815            select_next_due_operation(
816                &engine,
817                ws.path(),
818                &configs,
819                OperationFilter::Only(OperationKind::Verify),
820                true
821            ),
822            Some(("m/v".to_string(), OperationKind::Verify))
823        );
824
825        // No signal (unscoped source → no current token) → not due.
826        let mut no_signal = configs_with_live_source(verify_loop);
827        no_signal.bindings[0].config.sources[0].scope.clear();
828        assert_eq!(
829            select_next_due_operation(
830                &engine,
831                ws.path(),
832                &no_signal,
833                OperationFilter::Only(OperationKind::Verify),
834                true
835            ),
836            None
837        );
838    }
839
840    /// `Any` rotates round-robin across (binding, operation) pairs in pair-id
841    /// order, and the cursor is pair-keyed: build and verify pairs alternate.
842    #[test]
843    fn any_filter_rotates_across_pairs() {
844        let ws = tempfile::tempdir().unwrap();
845        std::fs::write(ws.path().join("a.rs"), "x").unwrap();
846        let engine = empty_engine();
847
848        // Two bindings: `m/a` build-loop (no sources) and `m/v` verify-loop
849        // over a live mtime source. Pair order: `m/a#build` < `m/v#verify`.
850        let mut configs = configs_with_live_source(Operations {
851            build: None,
852            sync: None,
853            verify: Some(VerifyOperation {
854                trigger: IngestTrigger::Loop,
855                batch_size: 20,
856                adjudication_cap: 50,
857                full_resync_every: 20,
858            }),
859        });
860        configs.bindings.push(record(
861            "a",
862            binding_with(Operations {
863                build: Some(build_op(IngestTrigger::Loop)),
864                sync: None,
865                verify: None,
866            }),
867        ));
868
869        let next = || {
870            select_next_due_operation(&engine, ws.path(), &configs, OperationFilter::Any, true)
871                .unwrap()
872        };
873        assert_eq!(next(), ("m/a".to_string(), OperationKind::Build));
874        assert_eq!(next(), ("m/v".to_string(), OperationKind::Verify));
875        assert_eq!(next(), ("m/a".to_string(), OperationKind::Build));
876    }
877
878    /// Pre-pair (single-key) backoff entries are discarded, not honoured: a
879    /// legacy `m/a` entry with pending skips does not delay the `m/a#build`
880    /// pair, and the rewritten cache carries only pair-keyed entries.
881    #[test]
882    fn legacy_single_key_backoff_entries_are_discarded() {
883        let ws = tempfile::tempdir().unwrap();
884        let engine = empty_engine();
885        let cache_root = ws.path().join(".memstead.cache").join("ingest");
886        std::fs::create_dir_all(&cache_root).unwrap();
887        let legacy: BTreeMap<String, BackoffEntry> = [(
888            "m/a".to_string(),
889            BackoffEntry {
890                skip_remaining: 5,
891                skip_level: 5,
892                snapshot: "s".to_string(),
893            },
894        )]
895        .into();
896        std::fs::write(
897            cache_root.join("ingest-backoff.json"),
898            serde_json::to_vec(&legacy).unwrap(),
899        )
900        .unwrap();
901
902        let configs = configs_of(vec![record(
903            "a",
904            binding_with(Operations {
905                build: Some(build_op(IngestTrigger::Loop)),
906                sync: None,
907                verify: None,
908            }),
909        )]);
910        assert_eq!(
911            select_next_due_operation(
912                &engine,
913                ws.path(),
914                &configs,
915                OperationFilter::Only(OperationKind::Build),
916                true
917            ),
918            Some(("m/a".to_string(), OperationKind::Build)),
919            "a legacy entry's pending skips are discarded, not honoured"
920        );
921
922        let rewritten: BTreeMap<String, BackoffEntry> =
923            serde_json::from_slice(&std::fs::read(cache_root.join("ingest-backoff.json")).unwrap())
924                .unwrap();
925        assert!(!rewritten.contains_key("m/a"), "legacy key pruned");
926        assert!(rewritten.contains_key("m/a#build"), "pair key written");
927    }
928
929    /// A peek (`consume: false`) before any scheduler state exists selects
930    /// without creating the cache files: a diagnostic render on a fresh
931    /// workspace leaves no trace.
932    #[test]
933    fn peek_on_fresh_workspace_writes_nothing() {
934        let ws = tempfile::tempdir().unwrap();
935        let engine = empty_engine();
936        let configs = configs_of(vec![record(
937            "a",
938            binding_with(Operations {
939                build: Some(build_op(IngestTrigger::Loop)),
940                sync: None,
941                verify: None,
942            }),
943        )]);
944        assert_eq!(
945            select_next_due_operation(&engine, ws.path(), &configs, OperationFilter::Any, false),
946            Some(("m/a".to_string(), OperationKind::Build))
947        );
948        let cache_root = ws.path().join(".memstead.cache").join("ingest");
949        assert!(
950            !cache_root.join("ingest-cursor.json").exists()
951                && !cache_root.join("ingest-backoff.json").exists(),
952            "a pure render must not mint scheduler state"
953        );
954    }
955
956    /// Render idempotence + prediction: repeated peeks return the same pair
957    /// the next consuming selection takes, and leave the cursor and backoff
958    /// files byte-identical — the criterion-3 contract (a re-rendered brief
959    /// never silently advances the rotation past a binding).
960    #[test]
961    fn peek_is_idempotent_and_predicts_consumption() {
962        let ws = tempfile::tempdir().unwrap();
963        let engine = empty_engine();
964        let loop_build = Operations {
965            build: Some(build_op(IngestTrigger::Loop)),
966            sync: None,
967            verify: None,
968        };
969        let configs = configs_of(vec![
970            record("a", binding_with(loop_build.clone())),
971            record("b", binding_with(loop_build)),
972        ]);
973        let cache_root = ws.path().join(".memstead.cache").join("ingest");
974
975        // One consuming pass seeds real cursor + backoff state.
976        assert_eq!(
977            select_next_due_operation(&engine, ws.path(), &configs, OperationFilter::Any, true),
978            Some(("m/a".to_string(), OperationKind::Build))
979        );
980        let cursor_bytes = std::fs::read(cache_root.join("ingest-cursor.json")).unwrap();
981        let backoff_bytes = std::fs::read(cache_root.join("ingest-backoff.json")).unwrap();
982
983        // Three peeks: same answer every time, zero state movement.
984        for _ in 0..3 {
985            assert_eq!(
986                select_next_due_operation(
987                    &engine,
988                    ws.path(),
989                    &configs,
990                    OperationFilter::Any,
991                    false
992                ),
993                Some(("m/b".to_string(), OperationKind::Build))
994            );
995        }
996        assert_eq!(
997            std::fs::read(cache_root.join("ingest-cursor.json")).unwrap(),
998            cursor_bytes,
999            "peeks left the cursor byte-identical"
1000        );
1001        assert_eq!(
1002            std::fs::read(cache_root.join("ingest-backoff.json")).unwrap(),
1003            backoff_bytes,
1004            "peeks left the backoff byte-identical"
1005        );
1006
1007        // The consuming pass takes exactly the pair the peeks promised.
1008        assert_eq!(
1009            select_next_due_operation(&engine, ws.path(), &configs, OperationFilter::Any, true),
1010            Some(("m/b".to_string(), OperationKind::Build))
1011        );
1012    }
1013
1014    /// `not_loop_declared` names every (binding, op) pair the filter admits
1015    /// but the binding does not loop-declare — the criterion-4 disclosure
1016    /// list for `--all` rendering.
1017    #[test]
1018    fn not_loop_declared_lists_undeclared_pairs() {
1019        let configs = configs_of(vec![
1020            // Loop build, no sync/verify blocks.
1021            record(
1022                "a",
1023                binding_with(Operations {
1024                    build: Some(build_op(IngestTrigger::Loop)),
1025                    sync: None,
1026                    verify: None,
1027                }),
1028            ),
1029            // Manual build only.
1030            record(
1031                "b",
1032                binding_with(Operations {
1033                    build: Some(build_op(IngestTrigger::Manual)),
1034                    sync: None,
1035                    verify: None,
1036                }),
1037            ),
1038        ]);
1039        assert_eq!(
1040            not_loop_declared(&configs, OperationFilter::Only(OperationKind::Build)),
1041            vec![("m/b".to_string(), OperationKind::Build)]
1042        );
1043        let any = not_loop_declared(&configs, OperationFilter::Any);
1044        assert_eq!(
1045            any,
1046            vec![
1047                ("m/a".to_string(), OperationKind::Sync),
1048                ("m/a".to_string(), OperationKind::Verify),
1049                ("m/b".to_string(), OperationKind::Build),
1050                ("m/b".to_string(), OperationKind::Sync),
1051                ("m/b".to_string(), OperationKind::Verify),
1052            ]
1053        );
1054    }
1055}