memstead_base/ingest/advance.rs
1//! `projection advance` — the disposition-gated, resumable baseline advance
2//! (bundle plan `03-projection-promotion`, decision D7).
3//!
4//! An ingest/sync agent works the changed slice a brief presented, then records
5//! a **disposition** for every artifact it judged. `advance_baseline` is the
6//! engine primitive behind `memstead projection advance`: it freezes the
7//! presented slice, subtracts already-disposed artifacts on re-presentation,
8//! appends new-HEAD deltas when the source moves mid-pass, and — when the
9//! remainder empties — advances the destination mem's `#synced` baseline token
10//! through the existing [`Engine::set_mem_sync_state`] writer.
11//!
12//! ## Durability (why not `.memstead.cache/`)
13//!
14//! Dispositions are **not** disposable: losing them recreates the stall the
15//! redesign exists to kill. The frozen-slice snapshot + accumulated dispositions
16//! live under engine-owned **workspace state**,
17//! `.memstead/state/advance/<mem>/<name>.json` — a sibling of `state/mounts.json`
18//! and valid on both backends — read fresh from disk per call, so resumability
19//! is on-disk, not in-memory: a disposition recorded in one process is honored
20//! by the next.
21//!
22//! ## The gate (atomic, engine-printed ids only)
23//!
24//! The advance gate accepts **only** artifact ids the engine itself printed
25//! (the frozen slice, grown by any new-HEAD deltas). A disposition naming an id
26//! the engine never presented refuses the **whole call atomically** — validated
27//! before any disk write, so a refused call leaves the store byte-identical.
28//!
29//! ## Auto-`worked` from anchors (E3a — closes plan 03 D7's deferral)
30//!
31//! With anchors live, a mutation that carried `anchors[]` during a run records,
32//! in the destination mem's anchors sidecar, which source artifacts an entity
33//! now describes. [`advance_baseline`] reads that sidecar and marks any
34//! **frozen-slice** artifact referenced by such an anchor `worked`
35//! automatically, so the advance gate requires an explicit disposition only for
36//! the residue. Two invariants keep this honest:
37//!
38//! - the derivation reads **anchors, never a commit diff** — the inference-from-
39//! diffs mechanism D7 rejected stays rejected; a write without `anchors[]`
40//! marks nothing;
41//! - only the intersection with the frozen slice is ever marked — an anchored
42//! write referencing an artifact outside the presented slice fabricates no
43//! slice entry.
44//!
45//! AC9's non-stalling property still rests on the persisted dispositions + slice
46//! subtraction; auto-`worked` only removes the explicit-disposition burden for
47//! artifacts anchored during the same pass.
48
49use std::collections::{BTreeMap, BTreeSet};
50use std::path::{Path, PathBuf};
51
52use crate::Engine;
53use crate::workspace_store::{StoreError, WORKSPACE_STORE_DIR};
54
55use super::cursor::{compute_source_cursor, enumerate_facet_files};
56use super::resolve::{ResolvedIngest, ResolvedSource};
57use super::slice::Slice;
58
59/// The engine-owned state directory for advance stores, under the workspace
60/// store: `<root>/.memstead/state/advance/`.
61const STATE_DIR: &str = "state";
62/// See [`STATE_DIR`].
63const ADVANCE_DIR: &str = "advance";
64
65/// One binding's durable advance state (D7) — the frozen presented slice and
66/// the dispositions accumulated against it. Persisted at
67/// `.memstead/state/advance/<mem>/<name>.json`, read fresh per call.
68///
69/// The frozen slice is the **union** of every slice the engine has presented
70/// for this advance session (the initial freeze plus any new-HEAD deltas
71/// appended as the source moved). Its member ids are exactly the artifact ids
72/// the advance gate accepts.
73#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
74pub struct AdvanceState {
75 /// The canonical binding id `<mem>/<stem>` (D3) this state belongs to.
76 pub binding: String,
77 /// The frozen presented slice (union of freeze + appended new-HEAD deltas).
78 pub frozen_slice: Slice,
79 /// artifact id → agent-supplied disposition, accumulated across calls.
80 pub dispositions: BTreeMap<String, String>,
81 /// The **durable authored-exclusion ledger**: artifact id → the agent's
82 /// rationale for deliberately excluding it (mined, warrants no destination
83 /// entity). Unlike [`Self::dispositions`] and [`Self::frozen_slice`] — the
84 /// transient advance progress dropped on completion — this survives
85 /// completion so the fidelity report consults it under exhaustive coverage:
86 /// an excluded-on-purpose artifact stops re-surfacing as `uncovered` and
87 /// keeps its reasoning. Generic across every binding and medium.
88 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
89 pub exclusions: BTreeMap<String, String>,
90}
91
92/// The verdict marking an artifact **deliberately excluded** from coverage —
93/// mined, warrants no destination entity. When supplied with a rationale (the
94/// [`DispositionInput::Reasoned`] form) it lands in the durable authored
95/// exclusion ledger ([`AdvanceState::exclusions`]) and persists past advance
96/// completion; any other verdict clears a prior exclusion for that artifact.
97pub const EXCLUDED_VERDICT: &str = "excluded";
98
99/// An agent-supplied disposition for one artifact: either a bare verdict
100/// (`"worked"`, `"skipped"`, …) or a verdict carrying an authored rationale.
101///
102/// The rationale-bearing form exists for the durable authored-exclusion record
103/// the option-(a) design names — `(artifact, disposition = "excluded",
104/// rationale)`. It is generic: any verdict may carry reasoning, but only the
105/// [`EXCLUDED_VERDICT`] one is retained past completion (an excluded artifact
106/// has no anchor, so under exhaustive coverage it would otherwise re-surface as
107/// `uncovered` on every subsequent verify). Serde is `untagged` so the common
108/// `"worked"` form and the `{"disposition": "...", "rationale": "..."}` form
109/// both parse from the same `--dispositions` payload.
110#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
111#[serde(untagged)]
112pub enum DispositionInput {
113 /// A bare verdict string, e.g. `"worked"`.
114 Verdict(String),
115 /// A verdict with an authored rationale.
116 Reasoned {
117 /// The verdict proper (e.g. `"excluded"`).
118 disposition: String,
119 /// The agent's reasoning for this disposition.
120 rationale: String,
121 },
122}
123
124impl DispositionInput {
125 /// The verdict string (the disposition proper).
126 pub fn verdict(&self) -> &str {
127 match self {
128 DispositionInput::Verdict(v) => v,
129 DispositionInput::Reasoned { disposition, .. } => disposition,
130 }
131 }
132
133 /// The authored rationale, if the reasoned form was supplied.
134 pub fn rationale(&self) -> Option<&str> {
135 match self {
136 DispositionInput::Verdict(_) => None,
137 DispositionInput::Reasoned { rationale, .. } => Some(rationale),
138 }
139 }
140}
141
142impl AdvanceState {
143 /// Count of accumulated dispositions — the `disposed` figure `memstead
144 /// status` reports for this binding (D11).
145 pub fn disposed(&self) -> usize {
146 self.dispositions.len()
147 }
148
149 /// Count of frozen-slice artifacts not yet disposed — the `pending`
150 /// remainder `memstead status` reports (D11). Same subtraction the
151 /// re-presentation applies ([`subtract_disposed`]), collapsed to a count.
152 pub fn pending(&self) -> usize {
153 artifact_set(&self.frozen_slice)
154 .iter()
155 .filter(|a| !self.dispositions.contains_key(a.as_str()))
156 .count()
157 }
158}
159
160/// The outcome of an [`advance_baseline`] call.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct AdvanceOutcome {
163 /// The binding id advanced.
164 pub binding: String,
165 /// The re-presented remainder — the frozen slice with every disposed
166 /// artifact removed (disposed artifacts absent, D7). Empty when complete.
167 pub remainder: Slice,
168 /// Total dispositions accumulated (this call + prior, persisted).
169 pub disposed: usize,
170 /// Remaining (undisposed) artifact count — `remainder`'s total size.
171 pub pending: usize,
172 /// True when the remainder emptied this call: the `#synced` token(s)
173 /// advanced through the engine writer and the durable store was dropped.
174 pub completed: bool,
175 /// The `sync_state` keys whose baseline token advanced on completion
176 /// (empty on a non-completing call, or when the source had not moved).
177 pub tokens_written: Vec<String>,
178 /// Warnings surfaced by the underlying `set_mem_sync_state` writes (e.g.
179 /// `MEM_RELOADED` drift notices), rendered to strings.
180 pub warnings: Vec<String>,
181}
182
183/// Why [`advance_baseline`] could not complete.
184#[derive(Debug, thiserror::Error)]
185pub enum AdvanceError {
186 /// The binding id is not the canonical `<mem>/<stem>` shape.
187 #[error("malformed binding id '{0}': expected `<mem>/<stem>`")]
188 MalformedId(String),
189 /// One or more disposition ids were never presented by the engine — the
190 /// gate refuses the whole call (no partial write). Names each offending id.
191 #[error(
192 "disposition names {} artifact id(s) the engine did not present: {}; the advance gate \
193 accepts only ids from the presented slice ({printed} presented)",
194 artifacts.len(),
195 fmt_list(artifacts)
196 )]
197 UnknownArtifact {
198 /// The offending, never-presented ids (sorted).
199 artifacts: Vec<String>,
200 /// How many ids the engine did present (the accepted set size).
201 printed: usize,
202 },
203 /// Reading or writing the durable advance store failed.
204 #[error("advance store error: {0}")]
205 Store(#[source] StoreError),
206 /// The `set_mem_sync_state` baseline write failed on completion.
207 #[error("could not advance baseline token: {0}")]
208 Engine(String),
209}
210
211/// Render an id list for an error message: `a, b, c` or `(none)`.
212fn fmt_list(names: &[String]) -> String {
213 if names.is_empty() {
214 "(none)".to_string()
215 } else {
216 names.join(", ")
217 }
218}
219
220/// Split a canonical binding id `<mem>/<stem>` into its two single-component
221/// halves, or refuse. Mirrors the store's component guard so a caller-supplied
222/// id can never escape the `.memstead/state/advance/` tier.
223fn split_binding_id(binding_id: &str) -> Result<(String, String), AdvanceError> {
224 binding_id
225 .split_once('/')
226 .filter(|(m, n)| is_single_component(m) && is_single_component(n))
227 .map(|(m, n)| (m.to_string(), n.to_string()))
228 .ok_or_else(|| AdvanceError::MalformedId(binding_id.to_string()))
229}
230
231/// Is `value` a single, plain path component — safe as a `<mem>` / `<name>`
232/// directory or file segment? (No separators, traversal segments, drive/stream
233/// colon, or NUL.) Shared with the findings store's identical path guard.
234pub(crate) fn is_single_component(value: &str) -> bool {
235 !value.is_empty()
236 && value != "."
237 && value != ".."
238 && !value.contains('/')
239 && !value.contains('\\')
240 && !value.contains(':')
241 && !value.contains('\0')
242}
243
244/// The durable store path for a binding: `.memstead/state/advance/<mem>/<name>.json`.
245pub fn advance_store_path(workspace_root: &Path, mem: &str, name: &str) -> PathBuf {
246 workspace_root
247 .join(WORKSPACE_STORE_DIR)
248 .join(STATE_DIR)
249 .join(ADVANCE_DIR)
250 .join(mem)
251 .join(format!("{name}.json"))
252}
253
254/// Read the durable advance state for a binding, or `None` when none exists
255/// (never advanced, or completed and dropped). A malformed file surfaces a
256/// typed [`StoreError::Parse`] naming the path.
257pub fn read_advance_store(
258 workspace_root: &Path,
259 mem: &str,
260 name: &str,
261) -> Result<Option<AdvanceState>, StoreError> {
262 let path = advance_store_path(workspace_root, mem, name);
263 match std::fs::read(&path) {
264 Ok(bytes) => serde_json::from_slice(&bytes)
265 .map(Some)
266 .map_err(|e| StoreError::Parse {
267 path,
268 message: e.to_string(),
269 }),
270 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
271 Err(e) => Err(StoreError::Io { path, source: e }),
272 }
273}
274
275/// Persist the durable advance state for a binding (pretty JSON), creating
276/// parent directories.
277pub fn write_advance_store(
278 workspace_root: &Path,
279 mem: &str,
280 name: &str,
281 state: &AdvanceState,
282) -> Result<(), StoreError> {
283 let path = advance_store_path(workspace_root, mem, name);
284 if let Some(parent) = path.parent() {
285 std::fs::create_dir_all(parent).map_err(|e| StoreError::Io {
286 path: parent.to_path_buf(),
287 source: e,
288 })?;
289 }
290 let bytes = serde_json::to_vec_pretty(state).map_err(|e| StoreError::Parse {
291 path: path.clone(),
292 message: e.to_string(),
293 })?;
294 std::fs::write(&path, bytes).map_err(|e| StoreError::Io { path, source: e })
295}
296
297/// Drop the durable advance store for a binding (called on completion). A
298/// missing file is a successful no-op — completion is idempotent.
299pub fn delete_advance_store(
300 workspace_root: &Path,
301 mem: &str,
302 name: &str,
303) -> Result<(), StoreError> {
304 let path = advance_store_path(workspace_root, mem, name);
305 match std::fs::remove_file(&path) {
306 Ok(()) => Ok(()),
307 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
308 Err(e) => Err(StoreError::Io { path, source: e }),
309 }
310}
311
312/// Union `from` into `into`, keeping each class sorted + de-duplicated.
313fn union_slice(into: &mut Slice, from: &Slice) {
314 into.added.extend(from.added.iter().cloned());
315 into.modified.extend(from.modified.iter().cloned());
316 into.deleted.extend(from.deleted.iter().cloned());
317 for v in [&mut into.added, &mut into.modified, &mut into.deleted] {
318 v.sort();
319 v.dedup();
320 }
321}
322
323/// The full set of artifact ids a slice presents (across all three classes) —
324/// the accepted set for the advance gate.
325fn artifact_set(slice: &Slice) -> BTreeSet<String> {
326 slice
327 .added
328 .iter()
329 .chain(slice.modified.iter())
330 .chain(slice.deleted.iter())
331 .cloned()
332 .collect()
333}
334
335/// The remainder slice: the frozen slice with every disposed id removed from
336/// each class (disposed artifacts absent, D7).
337fn subtract_disposed(frozen: &Slice, dispositions: &BTreeMap<String, String>) -> Slice {
338 let keep = |v: &[String]| -> Vec<String> {
339 v.iter()
340 .filter(|a| !dispositions.contains_key(*a))
341 .cloned()
342 .collect()
343 };
344 Slice {
345 added: keep(&frozen.added),
346 modified: keep(&frozen.modified),
347 deleted: keep(&frozen.deleted),
348 }
349}
350
351/// The disposition-gated baseline advance (D7).
352///
353/// Freezes the currently-presented slice (or reloads a frozen one), appends any
354/// new-HEAD deltas, gates the supplied dispositions against the presented ids
355/// (atomic — an unknown id refuses before any write), accumulates them, and
356/// re-presents the remainder with disposed artifacts absent. When the remainder
357/// empties, the destination mem's `#synced` baseline token(s) advance through
358/// the engine's [`Engine::set_mem_sync_state`] writer — the provenance
359/// piggybacks that write's commit note, adding no new channel — and the durable
360/// store is dropped.
361///
362/// `resolved.name` must be the canonical binding id `<mem>/<stem>` (D3), as
363/// produced by [`super::resolve::resolve_binding_run`]; `dispositions` maps each
364/// judged artifact id to an agent-supplied [`DispositionInput`] — a bare verdict
365/// or a verdict with an authored rationale (in E2 the agent supplies one for
366/// **every** artifact — see the module docs). An `excluded` verdict with a
367/// rationale is recorded in the durable authored-exclusion ledger; any other
368/// verdict clears a prior exclusion for that artifact.
369pub fn advance_baseline(
370 engine: &mut Engine,
371 workspace_root: &Path,
372 resolved: &ResolvedIngest,
373 dispositions: &BTreeMap<String, DispositionInput>,
374) -> Result<AdvanceOutcome, AdvanceError> {
375 let binding_id = resolved.name.clone();
376 let (mem, name) = split_binding_id(&binding_id)?;
377
378 // Current source cursor (immutable borrow ends before the mutating writes).
379 // Its union is the slice relative to the *unchanged* `#synced` baseline, so
380 // when the source moves mid-pass this already reflects freeze + new deltas.
381 let cursor = compute_source_cursor(engine, resolved, workspace_root);
382
383 // Load-or-init the durable store (resumability is on-disk, not in-memory).
384 let mut state = read_advance_store(workspace_root, &mem, &name)
385 .map_err(AdvanceError::Store)?
386 .unwrap_or_else(|| AdvanceState {
387 binding: binding_id.clone(),
388 ..Default::default()
389 });
390
391 // Freeze / append: union the currently-presented slice into the frozen one.
392 union_slice(&mut state.frozen_slice, &cursor.union);
393 let printed = artifact_set(&state.frozen_slice);
394
395 // Gate (atomic): every disposition id must be one the engine presented.
396 // Validate BEFORE any disk write so a refusal leaves the store untouched.
397 let mut unknown: Vec<String> = dispositions
398 .keys()
399 .filter(|a| !printed.contains(a.as_str()))
400 .cloned()
401 .collect();
402 if !unknown.is_empty() {
403 unknown.sort();
404 unknown.dedup();
405 return Err(AdvanceError::UnknownArtifact {
406 artifacts: unknown,
407 printed: printed.len(),
408 });
409 }
410
411 // Accumulate the new (agent-supplied) dispositions. An `excluded` verdict
412 // with a rationale lands in the durable exclusion ledger (survives
413 // completion); any other verdict clears a prior exclusion for that artifact
414 // (a re-judged artifact must not keep stale "excluded" reasoning).
415 for (artifact, input) in dispositions {
416 state
417 .dispositions
418 .insert(artifact.clone(), input.verdict().to_string());
419 if input.verdict() == EXCLUDED_VERDICT {
420 state.exclusions.insert(
421 artifact.clone(),
422 input.rationale().unwrap_or("").to_string(),
423 );
424 } else {
425 state.exclusions.remove(artifact);
426 }
427 }
428
429 // Auto-`worked` (E3a): mark every frozen-slice artifact that an anchor in
430 // the destination mem now references. Reads the anchors sidecar, never a
431 // commit diff (D7's rejected mechanism stays rejected); scoped to the
432 // frozen slice (`printed`) so an anchored write outside the slice
433 // fabricates no entry; skips artifacts already carrying an explicit
434 // disposition (the agent's judgement wins).
435 let auto_worked: Vec<String> = printed
436 .iter()
437 .filter(|art| !state.dispositions.contains_key(art.as_str()))
438 .filter(|art| {
439 engine
440 .anchors_referencing_artifact(art)
441 .iter()
442 .any(|(eid, _)| eid.mem() == resolved.destination_mem.as_str())
443 })
444 .cloned()
445 .collect();
446 for art in auto_worked {
447 state.dispositions.insert(art, "worked".to_string());
448 }
449
450 // Re-present the remainder (disposed absent).
451 let remainder = subtract_disposed(&state.frozen_slice, &state.dispositions);
452 let pending = remainder.added.len() + remainder.modified.len() + remainder.deleted.len();
453 let completed = pending == 0;
454
455 let mut warnings: Vec<String> = Vec::new();
456 let mut tokens_written: Vec<String> = Vec::new();
457 if completed {
458 // Advance the baseline token for every facet that moved (current cursor
459 // tokens = the latest HEAD) via the engine writer. Provenance piggybacks
460 // the write's commit note — no new channel (D7).
461 let note = format!(
462 "projection advance {binding_id}: {} artifact(s) disposed, baseline advanced",
463 state.dispositions.len()
464 );
465 for c in cursor.write_commands.iter().chain(cursor.reseed.iter()) {
466 let outcome = engine
467 .set_mem_sync_state(&resolved.destination_mem, &c.key, &c.token, Some(¬e))
468 .map_err(|e| AdvanceError::Engine(e.to_string()))?;
469 warnings.extend(outcome.warnings.iter().map(ToString::to_string));
470 tokens_written.push(c.key.clone());
471 }
472 // Transient progress (frozen slice + per-run dispositions) is consumed.
473 // If any durable authored exclusions accumulated, retain a slimmed store
474 // holding only them (empty slice, no transient dispositions) so the
475 // fidelity report keeps consulting them; otherwise drop the store
476 // entirely (completion idempotent — the no-exclusion path is unchanged).
477 if state.exclusions.is_empty() {
478 delete_advance_store(workspace_root, &mem, &name).map_err(AdvanceError::Store)?;
479 } else {
480 let durable = AdvanceState {
481 binding: binding_id.clone(),
482 frozen_slice: Slice::default(),
483 dispositions: BTreeMap::new(),
484 exclusions: state.exclusions.clone(),
485 };
486 write_advance_store(workspace_root, &mem, &name, &durable)
487 .map_err(AdvanceError::Store)?;
488 }
489 } else {
490 // Persist the accumulated frozen slice + dispositions for resumability.
491 write_advance_store(workspace_root, &mem, &name, &state).map_err(AdvanceError::Store)?;
492 }
493
494 Ok(AdvanceOutcome {
495 binding: binding_id,
496 remainder,
497 disposed: state.dispositions.len(),
498 pending,
499 completed,
500 tokens_written,
501 warnings,
502 })
503}
504
505/// The outcome of a [`record_exclusions`] call.
506#[derive(Debug, Clone, PartialEq, Eq)]
507pub struct ExcludeOutcome {
508 /// The binding id whose exclusion ledger was written.
509 pub binding: String,
510 /// Total authored exclusions in the ledger after this call (this call + prior).
511 pub excluded: usize,
512 /// How many supplied artifacts were newly added (not already in the ledger).
513 pub added: usize,
514}
515
516/// Why [`record_exclusions`] could not complete.
517#[derive(Debug, thiserror::Error)]
518pub enum ExcludeError {
519 /// The binding id is not the canonical `<mem>/<stem>` shape.
520 #[error("malformed binding id '{0}': expected `<mem>/<stem>`")]
521 MalformedId(String),
522 /// One or more artifacts are not members of the binding's enumerable source
523 /// `S(D)` — the gate refuses the whole call (no partial write). Names each.
524 #[error(
525 "exclusion names {} artifact id(s) not in the binding's enumerable source S(D): {}; \
526 only an in-scope source member can be declared excluded ({printed} enumerated)",
527 artifacts.len(),
528 fmt_list(artifacts)
529 )]
530 NotSourceMember {
531 /// The offending, non-member ids (sorted).
532 artifacts: Vec<String>,
533 /// How many artifacts `S(D)` did enumerate (the accepted set size).
534 printed: usize,
535 },
536 /// Reading or writing the durable advance store failed.
537 #[error("advance store error: {0}")]
538 Store(#[source] StoreError),
539}
540
541/// Declare **authored exclusions** for in-scope source artifacts — the direct
542/// write path for the durable exclusion ledger [`advance_baseline`] also feeds.
543///
544/// Unlike the advance gate (which accepts only artifacts in the *changed slice*),
545/// this gates on **enumerable `S(D)` membership**: an artifact must be a real
546/// in-scope member of the binding's source, and a *stable, unchanged* artifact
547/// qualifies. That is what a deliberate editorial exclusion is — "this in-scope
548/// artifact is mined and warrants no destination entity, because …" — a decision
549/// independent of change detection. Each accepted `(artifact, rationale)` lands
550/// in the ledger the fidelity report consults, so the artifact stops re-surfacing
551/// as `uncovered` under exhaustive coverage and keeps its reasoning. Atomic: an
552/// artifact outside `S(D)` refuses the whole call before any write. Merges into
553/// any in-flight advance store rather than clobbering it. Generic across every
554/// enumerable binding and medium.
555pub fn record_exclusions(
556 workspace_root: &Path,
557 resolved: &ResolvedIngest,
558 exclusions: &BTreeMap<String, String>,
559) -> Result<ExcludeOutcome, ExcludeError> {
560 let binding_id = resolved.name.clone();
561 let (mem, name) =
562 split_binding_id(&binding_id).map_err(|_| ExcludeError::MalformedId(binding_id.clone()))?;
563
564 // Enumerate S(D) — the in-scope source-artifact set, the same enumeration the
565 // fidelity report uses for its coverage denominator.
566 let mut s_d: BTreeSet<String> = BTreeSet::new();
567 for source in &resolved.sources {
568 if let ResolvedSource::Primary(p) = source {
569 for f in enumerate_facet_files(p, &resolved.deny_paths, workspace_root) {
570 s_d.insert(f);
571 }
572 }
573 }
574
575 // Gate (atomic): every exclusion id must be an S(D) member. Validate BEFORE
576 // any disk write so a refusal leaves the store untouched.
577 let mut not_member: Vec<String> = exclusions
578 .keys()
579 .filter(|a| !s_d.contains(a.as_str()))
580 .cloned()
581 .collect();
582 if !not_member.is_empty() {
583 not_member.sort();
584 not_member.dedup();
585 return Err(ExcludeError::NotSourceMember {
586 artifacts: not_member,
587 printed: s_d.len(),
588 });
589 }
590
591 // Merge into the durable exclusion ledger, preserving any in-flight advance
592 // progress already in the same store.
593 let mut state = read_advance_store(workspace_root, &mem, &name)
594 .map_err(ExcludeError::Store)?
595 .unwrap_or_else(|| AdvanceState {
596 binding: binding_id.clone(),
597 ..Default::default()
598 });
599 let mut added = 0usize;
600 for (artifact, rationale) in exclusions {
601 if state
602 .exclusions
603 .insert(artifact.clone(), rationale.clone())
604 .is_none()
605 {
606 added += 1;
607 }
608 }
609 write_advance_store(workspace_root, &mem, &name, &state).map_err(ExcludeError::Store)?;
610
611 Ok(ExcludeOutcome {
612 binding: binding_id,
613 excluded: state.exclusions.len(),
614 added,
615 })
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621 use crate::binding::BuildMode;
622 use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
623 use crate::storage::FilesystemMemWriter;
624 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
625 use tempfile::TempDir;
626
627 // ── pure helpers ─────────────────────────────────────────────────────
628
629 fn slice(added: &[&str], modified: &[&str], deleted: &[&str]) -> Slice {
630 Slice {
631 added: added.iter().map(|s| s.to_string()).collect(),
632 modified: modified.iter().map(|s| s.to_string()).collect(),
633 deleted: deleted.iter().map(|s| s.to_string()).collect(),
634 }
635 }
636
637 fn disp(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
638 pairs
639 .iter()
640 .map(|(a, d)| (a.to_string(), d.to_string()))
641 .collect()
642 }
643
644 /// The [`DispositionInput`] map an `advance_baseline` call takes: bare
645 /// verdicts (the common form).
646 fn input(pairs: &[(&str, &str)]) -> BTreeMap<String, DispositionInput> {
647 pairs
648 .iter()
649 .map(|(a, d)| (a.to_string(), DispositionInput::Verdict(d.to_string())))
650 .collect()
651 }
652
653 /// The store round-trips and `delete` is idempotent.
654 #[test]
655 fn advance_store_round_trips_and_delete_is_idempotent() {
656 let tmp = TempDir::new().unwrap();
657 let root = tmp.path();
658 assert!(
659 read_advance_store(root, "engine", "graph")
660 .unwrap()
661 .is_none()
662 );
663
664 let state = AdvanceState {
665 binding: "engine/graph".to_string(),
666 frozen_slice: slice(&["c.rs"], &["a.rs"], &["b.rs"]),
667 dispositions: disp(&[("a.rs", "worked")]),
668 exclusions: BTreeMap::new(),
669 };
670 write_advance_store(root, "engine", "graph", &state).unwrap();
671 assert!(
672 advance_store_path(root, "engine", "graph")
673 .ends_with("state/advance/engine/graph.json")
674 );
675 let back = read_advance_store(root, "engine", "graph")
676 .unwrap()
677 .unwrap();
678 assert_eq!(back, state);
679
680 delete_advance_store(root, "engine", "graph").unwrap();
681 assert!(
682 read_advance_store(root, "engine", "graph")
683 .unwrap()
684 .is_none()
685 );
686 // Idempotent: deleting an absent store is a no-op, not an error.
687 delete_advance_store(root, "engine", "graph").unwrap();
688 }
689
690 /// `subtract_disposed` removes disposed ids from every class.
691 #[test]
692 fn subtract_disposed_removes_disposed_from_every_class() {
693 let frozen = slice(&["c.rs"], &["a.rs"], &["b.rs"]);
694 let out = subtract_disposed(&frozen, &disp(&[("a.rs", "worked"), ("c.rs", "skipped")]));
695 assert_eq!(out, slice(&[], &[], &["b.rs"]));
696 }
697
698 // ── AC9 — full engine advance over a moving HEAD ─────────────────────
699
700 fn git(repo: &Path, args: &[&str]) {
701 let out = std::process::Command::new("git")
702 .args(args)
703 .current_dir(repo)
704 .env("GIT_AUTHOR_NAME", "t")
705 .env("GIT_AUTHOR_EMAIL", "t@t")
706 .env("GIT_COMMITTER_NAME", "t")
707 .env("GIT_COMMITTER_EMAIL", "t@t")
708 .output()
709 .unwrap();
710 assert!(
711 out.status.success(),
712 "git {args:?}: {}",
713 String::from_utf8_lossy(&out.stderr)
714 );
715 }
716
717 fn head_sha(repo: &Path) -> String {
718 String::from_utf8(
719 std::process::Command::new("git")
720 .args(["rev-parse", "HEAD"])
721 .current_dir(repo)
722 .output()
723 .unwrap()
724 .stdout,
725 )
726 .unwrap()
727 .trim()
728 .to_string()
729 }
730
731 /// A discovery-mode resolved binding whose one primary source is a git
732 /// codebase rooted at the workspace root (medium pointer `""`), scoped to
733 /// `**/*.rs`, keyed `engine/graph` → dest mem `engine`.
734 fn resolved_engine_graph() -> ResolvedIngest {
735 use super::super::resolve::{ResolvedPrimarySource, ResolvedSource};
736 ResolvedIngest {
737 name: "engine/graph".to_string(),
738 mode: BuildMode::Discovery,
739 trigger: IngestTrigger::Loop,
740 batch_size: 20,
741 deny_paths: vec![],
742 projection_ref: "engine/graph".to_string(),
743 projection_mem: "engine".to_string(),
744 projection_name: "graph".to_string(),
745 intent: None,
746 sources: vec![ResolvedSource::Primary(ResolvedPrimarySource {
747 facet_ref: "source-tree".to_string(),
748 medium: "src".to_string(),
749 medium_type: MediumType::Codebase,
750 medium_pointer: String::new(),
751 declared_change_detection: Some("git".to_string()),
752 scope: vec![PatternEntry {
753 path: "**/*.rs".to_string(),
754 mode: PatternMode::Allow,
755 }],
756 preparation: None,
757 })],
758 destination_mem: "engine".to_string(),
759 rules: None,
760 post_actions: None,
761 }
762 }
763
764 /// Build an engine over one writable folder mem `engine` rooted at `root`
765 /// (which is also the git source tree), with a `.memstead/config.json` so
766 /// `sync_state` can be read/written.
767 fn engine_at(root: &Path) -> Engine {
768 // Seed the mem config **once** — a later rebuild must not clobber the
769 // `sync_state` a prior engine persisted (that is what makes the
770 // resumability leg meaningful: each `engine_at` models a fresh process).
771 let config_path = root.join(".memstead").join("config.json");
772 if !config_path.exists() {
773 std::fs::create_dir_all(root.join(".memstead")).unwrap();
774 std::fs::write(&config_path, br#"{"format":1,"schema":"default@1.0.0"}"#).unwrap();
775 }
776 let mount = Mount {
777 mem: "engine".to_string(),
778 schema: Some("default@1.0.0".parse().unwrap()),
779 storage: MountStorage::Folder {
780 path: root.to_path_buf(),
781 },
782 capability: MountCapability::Write,
783 lifecycle: MountLifecycle::Eager,
784 cross_linkable: false,
785 migration_target: None,
786 };
787 Engine::from_mounts(vec![(
788 mount,
789 Box::new(FilesystemMemWriter::new(root.to_path_buf()))
790 as Box<dyn crate::backend::MemBackend>,
791 )])
792 .unwrap()
793 }
794
795 fn synced_key() -> &'static str {
796 "engine/graph/source-tree#synced"
797 }
798
799 /// AC9 — `projection advance` is non-stalling under a moving HEAD, and its
800 /// gate + resumability hold:
801 ///
802 /// 1. freeze a slice, dispose part → the remainder is the rest;
803 /// 2. an unknown artifact id refuses the whole call **atomically** (the
804 /// store is byte-identical after the refusal);
805 /// 3. a fresh process (new engine) honors the on-disk dispositions
806 /// (resumability is on-disk, not in-memory);
807 /// 4. the source HEAD advances mid-pass → the re-presented slice equals
808 /// (old remainder + new deltas) with disposed artifacts absent;
809 /// 5. disposing the rest empties the remainder → the `#synced` token
810 /// advances via the engine writer to the current HEAD.
811 #[test]
812 fn advance_is_non_stalling_under_a_moving_head_with_gate_and_resumability() {
813 let tmp = TempDir::new().unwrap();
814 let root = tmp.path();
815
816 // Source git tree: baseline commit with a.rs + b.rs.
817 git(root, &["init", "-q"]);
818 std::fs::write(root.join("a.rs"), "one").unwrap();
819 std::fs::write(root.join("b.rs"), "bee").unwrap();
820 git(root, &["add", "a.rs", "b.rs"]);
821 git(root, &["commit", "-qm", "base"]);
822 let baseline = head_sha(root);
823
824 // Move to head1: modify a.rs, delete b.rs.
825 std::fs::write(root.join("a.rs"), "one-longer").unwrap();
826 std::fs::remove_file(root.join("b.rs")).unwrap();
827 git(root, &["add", "-A"]);
828 git(root, &["commit", "-qm", "head1"]);
829
830 let resolved = resolved_engine_graph();
831
832 // Seed the `#synced` baseline so the source shows a real moved slice.
833 {
834 let mut engine = engine_at(root);
835 engine
836 .set_mem_sync_state("engine", synced_key(), &baseline, None)
837 .unwrap();
838 }
839
840 // (1) Freeze + dispose part (a.rs). Remainder = the rest (b.rs deleted).
841 {
842 let mut engine = engine_at(root);
843 let out = advance_baseline(&mut engine, root, &resolved, &input(&[("a.rs", "worked")]))
844 .unwrap();
845 assert!(!out.completed, "one artifact still pending");
846 assert_eq!(out.remainder, slice(&[], &[], &["b.rs"]));
847 assert_eq!(out.pending, 1);
848 assert_eq!(out.disposed, 1);
849 }
850 // The dispositions persisted to disk.
851 let on_disk = read_advance_store(root, "engine", "graph")
852 .unwrap()
853 .unwrap();
854 assert_eq!(on_disk.dispositions, disp(&[("a.rs", "worked")]));
855
856 // (2) An unknown artifact id refuses the whole call atomically — the
857 // store is byte-identical afterwards (no partial write).
858 let before = std::fs::read(advance_store_path(root, "engine", "graph")).unwrap();
859 {
860 let mut engine = engine_at(root);
861 let err = advance_baseline(
862 &mut engine,
863 root,
864 &resolved,
865 &input(&[("never-presented.rs", "worked")]),
866 )
867 .unwrap_err();
868 assert!(
869 matches!(err, AdvanceError::UnknownArtifact { .. }),
870 "expected UnknownArtifact, got {err:?}"
871 );
872 }
873 let after = std::fs::read(advance_store_path(root, "engine", "graph")).unwrap();
874 assert_eq!(before, after, "refused call must not touch the store");
875
876 // (4) Source moves mid-pass → add c.rs at head2.
877 std::fs::write(root.join("c.rs"), "cee").unwrap();
878 git(root, &["add", "-A"]);
879 git(root, &["commit", "-qm", "head2"]);
880
881 // (3)+(4) A fresh engine (new process) honors the on-disk a.rs
882 // disposition, and re-presents (old remainder [b.rs] + new delta [c.rs])
883 // with the disposed a.rs absent. Empty dispositions = pure re-present.
884 {
885 let mut engine = engine_at(root);
886 let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
887 assert!(!out.completed);
888 assert_eq!(
889 out.remainder,
890 slice(&["c.rs"], &[], &["b.rs"]),
891 "re-present = old remainder (b.rs) + new delta (c.rs); disposed a.rs absent"
892 );
893 assert_eq!(out.disposed, 1, "no new disposition this call");
894 }
895
896 // (5) Dispose the rest → remainder empties → the token advances.
897 let head2 = head_sha(root);
898 {
899 let mut engine = engine_at(root);
900 let out = advance_baseline(
901 &mut engine,
902 root,
903 &resolved,
904 &input(&[("b.rs", "worked"), ("c.rs", "worked")]),
905 )
906 .unwrap();
907 assert!(out.completed, "every artifact disposed → complete");
908 assert_eq!(out.pending, 0);
909 assert_eq!(out.tokens_written, vec![synced_key().to_string()]);
910
911 // The `#synced` baseline advanced to the current HEAD (head2).
912 let token = engine
913 .mem_config_for("engine")
914 .and_then(|c| c.sync_state.get(synced_key()).cloned());
915 assert_eq!(token.as_deref(), Some(head2.as_str()));
916 }
917 // The durable store was dropped on completion.
918 assert!(
919 read_advance_store(root, "engine", "graph")
920 .unwrap()
921 .is_none()
922 );
923 }
924
925 /// The durable authored-exclusion ledger survives completion (unlike the
926 /// transient dispositions/frozen slice), and a later non-excluded verdict for
927 /// the same artifact clears it — dropping the store when nothing durable is
928 /// left. This is the persistence the fidelity report relies on so an
929 /// excluded-on-purpose artifact stops re-surfacing as `uncovered`.
930 #[test]
931 fn advance_retains_authored_exclusions_past_completion_and_clears_on_rejudge() {
932 let tmp = TempDir::new().unwrap();
933 let root = tmp.path();
934
935 // Baseline a.rs; move to head1 (modify a.rs) so the slice = {modified a.rs}.
936 git(root, &["init", "-q"]);
937 std::fs::write(root.join("a.rs"), "one").unwrap();
938 git(root, &["add", "a.rs"]);
939 git(root, &["commit", "-qm", "base"]);
940 let baseline = head_sha(root);
941 std::fs::write(root.join("a.rs"), "one-longer").unwrap();
942 git(root, &["add", "-A"]);
943 git(root, &["commit", "-qm", "head1"]);
944
945 let resolved = resolved_engine_graph();
946 {
947 let mut engine = engine_at(root);
948 engine
949 .set_mem_sync_state("engine", synced_key(), &baseline, None)
950 .unwrap();
951 }
952
953 // Dispose a.rs as EXCLUDED with a rationale → the only slice artifact is
954 // disposed → the advance completes. Exclusions are non-empty, so the
955 // store is RETAINED (not dropped) holding only the exclusion.
956 let excluded = {
957 let mut m = BTreeMap::new();
958 m.insert(
959 "a.rs".to_string(),
960 DispositionInput::Reasoned {
961 disposition: EXCLUDED_VERDICT.to_string(),
962 rationale: "mined; warrants no destination entity".to_string(),
963 },
964 );
965 m
966 };
967 {
968 let mut engine = engine_at(root);
969 let out = advance_baseline(&mut engine, root, &resolved, &excluded).unwrap();
970 assert!(out.completed, "the sole slice artifact was disposed");
971 }
972 let retained = read_advance_store(root, "engine", "graph")
973 .unwrap()
974 .expect("an authored exclusion keeps the store alive past completion");
975 assert!(
976 retained.frozen_slice == Slice::default() && retained.dispositions.is_empty(),
977 "transient progress is dropped on completion"
978 );
979 assert_eq!(
980 retained.exclusions.get("a.rs").map(String::as_str),
981 Some("mined; warrants no destination entity"),
982 "the durable exclusion + its rationale persist"
983 );
984
985 // Move to head2 (modify a.rs again) → a.rs re-enters the slice → re-judge
986 // it as `worked`. The non-excluded verdict clears the stale exclusion, and
987 // with nothing durable left the store is dropped.
988 std::fs::write(root.join("a.rs"), "one-longer-still").unwrap();
989 git(root, &["add", "-A"]);
990 git(root, &["commit", "-qm", "head2"]);
991 {
992 let mut engine = engine_at(root);
993 let out = advance_baseline(&mut engine, root, &resolved, &input(&[("a.rs", "worked")]))
994 .unwrap();
995 assert!(out.completed);
996 }
997 assert!(
998 read_advance_store(root, "engine", "graph")
999 .unwrap()
1000 .is_none(),
1001 "re-judging the artifact cleared the exclusion; nothing durable remains"
1002 );
1003 }
1004
1005 /// `record_exclusions` gates on enumerable `S(D)` membership (not the changed
1006 /// slice), so a **stable, unchanged** in-scope artifact can be declared
1007 /// excluded — the direct write path the option-(a) migration needs. A
1008 /// non-member refuses the whole call atomically; a re-declare merges.
1009 #[test]
1010 fn record_exclusions_gates_on_source_membership_and_merges() {
1011 let tmp = TempDir::new().unwrap();
1012 let root = tmp.path();
1013
1014 // A source tree with two in-scope `.rs` members. No commits move after
1015 // this — the artifacts are stable, never in a changed slice.
1016 git(root, &["init", "-q"]);
1017 std::fs::write(root.join("a.rs"), "one").unwrap();
1018 std::fs::write(root.join("b.rs"), "two").unwrap();
1019 git(root, &["add", "-A"]);
1020 git(root, &["commit", "-qm", "base"]);
1021
1022 let resolved = resolved_engine_graph();
1023
1024 // Declare a.rs excluded with a rationale — accepted (S(D) member).
1025 let out = record_exclusions(
1026 root,
1027 &resolved,
1028 &BTreeMap::from([("a.rs".to_string(), "mined; no entity".to_string())]),
1029 )
1030 .unwrap();
1031 assert_eq!((out.added, out.excluded), (1, 1));
1032 let state = read_advance_store(root, "engine", "graph")
1033 .unwrap()
1034 .unwrap();
1035 assert_eq!(
1036 state.exclusions.get("a.rs").map(String::as_str),
1037 Some("mined; no entity")
1038 );
1039
1040 // An artifact outside S(D) refuses the whole call — the store is untouched.
1041 let err = record_exclusions(
1042 root,
1043 &resolved,
1044 &BTreeMap::from([("does-not-exist.rs".to_string(), "x".to_string())]),
1045 )
1046 .unwrap_err();
1047 assert!(
1048 matches!(err, ExcludeError::NotSourceMember { .. }),
1049 "got {err:?}"
1050 );
1051 assert_eq!(
1052 read_advance_store(root, "engine", "graph")
1053 .unwrap()
1054 .unwrap()
1055 .exclusions
1056 .len(),
1057 1,
1058 "refused call left the ledger unchanged"
1059 );
1060
1061 // Re-declaring merges (b.rs added alongside a.rs).
1062 let out2 = record_exclusions(
1063 root,
1064 &resolved,
1065 &BTreeMap::from([("b.rs".to_string(), "also mined".to_string())]),
1066 )
1067 .unwrap();
1068 assert_eq!((out2.added, out2.excluded), (1, 2));
1069 }
1070
1071 /// `DispositionInput` parses both the bare-verdict and the reasoned forms
1072 /// from one `--dispositions` payload (serde `untagged`).
1073 #[test]
1074 fn disposition_input_parses_bare_and_reasoned_forms() {
1075 let map: BTreeMap<String, DispositionInput> = serde_json::from_str(
1076 r#"{"a.rs": "worked", "b.rs": {"disposition": "excluded", "rationale": "generated"}}"#,
1077 )
1078 .unwrap();
1079 assert_eq!(map["a.rs"].verdict(), "worked");
1080 assert_eq!(map["a.rs"].rationale(), None);
1081 assert_eq!(map["b.rs"].verdict(), EXCLUDED_VERDICT);
1082 assert_eq!(map["b.rs"].rationale(), Some("generated"));
1083 }
1084
1085 /// AC9a — an anchored write auto-marks its referenced frozen-slice
1086 /// artifacts `worked`, so `advance` needs an explicit disposition only for
1087 /// the residue, held across a HEAD move. Refusals: an artifact with no
1088 /// anchor is never auto-worked, and an anchor referencing an artifact
1089 /// OUTSIDE the presented slice fabricates no slice entry.
1090 #[test]
1091 fn advance_auto_worked_from_anchors_subtracts_slice_and_never_fabricates() {
1092 use crate::vcs::Actor;
1093 use indexmap::IndexMap;
1094
1095 let tmp = TempDir::new().unwrap();
1096 let root = tmp.path();
1097
1098 // Baseline: a commit carrying no `.rs` files. `#synced` pins it, so the
1099 // moved slice below is purely the added `.rs` sources.
1100 git(root, &["init", "-q"]);
1101 std::fs::write(root.join(".keep"), "x").unwrap();
1102 git(root, &["add", ".keep"]);
1103 git(root, &["commit", "-qm", "base"]);
1104 let baseline = head_sha(root);
1105
1106 let resolved = resolved_engine_graph();
1107 {
1108 let mut engine = engine_at(root);
1109 engine
1110 .set_mem_sync_state("engine", synced_key(), &baseline, None)
1111 .unwrap();
1112 }
1113
1114 // head1: add a.rs + b.rs → slice = added [a.rs, b.rs].
1115 std::fs::write(root.join("a.rs"), "one").unwrap();
1116 std::fs::write(root.join("b.rs"), "bee").unwrap();
1117 git(root, &["add", "a.rs", "b.rs"]);
1118 git(root, &["commit", "-qm", "head1"]);
1119
1120 // An anchored write into the destination mem `engine`: entity
1121 // `covers-a` file-anchors `a.rs` (inside the slice) AND `zzz.rs`
1122 // (outside it — must fabricate nothing).
1123 let make_anchor = |artifact: &str| crate::anchor::AnchorInput {
1124 artifact: Some(artifact.to_string()),
1125 grain: Some("file".to_string()),
1126 class: Some("anchored".to_string()),
1127 hash: Some("h".to_string()),
1128 hash_stability: Some("stable".to_string()),
1129 ..Default::default()
1130 };
1131 let mut sections = IndexMap::new();
1132 sections.insert("identity".to_string(), "Covers a.".to_string());
1133 sections.insert("purpose".to_string(), "Track a.rs.".to_string());
1134 {
1135 let mut engine = engine_at(root);
1136 engine
1137 .create_entity(
1138 crate::CreateEntityArgs {
1139 mem: "engine".to_string(),
1140 title: "Covers A".to_string(),
1141 entity_type: "spec".to_string(),
1142 sections,
1143 metadata: IndexMap::new(),
1144 relations: Vec::new(),
1145 anchors: vec![make_anchor("a.rs"), make_anchor("zzz.rs")],
1146 dry_run: false,
1147 },
1148 Actor::Agent,
1149 None,
1150 Some("anchored write"),
1151 )
1152 .unwrap();
1153 }
1154
1155 // (1) Advance with NO explicit dispositions → a.rs auto-worked from the
1156 // anchor; b.rs (no anchor) stays pending; zzz.rs (outside the slice)
1157 // fabricates nothing.
1158 {
1159 let mut engine = engine_at(root);
1160 let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1161 assert!(!out.completed, "b.rs still pending");
1162 assert_eq!(
1163 out.remainder,
1164 slice(&["b.rs"], &[], &[]),
1165 "a.rs auto-worked from its anchor; zzz.rs never became a slice member"
1166 );
1167 assert_eq!(out.disposed, 1, "only a.rs auto-worked");
1168 assert_eq!(out.pending, 1);
1169 }
1170
1171 // (2) HEAD moves (add c.rs). Re-present with no dispositions → old
1172 // remainder [b.rs] + new delta [c.rs]; a.rs stays absent (its
1173 // auto-`worked` persisted); c.rs is unanchored so it is NOT auto-worked.
1174 std::fs::write(root.join("c.rs"), "cee").unwrap();
1175 git(root, &["add", "-A"]);
1176 git(root, &["commit", "-qm", "head2"]);
1177 {
1178 let mut engine = engine_at(root);
1179 let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1180 assert_eq!(
1181 out.remainder,
1182 slice(&["b.rs", "c.rs"], &[], &[]),
1183 "auto-worked a.rs absent; unanchored b.rs + new c.rs pending"
1184 );
1185 assert_eq!(
1186 out.disposed, 1,
1187 "still only a.rs auto-worked; c.rs unanchored"
1188 );
1189 assert!(!out.completed);
1190 }
1191 }
1192}