majordomus_cli/reasoning/policy.rs
1//! The consultation policy: given what is uncertain and how much it matters, whether
2//! independent review is worth having, how much, and from which available advisors.
3//!
4//! Pure over its inputs, like `models.route`: the same assessment against the same
5//! availability gets the same plan, and every advisor that was not selected says why.
6//! It names capabilities and never an advisor: a repository's preference ("ask this one
7//! first") is the catalogue's declaration order, which reaches this function only as the
8//! order of the states it is handed.
9//!
10//! The budget is the number of advisors, by materiality and mode:
11//!
12//! | mode | trivial | low | material | high | critical |
13//! |-----------|---------|-----|----------|------|----------|
14//! | ci | 0 | 0 | 0 | 0 | 0 |
15//! | fast | 0 | 0 | 0 | 1 | 1 |
16//! | standard | 0 | 0 | 1 | 2 | 2 |
17//! | offline | 0 | 0 | 1 | 2 | 2 |
18//! | strict | 0 | 0 | 1 | 2 | 3 |
19//!
20//! High confidence in the local conclusion lowers it by one. Selection is greedy and
21//! explainable: each next advisor is the one covering the most requested capabilities not
22//! yet covered, then one from a source not yet asked (independence: two answers from one
23//! vendor are one opinion twice), then declaration order. Zero selected is a plan, not a
24//! failure: the plan then carries the structured local review a session performs instead.
25//!
26//! What the policy never does is count. There is no quorum, no threshold of agreeing
27//! advisors, no majority: how advice becomes a decision is the conclusion's business,
28//! and a conclusion is refused while a disagreement it covers is unresolved (see
29//! [`super::store`]).
30//!
31//! # Example
32//!
33//! High uncertainty under `standard` allows two advisors. Of three, one is rate limited
34//! and is excluded with its reason; the other two are selected in preference order.
35//!
36//! ```
37//! use majordomus_cli::reasoning::availability::{AdvisorState, ModeResolution, ReasoningMode};
38//! use majordomus_cli::reasoning::policy::{plan, Materiality, PlanOutcome, PlanRequest};
39//!
40//! fn advisor(id: &str, adapter: &str, status: &str) -> AdvisorState {
41//! serde_json::from_value(serde_json::json!({
42//! "id": id, "title": id, "transport": "api", "adapter": adapter,
43//! "capabilities": ["independent_reasoning"], "status": status,
44//! "reason": "present", "probe": "",
45//! }))
46//! .unwrap()
47//! }
48//! let states = [
49//! advisor("first", "x-api", "available"),
50//! advisor("limited", "y-api", "rate_limited"),
51//! advisor("second", "z-cli", "available"),
52//! ];
53//! let mode = ModeResolution { mode: ReasoningMode::Standard, source: "default".into() };
54//! let request = PlanRequest { materiality: Materiality::High, ..Default::default() };
55//! let p = plan(&request, &mode, &states);
56//! assert_eq!(p.outcome, PlanOutcome::Consult);
57//! let selected: Vec<&str> = p.selected.iter().map(|s| s.advisor.as_str()).collect();
58//! assert_eq!(selected, ["first", "second"]);
59//! assert_eq!(p.excluded[0].advisor, "limited");
60//! assert!(p.excluded[0].reason.starts_with("rate_limited"));
61//! ```
62
63use schemars::JsonSchema;
64use serde::{Deserialize, Serialize};
65
66use super::availability::{AdvisorState, AdvisorStatus, ModeResolution, ReasoningMode};
67
68/// How much the uncertain decision could matter. Ordered from `trivial` to `critical`:
69/// with the mode it sets the review budget, and from `material` up a decision taken alone
70/// carries the structured local review.
71///
72/// ```
73/// use majordomus_cli::reasoning::policy::Materiality;
74///
75/// assert_eq!(Materiality::default(), Materiality::Low);
76/// assert!(Materiality::Material < Materiality::Critical);
77/// let m: Materiality = serde_json::from_str("\"high\"").unwrap();
78/// assert_eq!(m, Materiality::High);
79/// ```
80#[derive(
81 Debug,
82 Clone,
83 Copy,
84 Default,
85 PartialEq,
86 Eq,
87 PartialOrd,
88 Ord,
89 Hash,
90 Serialize,
91 Deserialize,
92 JsonSchema,
93)]
94#[serde(rename_all = "snake_case")]
95pub enum Materiality {
96 /// A name, a format, an obvious convention: never reviewed.
97 Trivial,
98 /// Local and reversible.
99 #[default]
100 Low,
101 /// Could affect correctness, an interface, persistent data, concurrency, operations.
102 Material,
103 /// Architecture, security, compatibility or a public contract.
104 High,
105 /// Irreversible or destructive, or repository governance itself.
106 Critical,
107}
108
109/// How sure the primary executor is of its current hypothesis. `high` lowers the review
110/// budget by one; the other levels leave it as the mode and materiality set it.
111///
112/// ```
113/// use majordomus_cli::reasoning::availability::{ModeResolution, ReasoningMode};
114/// use majordomus_cli::reasoning::policy::{plan, Materiality, PlanRequest, ReasoningConfidence};
115///
116/// let mode = ModeResolution { mode: ReasoningMode::Strict, source: "default".into() };
117/// let budget = |confidence: ReasoningConfidence| {
118/// let request = PlanRequest { materiality: Materiality::Critical, confidence, ..Default::default() };
119/// plan(&request, &mode, &[]).budget
120/// };
121/// assert_eq!(budget(ReasoningConfidence::Medium), 3);
122/// assert_eq!(budget(ReasoningConfidence::High), 2);
123/// ```
124#[derive(
125 Debug,
126 Clone,
127 Copy,
128 Default,
129 PartialEq,
130 Eq,
131 PartialOrd,
132 Ord,
133 Hash,
134 Serialize,
135 Deserialize,
136 JsonSchema,
137)]
138#[serde(rename_all = "snake_case")]
139pub enum ReasoningConfidence {
140 /// A guess the evidence does not yet support.
141 Low,
142 /// Supported, with open questions.
143 #[default]
144 Medium,
145 /// Supported by evidence that would be hard to overturn.
146 High,
147}
148
149/// What the policy is asked about: the materiality and confidence of one uncertainty,
150/// the advisory capabilities review needs, and any standing conclusion on the subject.
151///
152/// ```
153/// use majordomus_cli::reasoning::availability::{ModeResolution, ReasoningMode};
154/// use majordomus_cli::reasoning::policy::{plan, PlanOutcome, PlanRequest};
155///
156/// let request: PlanRequest = serde_json::from_str(
157/// r#"{"materiality":"high","confidence":"low","prior_conclusion":"conclusion-1"}"#,
158/// ).unwrap();
159/// assert!(request.capabilities.is_empty() && !request.new_evidence);
160/// let mode = ModeResolution { mode: ReasoningMode::Standard, source: "default".into() };
161/// // a standing conclusion and no new evidence: reuse it rather than ask again
162/// assert_eq!(plan(&request, &mode, &[]).outcome, PlanOutcome::ReusePrior);
163/// ```
164#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
165pub struct PlanRequest {
166 /// Materiality of the uncertainty.
167 pub materiality: Materiality,
168 /// Confidence in the local hypothesis.
169 pub confidence: ReasoningConfidence,
170 /// The advisory capabilities review needs; empty means `independent_reasoning`.
171 #[serde(default)]
172 pub capabilities: Vec<String>,
173 /// A standing conclusion on the same subject, when one exists.
174 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub prior_conclusion: Option<String>,
176 /// The assessment brings evidence the prior conclusion did not have.
177 #[serde(default)]
178 pub new_evidence: bool,
179}
180
181/// What the plan decided: consult the selected advisors, decide on local evidence, or
182/// reuse a standing conclusion. Deciding locally is an ordinary outcome, not a failure.
183///
184/// ```
185/// use majordomus_cli::reasoning::availability::{ModeResolution, ReasoningMode};
186/// use majordomus_cli::reasoning::policy::{plan, Materiality, PlanOutcome, PlanRequest};
187///
188/// let mode = ModeResolution { mode: ReasoningMode::Ci, source: "CI".into() };
189/// let request = PlanRequest { materiality: Materiality::Critical, ..Default::default() };
190/// let outcome: PlanOutcome = plan(&request, &mode, &[]).outcome;
191/// assert_eq!(outcome, PlanOutcome::DecideLocally);
192/// assert_eq!(serde_json::to_value(outcome).unwrap(), "decide_locally");
193/// ```
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
195#[serde(rename_all = "snake_case")]
196pub enum PlanOutcome {
197 /// Consult the selected advisors, then conclude.
198 Consult,
199 /// Decide on local evidence; no independent review is called for or none is available.
200 DecideLocally,
201 /// A standing conclusion already answers this; reuse it rather than ask again.
202 ReusePrior,
203}
204
205/// One selected advisor, the requested capabilities it covers, and why the greedy
206/// selection chose it at its position.
207///
208/// ```
209/// use majordomus_cli::reasoning::availability::{AdvisorState, ModeResolution, ReasoningMode};
210/// use majordomus_cli::reasoning::policy::{plan, Materiality, PlanRequest, SelectedAdvisor};
211///
212/// fn advisor(id: &str, adapter: &str, status: &str) -> AdvisorState {
213/// serde_json::from_value(serde_json::json!({
214/// "id": id, "title": id, "transport": "api", "adapter": adapter,
215/// "capabilities": ["independent_reasoning"], "status": status,
216/// "reason": "present", "probe": "",
217/// }))
218/// .unwrap()
219/// }
220/// let mode = ModeResolution { mode: ReasoningMode::Standard, source: "default".into() };
221/// let request = PlanRequest { materiality: Materiality::Material, ..Default::default() };
222/// let p = plan(&request, &mode, &[advisor("a", "x-api", "available")]);
223/// let chosen: &SelectedAdvisor = &p.selected[0];
224/// assert_eq!(chosen.advisor, "a");
225/// assert_eq!(chosen.covers, ["independent_reasoning"]);
226/// assert!(chosen.why.starts_with("the first available advisor"));
227/// ```
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
229pub struct SelectedAdvisor {
230 /// The advisor id.
231 pub advisor: String,
232 /// The requested capabilities it covers.
233 pub covers: Vec<String>,
234 /// Why it was chosen.
235 pub why: String,
236}
237
238/// One advisor left out of the plan, its standing when the plan was made, and why: its
239/// status, a capability it does not offer, or a budget already spent.
240///
241/// ```
242/// use majordomus_cli::reasoning::availability::{AdvisorState, AdvisorStatus, ModeResolution, ReasoningMode};
243/// use majordomus_cli::reasoning::policy::{plan, ExcludedAdvisor, Materiality, PlanRequest};
244///
245/// fn advisor(id: &str, adapter: &str, status: &str) -> AdvisorState {
246/// serde_json::from_value(serde_json::json!({
247/// "id": id, "title": id, "transport": "api", "adapter": adapter,
248/// "capabilities": ["independent_reasoning"], "status": status,
249/// "reason": "present", "probe": "",
250/// }))
251/// .unwrap()
252/// }
253/// let mode = ModeResolution { mode: ReasoningMode::Standard, source: "default".into() };
254/// let request = PlanRequest { materiality: Materiality::Material, ..Default::default() };
255/// let states = [advisor("a", "x-api", "available"), advisor("b", "y-api", "available")];
256/// let p = plan(&request, &mode, &states);
257/// let left: &ExcludedAdvisor = &p.excluded[0];
258/// assert_eq!((left.advisor.as_str(), left.status), ("b", AdvisorStatus::Available));
259/// assert!(left.reason.contains("budget was spent"));
260/// ```
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
262pub struct ExcludedAdvisor {
263 /// The advisor id.
264 pub advisor: String,
265 /// Its standing when the plan was made.
266 pub status: AdvisorStatus,
267 /// Why it is not in the plan.
268 pub reason: String,
269}
270
271/// The plan: the decision with its reasons attached. It carries what it answered (mode,
272/// materiality, confidence, capabilities), the budget, the outcome with one sentence of
273/// why, every advisor selected or excluded, and the local review when one is due.
274///
275/// ```
276/// use majordomus_cli::reasoning::availability::{ModeResolution, ReasoningMode};
277/// use majordomus_cli::reasoning::policy::{plan, Materiality, PlanRequest, ReviewPlan, LOCAL_REVIEW};
278///
279/// let mode = ModeResolution { mode: ReasoningMode::Offline, source: "MAJORDOMUS_REASONING_MODE".into() };
280/// let request = PlanRequest { materiality: Materiality::Material, ..Default::default() };
281/// let p: ReviewPlan = plan(&request, &mode, &[]);
282/// assert_eq!(p.mode_source, "MAJORDOMUS_REASONING_MODE");
283/// assert_eq!(p.capabilities, ["independent_reasoning"]);
284/// assert_eq!((p.budget, p.available), (1, 0));
285/// assert_eq!(p.local_review.len(), LOCAL_REVIEW.len());
286/// assert!(p.reason.starts_with("no available advisor offers"));
287/// ```
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
289pub struct ReviewPlan {
290 /// The mode it was made under.
291 pub mode: ReasoningMode,
292 /// What decided the mode.
293 pub mode_source: String,
294 /// The materiality it answered.
295 pub materiality: Materiality,
296 /// The confidence it answered.
297 pub confidence: ReasoningConfidence,
298 /// The capabilities review was asked for.
299 pub capabilities: Vec<String>,
300 /// How many advisors the mode and materiality allow.
301 pub budget: usize,
302 /// What to do.
303 pub outcome: PlanOutcome,
304 /// Why, in one sentence.
305 pub reason: String,
306 /// The advisors to consult, in selection order.
307 pub selected: Vec<SelectedAdvisor>,
308 /// Every advisor not selected, with its reason.
309 pub excluded: Vec<ExcludedAdvisor>,
310 /// Advisors available when the plan was made, whatever was selected.
311 pub available: usize,
312 /// The structured local review to perform, when material uncertainty meets no advisor.
313 #[serde(default, skip_serializing_if = "Vec::is_empty")]
314 pub local_review: Vec<String>,
315}
316
317/// The capability review defaults to.
318pub const DEFAULT_CAPABILITY: &str = "independent_reasoning";
319
320/// The adversarial self-review a session performs when it must decide alone.
321pub const LOCAL_REVIEW: &[&str] = &[
322 "state the hypothesis and the strongest alternative to it",
323 "list each assumption with the evidence that supports it, and mark the unsupported ones",
324 "name what observation would falsify the hypothesis",
325 "run the smallest experiment that discriminates between hypothesis and alternative",
326 "record the conclusion with its evidence, rejected alternatives, risks and validation plan",
327];
328
329/// The budget of a mode and materiality, before confidence: how many advisors a plan may
330/// select, as the table in the module header states it.
331///
332/// ```
333/// use majordomus_cli::reasoning::availability::ReasoningMode;
334/// use majordomus_cli::reasoning::policy::{budget, Materiality};
335///
336/// assert_eq!(budget(ReasoningMode::Ci, Materiality::Critical), 0);
337/// assert_eq!(budget(ReasoningMode::Fast, Materiality::Material), 0);
338/// assert_eq!(budget(ReasoningMode::Standard, Materiality::High), 2);
339/// assert_eq!(budget(ReasoningMode::Strict, Materiality::Critical), 3);
340/// ```
341pub fn budget(mode: ReasoningMode, materiality: Materiality) -> usize {
342 use Materiality::*;
343 use ReasoningMode::*;
344 match (mode, materiality) {
345 (Ci, _) | (_, Trivial) | (_, Low) => 0,
346 (Fast, Material) => 0,
347 (Fast, _) => 1,
348 (Standard | Offline | Strict, Material) => 1,
349 (Standard | Offline, High | Critical) => 2,
350 (Strict, High) => 2,
351 (Strict, Critical) => 3,
352 }
353}
354
355/// The source an advisor's answer comes from, for independence: its adapter family is
356/// what the states carry, so two advisors through one adapter count as one source.
357fn source_of(state: &AdvisorState) -> &str {
358 &state.adapter
359}
360
361/// Make the plan for one request under one mode, over the advisors' states in preference
362/// order. Pure: the same inputs give the same plan, and every advisor not selected is in
363/// `excluded` with its reason.
364///
365/// ```
366/// use majordomus_cli::reasoning::availability::{ModeResolution, ReasoningMode};
367/// use majordomus_cli::reasoning::policy::*;
368///
369/// let mode = ModeResolution { mode: ReasoningMode::Standard, source: "default".into() };
370/// let request = PlanRequest { materiality: Materiality::High, ..Default::default() };
371/// // No advisor at all: a plan, not an error.
372/// let p = plan(&request, &mode, &[]);
373/// assert_eq!(p.outcome, PlanOutcome::DecideLocally);
374/// assert_eq!(p.budget, 2);
375/// assert!(p.selected.is_empty());
376/// assert!(!p.local_review.is_empty(), "the local path is spelled out");
377/// ```
378pub fn plan(request: &PlanRequest, mode: &ModeResolution, states: &[AdvisorState]) -> ReviewPlan {
379 let wanted: Vec<String> = if request.capabilities.is_empty() {
380 vec![DEFAULT_CAPABILITY.to_string()]
381 } else {
382 request.capabilities.clone()
383 };
384 let mut allowed = budget(mode.mode, request.materiality);
385 if request.confidence == ReasoningConfidence::High {
386 allowed = allowed.saturating_sub(1);
387 }
388 let available = states
389 .iter()
390 .filter(|s| s.status == AdvisorStatus::Available)
391 .count();
392 let mut out = ReviewPlan {
393 mode: mode.mode,
394 mode_source: mode.source.clone(),
395 materiality: request.materiality,
396 confidence: request.confidence,
397 capabilities: wanted.clone(),
398 budget: allowed,
399 outcome: PlanOutcome::DecideLocally,
400 reason: String::new(),
401 selected: Vec::new(),
402 excluded: Vec::new(),
403 available,
404 local_review: Vec::new(),
405 };
406 let material = request.materiality >= Materiality::Material;
407
408 if let (Some(prior), false) = (&request.prior_conclusion, request.new_evidence) {
409 out.outcome = PlanOutcome::ReusePrior;
410 out.budget = 0;
411 out.reason = format!(
412 "conclusion {prior} already answers this subject and the assessment brings no new evidence"
413 );
414 out.excluded = states
415 .iter()
416 .map(|s| ExcludedAdvisor {
417 advisor: s.id.clone(),
418 status: s.status,
419 reason: "a standing conclusion answers the subject".into(),
420 })
421 .collect();
422 return out;
423 }
424
425 let mut pool: Vec<&AdvisorState> = Vec::new();
426 for s in states {
427 if s.status != AdvisorStatus::Available {
428 out.excluded.push(ExcludedAdvisor {
429 advisor: s.id.clone(),
430 status: s.status,
431 reason: format!("{}: {}", s.status.as_str(), s.reason),
432 });
433 } else if !s.capabilities.iter().any(|c| wanted.contains(c)) {
434 out.excluded.push(ExcludedAdvisor {
435 advisor: s.id.clone(),
436 status: s.status,
437 reason: format!("offers none of {}", wanted.join(", ")),
438 });
439 } else {
440 pool.push(s);
441 }
442 }
443
444 let mut covered: Vec<String> = Vec::new();
445 let mut sources: Vec<&str> = Vec::new();
446 while out.selected.len() < allowed && !pool.is_empty() {
447 let score = |s: &AdvisorState| {
448 let fresh = s
449 .capabilities
450 .iter()
451 .filter(|c| wanted.contains(c) && !covered.contains(c))
452 .count();
453 let independent = usize::from(!sources.contains(&source_of(s)));
454 (fresh, independent)
455 };
456 // max by score; declaration order breaks ties (the first of equals wins)
457 let mut best = 0;
458 for (i, s) in pool.iter().enumerate() {
459 if score(s) > score(pool[best]) {
460 best = i;
461 }
462 }
463 let chosen = pool.remove(best);
464 let covers: Vec<String> = chosen
465 .capabilities
466 .iter()
467 .filter(|c| wanted.contains(c))
468 .cloned()
469 .collect();
470 let (fresh, independent) = score(chosen);
471 let why = if out.selected.is_empty() {
472 "the first available advisor in preference order covering the most requested capabilities".to_string()
473 } else if fresh > 0 {
474 format!("covers {fresh} requested capability(ies) the selection did not")
475 } else if independent == 1 {
476 "an independent source for a second opinion".to_string()
477 } else {
478 "the next available advisor in preference order".to_string()
479 };
480 for c in &covers {
481 if !covered.contains(c) {
482 covered.push(c.clone());
483 }
484 }
485 sources.push(source_of(chosen));
486 out.selected.push(SelectedAdvisor {
487 advisor: chosen.id.clone(),
488 covers,
489 why,
490 });
491 }
492 for s in pool {
493 out.excluded.push(ExcludedAdvisor {
494 advisor: s.id.clone(),
495 status: s.status,
496 reason: "the budget was spent on advisors earlier in the selection".into(),
497 });
498 }
499 // Excluded in the states' order, so a reader finds each advisor where it is declared.
500 let mut left_out = std::mem::take(&mut out.excluded);
501 for s in states {
502 if let Some(i) = left_out.iter().position(|e| e.advisor == s.id) {
503 out.excluded.push(left_out.swap_remove(i));
504 }
505 }
506
507 if !out.selected.is_empty() {
508 out.outcome = PlanOutcome::Consult;
509 out.reason = format!(
510 "{} uncertainty under mode {}: {} independent review(s) within a budget of {allowed}",
511 materiality_word(request.materiality),
512 mode.mode.as_str(),
513 out.selected.len()
514 );
515 } else {
516 out.reason = if !material {
517 format!(
518 "{} uncertainty is decided on local evidence; review would cost more than it could change",
519 materiality_word(request.materiality)
520 )
521 } else if allowed == 0 {
522 format!(
523 "mode {} with {} confidence allows no independent review for {} uncertainty",
524 mode.mode.as_str(),
525 confidence_word(request.confidence),
526 materiality_word(request.materiality)
527 )
528 } else {
529 format!(
530 "no available advisor offers {}; decide on local evidence after a structured local review",
531 wanted.join(", ")
532 )
533 };
534 if material {
535 out.local_review = LOCAL_REVIEW.iter().map(|s| s.to_string()).collect();
536 }
537 }
538 out
539}
540
541/// The word that names a materiality, the same one its JSON form carries: what a plan's
542/// reason sentence prints.
543///
544/// ```
545/// use majordomus_cli::reasoning::policy::{materiality_word, Materiality};
546///
547/// assert_eq!(materiality_word(Materiality::Critical), "critical");
548/// assert_eq!(serde_json::to_value(Materiality::Low).unwrap(), materiality_word(Materiality::Low));
549/// ```
550pub fn materiality_word(m: Materiality) -> &'static str {
551 match m {
552 Materiality::Trivial => "trivial",
553 Materiality::Low => "low",
554 Materiality::Material => "material",
555 Materiality::High => "high",
556 Materiality::Critical => "critical",
557 }
558}
559
560fn confidence_word(c: ReasoningConfidence) -> &'static str {
561 match c {
562 ReasoningConfidence::Low => "low",
563 ReasoningConfidence::Medium => "medium",
564 ReasoningConfidence::High => "high",
565 }
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571 use crate::reasoning::catalogue::AdvisorTransport;
572
573 fn state(id: &str, adapter: &str, caps: &[&str], status: AdvisorStatus) -> AdvisorState {
574 AdvisorState {
575 id: id.into(),
576 title: id.into(),
577 transport: AdvisorTransport::Api,
578 adapter: adapter.into(),
579 capabilities: caps.iter().map(|c| c.to_string()).collect(),
580 model: None,
581 executable: None,
582 credential: None,
583 status,
584 reason: "present".into(),
585 probe: String::new(),
586 recovering: false,
587 circuit: None,
588 }
589 }
590
591 fn standard() -> ModeResolution {
592 ModeResolution {
593 mode: ReasoningMode::Standard,
594 source: "default".into(),
595 }
596 }
597
598 fn request(m: Materiality, caps: &[&str]) -> PlanRequest {
599 PlanRequest {
600 materiality: m,
601 capabilities: caps.iter().map(|c| c.to_string()).collect(),
602 ..Default::default()
603 }
604 }
605
606 #[test]
607 fn trivial_uncertainty_never_consults() {
608 let s = [state(
609 "a",
610 "x",
611 &["independent_reasoning"],
612 AdvisorStatus::Available,
613 )];
614 for m in [Materiality::Trivial, Materiality::Low] {
615 let p = plan(&request(m, &[]), &standard(), &s);
616 assert_eq!(p.outcome, PlanOutcome::DecideLocally);
617 assert!(p.selected.is_empty());
618 assert!(p.local_review.is_empty(), "no ceremony for trivia either");
619 }
620 }
621
622 #[test]
623 fn material_uncertainty_consults_the_first_preferred_advisor() {
624 let s = [
625 state(
626 "a",
627 "x",
628 &["independent_reasoning"],
629 AdvisorStatus::Available,
630 ),
631 state(
632 "b",
633 "y",
634 &["independent_reasoning"],
635 AdvisorStatus::Available,
636 ),
637 ];
638 let p = plan(&request(Materiality::Material, &[]), &standard(), &s);
639 assert_eq!(p.outcome, PlanOutcome::Consult);
640 assert_eq!(p.selected.len(), 1);
641 assert_eq!(
642 p.selected[0].advisor, "a",
643 "declaration order is the preference"
644 );
645 assert_eq!(p.excluded[0].advisor, "b");
646 }
647
648 #[test]
649 fn an_unavailable_preference_falls_through_to_the_next_suitable_advisor() {
650 let s = [
651 state(
652 "a",
653 "x",
654 &["independent_reasoning"],
655 AdvisorStatus::NotConfigured,
656 ),
657 state("b", "y", &["code_review"], AdvisorStatus::Available),
658 state(
659 "c",
660 "z",
661 &["independent_reasoning"],
662 AdvisorStatus::Available,
663 ),
664 ];
665 let p = plan(&request(Materiality::Material, &[]), &standard(), &s);
666 assert_eq!(p.selected[0].advisor, "c");
667 let reasons: Vec<(&str, &str)> = p
668 .excluded
669 .iter()
670 .map(|e| (e.advisor.as_str(), e.reason.as_str()))
671 .collect();
672 assert_eq!(reasons[0], ("a", "not_configured: present"));
673 assert!(reasons[1].1.starts_with("offers none of"));
674 }
675
676 #[test]
677 fn high_blast_radius_asks_for_complementary_and_independent_review() {
678 let s = [
679 state(
680 "a",
681 "x",
682 &["independent_reasoning", "architecture_review"],
683 AdvisorStatus::Available,
684 ),
685 state(
686 "a2",
687 "x",
688 &["independent_reasoning", "architecture_review"],
689 AdvisorStatus::Available,
690 ),
691 state(
692 "b",
693 "y",
694 &["code_review", "independent_reasoning"],
695 AdvisorStatus::Available,
696 ),
697 ];
698 let p = plan(
699 &request(Materiality::High, &["independent_reasoning", "code_review"]),
700 &standard(),
701 &s,
702 );
703 let chosen: Vec<&str> = p.selected.iter().map(|s| s.advisor.as_str()).collect();
704 // `b` covers both requested capabilities, then `a` is an independent source; `a2`
705 // shares `a`'s adapter and is left for the budget.
706 assert_eq!(chosen, ["b", "a"]);
707 }
708
709 #[test]
710 fn zero_advisors_is_a_plan_with_the_local_path_spelled_out() {
711 let p = plan(&request(Materiality::Critical, &[]), &standard(), &[]);
712 assert_eq!(p.outcome, PlanOutcome::DecideLocally);
713 assert_eq!(p.available, 0);
714 assert_eq!(p.local_review.len(), LOCAL_REVIEW.len());
715 assert!(p.reason.contains("no available advisor"));
716 }
717
718 #[test]
719 fn ci_mode_allows_nothing_and_says_so() {
720 let ci = ModeResolution {
721 mode: ReasoningMode::Ci,
722 source: "CI".into(),
723 };
724 let p = plan(&request(Materiality::Critical, &[]), &ci, &[]);
725 assert_eq!(p.budget, 0);
726 assert!(p.reason.contains("mode ci"));
727 }
728
729 #[test]
730 fn a_standing_conclusion_is_reused_unless_new_evidence_arrives() {
731 let s = [state(
732 "a",
733 "x",
734 &["independent_reasoning"],
735 AdvisorStatus::Available,
736 )];
737 let mut r = request(Materiality::High, &[]);
738 r.prior_conclusion = Some("conclusion-1".into());
739 let p = plan(&r, &standard(), &s);
740 assert_eq!(p.outcome, PlanOutcome::ReusePrior);
741 assert!(p.selected.is_empty());
742 r.new_evidence = true;
743 assert_eq!(plan(&r, &standard(), &s).outcome, PlanOutcome::Consult);
744 }
745
746 #[test]
747 fn the_budget_table_is_the_documented_one() {
748 use Materiality::*;
749 let rows = [
750 (ReasoningMode::Ci, [0, 0, 0, 0, 0]),
751 (ReasoningMode::Fast, [0, 0, 0, 1, 1]),
752 (ReasoningMode::Standard, [0, 0, 1, 2, 2]),
753 (ReasoningMode::Offline, [0, 0, 1, 2, 2]),
754 (ReasoningMode::Strict, [0, 0, 1, 2, 3]),
755 ];
756 for (mode, want) in rows {
757 let got: Vec<usize> = [Trivial, Low, Material, High, Critical]
758 .iter()
759 .map(|m| budget(mode, *m))
760 .collect();
761 assert_eq!(got, want, "{mode:?}");
762 }
763 }
764}