Skip to main content

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}