Skip to main content

fallow_graph/graph/
partition_order.rs

1//! Partition + order engine: from a changed-file set, split the change into
2//! coherent, independently-reviewable UNITS and suggest a dependency-sensible
3//! review ORDER.
4//!
5//! v1 partitioning is BY-MODULE only (the load-bearing panel decision): a "unit"
6//! is the parent directory of a changed file, root-relative. This is the only
7//! clustering definition that is byte-identical-deterministic straight from the
8//! graph with zero heuristics; feature-cluster and concern partitioning are
9//! explicitly DEFERRED (they need scoring heuristics whose tie-breaks are a fresh
10//! nondeterminism + false-positive surface).
11//!
12//! The ORDER is a dependency-sensible topological sequence over the unit DAG: a
13//! unit that DEFINES what another CONSUMES comes first (review the load-bearing
14//! definition before its consumers), mechanical/leaf units last, ties broken by
15//! the path sort. Inter-unit edges come from the graph's forward edges
16//! ([`super::ModuleGraph::edges_for`], `mod.rs` L255; the inverse is
17//! `reverse_deps`, L75).
18//!
19//! Determinism (the roadmap done-condition "Same PR run twice -> byte-identical
20//! unit assignment and order"): the engine is a pure function of
21//! `(graph, changed_file_ids)`. No timestamps, no randomness. No `FxHashMap`
22//! iteration order ever reaches output; every collection is materialized into a
23//! `Vec` and explicitly sorted before use. FileIds are path-sorted and stable
24//! cross-run (ADR-004), so sorting by FileId == sorting by path. The only choice
25//! point in the topological sort is a min-pick over sorted `module_dir` strings.
26
27use std::path::Path;
28
29use super::relativize;
30
31use fallow_types::discover::FileId;
32use rustc_hash::{FxHashMap, FxHashSet};
33
34use super::ModuleGraph;
35
36/// A single review unit: a coherent by-module cluster of the changed set. The
37/// `module_dir` is the root-relative parent directory shared by `files`; the
38/// changed root file (one with no parent directory) clusters under the
39/// repository-root key (the empty string).
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ReviewUnit {
42    /// The module directory the unit covers (root-relative, forward-slashed).
43    /// The empty string is the repository-root group for changed files with no
44    /// parent directory.
45    module_dir: String,
46    /// The changed files in this unit, `FileId`-sorted (== path-sorted, ADR-004).
47    files: Vec<FileId>,
48}
49
50/// Result of a partition + order computation, keyed by `FileId` / `module_dir`.
51/// The caller relativizes via [`ModuleGraph::partition_order_with_paths`] for
52/// serialization, mirroring the `impact_closure` / `closure_with_paths` pair.
53#[derive(Debug, Clone, Default, PartialEq, Eq)]
54pub struct PartitionOrder {
55    /// The by-module units, sorted by `module_dir` string.
56    units: Vec<ReviewUnit>,
57    /// The dependency-sensible review order: `module_dir` strings, definitions
58    /// before consumers, mechanical/leaf units last, ties broken by the path
59    /// sort. One entry per unit; a permutation of the `units` `module_dir` set.
60    order: Vec<String>,
61    /// Connected components of the inter-unit dependency graph: groups of units
62    /// that share no import edge with any unit outside their group. Each slice
63    /// is `module_dir`-sorted; slices are sorted by their first entry. Two or
64    /// more slices mean the change has no import edge across that seam; whether
65    /// the pieces can land separately is still the reviewer's call.
66    independent_slices: Vec<Vec<String>>,
67}
68
69/// The same partition + order with each unit's `FileId`s resolved to
70/// root-relative, forward-slashed path strings, sorted for deterministic output.
71#[derive(Debug, Clone, Default, PartialEq, Eq)]
72pub struct PartitionOrderPaths {
73    /// The by-module units with file paths resolved, sorted by `module_dir`.
74    pub units: Vec<ReviewUnitPaths>,
75    /// The dependency-sensible review order of `module_dir` strings.
76    pub order: Vec<String>,
77    /// Connected components of the inter-unit dependency graph, each a sorted
78    /// list of `module_dir` strings, sorted by first entry.
79    pub independent_slices: Vec<Vec<String>>,
80}
81
82/// A [`ReviewUnit`] with `FileId`s resolved to root-relative paths.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct ReviewUnitPaths {
85    /// The module directory the unit covers (root-relative, forward-slashed).
86    pub module_dir: String,
87    /// The changed files in this unit, path-sorted.
88    pub files: Vec<String>,
89}
90
91impl ModuleGraph {
92    /// Compute the by-module partition and dependency-sensible order for a
93    /// changed-file seed set.
94    ///
95    /// Out-of-range or duplicate ids in `changed` are tolerated (dropped /
96    /// deduped). The partition groups each changed file by its parent directory;
97    /// the order is a deterministic topological sort over the inter-unit DAG
98    /// (definitions before consumers, ties broken by the `module_dir` sort).
99    #[must_use]
100    pub fn partition_order(&self, changed: &[FileId]) -> PartitionOrder {
101        // Dedup + drop out-of-range ids, keeping a path-stable working set.
102        let mut seen = FxHashSet::default();
103        let mut changed_ids: Vec<FileId> = Vec::with_capacity(changed.len());
104        for &id in changed {
105            if (id.0 as usize) < self.modules.len() && seen.insert(id) {
106                changed_ids.push(id);
107            }
108        }
109        changed_ids.sort_unstable_by_key(|f| f.0);
110
111        let units = self.build_units(&changed_ids);
112        let deps = self.unit_deps(&units, &changed_ids);
113        let order = if units.is_empty() {
114            Vec::new()
115        } else {
116            kahn_min_pick(&units, &deps)
117        };
118        let independent_slices = independent_slices(&units, &deps);
119        PartitionOrder {
120            units,
121            order,
122            independent_slices,
123        }
124    }
125
126    /// Group changed files by their parent directory (the module). Returns the
127    /// units sorted by `module_dir`, each unit's files `FileId`-sorted.
128    fn build_units(&self, changed_ids: &[FileId]) -> Vec<ReviewUnit> {
129        // module_dir -> files. FxHashMap iteration order never reaches output:
130        // the keys are pulled into a Vec and sorted below.
131        let mut by_dir: FxHashMap<String, Vec<FileId>> = FxHashMap::default();
132        for &id in changed_ids {
133            let Some(module) = self.modules.get(id.0 as usize) else {
134                continue;
135            };
136            let dir = module_dir_key(&module.path);
137            by_dir.entry(dir).or_default().push(id);
138        }
139
140        let mut units: Vec<ReviewUnit> = by_dir
141            .into_iter()
142            .map(|(module_dir, mut files)| {
143                files.sort_unstable_by_key(|f| f.0);
144                ReviewUnit { module_dir, files }
145            })
146            .collect();
147        units.sort_by(|a, b| a.module_dir.cmp(&b.module_dir));
148        units
149    }
150
151    /// Resolve the inter-unit dependency sets: `deps[c]` holds the indices of the
152    /// units `c` consumes from (its own unit excluded). Shared by the Kahn order
153    /// (definitions before consumers) and the independent-slice components.
154    fn unit_deps(&self, units: &[ReviewUnit], changed_ids: &[FileId]) -> Vec<FxHashSet<usize>> {
155        // FileId -> owning unit index, for resolving inter-unit edges.
156        let unit_of: FxHashMap<FileId, usize> = units
157            .iter()
158            .enumerate()
159            .flat_map(|(i, unit)| unit.files.iter().map(move |&f| (f, i)))
160            .collect();
161
162        let unit_count = units.len();
163        // `dep_count[c]` = number of distinct units `c` depends on (consumes from)
164        // that are still unemitted. A unit emerges ready once all its deps are
165        // emitted, so a pure definition (depends on nothing in the changed set)
166        // is ready first.
167        let mut deps: Vec<FxHashSet<usize>> = vec![FxHashSet::default(); unit_count];
168        for &id in changed_ids {
169            let Some(&consumer_unit) = unit_of.get(&id) else {
170                continue;
171            };
172            for dep_target in self.edges_for(id) {
173                let Some(&dep_unit) = unit_of.get(&dep_target) else {
174                    continue;
175                };
176                if dep_unit != consumer_unit {
177                    deps[consumer_unit].insert(dep_unit);
178                }
179            }
180        }
181        deps
182    }
183
184    /// Resolve a partition + order's `FileId`s to root-relative, forward-slashed
185    /// paths, sorted for deterministic output. Files whose module is missing are
186    /// dropped; a unit left empty after that drop is omitted. The `module_dir`
187    /// keys (and the `order` entries, which are `module_dir` strings) are
188    /// root-relativized too so the whole shape is root-relative.
189    #[must_use]
190    pub fn partition_order_with_paths(
191        &self,
192        partition: &PartitionOrder,
193        root: &Path,
194    ) -> PartitionOrderPaths {
195        let resolve = |id: FileId| -> Option<String> {
196            self.modules
197                .get(id.0 as usize)
198                .map(|m| relativize(&m.path, root))
199        };
200
201        let units: Vec<ReviewUnitPaths> = partition
202            .units
203            .iter()
204            .filter_map(|unit| {
205                let mut files: Vec<String> =
206                    unit.files.iter().filter_map(|&id| resolve(id)).collect();
207                if files.is_empty() {
208                    return None;
209                }
210                files.sort();
211                Some(ReviewUnitPaths {
212                    module_dir: relativize_dir(&unit.module_dir, root),
213                    files,
214                })
215            })
216            .collect();
217
218        let order: Vec<String> = partition
219            .order
220            .iter()
221            .map(|dir| relativize_dir(dir, root))
222            .collect();
223        let mut independent_slices: Vec<Vec<String>> = partition
224            .independent_slices
225            .iter()
226            .map(|slice| {
227                let mut dirs: Vec<String> =
228                    slice.iter().map(|dir| relativize_dir(dir, root)).collect();
229                dirs.sort();
230                dirs
231            })
232            .collect();
233        independent_slices.sort();
234
235        PartitionOrderPaths {
236            units,
237            order,
238            independent_slices,
239        }
240    }
241}
242
243/// Connected components over the UNDIRECTED inter-unit dependency graph. A
244/// component is a set of units reachable from each other through import edges
245/// in either direction; units in different components never touch. Each slice
246/// is `module_dir`-sorted and the slices are sorted by first entry, so the
247/// output is a pure function of `(units, deps)`.
248fn independent_slices(units: &[ReviewUnit], deps: &[FxHashSet<usize>]) -> Vec<Vec<String>> {
249    let unit_count = units.len();
250    let mut adjacency: Vec<Vec<usize>> = vec![Vec::new(); unit_count];
251    for (consumer, targets) in deps.iter().enumerate() {
252        for &target in targets {
253            adjacency[consumer].push(target);
254            adjacency[target].push(consumer);
255        }
256    }
257
258    let mut component_of: Vec<Option<usize>> = vec![None; unit_count];
259    let mut slices: Vec<Vec<String>> = Vec::new();
260    for start in 0..unit_count {
261        if component_of[start].is_some() {
262            continue;
263        }
264        let component = slices.len();
265        let mut stack = vec![start];
266        let mut members: Vec<String> = Vec::new();
267        while let Some(idx) = stack.pop() {
268            if component_of[idx].is_some() {
269                continue;
270            }
271            component_of[idx] = Some(component);
272            members.push(units[idx].module_dir.clone());
273            stack.extend(adjacency[idx].iter().copied());
274        }
275        members.sort();
276        slices.push(members);
277    }
278    slices.sort();
279    slices
280}
281
282/// Deterministic Kahn topological sort: emit a unit only once every unit it
283/// depends on (consumes from) has been emitted, so definitions precede
284/// consumers. The ready set is resolved by a min-pick over the `module_dir`
285/// strings, so ties (independent units) and any cycle break resolve by the path
286/// sort. A residual cycle's units are appended in `module_dir`-sorted order.
287fn kahn_min_pick(units: &[ReviewUnit], deps: &[FxHashSet<usize>]) -> Vec<String> {
288    let unit_count = units.len();
289    let mut remaining: FxHashSet<usize> = (0..unit_count).collect();
290    let mut emitted: FxHashSet<usize> = FxHashSet::default();
291    let mut order: Vec<String> = Vec::with_capacity(unit_count);
292
293    while !remaining.is_empty() {
294        // Find the lexicographically smallest module_dir among ready units (all
295        // deps emitted). Iterating `remaining` (an FxHashSet) is fine: we pick
296        // the min by module_dir, not by iteration order.
297        let mut ready: Option<usize> = None;
298        for &idx in &remaining {
299            let all_deps_emitted = deps[idx].iter().all(|d| emitted.contains(d));
300            if !all_deps_emitted {
301                continue;
302            }
303            ready = Some(match ready {
304                Some(cur) if units[cur].module_dir <= units[idx].module_dir => cur,
305                _ => idx,
306            });
307        }
308
309        match ready {
310            Some(idx) => {
311                order.push(units[idx].module_dir.clone());
312                emitted.insert(idx);
313                remaining.remove(&idx);
314            }
315            None => {
316                // Cycle: no ready unit but units remain. Append the rest in
317                // module_dir-sorted order (deterministic fallback).
318                let mut rest: Vec<usize> = remaining.iter().copied().collect();
319                rest.sort_by(|&a, &b| units[a].module_dir.cmp(&units[b].module_dir));
320                for idx in rest {
321                    order.push(units[idx].module_dir.clone());
322                }
323                break;
324            }
325        }
326    }
327
328    order
329}
330
331/// The root-relative parent-directory key for a module path. The repository-root
332/// file (no parent component) maps to the empty string (the root group).
333fn module_dir_key(path: &Path) -> String {
334    path.parent()
335        .map(|p| p.to_string_lossy().replace('\\', "/"))
336        .unwrap_or_default()
337}
338
339/// Root-relativize a `module_dir` key (a forward-slashed directory string).
340/// The empty root-group key stays empty; otherwise the `root` prefix is stripped
341/// via the same `Path`-based logic so the output matches the file path-space.
342fn relativize_dir(dir: &str, root: &Path) -> String {
343    if dir.is_empty() {
344        return String::new();
345    }
346    relativize(Path::new(dir), root)
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
353    use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
354    use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
355    use std::path::PathBuf;
356
357    fn file(id: u32, path: &str) -> DiscoveredFile {
358        DiscoveredFile {
359            id: FileId(id),
360            path: PathBuf::from(path),
361            size_bytes: 10,
362        }
363    }
364
365    fn named_import(source: &str, name: &str, target: FileId) -> ResolvedImport {
366        ResolvedImport {
367            info: ImportInfo {
368                source: source.to_string(),
369                imported_name: ImportedName::Named(name.to_string()),
370                local_name: name.to_string(),
371                is_type_only: false,
372                is_type_only_star: false,
373                from_style: false,
374                span: oxc_span::Span::new(0, 10),
375                source_span: oxc_span::Span::default(),
376            },
377            target: ResolveResult::InternalModule(target),
378        }
379    }
380
381    fn named_export(name: &str) -> ExportInfo {
382        ExportInfo {
383            name: ExportName::Named(name.to_string()),
384            local_name: Some(name.to_string()),
385            is_type_only: false,
386            visibility: VisibilityTag::None,
387            expected_unused_reason: None,
388            span: oxc_span::Span::new(0, 20),
389            members: vec![],
390            is_side_effect_used: false,
391            super_class: None,
392            deprecated: false,
393            deprecated_reason: None,
394        }
395    }
396
397    /// Three directories: `core/` defines, `mid/` consumes core, `app/` consumes
398    /// mid. Files: core/a.ts, core/b.ts, mid/m.ts, app/x.ts. entry is app/x.ts.
399    fn build_three_dir_graph() -> ModuleGraph {
400        let files = vec![
401            file(0, "/p/src/app/x.ts"),
402            file(1, "/p/src/core/a.ts"),
403            file(2, "/p/src/core/b.ts"),
404            file(3, "/p/src/mid/m.ts"),
405        ];
406        let entry_points = vec![EntryPoint {
407            path: PathBuf::from("/p/src/app/x.ts"),
408            source: EntryPointSource::PackageJsonMain,
409        }];
410        let resolved = vec![
411            ResolvedModule {
412                file_id: FileId(0),
413                path: PathBuf::from("/p/src/app/x.ts"),
414                resolved_imports: vec![named_import("../mid/m", "midFn", FileId(3))],
415                ..Default::default()
416            },
417            ResolvedModule {
418                file_id: FileId(1),
419                path: PathBuf::from("/p/src/core/a.ts"),
420                exports: vec![named_export("alpha")].into(),
421                ..Default::default()
422            },
423            ResolvedModule {
424                file_id: FileId(2),
425                path: PathBuf::from("/p/src/core/b.ts"),
426                exports: vec![named_export("beta")].into(),
427                ..Default::default()
428            },
429            ResolvedModule {
430                file_id: FileId(3),
431                path: PathBuf::from("/p/src/mid/m.ts"),
432                resolved_imports: vec![named_import("../core/a", "alpha", FileId(1))],
433                exports: vec![named_export("midFn")].into(),
434                ..Default::default()
435            },
436        ];
437        ModuleGraph::build(&resolved, &entry_points, &files)
438    }
439
440    #[test]
441    fn partition_groups_changed_files_by_module_directory() {
442        let graph = build_three_dir_graph();
443        // Change all four files.
444        let partition = graph.partition_order(&[FileId(0), FileId(1), FileId(2), FileId(3)]);
445        let paths = graph.partition_order_with_paths(&partition, Path::new("/p"));
446        // Three units, sorted by module_dir.
447        let dirs: Vec<&str> = paths.units.iter().map(|u| u.module_dir.as_str()).collect();
448        assert_eq!(dirs, vec!["src/app", "src/core", "src/mid"]);
449        // core/ groups its two files, path-sorted.
450        let core = paths
451            .units
452            .iter()
453            .find(|u| u.module_dir == "src/core")
454            .expect("core unit");
455        assert_eq!(core.files, vec!["src/core/a.ts", "src/core/b.ts"]);
456    }
457
458    #[test]
459    fn order_places_definitions_before_consumers() {
460        let graph = build_three_dir_graph();
461        // app consumes mid consumes core, so order = core, mid, app.
462        let partition = graph.partition_order(&[FileId(0), FileId(1), FileId(2), FileId(3)]);
463        assert_eq!(
464            partition.order,
465            vec![
466                "/p/src/core".to_string(),
467                "/p/src/mid".to_string(),
468                "/p/src/app".to_string(),
469            ]
470        );
471    }
472
473    #[test]
474    fn independent_units_order_by_path_sort() {
475        // Two unrelated directories (no inter-unit edge): order is the path sort.
476        let files = vec![file(0, "/p/src/billing/b.ts"), file(1, "/p/src/auth/a.ts")];
477        let entry_points = vec![EntryPoint {
478            path: PathBuf::from("/p/src/auth/a.ts"),
479            source: EntryPointSource::PackageJsonMain,
480        }];
481        let resolved = vec![
482            ResolvedModule {
483                file_id: FileId(0),
484                path: PathBuf::from("/p/src/billing/b.ts"),
485                ..Default::default()
486            },
487            ResolvedModule {
488                file_id: FileId(1),
489                path: PathBuf::from("/p/src/auth/a.ts"),
490                ..Default::default()
491            },
492        ];
493        let graph = ModuleGraph::build(&resolved, &entry_points, &files);
494        let partition = graph.partition_order(&[FileId(0), FileId(1)]);
495        assert_eq!(
496            partition.order,
497            vec!["/p/src/auth".to_string(), "/p/src/billing".to_string()]
498        );
499        assert_eq!(
500            partition.independent_slices,
501            vec![
502                vec!["/p/src/auth".to_string()],
503                vec!["/p/src/billing".to_string()]
504            ],
505            "no inter-unit edge: each unit is its own slice"
506        );
507    }
508
509    #[test]
510    fn connected_units_collapse_into_one_slice() {
511        // core <- mid <- app is one connected component whichever direction the
512        // edges point, so a chain never splits.
513        let graph = build_three_dir_graph();
514        let partition = graph.partition_order(&[FileId(0), FileId(1), FileId(2), FileId(3)]);
515        assert_eq!(
516            partition.independent_slices,
517            vec![vec![
518                "/p/src/app".to_string(),
519                "/p/src/core".to_string(),
520                "/p/src/mid".to_string()
521            ]]
522        );
523        let paths = graph.partition_order_with_paths(&partition, Path::new("/p"));
524        assert_eq!(
525            paths.independent_slices,
526            vec![vec![
527                "src/app".to_string(),
528                "src/core".to_string(),
529                "src/mid".to_string()
530            ]]
531        );
532    }
533
534    #[test]
535    fn partition_order_is_byte_identical_across_runs() {
536        let graph = build_three_dir_graph();
537        let changed = [FileId(0), FileId(1), FileId(2), FileId(3)];
538        let first = graph.partition_order(&changed);
539        let second = graph.partition_order(&changed);
540        // FileId-keyed shape is structurally identical.
541        assert_eq!(first, second);
542        // Path-resolved shape is byte-identical when debug-rendered (a proxy for
543        // serialization; the audit_brief layer serializes the same data).
544        let p1 = graph.partition_order_with_paths(&first, Path::new("/p"));
545        let p2 = graph.partition_order_with_paths(&second, Path::new("/p"));
546        assert_eq!(format!("{p1:?}"), format!("{p2:?}"));
547    }
548
549    #[test]
550    fn changed_set_order_does_not_affect_result() {
551        // Feeding the changed ids in a different input order yields the same
552        // partition + order (the engine sorts internally).
553        let graph = build_three_dir_graph();
554        let a = graph.partition_order(&[FileId(3), FileId(0), FileId(2), FileId(1)]);
555        let b = graph.partition_order(&[FileId(0), FileId(1), FileId(2), FileId(3)]);
556        assert_eq!(a, b);
557    }
558
559    #[test]
560    fn root_file_clusters_under_root_group() {
561        let files = vec![file(0, "index.ts")];
562        let entry_points = vec![EntryPoint {
563            path: PathBuf::from("index.ts"),
564            source: EntryPointSource::PackageJsonMain,
565        }];
566        let resolved = vec![ResolvedModule {
567            file_id: FileId(0),
568            path: PathBuf::from("index.ts"),
569            ..Default::default()
570        }];
571        let graph = ModuleGraph::build(&resolved, &entry_points, &files);
572        let partition = graph.partition_order(&[FileId(0)]);
573        assert_eq!(partition.units.len(), 1);
574        assert_eq!(partition.units[0].module_dir, "");
575    }
576
577    #[test]
578    fn empty_changed_set_yields_empty_partition() {
579        let graph = build_three_dir_graph();
580        let partition = graph.partition_order(&[]);
581        assert!(partition.units.is_empty());
582        assert!(partition.order.is_empty());
583    }
584
585    #[test]
586    fn scale_300_file_multi_module_graph_is_stable() {
587        // 30 directories x 10 files = 300 files. Each dir's first file imports the
588        // previous dir's first file (a chain across modules), so the order is a
589        // real topological sequence, not just the path sort.
590        const DIRS: u32 = 30;
591        const PER_DIR: u32 = 10;
592        let mut files = Vec::new();
593        let mut resolved = Vec::new();
594        for d in 0..DIRS {
595            for f in 0..PER_DIR {
596                let id = d * PER_DIR + f;
597                let path = format!("/p/src/mod{d:02}/file{f:02}.ts");
598                files.push(file(id, &path));
599            }
600        }
601        for d in 0..DIRS {
602            for f in 0..PER_DIR {
603                let id = d * PER_DIR + f;
604                let mut module = ResolvedModule {
605                    file_id: FileId(id),
606                    path: PathBuf::from(format!("/p/src/mod{d:02}/file{f:02}.ts")),
607                    exports: vec![named_export(&format!("e{id}"))].into(),
608                    ..Default::default()
609                };
610                // The first file of each dir (except dir 0) imports the first file
611                // of the previous dir: mod01 consumes mod00, mod02 consumes mod01.
612                if f == 0 && d > 0 {
613                    let dep = (d - 1) * PER_DIR;
614                    module.resolved_imports =
615                        vec![named_import("../prev", &format!("e{dep}"), FileId(dep))];
616                }
617                resolved.push(module);
618            }
619        }
620        let entry_points = vec![EntryPoint {
621            path: PathBuf::from("/p/src/mod00/file00.ts"),
622            source: EntryPointSource::PackageJsonMain,
623        }];
624        let graph = ModuleGraph::build(&resolved, &entry_points, &files);
625
626        let changed: Vec<FileId> = (0..DIRS * PER_DIR).map(FileId).collect();
627        let first = graph.partition_order(&changed);
628        let second = graph.partition_order(&changed);
629        assert_eq!(first, second, "300-file partition must be stable");
630        assert_eq!(first.units.len(), DIRS as usize, "one unit per directory");
631        // The dependency chain forces mod00 before mod01 before ... before mod29.
632        let expected: Vec<String> = (0..DIRS).map(|d| format!("/p/src/mod{d:02}")).collect();
633        assert_eq!(
634            first.order, expected,
635            "definitions precede consumers at scale"
636        );
637        // Path-resolved serialization proxy is byte-identical across runs.
638        let p1 = graph.partition_order_with_paths(&first, Path::new("/p"));
639        let p2 = graph.partition_order_with_paths(&second, Path::new("/p"));
640        assert_eq!(format!("{p1:?}"), format!("{p2:?}"));
641    }
642}