1use 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
135pub const MAX_QUESTIONS: u16 = 64;
137pub const NO_KEY_REASON: &str = "no OpenRouter key configured";
139pub const FINDING_MIN_PROBABILITY: f32 = 0.5;
141pub const FALSE_DEPENDENCY_PROPOSAL_P: f32 = 0.8;
143pub const CRASH_PROPOSAL_CONFIDENCE: f32 = 0.6;
145pub const CRASH_LENGTH_FACTOR: f32 = 0.7;
147pub const ADD_CAPACITY_COST_FACTOR: f32 = 0.3;
149pub const SPLIT_MIN_HOURS: f32 = 8.0;
151pub const SPLIT_MEDIAN_FACTOR: f32 = 2.0;
153pub 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];
161pub const SPLIT_LIKELY_LEVEL: usize = 3;
163pub const MISSING_DEPENDENCY_TOP_K: usize = 16;
165pub const MENTION_WEIGHT: u32 = 3;
167pub const SHARED_DIR_WEIGHT: u32 = 2;
169pub const NESTED_DIR_WEIGHT: u32 = 1;
172pub const MAX_CANDIDATE_PAIRS: usize = 10_000;
175pub const MAX_DIR_DEPTH: usize = 32;
178pub const SHARED_OWNER_WEIGHT: u32 = 1;
180pub const MAX_SIGNAL_GROUP: usize = 32;
182pub const MAX_STATE_TEXT_CHARS: usize = 500;
184pub const MIN_HOURS_SAVED: f32 = 0.01;
186pub const HOURS_SCALE: f64 = 100.0;
188pub const MAX_MODEL_CHARS: usize = 64;
190
191const ADD_CAPACITY: &str = "add_capacity";
193const FAST_TRACK: &str = "fast_track";
194const REDUCE_SCOPE: &str = "reduce_scope";
195const NONE: &str = "none";
196
197pub 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
210#[serde(deny_unknown_fields)]
211pub struct ReviewRequest {
212 #[serde(default)]
214 pub capacities: Option<ScheduleRequest>,
215 #[serde(default)]
217 pub max_questions: Option<u16>,
218}
219
220#[derive(Clone, Copy)]
222pub enum Judge<'a> {
223 Model(&'a dyn JudgmentModel),
225 NotConfigured,
227 ConfigError(&'a str),
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(rename_all = "snake_case")]
235pub enum ReviewStatus {
236 Ok,
238 ReviewUnavailable,
240 InvalidGraph,
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
246#[serde(rename_all = "snake_case")]
247pub enum FindingKind {
248 FalseDependency,
250 MissingDependency,
252 SplitCandidate,
254 InterfaceSplit,
256 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
274pub struct ReviewFinding {
275 pub kind: FindingKind,
276 pub ids: Vec<String>,
279 pub probability: f32,
281 pub evidence: String,
282}
283
284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
286pub struct Proposal {
287 pub id: String,
289 pub kind: FindingKind,
290 pub edits: Vec<GraphEdit>,
291 pub hours_saved: f32,
292 pub cost: f32,
294 pub score: f32,
296 pub rationale: String,
297}
298
299#[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 #[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 pub truncated: bool,
314 pub jev_called: bool,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub model: Option<String>,
318 #[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
348pub 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
486struct 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 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 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 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 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 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
989fn 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
1014struct Judged {
1017 probability: f32,
1018 choice: String,
1020 confidence: f32,
1022 evidence: String,
1023}
1024
1025fn 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 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 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
1123struct 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
1139fn 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
1186fn round_hours(hours: f32) -> f32 {
1191 ((f64::from(hours) * HOURS_SCALE).round() / HOURS_SCALE) as f32
1192}
1193
1194fn 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
1199pub(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
1278fn 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
1293fn 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 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 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 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 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}