Skip to main content

car_ast/
footprint.rs

1//! Advisory symbol-footprint scheduler for the Foreman pattern (B4).
2//!
3//! **This is NOT a soundness mechanism.** Correctness is owned entirely by the
4//! merge-verify gate (see `car-multi`'s foreman gate). This module only schedules
5//! subtasks for *better parallelism* — it decides which farmed-out subtasks may
6//! run concurrently versus serially. Because it is only a hint, it is allowed to
7//! be wrong in the *conservative* direction (serialize things that could have
8//! run in parallel) but never in the dangerous direction.
9//!
10//! That bias is enforced by being **fail-closed on uncertainty** — but the
11//! *kind* of uncertainty determines how conservative we must be ([`Scheduling`]):
12//!
13//! - **[`Precise`](Scheduling::Precise)** — the blast radius fully resolved.
14//!   Schedule on exact symbol-overlap.
15//! - **[`FileLevel`](Scheduling::FileLevel)** — a declared symbol isn't in the
16//!   index *because it's new* (greenfield "create `foo`"). A symbol that does
17//!   not exist yet has no existing callers, so there is no blast radius to miss;
18//!   the declared write *files* are still trustworthy. Fall back to file-level
19//!   disjointness — the **same basis as the no-footprint partitioner**, which
20//!   the system already trusts. This inherits the partitioner's one assumption:
21//!   the agent edits only the files it declared (callers are semantic impact,
22//!   not files it touches). A worktree collision on an *undeclared* file, or any
23//!   cross-file semantic break the lost blast radius would have flagged, is
24//!   caught by the gate's containment + union merge — not the scheduler.
25//! - **[`Serialize`](Scheduling::Serialize)** — the unresolved blast radius
26//!   cannot be safely downgraded. Under a truncated [`ProjectIndex`] an unknown
27//!   symbol is *ambiguous*: it could be genuinely new (no callers) OR an existing
28//!   symbol whose callers are merely **hidden** by the truncation — a real blast
29//!   radius wrongly read as "no callers" (the Phase-0 fail-open finding). The
30//!   declared file set is still known, but we cannot tell the two cases apart, so
31//!   we collapse the ambiguity safely and conflict with everything. (A
32//!   planner-declared uncertain footprint is likewise treated as `Serialize`.)
33//!
34//! Conflict model over footprints:
35//! - both `Precise`:    `write(A) ∩ write(B) ≠ ∅` (symbol blast radius)  → conflict.
36//! - either `FileLevel` (neither `Serialize`): declared write *files* overlap → conflict.
37//! - either `Serialize`: always conflict (serialized).
38//! - `write(A) ∩ read(B) ≠ ∅` (declared symbols)  → **edge** A→B: B runs after A.
39//! - otherwise                                      → **independent**: may run in parallel.
40
41use std::collections::{BTreeSet, HashMap, HashSet};
42
43use crate::index::ProjectIndex;
44
45/// A `(file, symbol)` location. `file` is repo-relative.
46#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
47pub struct SymbolRef {
48    pub file: String,
49    pub symbol: String,
50}
51
52impl SymbolRef {
53    pub fn new(file: impl Into<String>, symbol: impl Into<String>) -> Self {
54        Self {
55            file: file.into(),
56            symbol: symbol.into(),
57        }
58    }
59}
60
61/// The symbols a subtask is expected to write (define/modify) and read.
62#[derive(Debug, Clone, Default)]
63pub struct SymbolFootprint {
64    pub writes: HashSet<SymbolRef>,
65    pub reads: HashSet<SymbolRef>,
66    /// On a *declared* footprint: an input the planner sets when it could not
67    /// confidently determine the footprint → expands to [`Scheduling::Serialize`].
68    /// On an *expanded* footprint: a derived inspection view, `true` whenever the
69    /// blast radius wasn't fully resolved (i.e. `Scheduling::FileLevel` or
70    /// `Serialize`). The scheduler branches on [`Scheduling`], **never** on this —
71    /// `FileLevel` and `Serialize` both report `uncertain == true` but schedule
72    /// very differently.
73    pub uncertain: bool,
74}
75
76impl SymbolFootprint {
77    pub fn writing(writes: impl IntoIterator<Item = SymbolRef>) -> Self {
78        Self {
79            writes: writes.into_iter().collect(),
80            ..Default::default()
81        }
82    }
83
84    pub fn with_reads(mut self, reads: impl IntoIterator<Item = SymbolRef>) -> Self {
85        self.reads = reads.into_iter().collect();
86        self
87    }
88}
89
90/// How [`analyze`] must treat a footprint whose blast radius could not be fully
91/// resolved. The variants are ordered by conservatism: `Precise` < `FileLevel` <
92/// `Serialize`. See the module docs for the soundness argument behind each.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum Scheduling {
95    /// Blast radius fully resolved — schedule on exact symbol-overlap.
96    Precise,
97    /// Blast radius unresolved (a declared symbol is new/unknown, so it has no
98    /// existing callers to bound) but the declared write *files* are trustworthy.
99    /// Schedule on file-level disjointness.
100    FileLevel,
101    /// Neither blast radius nor file set trustworthy (truncated index, or
102    /// planner-declared uncertain). Conflict with everything — fail-closed.
103    Serialize,
104}
105
106/// A footprint that has been through [`expand_footprint`] (or whose caller has
107/// explicitly asserted it needs no expansion). [`analyze`] accepts only this, so
108/// a caller cannot *accidentally* schedule on a raw declared footprint — which
109/// would carry `Scheduling::Precise` and no blast radius, silently losing the
110/// fail-closed guarantee.
111#[derive(Debug, Clone)]
112pub struct ExpandedFootprint {
113    expanded: SymbolFootprint,
114    /// Declared write *files* (repo-relative) — the basis for `FileLevel`
115    /// scheduling. Always the *declared* writes, never the expanded blast radius:
116    /// the agent edits only what it declared, so only those files can produce a
117    /// worktree merge conflict; callers in the blast radius are semantic impact
118    /// the gate checks, not files the subtask touches.
119    declared_files: BTreeSet<String>,
120    scheduling: Scheduling,
121}
122
123impl ExpandedFootprint {
124    /// Escape hatch for callers that genuinely have no [`ProjectIndex`] (and for
125    /// tests): you assert the footprint is already as-expanded-as-it-gets. A
126    /// declared-`uncertain` footprint maps to [`Scheduling::Serialize`] (no index
127    /// means no basis to downgrade to file-level safely). Prefer
128    /// [`expand_footprint`]; reach for this only when there is no index.
129    pub fn assume_expanded(footprint: SymbolFootprint) -> Self {
130        let declared_files = footprint.writes.iter().map(|w| w.file.clone()).collect();
131        let scheduling = if footprint.uncertain {
132            Scheduling::Serialize
133        } else {
134            Scheduling::Precise
135        };
136        Self {
137            expanded: footprint,
138            declared_files,
139            scheduling,
140        }
141    }
142
143    pub fn inner(&self) -> &SymbolFootprint {
144        &self.expanded
145    }
146
147    pub fn scheduling(&self) -> Scheduling {
148        self.scheduling
149    }
150
151    /// Declared write files (repo-relative) — the `FileLevel` scheduling basis.
152    pub fn declared_files(&self) -> &BTreeSet<String> {
153        &self.declared_files
154    }
155}
156
157/// A subtask paired with its expanded footprint, for [`analyze`].
158#[derive(Debug, Clone)]
159pub struct FootprintSubtask {
160    pub id: String,
161    pub footprint: ExpandedFootprint,
162}
163
164/// The scheduling plan: parallel levels plus the dependency/conflict structure
165/// that produced them (kept for inspection and the audit trail).
166#[derive(Debug, Default, PartialEq, Eq)]
167pub struct DecompositionPlan {
168    /// Subtask ids grouped into levels; ids within a level may run concurrently.
169    pub levels: Vec<Vec<String>>,
170    /// `(a, b)`: b reads something a writes, so a must run before b.
171    pub edges: Vec<(String, String)>,
172    /// `(a, b)`: a and b write an overlapping symbol (or one is uncertain), so
173    /// they were placed in different levels.
174    pub conflicts: Vec<(String, String)>,
175}
176
177/// Expand a declared footprint to its blast radius: every transitive caller of a
178/// written symbol is also (potentially) affected and joins the write-set, up to
179/// `max_depth` hops. Fail-closed: a truncated index, or a declared symbol the
180/// index doesn't know, marks the result `uncertain`.
181pub fn expand_footprint(
182    index: &ProjectIndex,
183    declared: &SymbolFootprint,
184    max_depth: usize,
185) -> ExpandedFootprint {
186    let declared_files: BTreeSet<String> = declared.writes.iter().map(|w| w.file.clone()).collect();
187    let mut writes = declared.writes.clone();
188    // Scheduling conservatism only ever ratchets UP (Precise → FileLevel →
189    // Serialize), never down. Serialize is decided ONCE here, pre-loop: a
190    // truncated index can't tell a new symbol from one with hidden callers, and a
191    // planner-declared uncertain footprint is untrusted outright — both go
192    // straight to Serialize (the Phase-0 fail-open fix). The in-loop expansion
193    // below can only ever ratchet Precise → FileLevel, never reach Serialize and
194    // never step back down.
195    let mut scheduling = if declared.uncertain || index.truncated {
196        Scheduling::Serialize
197    } else {
198        Scheduling::Precise
199    };
200
201    let mut frontier: Vec<SymbolRef> = declared.writes.iter().cloned().collect();
202    for _ in 0..max_depth {
203        let mut next = Vec::new();
204        for w in &frontier {
205            // The index has never heard of this symbol — it's new (greenfield).
206            // A symbol that doesn't exist yet has no existing callers, so there is
207            // no blast radius to miss; the declared write file is still real.
208            // Downgrade to file-level scheduling (not Serialize) unless we are
209            // already forced higher by a truncated index / declared uncertainty.
210            if index.find(&w.symbol).is_empty() {
211                if scheduling == Scheduling::Precise {
212                    scheduling = Scheduling::FileLevel;
213                }
214                continue;
215            }
216            for cref in index.callers_of(&w.symbol) {
217                let caller = SymbolRef::new(cref.from_file.clone(), cref.from_symbol.clone());
218                if writes.insert(caller.clone()) {
219                    next.push(caller);
220                }
221            }
222        }
223        if next.is_empty() {
224            break;
225        }
226        frontier = next;
227    }
228
229    ExpandedFootprint {
230        expanded: SymbolFootprint {
231            writes,
232            reads: declared.reads.clone(),
233            // `uncertain` stays the inspection/back-compat view: true whenever the
234            // blast radius wasn't fully resolved (FileLevel or Serialize).
235            uncertain: scheduling != Scheduling::Precise,
236        },
237        declared_files,
238        scheduling,
239    }
240}
241
242/// Schedule subtasks into parallel levels from their footprints. Deterministic:
243/// ties break on subtask id. Cyclic read/write dependencies (which cannot be
244/// satisfied) are broken by serializing the remaining subtasks one per level.
245pub fn analyze(subtasks: &[FootprintSubtask]) -> DecompositionPlan {
246    let n = subtasks.len();
247    let mut conflict_pairs: HashSet<(usize, usize)> = HashSet::new();
248    let mut deps: HashMap<usize, BTreeSet<usize>> = (0..n).map(|i| (i, BTreeSet::new())).collect();
249    let mut conflicts = Vec::new();
250    let mut edges = Vec::new();
251
252    for i in 0..n {
253        for j in (i + 1)..n {
254            let fa = &subtasks[i].footprint;
255            let fb = &subtasks[j].footprint;
256            let a = fa.inner();
257            let b = fb.inner();
258
259            // Conflict basis depends on how much each footprint can be trusted:
260            //   - either Serialize        → always conflict (fail-closed)
261            //   - both Precise            → exact symbol blast-radius overlap
262            //   - otherwise (≥1 FileLevel,
263            //     neither Serialize)      → declared write-FILE overlap
264            // Exhaustive on purpose (no `_`): a future Scheduling variant must
265            // force a decision here, not silently inherit file-level scheduling
266            // — which for a *more*-conservative variant would be the exact
267            // fail-open hole this module exists to prevent (CLAUDE.md rule #2).
268            let conflict = match (fa.scheduling(), fb.scheduling()) {
269                // Either side untrusted even at file level → always conflict.
270                (Scheduling::Serialize, _) | (_, Scheduling::Serialize) => true,
271                // Both fully resolved → exact symbol blast-radius overlap.
272                (Scheduling::Precise, Scheduling::Precise) => !a.writes.is_disjoint(&b.writes),
273                // ≥1 FileLevel, neither Serialize → declared write-FILE overlap.
274                (Scheduling::FileLevel, Scheduling::FileLevel)
275                | (Scheduling::FileLevel, Scheduling::Precise)
276                | (Scheduling::Precise, Scheduling::FileLevel) => {
277                    !fa.declared_files().is_disjoint(fb.declared_files())
278                }
279            };
280            if conflict {
281                conflict_pairs.insert((i, j));
282                conflict_pairs.insert((j, i));
283                conflicts.push((subtasks[i].id.clone(), subtasks[j].id.clone()));
284            }
285
286            // Edge a→b: b reads what a writes (b runs after a).
287            if !a.writes.is_disjoint(&b.reads) {
288                deps.get_mut(&j).unwrap().insert(i);
289                edges.push((subtasks[i].id.clone(), subtasks[j].id.clone()));
290            }
291            // Edge b→a: a reads what b writes.
292            if !b.writes.is_disjoint(&a.reads) {
293                deps.get_mut(&i).unwrap().insert(j);
294                edges.push((subtasks[j].id.clone(), subtasks[i].id.clone()));
295            }
296        }
297    }
298
299    let mut placed = vec![false; n];
300    let mut levels: Vec<Vec<String>> = Vec::new();
301
302    while placed.iter().any(|p| !p) {
303        // Ready = not placed, all dependencies already placed.
304        let ready: Vec<usize> = (0..n)
305            .filter(|&i| !placed[i] && deps[&i].iter().all(|d| placed[*d]))
306            .collect();
307
308        let mut chosen: Vec<usize> = if ready.is_empty() {
309            // Dependency cycle: break it by serializing the lowest remaining id.
310            vec![(0..n).find(|&i| !placed[i]).unwrap()]
311        } else {
312            let mut level: Vec<usize> = Vec::new();
313            for &i in &ready {
314                if level.iter().all(|&k| !conflict_pairs.contains(&(i, k))) {
315                    level.push(i);
316                }
317            }
318            level
319        };
320        chosen.sort();
321
322        for &i in &chosen {
323            placed[i] = true;
324        }
325        levels.push(chosen.into_iter().map(|i| subtasks[i].id.clone()).collect());
326    }
327
328    DecompositionPlan {
329        levels,
330        edges,
331        conflicts,
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    fn sub(id: &str, fp: SymbolFootprint) -> FootprintSubtask {
340        FootprintSubtask {
341            id: id.to_string(),
342            footprint: ExpandedFootprint::assume_expanded(fp),
343        }
344    }
345
346    /// Build a subtask with an explicit [`Scheduling`] (tests can reach the
347    /// private fields because they are a child module).
348    fn sub_sched(id: &str, fp: SymbolFootprint, scheduling: Scheduling) -> FootprintSubtask {
349        let declared_files = fp.writes.iter().map(|w| w.file.clone()).collect();
350        FootprintSubtask {
351            id: id.to_string(),
352            footprint: ExpandedFootprint {
353                expanded: fp,
354                declared_files,
355                scheduling,
356            },
357        }
358    }
359
360    fn w(file: &str, sym: &str) -> SymbolRef {
361        SymbolRef::new(file, sym)
362    }
363
364    #[test]
365    fn disjoint_writes_run_in_one_level() {
366        let plan = analyze(&[
367            sub("a", SymbolFootprint::writing([w("a.rs", "fa")])),
368            sub("b", SymbolFootprint::writing([w("b.rs", "fb")])),
369        ]);
370        assert_eq!(plan.levels, vec![vec!["a".to_string(), "b".to_string()]]);
371        assert!(plan.conflicts.is_empty());
372    }
373
374    #[test]
375    fn overlapping_writes_serialize() {
376        let plan = analyze(&[
377            sub("a", SymbolFootprint::writing([w("lib.rs", "shared")])),
378            sub("b", SymbolFootprint::writing([w("lib.rs", "shared")])),
379        ]);
380        assert_eq!(plan.levels.len(), 2, "{plan:?}");
381        assert_eq!(plan.conflicts.len(), 1);
382    }
383
384    #[test]
385    fn write_read_dependency_orders_levels() {
386        // b reads what a writes → a before b.
387        let a = SymbolFootprint::writing([w("lib.rs", "api")]);
388        let b = SymbolFootprint::default().with_reads([w("lib.rs", "api")]);
389        let plan = analyze(&[sub("a", a), sub("b", b)]);
390        assert_eq!(
391            plan.levels,
392            vec![vec!["a".to_string()], vec!["b".to_string()]]
393        );
394        assert_eq!(plan.edges, vec![("a".to_string(), "b".to_string())]);
395    }
396
397    #[test]
398    fn uncertain_subtask_conflicts_with_everything() {
399        let mut uncertain = SymbolFootprint::writing([w("x.rs", "fx")]);
400        uncertain.uncertain = true;
401        let plan = analyze(&[
402            sub("u", uncertain),
403            sub("a", SymbolFootprint::writing([w("a.rs", "fa")])),
404            sub("b", SymbolFootprint::writing([w("b.rs", "fb")])),
405        ]);
406        // u must not share a level with a or b.
407        for level in &plan.levels {
408            if level.contains(&"u".to_string()) {
409                assert_eq!(level.len(), 1, "uncertain subtask is isolated: {plan:?}");
410            }
411        }
412        assert_eq!(plan.conflicts.len(), 2, "u conflicts with both");
413    }
414
415    #[test]
416    fn file_level_disjoint_files_run_in_parallel() {
417        // The greenfield fix: two subtasks creating NEW symbols (unknown to the
418        // index → FileLevel) in DIFFERENT files run concurrently instead of
419        // serializing. This is what unblocks the parallel-wins regime for
420        // create-new-symbol work.
421        let plan = analyze(&[
422            sub_sched(
423                "a",
424                SymbolFootprint::writing([w("a.rs", "new_a")]),
425                Scheduling::FileLevel,
426            ),
427            sub_sched(
428                "b",
429                SymbolFootprint::writing([w("b.rs", "new_b")]),
430                Scheduling::FileLevel,
431            ),
432        ]);
433        assert_eq!(
434            plan.levels,
435            vec![vec!["a".to_string(), "b".to_string()]],
436            "{plan:?}"
437        );
438        assert!(
439            plan.conflicts.is_empty(),
440            "disjoint files do not conflict: {plan:?}"
441        );
442    }
443
444    #[test]
445    fn file_level_same_file_serializes() {
446        // FileLevel still serializes same-file work — the agents would otherwise
447        // race the same file and merge-conflict at integration.
448        let plan = analyze(&[
449            sub_sched(
450                "a",
451                SymbolFootprint::writing([w("lib.rs", "new_a")]),
452                Scheduling::FileLevel,
453            ),
454            sub_sched(
455                "b",
456                SymbolFootprint::writing([w("lib.rs", "new_b")]),
457                Scheduling::FileLevel,
458            ),
459        ]);
460        assert_eq!(
461            plan.levels.len(),
462            2,
463            "same file → separate levels: {plan:?}"
464        );
465        assert_eq!(plan.conflicts.len(), 1);
466    }
467
468    #[test]
469    fn mixed_precise_and_file_level_compares_at_file_level() {
470        // A is Precise, B is FileLevel. The weaker (file) basis governs the pair.
471        // Disjoint files → parallel.
472        let parallel = analyze(&[
473            sub_sched(
474                "a",
475                SymbolFootprint::writing([w("a.rs", "fa")]),
476                Scheduling::Precise,
477            ),
478            sub_sched(
479                "b",
480                SymbolFootprint::writing([w("b.rs", "new_b")]),
481                Scheduling::FileLevel,
482            ),
483        ]);
484        assert_eq!(
485            parallel.levels,
486            vec![vec!["a".to_string(), "b".to_string()]],
487            "{parallel:?}"
488        );
489
490        // Same file → serialize even though the symbols differ (file basis).
491        let serial = analyze(&[
492            sub_sched(
493                "a",
494                SymbolFootprint::writing([w("lib.rs", "fa")]),
495                Scheduling::Precise,
496            ),
497            sub_sched(
498                "b",
499                SymbolFootprint::writing([w("lib.rs", "new_b")]),
500                Scheduling::FileLevel,
501            ),
502        ]);
503        assert_eq!(serial.levels.len(), 2, "{serial:?}");
504    }
505
506    #[test]
507    fn serialize_conflicts_with_everything_even_disjoint_files() {
508        // A truncated-index / planner-uncertain subtask (Serialize) still
509        // conflicts with everyone, even file-disjoint work — the Phase-0 fix.
510        let plan = analyze(&[
511            sub_sched(
512                "s",
513                SymbolFootprint::writing([w("s.rs", "fs")]),
514                Scheduling::Serialize,
515            ),
516            sub_sched(
517                "a",
518                SymbolFootprint::writing([w("a.rs", "fa")]),
519                Scheduling::FileLevel,
520            ),
521            sub_sched(
522                "b",
523                SymbolFootprint::writing([w("b.rs", "fb")]),
524                Scheduling::Precise,
525            ),
526        ]);
527        for level in &plan.levels {
528            if level.contains(&"s".to_string()) {
529                assert_eq!(level.len(), 1, "Serialize subtask is isolated: {plan:?}");
530            }
531        }
532        assert_eq!(plan.conflicts.len(), 2, "s conflicts with both a and b");
533    }
534
535    #[test]
536    fn file_level_still_honors_read_write_edges() {
537        // Even when FileLevel, a declared read/write dependency still orders the
538        // levels — the declared symbols remain meaningful for edges.
539        let writer = SymbolFootprint::writing([w("a.rs", "api")]);
540        let reader = SymbolFootprint::default().with_reads([w("a.rs", "api")]);
541        let plan = analyze(&[
542            sub_sched("writer", writer, Scheduling::FileLevel),
543            sub_sched("reader", reader, Scheduling::FileLevel),
544        ]);
545        assert_eq!(
546            plan.levels,
547            vec![vec!["writer".to_string()], vec!["reader".to_string()]],
548            "reader runs after writer: {plan:?}"
549        );
550    }
551
552    #[test]
553    fn cyclic_dependency_is_broken_by_serializing() {
554        // a writes X reads Y; b writes Y reads X → mutual dependency cycle.
555        let a = SymbolFootprint {
556            writes: [w("lib.rs", "X")].into_iter().collect(),
557            reads: [w("lib.rs", "Y")].into_iter().collect(),
558            uncertain: false,
559        };
560        let b = SymbolFootprint {
561            writes: [w("lib.rs", "Y")].into_iter().collect(),
562            reads: [w("lib.rs", "X")].into_iter().collect(),
563            uncertain: false,
564        };
565        let plan = analyze(&[sub("a", a), sub("b", b)]);
566        // Cannot satisfy both orders — must serialize, not deadlock.
567        assert_eq!(plan.levels.len(), 2, "{plan:?}");
568    }
569
570    #[test]
571    fn expand_marks_unknown_symbol_file_level() {
572        // A new/unknown symbol downgrades to FileLevel (not Serialize): no
573        // existing callers to miss, declared file still trustworthy.
574        let dir = tempfile::tempdir().unwrap();
575        std::fs::write(dir.path().join("lib.rs"), "pub fn known() {}\n").unwrap();
576        let index = ProjectIndex::build(dir.path());
577
578        let declared = SymbolFootprint::writing([w("lib.rs", "does_not_exist")]);
579        let expanded = expand_footprint(&index, &declared, 3);
580        assert_eq!(expanded.scheduling(), Scheduling::FileLevel);
581        assert!(
582            expanded.inner().uncertain,
583            "unknown symbol is still 'uncertain' for inspection"
584        );
585        assert!(expanded.declared_files().contains("lib.rs"));
586    }
587
588    #[test]
589    fn expand_known_symbol_is_precise() {
590        let dir = tempfile::tempdir().unwrap();
591        std::fs::write(dir.path().join("lib.rs"), "pub fn known() {}\n").unwrap();
592        let index = ProjectIndex::build(dir.path());
593
594        let declared = SymbolFootprint::writing([w("lib.rs", "known")]);
595        let expanded = expand_footprint(&index, &declared, 3);
596        assert_eq!(expanded.scheduling(), Scheduling::Precise);
597        assert!(!expanded.inner().uncertain);
598    }
599
600    #[test]
601    fn truncated_index_forces_serialize_even_for_known_symbol() {
602        // The Phase-0 fail-open fix, preserved EXACTLY: a truncated index has
603        // incomplete references, so expansion from it is Serialize (conflicts with
604        // everything) — NOT downgraded to file-level. Callers exist but are hidden.
605        let dir = tempfile::tempdir().unwrap();
606        std::fs::write(dir.path().join("lib.rs"), "pub fn known() {}\n").unwrap();
607        let mut index = ProjectIndex::build(dir.path());
608        assert!(!index.truncated, "small build is not truncated");
609        index.truncated = true; // simulate a budget-truncated build
610
611        let declared = SymbolFootprint::writing([w("lib.rs", "known")]);
612        let expanded = expand_footprint(&index, &declared, 3);
613        assert_eq!(
614            expanded.scheduling(),
615            Scheduling::Serialize,
616            "truncated index must stay fail-closed, not relax to file-level"
617        );
618    }
619
620    #[test]
621    fn greenfield_disjoint_files_parallelize_end_to_end() {
622        // The full fix, through expand + analyze: two subtasks each creating a new
623        // function in its own file, against a real index that doesn't know either
624        // symbol yet, schedule into ONE parallel level.
625        let dir = tempfile::tempdir().unwrap();
626        std::fs::write(dir.path().join("a.rs"), "// implement alpha\n").unwrap();
627        std::fs::write(dir.path().join("b.rs"), "// implement beta\n").unwrap();
628        let index = ProjectIndex::build(dir.path());
629
630        let fa = expand_footprint(&index, &SymbolFootprint::writing([w("a.rs", "alpha")]), 3);
631        let fb = expand_footprint(&index, &SymbolFootprint::writing([w("b.rs", "beta")]), 3);
632        assert_eq!(fa.scheduling(), Scheduling::FileLevel);
633        assert_eq!(fb.scheduling(), Scheduling::FileLevel);
634
635        let plan = analyze(&[
636            FootprintSubtask {
637                id: "a".into(),
638                footprint: fa,
639            },
640            FootprintSubtask {
641                id: "b".into(),
642                footprint: fb,
643            },
644        ]);
645        assert_eq!(
646            plan.levels,
647            vec![vec!["a".to_string(), "b".to_string()]],
648            "greenfield disjoint-file subtasks now run in parallel: {plan:?}"
649        );
650    }
651}