1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
16
17use petgraph::algo::is_cyclic_directed;
18use petgraph::graph::{DiGraph, NodeIndex};
19
20use crate::schema;
21
22pub 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 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 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 let class_order: Vec<Vec<String>> = groups
85 .iter()
86 .map(|g| order_classes(g, &class_index, &class_deps, doc))
87 .collect();
88
89 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 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, 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 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 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 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
195fn 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 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
258fn 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
288fn 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 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 return (None, schema::Cycle::Mutual);
327 }
328
329 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
340type CycleBreaker<'a> = dyn Fn(&[usize]) -> (Option<usize>, schema::Cycle) + 'a;
343
344struct Sorted {
345 order: Vec<usize>,
346 broken: HashMap<(usize, usize), schema::Cycle>,
349}
350
351fn 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 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 fn mutual(_: &[usize]) -> (Option<usize>, schema::Cycle) {
423 (None, schema::Cycle::Mutual)
424 }
425
426 #[test]
427 fn foundation_precedes_consumer() {
428 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 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 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 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 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 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 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}