Skip to main content

memstead_base/ingest/
selection.rs

1//! Ingest selection + backoff — pick the next *due* ingest in a `--all`
2//! rotation, skipping ones whose destination is unchanged and whose sources
3//! have not moved. Engine-side port of the plugin's `nextIngest` / `shouldSkip`
4//! / backoff state.
5//!
6//! The deterministic state (round-robin cursor, per-ingest backoff, one-shot
7//! ran-set) lives engine-side under `<workspace>/.memstead.cache/ingest/` —
8//! the same engine-internal bookkeeping location the mtime memo uses. This is
9//! not mem-repo / graph state; selection mutates it as its job.
10//!
11//! Backoff shape (mirrors the plugin exactly): a **linear-ramp** per-ingest
12//! skip counter. A destination-snapshot change *or* a moved source resets it
13//! to zero and runs; otherwise each unproductive pass grows the cooldown by
14//! one (capped at [`MAX_SKIP_LEVEL`]). A one-shot build never skips.
15
16use std::collections::{BTreeMap, BTreeSet};
17use std::path::Path;
18
19use serde::{Deserialize, Serialize};
20
21use crate::Engine;
22use crate::binding::BuildMode;
23use crate::pipeline_store::BindingConfigs;
24
25use super::cursor::source_moved;
26use super::resolve::{ResolvedIngest, resolve_binding_run};
27
28/// The backoff cooldown ceiling — after this many consecutive unproductive
29/// passes the skip count stops growing. Mirrors the plugin's `MAX_SKIP_LEVEL`.
30pub const MAX_SKIP_LEVEL: u32 = 10;
31
32/// Per-ingest destination-snapshot backoff state.
33#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
34pub struct BackoffEntry {
35    /// Passes still to skip before the next run.
36    #[serde(default)]
37    pub skip_remaining: u32,
38    /// Current cooldown level (grows by one per unproductive pass, capped).
39    #[serde(default)]
40    pub skip_level: u32,
41    /// The destination snapshot token this entry was last evaluated against.
42    #[serde(default)]
43    pub snapshot: String,
44}
45
46/// The round-robin cursor — the ingest the last rotation advanced to.
47#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
48pub struct Cursor {
49    /// The last ingest the cursor advanced to (`None` before the first pass).
50    #[serde(default)]
51    pub last: Option<String>,
52}
53
54/// Apply the destination-snapshot backoff to `entry`, mutating it, and return
55/// whether to **skip** this pass. `current` is the destination mem's current
56/// snapshot token (empty when none). Mirrors the backoff block of the plugin's
57/// `shouldSkip`:
58///
59///   - destination moved (`snapshot` set and `current` differs) → reset to
60///     zero, store `current`, **run**;
61///   - `skip_remaining > 0` → decrement, **skip**;
62///   - otherwise → if the snapshot is unchanged ramp the level (capped), set
63///     `skip_remaining = skip_level`, store `current`, **run**.
64pub fn apply_backoff(entry: &mut BackoffEntry, current: &str) -> bool {
65    if !entry.snapshot.is_empty() && current != entry.snapshot {
66        entry.skip_remaining = 0;
67        entry.skip_level = 0;
68        entry.snapshot = current.to_string();
69        return false;
70    }
71    if entry.skip_remaining > 0 {
72        entry.skip_remaining -= 1;
73        return true;
74    }
75    if !entry.snapshot.is_empty() && current == entry.snapshot {
76        entry.skip_level = (entry.skip_level + 1).min(MAX_SKIP_LEVEL);
77        entry.skip_remaining = entry.skip_level;
78    }
79    entry.snapshot = current.to_string();
80    false
81}
82
83/// Whether a binding should be skipped this rotation. A one-shot build never
84/// skips (one-shots are excluded from the eligible set once run). Discovery: a
85/// moved source overrides backoff; otherwise the destination-snapshot
86/// [`apply_backoff`].
87pub fn should_skip(
88    mode: BuildMode,
89    source_moved: bool,
90    entry: &mut BackoffEntry,
91    current: &str,
92) -> bool {
93    match mode {
94        BuildMode::OneShot => return false,
95        BuildMode::Discovery => {}
96    }
97    if source_moved {
98        return false;
99    }
100    apply_backoff(entry, current)
101}
102
103// ── state files (engine-internal cache) ─────────────────────────────────────
104
105fn read_json<T: Default + for<'de> Deserialize<'de>>(cache_root: &Path, name: &str) -> T {
106    std::fs::read(cache_root.join(name))
107        .ok()
108        .and_then(|b| serde_json::from_slice(&b).ok())
109        .unwrap_or_default()
110}
111
112fn write_json<T: Serialize>(cache_root: &Path, name: &str, value: &T) {
113    let _ = std::fs::create_dir_all(cache_root);
114    if let Ok(bytes) = serde_json::to_vec(value) {
115        let _ = std::fs::write(cache_root.join(name), bytes);
116    }
117}
118
119/// Read the set of one-shot ingests that have already run.
120fn read_one_shot_runs(cache_root: &Path) -> BTreeSet<String> {
121    let map: BTreeMap<String, bool> = read_json(cache_root, "ingest-one-shot-runs.json");
122    map.into_iter()
123        .filter(|(_, v)| *v)
124        .map(|(k, _)| k)
125        .collect()
126}
127
128/// Select the next *due* ingest for a `--all` rotation, advancing the
129/// round-robin cursor and the per-ingest backoff state. Returns the selected
130/// ingest name, or `None` when every eligible ingest is backing off this pass.
131pub fn select_next_due(
132    engine: &Engine,
133    workspace_root: &Path,
134    configs: &BindingConfigs,
135) -> Option<String> {
136    let cache_root = workspace_root.join(".memstead.cache").join("ingest");
137
138    // Eligible = all resolvable bindings minus one-shots that already ran. The
139    // selection cache is keyed off the canonical binding id (`<mem>/<stem>`,
140    // D3/D9), which is the resolved run's `name`.
141    let one_shot_ran = read_one_shot_runs(&cache_root);
142    let mut eligible: Vec<ResolvedIngest> = configs
143        .bindings
144        .iter()
145        .filter_map(|r| {
146            resolve_binding_run(configs, &format!("{}/{}", r.mem, r.name), &r.config).ok()
147        })
148        .filter(|ri| !(ri.mode == BuildMode::OneShot && one_shot_ran.contains(&ri.name)))
149        .collect();
150    eligible.sort_by(|a, b| a.name.cmp(&b.name));
151    let n = eligible.len();
152    if n == 0 {
153        return None;
154    }
155
156    // Advance the round-robin cursor by one from the last-picked position.
157    let mut cursor: Cursor = read_json(&cache_root, "ingest-cursor.json");
158    let start = cursor
159        .last
160        .as_ref()
161        .and_then(|last| eligible.iter().position(|ri| &ri.name == last))
162        .map_or(0, |i| (i + 1) % n);
163    cursor.last = Some(eligible[start].name.clone());
164    write_json(&cache_root, "ingest-cursor.json", &cursor);
165
166    // From the start, take the first ingest that is not backing off.
167    let mut backoff: BTreeMap<String, BackoffEntry> = read_json(&cache_root, "ingest-backoff.json");
168    let mut selected = None;
169    for offset in 0..n {
170        let ingest = &eligible[(start + offset) % n];
171        let current = engine
172            .mem_head_sha(&ingest.destination_mem)
173            .ok()
174            .flatten()
175            .unwrap_or_default();
176        let moved = source_moved(engine, ingest, workspace_root);
177        let entry = backoff.entry(ingest.name.clone()).or_default();
178        if !should_skip(ingest.mode, moved, entry, &current) {
179            selected = Some(ingest.name.clone());
180            break;
181        }
182    }
183    write_json(&cache_root, "ingest-backoff.json", &backoff);
184    selected
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    /// The linear-ramp backoff: first pass at a fresh snapshot runs and stores
192    /// it; a repeat (unchanged) ramps the cooldown and skips it down; a
193    /// destination change resets to zero and runs immediately.
194    #[test]
195    fn backoff_ramps_and_resets() {
196        let mut e = BackoffEntry::default();
197
198        // First evaluation: empty snapshot → runs, stores current.
199        assert!(!apply_backoff(&mut e, "sha1"));
200        assert_eq!(e.snapshot, "sha1");
201        assert_eq!(e.skip_level, 0);
202
203        // Unchanged again → ramp to level 1, skip_remaining 1, runs this pass.
204        assert!(!apply_backoff(&mut e, "sha1"));
205        assert_eq!(e.skip_level, 1);
206        assert_eq!(e.skip_remaining, 1);
207
208        // Next pass: skip_remaining 1 → skip, decrement to 0.
209        assert!(apply_backoff(&mut e, "sha1"));
210        assert_eq!(e.skip_remaining, 0);
211
212        // Next: remaining 0, unchanged → ramp to 2, runs.
213        assert!(!apply_backoff(&mut e, "sha1"));
214        assert_eq!(e.skip_level, 2);
215        assert_eq!(e.skip_remaining, 2);
216
217        // A destination change resets everything and runs immediately.
218        assert!(!apply_backoff(&mut e, "sha2"));
219        assert_eq!(e.skip_level, 0);
220        assert_eq!(e.skip_remaining, 0);
221        assert_eq!(e.snapshot, "sha2");
222    }
223
224    /// The ramp is capped at MAX_SKIP_LEVEL.
225    #[test]
226    fn backoff_caps_at_max_level() {
227        let mut e = BackoffEntry {
228            skip_level: MAX_SKIP_LEVEL,
229            skip_remaining: 0,
230            snapshot: "s".to_string(),
231        };
232        assert!(!apply_backoff(&mut e, "s")); // unchanged, remaining 0 → ramp
233        assert_eq!(e.skip_level, MAX_SKIP_LEVEL, "capped");
234        assert_eq!(e.skip_remaining, MAX_SKIP_LEVEL);
235    }
236
237    /// A one-shot build never skips; a moved source overrides backoff for
238    /// discovery; an unchanged discovery destination backs off.
239    #[test]
240    fn should_skip_honours_mode_and_source_movement() {
241        let mut e = BackoffEntry {
242            skip_remaining: 3,
243            skip_level: 3,
244            snapshot: "s".to_string(),
245        };
246        // A one-shot build never skips, regardless of backoff.
247        assert!(!should_skip(BuildMode::OneShot, false, &mut e.clone(), "s"));
248        // Discovery with a moved source → run (backoff untouched).
249        let mut e2 = e.clone();
250        assert!(!should_skip(BuildMode::Discovery, true, &mut e2, "s"));
251        assert_eq!(e2.skip_remaining, 3, "moved source does not touch backoff");
252        // Discovery, unchanged, cooling down → skip.
253        assert!(should_skip(BuildMode::Discovery, false, &mut e, "s"));
254    }
255}