Skip to main content

cpm_planner/
review.rs

1//! `plan.review`: deterministic lint, one batched Jev judgment call, and
2//! mechanically verified proposals (P6, #26).
3//!
4//! [`review`] runs, in order:
5//!
6//! 1. **Request checks.** `max_questions` must be in `1..=`[`MAX_QUESTIONS`]
7//!    (else [`PlannerError::InvalidGraph`]); `capacities` is checked by the
8//!    resource scheduler when the base plan is simulated.
9//! 2. **Lint** ([`crate::lint::lint`]), always reported.
10//! 3. **Lint short-circuit.** Any lint `error` finding returns
11//!    `invalid_graph` whatever the judge (configured, missing or
12//!    misconfigured); the judge is never called.
13//! 4. **Judge availability.** [`Judge::NotConfigured`] returns
14//!    `review_unavailable` with [`NO_KEY_REASON`]; [`Judge::ConfigError`]
15//!    returns `review_unavailable` with the caller's reason (cut to
16//!    [`MAX_ERROR_MESSAGE_CHARS`]). No questions are built.
17//! 5. **Base simulation** ([`simulate`], leveled when `capacities` is
18//!    given). Its makespan is the "before" of every proposal.
19//! 6. **Candidate generation** (deterministic, see below), then
20//!    **truncation** to the question cap, round-robin across kinds.
21//! 7. **One** [`JudgmentModel::decide`] call with every selected question
22//!    and one shared state. No candidates means no call. A judge failure
23//!    returns `review_unavailable` with `"<class>: <safe message>"` and
24//!    `failure_class` set to the class.
25//! 8. **Findings** from the answers, **proposals** from the confident ones,
26//!    each **verified** by [`apply_edits`] + [`simulate`] and dropped unless
27//!    it lints clean and saves time; then **ranking**.
28//!
29//! # Candidates
30//!
31//! `L` is a deliverable's scheduled length from [`compute_cpm`]; a
32//! "critical" deliverable has zero float.
33//!
34//! | kind | question | who is asked about | rank within kind |
35//! |---|---|---|---|
36//! | `crash_option` | Choice `add_capacity` / `fast_track` / `reduce_scope` / `none` | critical, non-milestone deliverables with `L > 0` | `L` desc, id |
37//! | `false_dependency` | Noul | every prerequisite edge | both ends critical, then one end, then neither; then the prerequisite's `L` desc; then ids |
38//! | `interface_split` | Noul | edges whose ends are both critical and whose kind is not `interface` | prerequisite's `L` desc, ids |
39//! | `split_candidate` | Score, [`SPLIT_LEVELS`] (5 levels) | non-milestones with `L >= `[`SPLIT_MIN_HOURS`] or `L >= `[`SPLIT_MEDIAN_FACTOR`]` × median L` (median over non-milestones with `L > 0`) | `L` desc, id |
40//! | `missing_dependency` | Noul | unordered pairs (neither reaches the other) with a heuristic score `> 0`, top [`MISSING_DEPENDENCY_TOP_K`] | score desc, ids |
41//!
42//! The missing-dependency score adds [`MENTION_WEIGHT`] when either
43//! deliverable's `metadata.description` contains the other's id as a token
44//! (whitespace-separated, outer punctuation trimmed); [`SHARED_DIR_WEIGHT`]
45//! when they own files in the same directory, or [`NESTED_DIR_WEIGHT`] when
46//! one file's directory is a proper ancestor of the other's (compared
47//! component-wise, so `src/ap` is not a prefix of `src/api`; top-level
48//! files and directories deeper than [`MAX_DIR_DEPTH`] components carry no
49//! signal); and [`SHARED_OWNER_WEIGHT`] when their `metadata.owner` strings
50//! are equal. An owner or same-directory group of more than
51//! [`MAX_SIGNAL_GROUP`] deliverables carries no signal, nor does an ancestor
52//! directory whose subtree (its own entries plus all descendant
53//! directories') has more than [`MAX_SIGNAL_GROUP`] entries. Directory and
54//! owner pairs are each generated in sorted order and stop at
55//! [`MAX_CANDIDATE_PAIRS`], so the work is bounded on any graph.
56//!
57//! The selected lists are interleaved round-robin in the table's order
58//! (crash, false dependency, interface split, split, missing dependency)
59//! until the cap is reached, so truncation keeps every kind represented;
60//! `truncated` is true when candidates were left out. Question ids are
61//! `q00`, `q01`, … in selection order; each question's `instructions` is
62//! `{ kind, ids, question }`. The shared state lists the plan makespan, the
63//! critical path, and every deliverable a question names (sorted by id),
64//! with free text cut to [`MAX_STATE_TEXT_CHARS`] chars.
65//!
66//! `prompt_hash` is the sha256 (lower-case hex) of the canonical request
67//! JSON `{ "questions": …, "state": … }` with object keys sorted at every
68//! level and no whitespace.
69//!
70//! # Findings
71//!
72//! A finding is reported when its probability is at least
73//! [`FINDING_MIN_PROBABILITY`]:
74//!
75//! - Noul kinds: the answer's `noul` (probability the statement is true).
76//! - `split_candidate`: the probability mass on levels
77//!   [`SPLIT_LIKELY_LEVEL`] and above.
78//! - `crash_option`: the probability of the chosen label, unless it is
79//!   `none`.
80//!
81//! Answers are validated defensively; each of these yields nothing (no
82//! finding, no proposal, nothing echoed): a missing answer, an answer of the
83//! wrong type, a non-finite or out-of-`[0, 1]` probability or confidence, a
84//! Choice whose `choice` or probability keys are not labels the question
85//! asked, and a Score outside `0..=4` or with probability keys other than
86//! `"0"`..`"4"`. The provider's model id is cut to [`MAX_MODEL_CHARS`]. Findings sort by kind, then
87//! ids.
88//!
89//! # Proposals
90//!
91//! - `false_dependency` with probability `>= `[`FALSE_DEPENDENCY_PROPOSAL_P`]
92//!   → `RemoveEdge`, cost `0`.
93//! - `crash_option` with answer confidence
94//!   `>= `[`CRASH_PROPOSAL_CONFIDENCE`]:
95//!   - `add_capacity` → `SetDuration` to [`CRASH_LENGTH_FACTOR`]` × L`,
96//!     effort unchanged (more hands shorten calendar time, not the work;
97//!     `duration_hours` replaces effort as the scheduled length in CPM,
98//!     leveling and simulate, while effort stays the cost basis). Cost
99//!     [`ADD_CAPACITY_COST_FACTOR`]` × L` hours of coordination overhead.
100//!   - `reduce_scope` → `SetEffort` to [`CRASH_LENGTH_FACTOR`]` × L`
101//!     (`SetDuration` instead when the deliverable has `duration_hours`,
102//!     since effort would not change its scheduled length). Cost `0`.
103//!   - `fast_track` and `none` propose nothing.
104//! - Splits, interface splits and missing dependencies are findings only:
105//!   the calling agent designs those edits.
106//!
107//! Each proposal is applied with [`apply_edits`] and simulated with the
108//! request's capacities. It is dropped when either fails, when the edited
109//! graph has a lint warning or error not present (same code and ids) in the
110//! base lint, or when `hours_saved` is below [`MIN_HOURS_SAVED`].
111//! `hours_saved = makespan(before) − makespan(after)`, using the leveled
112//! makespan when capacities are given and the CPM makespan otherwise.
113//! Edit hours, `hours_saved` and `cost` are rounded to `1 / `[`HOURS_SCALE`]
114//! hours, computed in f64. `score = hours_saved / max(cost, 1)`; proposals sort by score
115//! descending, then id.
116//!
117//! Output is a pure function of the graph, the request and the judge's
118//! answers, and never contains NaN or infinity.
119
120use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
121use std::path::Path;
122
123use serde::{Deserialize, Serialize};
124use serde_json::{Value, json};
125
126use crate::edits::{GraphEdit, apply_edits};
127use crate::lint::{LintReport, Severity, lint};
128use crate::llm::{Answer, JudgmentModel, MAX_ERROR_MESSAGE_CHARS, Question, Usage};
129use crate::plan::{Deliverable, PlanGraph, PlannerError, PrerequisiteKind};
130use crate::resource_schedule::ScheduleRequest;
131use crate::schedule::compute_cpm;
132use crate::simulate::{SimulateRequest, SimulationResult, simulate};
133use crate::task::Task;
134
135/// Most questions one review may send (and the default cap).
136pub const MAX_QUESTIONS: u16 = 64;
137/// `review_unavailable` reason when no OpenRouter key is configured.
138pub const NO_KEY_REASON: &str = "no OpenRouter key configured";
139/// Least probability for a finding to be reported.
140pub const FINDING_MIN_PROBABILITY: f32 = 0.5;
141/// Least false-dependency probability that proposes removing the edge.
142pub const FALSE_DEPENDENCY_PROPOSAL_P: f32 = 0.8;
143/// Least answer confidence for a crash option to be proposed.
144pub const CRASH_PROPOSAL_CONFIDENCE: f32 = 0.6;
145/// A crashed deliverable's new scheduled length, as a fraction of `L`.
146pub const CRASH_LENGTH_FACTOR: f32 = 0.7;
147/// Effort an `add_capacity` crash adds, as a fraction of `L`.
148pub const ADD_CAPACITY_COST_FACTOR: f32 = 0.3;
149/// A deliverable of at least this many hours is a split candidate.
150pub const SPLIT_MIN_HOURS: f32 = 8.0;
151/// A deliverable of at least this multiple of the median is a split candidate.
152pub const SPLIT_MEDIAN_FACTOR: f32 = 2.0;
153/// The split rubric, level 0 (no) to 4 (clearly).
154pub const SPLIT_LEVELS: [&str; 5] = [
155    "not splittable: one indivisible unit of work",
156    "splitting would gain little",
157    "could be split, with modest benefit",
158    "likely worth splitting into parts that can proceed in parallel",
159    "clearly should be split into parallel parts",
160];
161/// Split levels from this index up count toward the finding probability.
162pub const SPLIT_LIKELY_LEVEL: usize = 3;
163/// Most missing-dependency pairs asked about.
164pub const MISSING_DEPENDENCY_TOP_K: usize = 16;
165/// Missing-dependency score for a description mentioning the other id.
166pub const MENTION_WEIGHT: u32 = 3;
167/// Missing-dependency score for owning files in the same directory.
168pub const SHARED_DIR_WEIGHT: u32 = 2;
169/// Missing-dependency score for owning files where one's directory is a
170/// proper ancestor of the other's.
171pub const NESTED_DIR_WEIGHT: u32 = 1;
172/// Most directory-signal (and, separately, owner-signal) pairs generated
173/// before scoring.
174pub const MAX_CANDIDATE_PAIRS: usize = 10_000;
175/// Directories deeper than this many components carry no signal (bounds
176/// the ancestor walk).
177pub const MAX_DIR_DEPTH: usize = 32;
178/// Missing-dependency score for the same `metadata.owner`.
179pub const SHARED_OWNER_WEIGHT: u32 = 1;
180/// Directory / owner groups larger than this carry no pair signal.
181pub const MAX_SIGNAL_GROUP: usize = 32;
182/// Longest free-text field copied into the judge state, in chars.
183pub const MAX_STATE_TEXT_CHARS: usize = 500;
184/// Smallest saving that keeps a proposal, in hours.
185pub const MIN_HOURS_SAVED: f32 = 0.01;
186/// Proposal hours are rounded to `1 / HOURS_SCALE` (0.01 h).
187pub const HOURS_SCALE: f64 = 100.0;
188/// Longest provider-reported model id kept in the report, in chars.
189pub const MAX_MODEL_CHARS: usize = 64;
190
191/// Crash technique labels, as asked.
192const ADD_CAPACITY: &str = "add_capacity";
193const FAST_TRACK: &str = "fast_track";
194const REDUCE_SCOPE: &str = "reduce_scope";
195const NONE: &str = "none";
196
197/// `Ok` when `max_questions` is within `1..=MAX_QUESTIONS`.
198pub fn check_max_questions(max_questions: u16) -> Result<(), String> {
199    if (1..=MAX_QUESTIONS).contains(&max_questions) {
200        Ok(())
201    } else {
202        Err(format!(
203            "max_questions must be between 1 and {MAX_QUESTIONS}; got {max_questions}"
204        ))
205    }
206}
207
208/// What to review beyond the graph.
209#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
210#[serde(deny_unknown_fields)]
211pub struct ReviewRequest {
212    /// Resource capacities; when present, makespans are leveled.
213    #[serde(default)]
214    pub capacities: Option<ScheduleRequest>,
215    /// Question cap, `1..=64`; default 64.
216    #[serde(default)]
217    pub max_questions: Option<u16>,
218}
219
220/// The judge a review may use.
221#[derive(Clone, Copy)]
222pub enum Judge<'a> {
223    /// A configured judgment model.
224    Model(&'a dyn JudgmentModel),
225    /// No key configured: [`NO_KEY_REASON`].
226    NotConfigured,
227    /// The configuration is unusable; the caller supplies a key-free reason
228    /// (e.g. "key file ignored: world-readable").
229    ConfigError(&'a str),
230}
231
232/// Outcome class of a review.
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(rename_all = "snake_case")]
235pub enum ReviewStatus {
236    /// Jev answered; findings and proposals are filled.
237    Ok,
238    /// No judge, or the judge failed; `reason` says why.
239    ReviewUnavailable,
240    /// Lint found errors; the judge was not called.
241    InvalidGraph,
242}
243
244/// What a finding or proposal is about.
245#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
246#[serde(rename_all = "snake_case")]
247pub enum FindingKind {
248    /// An edge the dependent probably does not need.
249    FalseDependency,
250    /// An unordered pair that probably needs an edge.
251    MissingDependency,
252    /// A deliverable worth splitting into parallel parts.
253    SplitCandidate,
254    /// An edge the dependent could satisfy with an early interface.
255    InterfaceSplit,
256    /// A critical deliverable with a realistic crash technique.
257    CrashOption,
258}
259
260impl FindingKind {
261    fn as_str(self) -> &'static str {
262        match self {
263            Self::FalseDependency => "false_dependency",
264            Self::MissingDependency => "missing_dependency",
265            Self::SplitCandidate => "split_candidate",
266            Self::InterfaceSplit => "interface_split",
267            Self::CrashOption => "crash_option",
268        }
269    }
270}
271
272/// One calibrated judgment worth the agent's attention.
273#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
274pub struct ReviewFinding {
275    pub kind: FindingKind,
276    /// Deliverables involved: `[from, to]` for edges, `[a, b]` (sorted) for
277    /// pairs, `[id]` otherwise.
278    pub ids: Vec<String>,
279    /// In `[0, 1]`; see the module docs for its meaning per kind.
280    pub probability: f32,
281    pub evidence: String,
282}
283
284/// A verified edit batch `plan.fork { edits }` can apply.
285#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
286pub struct Proposal {
287    /// Stable id, e.g. `false_dependency:a->b`, `crash_option:reduce_scope:a`.
288    pub id: String,
289    pub kind: FindingKind,
290    pub edits: Vec<GraphEdit>,
291    pub hours_saved: f32,
292    /// Added effort hours.
293    pub cost: f32,
294    /// `hours_saved / max(cost, 1)`.
295    pub score: f32,
296    pub rationale: String,
297}
298
299/// Everything `plan.review` reports.
300#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
301pub struct ReviewReport {
302    pub status: ReviewStatus,
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub reason: Option<String>,
305    /// The judge failure's class ([`crate::llm::JudgmentErrorKind::as_str`],
306    /// e.g. `unauthorized`, `timeout`) when the judge was called and failed.
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub failure_class: Option<String>,
309    pub lint: LintReport,
310    pub findings: Vec<ReviewFinding>,
311    pub proposals: Vec<Proposal>,
312    /// Candidates were left out to respect the question cap.
313    pub truncated: bool,
314    pub jev_called: bool,
315    /// Model id the provider reported.
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub model: Option<String>,
318    /// Host of the judge endpoint.
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub endpoint: Option<String>,
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub usage: Option<Usage>,
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub prompt_hash: Option<String>,
325    pub question_count: usize,
326}
327
328impl ReviewReport {
329    fn new(status: ReviewStatus, reason: Option<String>, lint: LintReport) -> Self {
330        Self {
331            status,
332            reason,
333            failure_class: None,
334            lint,
335            findings: Vec::new(),
336            proposals: Vec::new(),
337            truncated: false,
338            jev_called: false,
339            model: None,
340            endpoint: None,
341            usage: None,
342            prompt_hash: None,
343            question_count: 0,
344        }
345    }
346}
347
348/// Review `graph`. See the module docs for the pipeline and every rule.
349///
350/// # Errors
351///
352/// [`PlannerError::InvalidGraph`] for an out-of-range `max_questions`, or
353/// when the base plan cannot be simulated (for example a resource with no
354/// capacity, or a graph submit would reject that lint did not flag).
355pub async fn review(
356    graph: &PlanGraph,
357    req: &ReviewRequest,
358    judge: Judge<'_>,
359) -> Result<ReviewReport, PlannerError> {
360    let cap = req.max_questions.unwrap_or(MAX_QUESTIONS);
361    check_max_questions(cap).map_err(|reason| PlannerError::InvalidGraph { reason })?;
362    let lint_report = lint(graph);
363    if lint_report
364        .findings
365        .iter()
366        .any(|f| f.severity == Severity::Error)
367    {
368        return Ok(ReviewReport::new(
369            ReviewStatus::InvalidGraph,
370            Some("lint reported errors; fix them before review".to_string()),
371            lint_report,
372        ));
373    }
374
375    let model = match judge {
376        Judge::Model(model) => model,
377        Judge::NotConfigured => {
378            return Ok(ReviewReport::new(
379                ReviewStatus::ReviewUnavailable,
380                Some(NO_KEY_REASON.to_string()),
381                lint_report,
382            ));
383        }
384        Judge::ConfigError(reason) => {
385            return Ok(ReviewReport::new(
386                ReviewStatus::ReviewUnavailable,
387                Some(reason.chars().take(MAX_ERROR_MESSAGE_CHARS).collect()),
388                lint_report,
389            ));
390        }
391    };
392
393    let sim_req = SimulateRequest {
394        schedule: req.capacities.clone(),
395        monte_carlo: None,
396    };
397    let base = simulate(graph, &sim_req)?;
398    let base_makespan = makespan(&base);
399    let cpm = compute_cpm(graph)?;
400    let tasks: HashMap<&str, &Task> = cpm.tasks.iter().map(|t| (t.id.as_str(), t)).collect();
401    let by_id: HashMap<&str, &Deliverable> = graph
402        .deliverables
403        .iter()
404        .map(|d| (d.id.as_str(), d))
405        .collect();
406    let ctx = Context {
407        graph,
408        tasks,
409        by_id,
410    };
411
412    let lists = [
413        ctx.crash_candidates(),
414        ctx.false_dependency_candidates(),
415        ctx.interface_split_candidates(),
416        ctx.split_candidates(),
417        ctx.missing_dependency_candidates(),
418    ];
419    let total: usize = lists.iter().map(Vec::len).sum();
420    let selected = round_robin(lists, usize::from(cap));
421
422    let mut report = ReviewReport::new(ReviewStatus::Ok, None, lint_report);
423    report.truncated = total > selected.len();
424    report.endpoint = model.endpoint_host();
425    if selected.is_empty() {
426        return Ok(report);
427    }
428
429    let questions: BTreeMap<String, Question> = selected
430        .iter()
431        .enumerate()
432        .map(|(i, c)| (question_id(i), c.question.clone()))
433        .collect();
434    let state = ctx.state(&selected, base_makespan, &cpm.critical_path);
435    report.question_count = questions.len();
436    report.prompt_hash = Some(prompt_hash(&state, &questions));
437    report.jev_called = true;
438
439    let decisions = match model.decide(state, questions).await {
440        Ok(decisions) => decisions,
441        Err(error) => {
442            report.status = ReviewStatus::ReviewUnavailable;
443            report.reason = Some(error.to_string());
444            report.failure_class = Some(error.kind().as_str().to_string());
445            return Ok(report);
446        }
447    };
448    report.model = Some(decisions.model.chars().take(MAX_MODEL_CHARS).collect());
449    report.usage = decisions.usage.map(sanitize_usage);
450
451    let mut drafts = Vec::new();
452    for (i, candidate) in selected.iter().enumerate() {
453        let Some(answer) = decisions.answers.get(&question_id(i)) else {
454            continue;
455        };
456        let Some(judged) = interpret(candidate, answer) else {
457            continue;
458        };
459        if judged.probability >= FINDING_MIN_PROBABILITY {
460            report.findings.push(ReviewFinding {
461                kind: candidate.kind,
462                ids: candidate.ids.clone(),
463                probability: judged.probability,
464                evidence: judged.evidence.clone(),
465            });
466        }
467        if let Some(draft) = ctx.draft(candidate, &judged) {
468            drafts.push(draft);
469        }
470    }
471    report
472        .findings
473        .sort_by(|a, b| (a.kind, &a.ids).cmp(&(b.kind, &b.ids)));
474
475    let base_issues = lint_issues(&report.lint);
476    report.proposals = drafts
477        .into_iter()
478        .filter_map(|d| verify(graph, &sim_req, &base_issues, base_makespan, d))
479        .collect();
480    report
481        .proposals
482        .sort_by(|a, b| b.score.total_cmp(&a.score).then_with(|| a.id.cmp(&b.id)));
483    Ok(report)
484}
485
486// ---------------------------------------------------------------- candidates
487
488struct Candidate {
489    kind: FindingKind,
490    ids: Vec<String>,
491    question: Question,
492}
493
494struct Context<'g> {
495    graph: &'g PlanGraph,
496    tasks: HashMap<&'g str, &'g Task>,
497    by_id: HashMap<&'g str, &'g Deliverable>,
498}
499
500impl Context<'_> {
501    fn length(&self, id: &str) -> f32 {
502        self.tasks.get(id).map_or(0.0, |t| t.effort_hours)
503    }
504
505    fn critical(&self, id: &str) -> bool {
506        self.tasks.get(id).is_some_and(|t| t.is_critical)
507    }
508
509    fn deliverable(&self, id: &str) -> Option<&Deliverable> {
510        self.by_id.get(id).copied()
511    }
512
513    fn crash_candidates(&self) -> Vec<Candidate> {
514        let mut ds: Vec<(&Deliverable, f32)> = self
515            .graph
516            .deliverables
517            .iter()
518            .filter(|d| !d.is_milestone() && self.critical(&d.id))
519            .map(|d| (d, self.length(&d.id)))
520            .filter(|(_, l)| *l > 0.0)
521            .collect();
522        ds.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.id.cmp(&b.0.id)));
523        ds.into_iter()
524            .map(|(d, l)| {
525                let criteria = BTreeMap::from([
526                    (
527                        ADD_CAPACITY.to_string(),
528                        Some(json!(
529                            "add a person or agent to shorten it, at extra coordination effort"
530                        )),
531                    ),
532                    (
533                        FAST_TRACK.to_string(),
534                        Some(json!("overlap it with its prerequisites or dependents")),
535                    ),
536                    (
537                        REDUCE_SCOPE.to_string(),
538                        Some(json!("cut or defer part of its scope")),
539                    ),
540                    (
541                        NONE.to_string(),
542                        Some(json!("no realistic way to shorten it")),
543                    ),
544                ]);
545                let ids = vec![d.id.clone()];
546                Candidate {
547                    kind: FindingKind::CrashOption,
548                    question: Question::Choice {
549                        instructions: instructions(
550                            FindingKind::CrashOption,
551                            &ids,
552                            format!(
553                                "'{}' is on the critical path ({} h). Which technique would most \
554                                 realistically shorten it?",
555                                d.id,
556                                fmt_hours(l)
557                            ),
558                        ),
559                        criteria,
560                    },
561                    ids,
562                }
563            })
564            .collect()
565    }
566
567    /// Every distinct `(from, to)` edge with the dependent.
568    fn edges(&self) -> Vec<(&str, &Deliverable)> {
569        let mut seen = HashSet::new();
570        let mut out = Vec::new();
571        for d in &self.graph.deliverables {
572            for p in &d.prerequisites {
573                if seen.insert((p.id(), d.id.as_str())) {
574                    out.push((p.id(), d));
575                }
576            }
577        }
578        out
579    }
580
581    fn consumes(to: &Deliverable, from: &str) -> String {
582        let stated: Vec<&str> = to
583            .prerequisites
584            .iter()
585            .filter(|p| p.id() == from)
586            .filter_map(|p| p.consumes())
587            .collect();
588        if stated.is_empty() {
589            "unstated".to_string()
590        } else {
591            truncate(&stated.join("; "))
592        }
593    }
594
595    fn false_dependency_candidates(&self) -> Vec<Candidate> {
596        let mut edges: Vec<(u8, f32, &str, &Deliverable)> = self
597            .edges()
598            .into_iter()
599            .map(|(from, to)| {
600                let tier = match (self.critical(from), self.critical(&to.id)) {
601                    (true, true) => 0,
602                    (true, false) | (false, true) => 1,
603                    (false, false) => 2,
604                };
605                (tier, self.length(from), from, to)
606            })
607            .collect();
608        edges.sort_by(|a, b| {
609            a.0.cmp(&b.0)
610                .then_with(|| b.1.total_cmp(&a.1))
611                .then_with(|| (a.2, &a.3.id).cmp(&(b.2, &b.3.id)))
612        });
613        edges
614            .into_iter()
615            .map(|(_, _, from, to)| {
616                let ids = vec![from.to_string(), to.id.clone()];
617                Candidate {
618                    kind: FindingKind::FalseDependency,
619                    question: Question::Noul {
620                        instructions: instructions(
621                            FindingKind::FalseDependency,
622                            &ids,
623                            format!(
624                                "'{to}' lists '{from}' as a prerequisite (stated as consuming: \
625                                 {consumes}). Is this a false dependency, i.e. can '{to}' be done \
626                                 correctly without waiting for '{from}' to finish?",
627                                to = to.id,
628                                consumes = Self::consumes(to, from),
629                            ),
630                        ),
631                        criteria: Some(BTreeMap::from([
632                            (
633                                "true".to_string(),
634                                json!(format!("'{}' does not need '{from}' to finish", to.id)),
635                            ),
636                            (
637                                "false".to_string(),
638                                json!(format!(
639                                    "'{}' genuinely needs what '{from}' produces",
640                                    to.id
641                                )),
642                            ),
643                        ])),
644                    },
645                    ids,
646                }
647            })
648            .collect()
649    }
650
651    fn interface_split_candidates(&self) -> Vec<Candidate> {
652        let mut edges: Vec<(f32, &str, &Deliverable)> = self
653            .edges()
654            .into_iter()
655            .filter(|(from, to)| self.critical(from) && self.critical(&to.id))
656            .filter(|(from, to)| {
657                !to.prerequisites
658                    .iter()
659                    .any(|p| p.id() == *from && p.kind() == Some(PrerequisiteKind::Interface))
660            })
661            .map(|(from, to)| (self.length(from), from, to))
662            .collect();
663        edges.sort_by(|a, b| {
664            b.0.total_cmp(&a.0)
665                .then_with(|| (a.1, &a.2.id).cmp(&(b.1, &b.2.id)))
666        });
667        edges
668            .into_iter()
669            .map(|(_, from, to)| {
670                let ids = vec![from.to_string(), to.id.clone()];
671                Candidate {
672                    kind: FindingKind::InterfaceSplit,
673                    question: Question::Noul {
674                        instructions: instructions(
675                            FindingKind::InterfaceSplit,
676                            &ids,
677                            format!(
678                                "Could '{to}' start against an interface or contract that \
679                                 '{from}' publishes early (splitting '{from}' into interface and \
680                                 implementation), instead of waiting for all of '{from}'?",
681                                to = to.id
682                            ),
683                        ),
684                        criteria: None,
685                    },
686                    ids,
687                }
688            })
689            .collect()
690    }
691
692    fn split_candidates(&self) -> Vec<Candidate> {
693        let mut lengths: Vec<f32> = self
694            .graph
695            .deliverables
696            .iter()
697            .filter(|d| !d.is_milestone())
698            .map(|d| self.length(&d.id))
699            .filter(|l| *l > 0.0)
700            .collect();
701        lengths.sort_by(f32::total_cmp);
702        let median = match lengths.len() {
703            0 => 0.0,
704            n if n % 2 == 1 => lengths[n / 2],
705            n => (lengths[n / 2 - 1] + lengths[n / 2]) / 2.0,
706        };
707        let threshold = if median > 0.0 {
708            SPLIT_MIN_HOURS.min(SPLIT_MEDIAN_FACTOR * median)
709        } else {
710            SPLIT_MIN_HOURS
711        };
712        let mut ds: Vec<(&Deliverable, f32)> = self
713            .graph
714            .deliverables
715            .iter()
716            .filter(|d| !d.is_milestone())
717            .map(|d| (d, self.length(&d.id)))
718            .filter(|(_, l)| *l > 0.0 && *l >= threshold)
719            .collect();
720        ds.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.id.cmp(&b.0.id)));
721        ds.into_iter()
722            .map(|(d, l)| {
723                let ids = vec![d.id.clone()];
724                Candidate {
725                    kind: FindingKind::SplitCandidate,
726                    question: Question::Score {
727                        instructions: instructions(
728                            FindingKind::SplitCandidate,
729                            &ids,
730                            format!(
731                                "'{}' is scheduled for {} h (plan median {} h). How strongly \
732                                 should it be split into smaller deliverables that can proceed in \
733                                 parallel?",
734                                d.id,
735                                fmt_hours(l),
736                                fmt_hours(median)
737                            ),
738                        ),
739                        criteria: SPLIT_LEVELS.iter().map(|s| json!(s)).collect(),
740                    },
741                    ids,
742                }
743            })
744            .collect()
745    }
746
747    fn missing_dependency_candidates(&self) -> Vec<Candidate> {
748        let ds = &self.graph.deliverables;
749        let index: HashMap<&str, usize> = ds
750            .iter()
751            .enumerate()
752            .map(|(i, d)| (d.id.as_str(), i))
753            .collect();
754        let pair = |a: usize, b: usize| ordered_pair(ds, a, b);
755
756        let mut mentions: BTreeSet<(usize, usize)> = BTreeSet::new();
757        for (i, d) in ds.iter().enumerate() {
758            let Some(text) = d.metadata.get("description").and_then(Value::as_str) else {
759                continue;
760            };
761            for token in text.split_whitespace() {
762                let token = token.trim_matches(|c: char| !c.is_alphanumeric() && c != '_');
763                if let Some(&j) = index.get(token)
764                    && j != i
765                {
766                    mentions.insert(pair(i, j));
767                }
768            }
769        }
770
771        // Members in deliverable-id order, so the pair cap does not depend
772        // on declaration order.
773        let mut owners: BTreeMap<&str, BTreeSet<(&str, usize)>> = BTreeMap::new();
774        for (i, d) in ds.iter().enumerate() {
775            if let Some(owner) = d.metadata.get("owner").and_then(Value::as_str) {
776                owners.entry(owner).or_default().insert((d.id.as_str(), i));
777            }
778        }
779        let mut owner_pairs: BTreeSet<(usize, usize)> = BTreeSet::new();
780        'owners: for members in owners.values().filter(|m| m.len() <= MAX_SIGNAL_GROUP) {
781            let members: Vec<usize> = members.iter().map(|(_, i)| *i).collect();
782            for (k, &a) in members.iter().enumerate() {
783                for &b in &members[k + 1..] {
784                    if owner_pairs.len() >= MAX_CANDIDATE_PAIRS {
785                        break 'owners;
786                    }
787                    owner_pairs.insert(pair(a, b));
788                }
789            }
790        }
791        let dir_pairs = directory_pairs(ds);
792
793        let mut scores: BTreeMap<(usize, usize), u32> = BTreeMap::new();
794        for p in &mentions {
795            *scores.entry(*p).or_default() += MENTION_WEIGHT;
796        }
797        for (p, weight) in &dir_pairs {
798            *scores.entry(*p).or_default() += weight;
799        }
800        for p in &owner_pairs {
801            *scores.entry(*p).or_default() += SHARED_OWNER_WEIGHT;
802        }
803        if scores.is_empty() {
804            return Vec::new();
805        }
806        let reach = crate::graph::reachability(self.graph);
807        let ordered = |a: &str, b: &str| {
808            reach.get(a).is_some_and(|r| r.contains(b))
809                || reach.get(b).is_some_and(|r| r.contains(a))
810        };
811        let mut ranked: Vec<((usize, usize), u32)> = scores
812            .into_iter()
813            .filter(|((a, b), _)| !ordered(&ds[*a].id, &ds[*b].id))
814            .collect();
815        ranked.sort_by(|x, y| {
816            y.1.cmp(&x.1)
817                .then_with(|| (&ds[x.0.0].id, &ds[x.0.1].id).cmp(&(&ds[y.0.0].id, &ds[y.0.1].id)))
818        });
819        ranked.truncate(MISSING_DEPENDENCY_TOP_K);
820        ranked
821            .into_iter()
822            .map(|((a, b), _)| {
823                let key = (a, b);
824                let mut signals = Vec::new();
825                if mentions.contains(&key) {
826                    signals.push("a description mentions the other");
827                }
828                match dir_pairs.get(&key) {
829                    Some(&SHARED_DIR_WEIGHT) => {
830                        signals.push("they own files in the same directory");
831                    }
832                    Some(_) => signals.push("they own files in nested directories"),
833                    None => {}
834                }
835                if owner_pairs.contains(&key) {
836                    signals.push("they share metadata.owner");
837                }
838                let ids = vec![ds[a].id.clone(), ds[b].id.clone()];
839                Candidate {
840                    kind: FindingKind::MissingDependency,
841                    question: Question::Noul {
842                        instructions: instructions(
843                            FindingKind::MissingDependency,
844                            &ids,
845                            format!(
846                                "'{}' and '{}' are unordered in the plan, yet {}. Does one of them \
847                                 need the other's output first (a missing dependency)?",
848                                ids[0],
849                                ids[1],
850                                signals.join(" and ")
851                            ),
852                        ),
853                        criteria: None,
854                    },
855                    ids,
856                }
857            })
858            .collect()
859    }
860
861    /// The shared state: plan summary plus every deliverable a question
862    /// names, sorted by id.
863    fn state(&self, selected: &[Candidate], makespan: f32, critical_path: &[String]) -> Value {
864        let named: BTreeSet<&str> = selected
865            .iter()
866            .flat_map(|c| c.ids.iter().map(String::as_str))
867            .collect();
868        let deliverables: Vec<Value> = named
869            .into_iter()
870            .filter_map(|id| self.deliverable(id))
871            .map(|d| {
872                let text = |key: &str| {
873                    d.metadata.get(key).map(|v| match v {
874                        Value::String(s) => truncate(s),
875                        other => truncate(&other.to_string()),
876                    })
877                };
878                let task = self.tasks.get(d.id.as_str());
879                json!({
880                    "id": d.id,
881                    "scheduled_hours": self.length(&d.id),
882                    "float_hours": task.map_or(0.0, |t| t.float),
883                    "critical": self.critical(&d.id),
884                    "milestone": d.is_milestone(),
885                    "description": text("description"),
886                    "artifact": text("artifact"),
887                    "owner": text("owner"),
888                    "owned_files": d
889                        .owned_files
890                        .iter()
891                        .map(|f| f.path().to_string_lossy().into_owned())
892                        .collect::<Vec<_>>(),
893                    "prerequisites": d
894                        .prerequisites
895                        .iter()
896                        .map(|p| json!({
897                            "id": p.id(),
898                            "consumes": p.consumes().map(truncate),
899                            "kind": p.kind(),
900                        }))
901                        .collect::<Vec<_>>(),
902                })
903            })
904            .collect();
905        json!({
906            "task": "Review a software delivery plan scheduled with the critical path \
907                     method. Each question names deliverables by id; their details are in \
908                     `deliverables`.",
909            "makespan_hours": makespan,
910            "critical_path": critical_path
911                .iter()
912                .filter(|id| self.deliverable(id).is_some())
913                .collect::<Vec<_>>(),
914            "deliverables": deliverables,
915        })
916    }
917
918    /// The proposal a judged candidate earns, if any (unverified).
919    fn draft(&self, candidate: &Candidate, judged: &Judged) -> Option<Draft> {
920        match candidate.kind {
921            FindingKind::FalseDependency if judged.probability >= FALSE_DEPENDENCY_PROPOSAL_P => {
922                let (from, to) = (&candidate.ids[0], &candidate.ids[1]);
923                Some(Draft {
924                    id: format!("false_dependency:{from}->{to}"),
925                    kind: candidate.kind,
926                    edits: vec![GraphEdit::RemoveEdge {
927                        from: from.clone(),
928                        to: to.clone(),
929                    }],
930                    cost: 0.0,
931                    rationale: format!(
932                        "Jev: P('{to}' does not need '{from}') = {:.2}; removing the edge",
933                        judged.probability
934                    ),
935                })
936            }
937            FindingKind::CrashOption
938                if judged.confidence >= CRASH_PROPOSAL_CONFIDENCE
939                    && (judged.choice == ADD_CAPACITY || judged.choice == REDUCE_SCOPE) =>
940            {
941                let id = &candidate.ids[0];
942                let d = self.deliverable(id)?;
943                let length = self.length(id);
944                let hours = round_hours(length * CRASH_LENGTH_FACTOR);
945                // add_capacity shortens calendar time, not the work: the
946                // duration replaces effort as the scheduled length while
947                // effort stays the cost basis.
948                let edit = if judged.choice == ADD_CAPACITY || d.duration_hours.is_some() {
949                    GraphEdit::SetDuration {
950                        id: id.clone(),
951                        hours: Some(hours),
952                    }
953                } else {
954                    GraphEdit::SetEffort {
955                        id: id.clone(),
956                        hours,
957                    }
958                };
959                let cost = if judged.choice == ADD_CAPACITY {
960                    round_hours(length * ADD_CAPACITY_COST_FACTOR)
961                } else {
962                    0.0
963                };
964                Some(Draft {
965                    id: format!("crash_option:{}:{id}", judged.choice),
966                    kind: candidate.kind,
967                    edits: vec![edit],
968                    cost,
969                    rationale: format!(
970                        "Jev picks {} for '{id}' (confidence {:.2}); {} h -> {} h at {} h added \
971                         effort",
972                        judged.choice,
973                        judged.confidence,
974                        fmt_hours(length),
975                        fmt_hours(hours),
976                        fmt_hours(cost)
977                    ),
978                })
979            }
980            _ => None,
981        }
982    }
983}
984
985fn instructions(kind: FindingKind, ids: &[String], question: String) -> Value {
986    json!({ "kind": kind.as_str(), "ids": ids, "question": question })
987}
988
989/// Interleave the lists, one from each in turn, until `cap` are taken.
990fn round_robin<const N: usize>(lists: [Vec<Candidate>; N], cap: usize) -> Vec<Candidate> {
991    let mut iters: Vec<_> = lists.into_iter().map(Vec::into_iter).collect();
992    let mut out = Vec::new();
993    loop {
994        let mut progressed = false;
995        for it in &mut iters {
996            if out.len() >= cap {
997                return out;
998            }
999            if let Some(c) = it.next() {
1000                out.push(c);
1001                progressed = true;
1002            }
1003        }
1004        if !progressed {
1005            return out;
1006        }
1007    }
1008}
1009
1010fn question_id(index: usize) -> String {
1011    format!("q{index:02}")
1012}
1013
1014// ---------------------------------------------------------------- answers
1015
1016struct Judged {
1017    probability: f32,
1018    /// Choice answers only.
1019    choice: String,
1020    /// Answer confidence for Choice answers; the probability otherwise.
1021    confidence: f32,
1022    evidence: String,
1023}
1024
1025/// A probability Jev could legitimately return: finite and in `[0, 1]`.
1026fn probability(value: f64) -> Option<f32> {
1027    (value.is_finite() && (0.0..=1.0).contains(&value)).then_some(value as f32)
1028}
1029
1030fn interpret(candidate: &Candidate, answer: &Answer) -> Option<Judged> {
1031    let ids = &candidate.ids;
1032    match (candidate.kind, answer) {
1033        (
1034            FindingKind::FalseDependency
1035            | FindingKind::MissingDependency
1036            | FindingKind::InterfaceSplit,
1037            Answer::Noul { noul },
1038        ) => {
1039            let p = probability(*noul)?;
1040            let evidence = match candidate.kind {
1041                FindingKind::FalseDependency => {
1042                    format!("P('{}' does not need '{}') = {p:.2}", ids[1], ids[0])
1043                }
1044                FindingKind::MissingDependency => {
1045                    format!("P('{}' and '{}' need an ordering) = {p:.2}", ids[0], ids[1])
1046                }
1047                _ => format!(
1048                    "P('{}' can start against an early interface of '{}') = {p:.2}",
1049                    ids[1], ids[0]
1050                ),
1051            };
1052            Some(Judged {
1053                probability: p,
1054                choice: String::new(),
1055                confidence: p,
1056                evidence,
1057            })
1058        }
1059        (
1060            FindingKind::SplitCandidate,
1061            Answer::Score {
1062                score,
1063                probabilities,
1064                ..
1065            },
1066        ) => {
1067            let top = SPLIT_LEVELS.len() - 1;
1068            let score =
1069                (score.is_finite() && (0.0..=top as f64).contains(score)).then_some(*score)?;
1070            let mut mass = 0.0f64;
1071            for (level, p) in probabilities {
1072                let p = probability(*p)?;
1073                // Only the asked levels, spelled canonically ("0".."4").
1074                let index = level.parse::<usize>().ok().filter(|i| *i <= top)?;
1075                if index.to_string() != *level {
1076                    return None;
1077                }
1078                if index >= SPLIT_LIKELY_LEVEL {
1079                    mass += f64::from(p);
1080                }
1081            }
1082            let p = probability(mass.min(1.0))?;
1083            Some(Judged {
1084                probability: p,
1085                choice: String::new(),
1086                confidence: p,
1087                evidence: format!(
1088                    "split score {score:.2} of {}; P(level >= {SPLIT_LIKELY_LEVEL}) = {p:.2}",
1089                    SPLIT_LEVELS.len() - 1
1090                ),
1091            })
1092        }
1093        (
1094            FindingKind::CrashOption,
1095            Answer::Choice {
1096                choice,
1097                probabilities,
1098                confidence,
1099            },
1100        ) => {
1101            let Question::Choice { criteria, .. } = &candidate.question else {
1102                return None;
1103            };
1104            if !criteria.contains_key(choice)
1105                || probabilities.keys().any(|k| !criteria.contains_key(k))
1106            {
1107                return None;
1108            }
1109            let p = probability(*probabilities.get(choice)?)?;
1110            let confidence = probability(*confidence)?;
1111            Some(Judged {
1112                // `none` is not a finding.
1113                probability: if choice == NONE { 0.0 } else { p },
1114                choice: choice.clone(),
1115                confidence,
1116                evidence: format!("Jev picks {choice} for '{}' (P = {p:.2})", ids[0]),
1117            })
1118        }
1119        _ => None,
1120    }
1121}
1122
1123// ---------------------------------------------------------------- verification
1124
1125struct Draft {
1126    id: String,
1127    kind: FindingKind,
1128    edits: Vec<GraphEdit>,
1129    cost: f32,
1130    rationale: String,
1131}
1132
1133fn makespan(sim: &SimulationResult) -> f32 {
1134    sim.resource_schedule
1135        .as_ref()
1136        .map_or(sim.critical_path_hours, |r| r.makespan)
1137}
1138
1139/// `(code, ids)` of every warning or error finding.
1140fn lint_issues(report: &LintReport) -> HashSet<(String, Vec<String>)> {
1141    report
1142        .findings
1143        .iter()
1144        .filter(|f| f.severity != Severity::Info)
1145        .map(|f| (f.code.clone(), f.ids.clone()))
1146        .collect()
1147}
1148
1149fn verify(
1150    graph: &PlanGraph,
1151    sim_req: &SimulateRequest,
1152    base_issues: &HashSet<(String, Vec<String>)>,
1153    base_makespan: f32,
1154    draft: Draft,
1155) -> Option<Proposal> {
1156    let edited = apply_edits(graph, &draft.edits).ok()?;
1157    let after = simulate(&edited, sim_req).ok()?;
1158    if !lint_issues(&after.lint).is_subset(base_issues) {
1159        return None;
1160    }
1161    let after_makespan = makespan(&after);
1162    let hours_saved = round_hours(base_makespan - after_makespan);
1163    if !hours_saved.is_finite() || hours_saved < MIN_HOURS_SAVED {
1164        return None;
1165    }
1166    let score = hours_saved / draft.cost.max(1.0);
1167    if !score.is_finite() {
1168        return None;
1169    }
1170    Some(Proposal {
1171        rationale: format!(
1172            "{}; simulated makespan {} h -> {} h",
1173            draft.rationale,
1174            fmt_hours(base_makespan),
1175            fmt_hours(after_makespan)
1176        ),
1177        id: draft.id,
1178        kind: draft.kind,
1179        edits: draft.edits,
1180        hours_saved,
1181        cost: draft.cost,
1182        score,
1183    })
1184}
1185
1186// ---------------------------------------------------------------- helpers
1187
1188/// Round to `1 / HOURS_SCALE` hours, in f64 so the f32 input's binary error
1189/// cannot push a value across a rounding boundary.
1190fn round_hours(hours: f32) -> f32 {
1191    ((f64::from(hours) * HOURS_SCALE).round() / HOURS_SCALE) as f32
1192}
1193
1194/// `(a, b)` with the smaller id first.
1195fn ordered_pair(ds: &[Deliverable], a: usize, b: usize) -> (usize, usize) {
1196    if ds[a].id <= ds[b].id { (a, b) } else { (b, a) }
1197}
1198
1199/// Directory-signal pairs and their weight: [`SHARED_DIR_WEIGHT`] for files
1200/// in the same directory, [`NESTED_DIR_WEIGHT`] when one file's directory
1201/// is a proper ancestor of the other's (component-wise; the larger weight
1202/// wins when both hold).
1203///
1204/// Bounds, all deterministic: directories deeper than [`MAX_DIR_DEPTH`]
1205/// components and top-level files carry no signal; a same-directory group
1206/// of more than [`MAX_SIGNAL_GROUP`] deliverables is skipped, and so is an
1207/// ancestor whose subtree (its own entries plus every descendant
1208/// directory's) has more than [`MAX_SIGNAL_GROUP`] entries. Directories are
1209/// visited in sorted order (members in deliverable-id order, nearest
1210/// ancestor first) and generation stops at [`MAX_CANDIDATE_PAIRS`] pairs, so
1211/// which pairs survive the cap does not depend on declaration order.
1212pub(crate) fn directory_pairs(ds: &[Deliverable]) -> BTreeMap<(usize, usize), u32> {
1213    let mut dirs: BTreeMap<&Path, BTreeSet<(&str, usize)>> = BTreeMap::new();
1214    for (i, d) in ds.iter().enumerate() {
1215        for f in &d.owned_files {
1216            if let Some(dir) = f.path().parent()
1217                && dir.parent().is_some()
1218                && dir.components().nth(MAX_DIR_DEPTH).is_none()
1219            {
1220                dirs.entry(dir).or_default().insert((d.id.as_str(), i));
1221            }
1222        }
1223    }
1224    let mut subtree: HashMap<&Path, usize> = HashMap::new();
1225    for (dir, members) in &dirs {
1226        for ancestor in dir.ancestors().filter(|a| a.parent().is_some()) {
1227            *subtree.entry(ancestor).or_default() += members.len();
1228        }
1229    }
1230
1231    let mut pairs: BTreeMap<(usize, usize), u32> = BTreeMap::new();
1232    let mut add = |a: usize, b: usize, weight: u32| {
1233        if pairs.len() >= MAX_CANDIDATE_PAIRS {
1234            return false;
1235        }
1236        let w = pairs.entry(ordered_pair(ds, a, b)).or_default();
1237        *w = (*w).max(weight);
1238        true
1239    };
1240    for (dir, members) in &dirs {
1241        if members.len() <= MAX_SIGNAL_GROUP {
1242            let list: Vec<usize> = members.iter().map(|(_, i)| *i).collect();
1243            for (k, &a) in list.iter().enumerate() {
1244                for &b in &list[k + 1..] {
1245                    if !add(a, b, SHARED_DIR_WEIGHT) {
1246                        return pairs;
1247                    }
1248                }
1249            }
1250        }
1251        for ancestor in dir.ancestors().skip(1).filter(|a| a.parent().is_some()) {
1252            if subtree.get(ancestor).is_none_or(|n| *n > MAX_SIGNAL_GROUP) {
1253                continue;
1254            }
1255            let Some(others) = dirs.get(ancestor) else {
1256                continue;
1257            };
1258            for &(_, a) in members {
1259                for &(_, b) in others.iter().filter(|(_, b)| *b != a) {
1260                    if !add(a, b, NESTED_DIR_WEIGHT) {
1261                        return pairs;
1262                    }
1263                }
1264            }
1265        }
1266    }
1267    pairs
1268}
1269
1270fn fmt_hours(hours: f32) -> String {
1271    format!("{hours:.2}")
1272}
1273
1274fn truncate(text: &str) -> String {
1275    text.chars().take(MAX_STATE_TEXT_CHARS).collect()
1276}
1277
1278/// Drop a provider-reported cost holding a non-finite figure.
1279fn sanitize_usage(mut usage: Usage) -> Usage {
1280    let finite = |v: Option<f64>| v.is_none_or(f64::is_finite);
1281    if let Some(cost) = &usage.cost
1282        && !(cost.total.is_finite()
1283            && finite(cost.input)
1284            && finite(cost.output)
1285            && finite(cost.cache_read)
1286            && finite(cost.cache_write))
1287    {
1288        usage.cost = None;
1289    }
1290    usage
1291}
1292
1293/// sha256 hex of `{ "questions": …, "state": … }`, keys sorted at every
1294/// level, compact.
1295fn prompt_hash(state: &Value, questions: &BTreeMap<String, Question>) -> String {
1296    let request = json!({ "state": state, "questions": questions });
1297    let mut canonical = String::new();
1298    write_canonical(&request, &mut canonical);
1299    crate::skills::sha256_hex(canonical.as_bytes())
1300}
1301
1302fn write_canonical(value: &Value, out: &mut String) {
1303    match value {
1304        Value::Object(map) => {
1305            let sorted: BTreeMap<&String, &Value> = map.iter().collect();
1306            out.push('{');
1307            for (i, (k, v)) in sorted.into_iter().enumerate() {
1308                if i > 0 {
1309                    out.push(',');
1310                }
1311                out.push_str(&Value::String(k.clone()).to_string());
1312                out.push(':');
1313                write_canonical(v, out);
1314            }
1315            out.push('}');
1316        }
1317        Value::Array(items) => {
1318            out.push('[');
1319            for (i, v) in items.iter().enumerate() {
1320                if i > 0 {
1321                    out.push(',');
1322                }
1323                write_canonical(v, out);
1324            }
1325            out.push(']');
1326        }
1327        scalar => out.push_str(&scalar.to_string()),
1328    }
1329}
1330
1331#[cfg(test)]
1332mod tests {
1333    use super::*;
1334    use crate::plan::OwnedFile;
1335    use std::path::PathBuf;
1336
1337    fn owning(id: String, path: String) -> Deliverable {
1338        Deliverable {
1339            id,
1340            owned_files: vec![OwnedFile::Path(PathBuf::from(path))],
1341            prerequisites: Vec::new(),
1342            estimated_effort_hours: Some(1.0),
1343            duration_hours: None,
1344            estimate: None,
1345            metadata: Value::Null,
1346            milestone: false,
1347            earning_rule: None,
1348        }
1349    }
1350
1351    #[test]
1352    fn deep_directory_chain_keeps_pair_count_bounded() {
1353        // Deliverable i owns a file i + 1 directories deep, one chain.
1354        let mut dir = String::new();
1355        let ds: Vec<Deliverable> = (0..5000)
1356            .map(|i| {
1357                dir.push_str("d/");
1358                owning(format!("x{i:04}"), format!("{dir}f.rs"))
1359            })
1360            .collect();
1361        assert!(directory_pairs(&ds).len() <= MAX_CANDIDATE_PAIRS);
1362    }
1363
1364    #[test]
1365    fn wide_directory_tree_stops_at_the_pair_cap() {
1366        // 1000 sibling directories of 20 deliverables each: 190k
1367        // same-directory pairs uncapped.
1368        let ds: Vec<Deliverable> = (0..20_000)
1369            .map(|i| owning(format!("x{i:05}"), format!("src/m{:03}/f{i}.rs", i / 20)))
1370            .collect();
1371        assert_eq!(directory_pairs(&ds).len(), MAX_CANDIDATE_PAIRS);
1372    }
1373
1374    /// Pairs named by id, so two declaration orders can be compared.
1375    fn id_pairs(ds: &[Deliverable]) -> BTreeMap<(String, String), u32> {
1376        directory_pairs(ds)
1377            .into_iter()
1378            .map(|((a, b), w)| ((ds[a].id.clone(), ds[b].id.clone()), w))
1379            .collect()
1380    }
1381
1382    #[test]
1383    fn pair_selection_is_independent_of_declaration_order() {
1384        // The cap is hit mid-directory, so the member order decides which
1385        // pairs of that directory survive.
1386        let ds: Vec<Deliverable> = (0..20_000)
1387            .map(|i| owning(format!("x{i:05}"), format!("src/m{:03}/f{i}.rs", i / 20)))
1388            .collect();
1389        let mut reversed = ds.clone();
1390        reversed.reverse();
1391        assert_eq!(id_pairs(&ds), id_pairs(&reversed));
1392    }
1393}