Skip to main content

differential_engine/
ordering.rs

1//! The ordering stage: foundation-first arrangement of the focus section.
2//!
3//! The measured failure this fixes: the group introducing the abstraction
4//! everything else consumes landed 9th of 13 in model order, so the reviewer
5//! met consumers before the thing they consume.
6//!
7//! It does not compute the dependency graph. `artefact::graph` builds that
8//! from classes, before the model runs, and this stage contracts it onto
9//! groups. That split matters: the stage used to union symbols across a whole
10//! group before computing a single edge, which threw away every distinction
11//! inside a group and let a cycle appear where the classes had none.
12//!
13//! Deterministic and model-free: runs unconditionally after grouping.
14
15use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
16
17use petgraph::algo::is_cyclic_directed;
18use petgraph::graph::{DiGraph, NodeIndex};
19
20use crate::schema;
21
22/// Reorder the focus section foundation-first, fill `depends_on`, `role` and
23/// `pivot`, order each group's classes, rewrite `rank`, regroup the reading
24/// plan, and append the `order` stage.
25pub fn apply(doc: &mut schema::PlanDocument) {
26    let Some(groups) = doc.groups.take() else {
27        return;
28    };
29
30    let class_index: HashMap<&str, usize> = doc
31        .classes
32        .iter()
33        .enumerate()
34        .map(|(i, c)| (c.id.as_str(), i))
35        .collect();
36    let n_classes = doc.classes.len();
37
38    // Which group owns each class. Noise groups are excluded from the graph
39    // entirely, exactly as they were before: generated content orders nothing.
40    let mut group_of_class: Vec<Option<usize>> = vec![None; n_classes];
41    for (gi, g) in groups.iter().enumerate() {
42        if g.effort == schema::Effort::Noise {
43            continue;
44        }
45        for cid in &g.class_ids {
46            if let Some(&ci) = class_index.get(cid.as_str()) {
47                group_of_class[ci] = Some(gi);
48            }
49        }
50    }
51
52    // --- the class edges, kept whole and contracted onto groups -------------
53    // `class_deps` keeps the intra-group edges the group graph cannot express.
54    // They are what orders the classes inside a group, and what tells a group
55    // cycle apart from a real one.
56    let mut class_deps: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); n_classes];
57    let mut group_edges: Vec<BTreeMap<usize, BTreeSet<&str>>> = vec![BTreeMap::new(); groups.len()];
58    for (ci, c) in doc.classes.iter().enumerate() {
59        let Some(gi) = group_of_class[ci] else {
60            continue;
61        };
62        for e in &c.depends_on {
63            let Some(&target) = class_index.get(e.on.as_str()) else {
64                continue;
65            };
66            let Some(gj) = group_of_class[target] else {
67                continue;
68            };
69            class_deps[ci].insert(target);
70            if gj != gi {
71                group_edges[gi]
72                    .entry(gj)
73                    .or_default()
74                    .extend(e.via.iter().map(String::as_str));
75            }
76        }
77    }
78    let deps: Vec<HashSet<usize>> = group_edges
79        .iter()
80        .map(|m| m.keys().copied().collect())
81        .collect();
82
83    // --- classes inside each group, foundation-first ------------------------
84    let class_order: Vec<Vec<String>> = groups
85        .iter()
86        .map(|g| order_classes(g, &class_index, &class_deps, doc))
87        .collect();
88
89    // --- foundation-first reorder of the contiguous focus prefix -------------
90    // The audit back-fill group is always assembled last and must stay trailing
91    // (untriaged classes are read at the end); when every group is focus it
92    // would otherwise fall inside the prefix.
93    let mut focus_len = groups
94        .iter()
95        .position(|g| g.effort != schema::Effort::Focus)
96        .unwrap_or(groups.len());
97    if focus_len == groups.len() && doc.audit.classes_missing.unwrap_or(0) > 0 {
98        focus_len -= 1;
99    }
100    let classes_of = |gi: usize| -> Vec<usize> {
101        groups[gi]
102            .class_ids
103            .iter()
104            .filter_map(|c| class_index.get(c.as_str()).copied())
105            .collect()
106    };
107    let hunk_count = |gi: usize| -> usize {
108        classes_of(gi)
109            .iter()
110            .map(|&ci| doc.classes[ci].hunk_ids.len())
111            .sum()
112    };
113    let sorted = toposort_prefix(focus_len, &deps, &hunk_count, &|remaining| {
114        break_cycle(remaining, &classes_of, &class_deps)
115    });
116
117    let mut new_order: Vec<usize> = sorted.order;
118    new_order.extend(focus_len..groups.len());
119    let rank_of: HashMap<usize, usize> = new_order
120        .iter()
121        .enumerate()
122        .map(|(rank, &gi)| (gi, rank))
123        .collect();
124
125    // --- roles ------------------------------------------------------------------
126    let depended_on: HashSet<usize> = deps.iter().flatten().copied().collect();
127    let role_of = |gi: usize| -> Option<schema::Role> {
128        let g = &groups[gi];
129        match g.effort {
130            schema::Effort::Noise => g.role, // set by grouping
131            schema::Effort::Skim => Some(schema::Role::Mechanical),
132            schema::Effort::Focus => {
133                if depended_on.contains(&gi) {
134                    Some(schema::Role::Foundation)
135                } else if !deps[gi].is_empty() {
136                    Some(schema::Role::Consumer)
137                } else {
138                    None
139                }
140            }
141        }
142    };
143
144    // --- rebuild groups in the new order ----------------------------------------
145    let mut reordered: Vec<schema::Group> = Vec::with_capacity(groups.len());
146    for (rank, &gi) in new_order.iter().enumerate() {
147        let mut g = groups[gi].clone();
148        g.rank = rank as u32;
149        g.role = role_of(gi);
150        g.class_ids = class_order[gi].clone();
151        g.depends_on = group_edges[gi]
152            .iter()
153            .map(|(&target, via)| schema::Edge {
154                on: groups[target].id.clone(),
155                via: via.iter().map(|s| (*s).to_string()).collect(),
156                // Only an edge the sort could not honour carries a verdict, and
157                // only the sort knows which those were.
158                cycle: sorted.broken.get(&(gi, target)).copied(),
159            })
160            .collect();
161        g.pivot = sorted.broken.keys().any(|&(from, _)| from == gi).then(|| {
162            pivot(
163                &g.class_ids,
164                &class_index,
165                &class_deps,
166                &group_of_class,
167                &rank_of,
168                rank,
169            )
170        });
171        reordered.push(g);
172    }
173
174    // Reading plan: stable regroup of the existing steps by the new group order.
175    if let Some(plan) = doc.reading_plan.take() {
176        let mut by_group: HashMap<&str, Vec<schema::ReadingStep>> = HashMap::new();
177        for step in &plan {
178            by_group
179                .entry(step.group.as_str())
180                .or_default()
181                .push(step.clone());
182        }
183        doc.reading_plan = Some(
184            reordered
185                .iter()
186                .flat_map(|g| by_group.remove(g.id.as_str()).unwrap_or_default())
187                .collect(),
188        );
189    }
190
191    doc.groups = Some(reordered);
192    doc.generator.stages.push("order".to_string());
193}
194
195/// A group's classes, foundation-first. Ties break by descending member count,
196/// then original position — the same rule the group sort uses.
197///
198/// This is the information `def_gi != gi` used to discard. An edge between two
199/// classes of one group said nothing at group level, so it was dropped; here it
200/// is the only thing that can order them.
201fn order_classes(
202    group: &schema::Group,
203    class_index: &HashMap<&str, usize>,
204    class_deps: &[BTreeSet<usize>],
205    doc: &schema::PlanDocument,
206) -> Vec<String> {
207    let members: Vec<usize> = group
208        .class_ids
209        .iter()
210        .filter_map(|c| class_index.get(c.as_str()).copied())
211        .collect();
212    if members.len() < 2 {
213        return group.class_ids.clone();
214    }
215    let inside: HashSet<usize> = members.iter().copied().collect();
216    let deps: HashMap<usize, HashSet<usize>> = members
217        .iter()
218        .map(|&ci| {
219            (
220                ci,
221                class_deps[ci]
222                    .iter()
223                    .copied()
224                    .filter(|d| inside.contains(d))
225                    .collect(),
226            )
227        })
228        .collect();
229
230    // The same walk as `toposort_prefix`, for classes, and written out for
231    // the same reason: the tie-break is the content (see its note on petgraph).
232    let mut remaining = members.clone();
233    let mut emitted: HashSet<usize> = HashSet::new();
234    let mut out: Vec<String> = Vec::with_capacity(members.len());
235    while !remaining.is_empty() {
236        let ready: Vec<usize> = remaining
237            .iter()
238            .copied()
239            .filter(|ci| deps[ci].iter().all(|d| emitted.contains(d)))
240            .collect();
241        let pool = if ready.is_empty() { &remaining } else { &ready };
242        let chosen = *pool
243            .iter()
244            .max_by_key(|&&ci| {
245                (
246                    doc.classes[ci].hunk_ids.len(),
247                    usize::MAX - members.iter().position(|&m| m == ci).unwrap_or(0),
248                )
249            })
250            .expect("pool is non-empty");
251        remaining.retain(|&ci| ci != chosen);
252        emitted.insert(chosen);
253        out.push(doc.classes[chosen].id.clone());
254    }
255    out
256}
257
258/// How many leading classes depend on nothing ranked later.
259///
260/// The index where the group stops being a foundation and starts being a
261/// consumer. Nothing acts on it: the group is never split (ADR 0022).
262fn pivot(
263    class_ids: &[String],
264    class_index: &HashMap<&str, usize>,
265    class_deps: &[BTreeSet<usize>],
266    group_of_class: &[Option<usize>],
267    rank_of: &HashMap<usize, usize>,
268    own_rank: usize,
269) -> u32 {
270    let mut n = 0u32;
271    for cid in class_ids {
272        let Some(&ci) = class_index.get(cid.as_str()) else {
273            break;
274        };
275        let looks_later = class_deps[ci].iter().any(|&d| {
276            group_of_class[d]
277                .and_then(|g| rank_of.get(&g))
278                .is_some_and(|&r| r > own_rank)
279        });
280        if looks_later {
281            break;
282        }
283        n += 1;
284    }
285    n
286}
287
288/// Which group to emit when nothing is ready, and why the cycle exists.
289///
290/// The class graph is finer than the group graph, and contracting a directed
291/// acyclic graph can create cycles. So when the groups deadlock, ask the
292/// classes: if they are acyclic here, the deadlock is an artefact of grouping
293/// and their order is the right one to follow. If they deadlock too, the mutual
294/// dependency is in the change and the old size-based fallback is as good an
295/// answer as there is.
296fn break_cycle(
297    remaining: &[usize],
298    classes_of: &dyn Fn(usize) -> Vec<usize>,
299    class_deps: &[BTreeSet<usize>],
300) -> (Option<usize>, schema::Cycle) {
301    let mut owner: HashMap<usize, usize> = HashMap::new();
302    for &gi in remaining {
303        for ci in classes_of(gi) {
304            owner.insert(ci, gi);
305        }
306    }
307    let inside: HashSet<usize> = owner.keys().copied().collect();
308    let mut ids: Vec<usize> = inside.iter().copied().collect();
309    ids.sort_unstable();
310
311    // Do the classes deadlock too? This was a Kahn walk that rescanned every
312    // remaining class on every step to find out — O(n^2) to answer a yes/no
313    // question about a graph. `is_cyclic_directed` is the same answer: a Kahn
314    // walk runs out of ready nodes exactly when a cycle is left.
315    let mut graph = DiGraph::<(), ()>::new();
316    let nodes: HashMap<usize, NodeIndex> = ids.iter().map(|&ci| (ci, graph.add_node(()))).collect();
317    for &ci in &ids {
318        for d in &class_deps[ci] {
319            if let Some(&to) = nodes.get(d) {
320                graph.add_edge(nodes[&ci], to, ());
321            }
322        }
323    }
324    if is_cyclic_directed(&graph) {
325        // A real mutual dependency, in the change rather than in the grouping.
326        return (None, schema::Cycle::Mutual);
327    }
328
329    // Which group to emit: the owner of the lowest-numbered class nothing in
330    // play blocks. The walk assigned this on its FIRST step and never again,
331    // so every later step only ever decided the verdict above. Ties by class
332    // index, which is descending member count already.
333    let first = ids
334        .iter()
335        .copied()
336        .find(|&ci| class_deps[ci].iter().all(|d| !inside.contains(d)));
337    (first.map(|ci| owner[&ci]), schema::Cycle::Artefact)
338}
339
340/// What to do when the group sort deadlocks: which group to emit first, and why
341/// the cycle exists at all.
342type CycleBreaker<'a> = dyn Fn(&[usize]) -> (Option<usize>, schema::Cycle) + 'a;
343
344struct Sorted {
345    order: Vec<usize>,
346    /// Edges the sort could not honour, and why. Keyed `(from, to)` by group
347    /// position in the pre-sort order.
348    broken: HashMap<(usize, usize), schema::Cycle>,
349}
350
351/// Kahn's algorithm over the focus prefix. Ready-node tie-break: descending
352/// hunk count, then original position (stable). On a deadlock, `resolve` picks
353/// the node and says why the cycle exists; the edges that node could not
354/// honour are recorded with that verdict.
355///
356/// Written out rather than `petgraph::algo::toposort` (design rule 5): that
357/// one has no tie-break for which ready node goes first, and no hook to break
358/// a cycle with a reason — it stops at the first one. Both are the ordering's
359/// whole content, so the walk is ours and the graph questions around it are
360/// petgraph's (`break_cycle` uses `is_cyclic_directed`).
361fn toposort_prefix(
362    len: usize,
363    deps: &[HashSet<usize>],
364    hunk_count: &dyn Fn(usize) -> usize,
365    resolve: &CycleBreaker<'_>,
366) -> Sorted {
367    let mut remaining: Vec<usize> = (0..len).collect();
368    let mut emitted: HashSet<usize> = HashSet::new();
369    let mut out = Vec::with_capacity(len);
370    let mut broken: HashMap<(usize, usize), schema::Cycle> = HashMap::new();
371
372    while !remaining.is_empty() {
373        let ready: Vec<usize> = remaining
374            .iter()
375            .copied()
376            .filter(|&gi| deps[gi].iter().all(|d| *d >= len || emitted.contains(d)))
377            .collect();
378        let chosen = if let Some(&gi) = ready
379            .iter()
380            .max_by_key(|&&gi| (hunk_count(gi), usize::MAX - gi))
381        {
382            gi
383        } else {
384            // No ready node means a cycle. The class graph decides which group
385            // goes first and what kind of cycle this is; a size-based pick is
386            // the fallback when even the classes deadlock.
387            let (pick, why) = resolve(&remaining);
388            let gi = pick
389                .filter(|gi| remaining.contains(gi))
390                .or_else(|| {
391                    remaining
392                        .iter()
393                        .copied()
394                        .max_by_key(|&gi| (hunk_count(gi), usize::MAX - gi))
395                })
396                .expect("remaining is non-empty");
397            for &d in &deps[gi] {
398                if d < len && !emitted.contains(&d) {
399                    broken.insert((gi, d), why);
400                }
401            }
402            gi
403        };
404
405        remaining.retain(|&gi| gi != chosen);
406        emitted.insert(chosen);
407        out.push(chosen);
408    }
409    Sorted { order: out, broken }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    fn set(v: &[usize]) -> HashSet<usize> {
417        v.iter().copied().collect()
418    }
419
420    /// The classes deadlock too, so every test that is only about the group
421    /// sort gets the old size-based fallback.
422    fn mutual(_: &[usize]) -> (Option<usize>, schema::Cycle) {
423        (None, schema::Cycle::Mutual)
424    }
425
426    #[test]
427    fn foundation_precedes_consumer() {
428        // 1 depends on 0; equal sizes → 0 first regardless of position.
429        let deps = vec![set(&[]), set(&[0])];
430        let counts = [5usize, 5];
431        let s = toposort_prefix(2, &deps, &|i| counts[i], &mutual);
432        assert_eq!(s.order, vec![0, 1]);
433        assert!(s.broken.is_empty());
434
435        let deps = vec![set(&[1]), set(&[])];
436        let s = toposort_prefix(2, &deps, &|i| counts[i], &mutual);
437        assert_eq!(s.order, vec![1, 0]);
438    }
439
440    #[test]
441    fn ties_break_by_descending_hunk_count_then_position() {
442        let deps = vec![set(&[]), set(&[]), set(&[])];
443        let counts = [3usize, 9, 9];
444        let s = toposort_prefix(3, &deps, &|i| counts[i], &mutual);
445        assert_eq!(s.order, vec![1, 2, 0]);
446    }
447
448    #[test]
449    fn edges_outside_the_prefix_do_not_block() {
450        // Group 0 depends on group 3 (a skim group outside the focus prefix).
451        let deps = vec![set(&[3]), set(&[]), set(&[]), set(&[])];
452        let counts = [4usize, 2, 1, 9];
453        let s = toposort_prefix(3, &deps, &|i| counts[i], &mutual);
454        assert_eq!(s.order, vec![0, 1, 2]);
455        assert!(s.broken.is_empty(), "an edge it never had to honour");
456    }
457
458    #[test]
459    fn a_mutual_cycle_falls_back_to_size_and_says_so() {
460        // 0 <-> 1 cycle plus independent 2.
461        let deps = vec![set(&[1]), set(&[0]), set(&[])];
462        let counts = [2usize, 8, 1];
463        let s = toposort_prefix(3, &deps, &|i| counts[i], &mutual);
464        assert_eq!(s.order[0], 2, "the only ready node goes first");
465        assert_eq!(s.order[1], 1, "cycle broken on the larger node");
466        assert_eq!(s.order[2], 0);
467        assert_eq!(s.broken.get(&(1, 0)), Some(&schema::Cycle::Mutual));
468        assert_eq!(s.broken.len(), 1, "only the edge it could not honour");
469    }
470
471    #[test]
472    fn an_artefact_cycle_follows_the_classes_instead_of_size() {
473        // The classes say group 0 first; size says group 1. The classes win,
474        // which is the whole point: contracting them is what made the cycle.
475        let deps = vec![set(&[1]), set(&[0])];
476        let counts = [2usize, 8];
477        let s = toposort_prefix(2, &deps, &|i| counts[i], &|_| {
478            (Some(0), schema::Cycle::Artefact)
479        });
480        assert_eq!(s.order, vec![0, 1]);
481        assert_eq!(s.broken.get(&(0, 1)), Some(&schema::Cycle::Artefact));
482    }
483
484    #[test]
485    fn a_pick_outside_the_remaining_set_is_ignored() {
486        // Defensive: the verdict is still used, the pick is not.
487        let deps = vec![set(&[1]), set(&[0])];
488        let counts = [2usize, 8];
489        let s = toposort_prefix(2, &deps, &|i| counts[i], &|_| {
490            (Some(99), schema::Cycle::Artefact)
491        });
492        assert_eq!(s.order, vec![1, 0], "falls back to size");
493    }
494
495    #[test]
496    fn classes_deadlocking_is_a_mutual_cycle() {
497        // c0 and c1 need each other; they sit in groups 0 and 1.
498        let class_deps = vec![set2(&[1]), set2(&[0])];
499        let classes_of = |gi: usize| vec![gi];
500        let (pick, why) = break_cycle(&[0, 1], &classes_of, &class_deps);
501        assert_eq!(why, schema::Cycle::Mutual);
502        assert!(pick.is_none(), "no honest order to offer");
503    }
504
505    #[test]
506    fn acyclic_classes_name_the_group_to_read_first() {
507        // c0 defines, c1 uses it. Group 1 owns c0, group 0 owns c1: the group
508        // graph is a cycle only because group 0 also holds c2, which c0 uses.
509        let class_deps = vec![set2(&[]), set2(&[0]), set2(&[])];
510        let classes_of = |gi: usize| if gi == 0 { vec![1, 2] } else { vec![0] };
511        let (pick, why) = break_cycle(&[0, 1], &classes_of, &class_deps);
512        assert_eq!(why, schema::Cycle::Artefact);
513        assert_eq!(pick, Some(1), "the group holding the earliest class");
514    }
515
516    fn set2(v: &[usize]) -> BTreeSet<usize> {
517        v.iter().copied().collect()
518    }
519}