Skip to main content

majordomus_cli/reasoning/
state.rs

1//! The reasoning state of a session, derived from its records and nothing else: what is
2//! uncertain, what was asked and what came back, where advice disagreed and what settled
3//! it, what was concluded and whether it was proven — plus the timeline, the degradation
4//! that happened along the way, and the report a handover or a final summary carries.
5//!
6//! Deterministic: the records are read in `(recorded_at, id)` order, every list here keeps
7//! that order, and the report is rendered from these lists alone. The same records give
8//! the same state on every surface, which is what lets the CLI, HTTP, MCP, the Cockpit,
9//! `majordomus context` and a handover all say the same thing.
10//!
11//! The lifecycle: records are admitted and stored, [`derive()`] turns them into a
12//! [`ReasoningState`], and [`render_markdown`] turns that state into the paragraph a
13//! handover carries. [`explain`] answers for one record with its whole chain.
14//!
15//! ```
16//! use majordomus_cli::reasoning::availability::{ModeResolution, ReasoningMode};
17//! use majordomus_cli::reasoning::record::*;
18//! use majordomus_cli::reasoning::state::{derive, explain, render_markdown, AssessmentStanding};
19//! use majordomus_cli::reasoning::store::{admit, Admission, Loaded};
20//!
21//! let mode = ModeResolution { mode: ReasoningMode::Standard, source: "default".into() };
22//! let admission = Admission { mode: &mode, availability: &[] };
23//! let mut loaded = Loaded::default();
24//! let add = |loaded: &mut Loaded, input: ReasoningInput| {
25//!     let body = admit(input, loaded, &admission).unwrap();
26//!     let n = loaded.records.len() + 1;
27//!     let id = format!("{}-{n}", body.kind());
28//!     loaded.records.push(ReasoningRecord {
29//!         schema: RECORD_SCHEMA.into(),
30//!         version: RECORD_VERSION,
31//!         id: id.clone(),
32//!         task: "t".into(),
33//!         recorded_at: format!("2026-10-01T00:00:{n:02}Z"),
34//!         head: None,
35//!         redacted: vec![],
36//!         body,
37//!     });
38//!     id
39//! };
40//!
41//! let a = add(&mut loaded, ReasoningInput::Assessment(AssessmentInput {
42//!     subject: "whether the cache survives a restart".into(),
43//!     ..Default::default()
44//! }));
45//! add(&mut loaded, ReasoningInput::Plan(PlanInput { assessment: a.clone() }));
46//! let k = add(&mut loaded, ReasoningInput::Conclusion(ConclusionInput {
47//!     assessment: a.clone(),
48//!     decision: "rebuild the cache on start".into(),
49//!     rationale: "nothing persists it".into(),
50//!     ..Default::default()
51//! }));
52//! add(&mut loaded, ReasoningInput::Validation(ValidationInput {
53//!     conclusion: k.clone(),
54//!     check: "a restarted worker rebuilds it".into(),
55//!     command: None,
56//!     outcome: ValidationOutcome::Pass,
57//!     evidence: vec![],
58//! }));
59//!
60//! let state = derive(&loaded, Some("t"));
61//! assert_eq!(state.assessments[0].standing, AssessmentStanding::Validated);
62//! assert_eq!(state.timeline.len(), 4);
63//! let report = render_markdown(&state);
64//! assert!(report.contains("- Decision conclusion-3: rebuild the cache on start"));
65//! assert_eq!(explain(&loaded, &k).unwrap().chain.len(), 4);
66//! ```
67
68use schemars::JsonSchema;
69use serde::{Deserialize, Serialize};
70
71use super::policy::{Materiality, PlanOutcome};
72use super::record::*;
73use super::store::{assessment_of, resolution_of, standing_conclusion, Loaded};
74
75/// Where an assessment stands, from open through validated.
76///
77/// The standing is derived, never stored: an unresolved disagreement makes it disputed
78/// whatever else is recorded; otherwise its standing conclusion and that conclusion's
79/// validations decide it, and without a conclusion a recorded plan makes it planned.
80///
81/// ```
82/// # use majordomus_cli::reasoning::record::ReasoningRecord;
83/// # use serde_json::json;
84/// # fn rec(id: &str, n: u32, body: serde_json::Value) -> ReasoningRecord {
85/// #     serde_json::from_value(json!({
86/// #         "schema": "majordomus/reasoning-record", "version": 1, "id": id, "task": "t",
87/// #         "recorded_at": format!("2026-10-01T00:00:{n:02}Z"), "body": body,
88/// #     }))
89/// #     .unwrap()
90/// # }
91/// use majordomus_cli::reasoning::state::{derive, AssessmentStanding};
92/// use majordomus_cli::reasoning::store::Loaded;
93///
94/// let mut loaded = Loaded::default();
95/// loaded.records.push(rec("assessment-1", 1, json!({ "kind": "assessment", "subject": "s" })));
96/// assert_eq!(derive(&loaded, None).assessments[0].standing, AssessmentStanding::Open);
97///
98/// loaded.records.push(rec("disagreement-2", 2, json!({
99///     "kind": "disagreement", "assessment": "assessment-1",
100///     "positions": [
101///         { "source": "primary", "position": "A" },
102///         { "source": "consultation-9", "position": "B" },
103///     ],
104///     "discriminating_question": "do callers retry?",
105/// })));
106/// assert_eq!(derive(&loaded, None).assessments[0].standing, AssessmentStanding::Disputed);
107/// ```
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
109#[serde(rename_all = "snake_case")]
110pub enum AssessmentStanding {
111    /// Stated; no plan yet.
112    Open,
113    /// Planned; no conclusion yet.
114    Planned,
115    /// A disagreement on it is unsettled.
116    Disputed,
117    /// Concluded; not yet validated.
118    Concluded,
119    /// Concluded, and every validation recorded passed.
120    Validated,
121    /// Concluded, and a validation failed.
122    ValidationFailed,
123}
124
125/// One assessment and where it stands.
126///
127/// The view carries what a reader needs to pick the work up: the subject, its
128/// materiality, its derived standing, the conclusion that stands for it and the
129/// questions it left open.
130///
131/// ```
132/// # use majordomus_cli::reasoning::record::ReasoningRecord;
133/// # use serde_json::json;
134/// # fn rec(id: &str, n: u32, body: serde_json::Value) -> ReasoningRecord {
135/// #     serde_json::from_value(json!({
136/// #         "schema": "majordomus/reasoning-record", "version": 1, "id": id, "task": "t",
137/// #         "recorded_at": format!("2026-10-01T00:00:{n:02}Z"), "body": body,
138/// #     }))
139/// #     .unwrap()
140/// # }
141/// use majordomus_cli::reasoning::policy::Materiality;
142/// use majordomus_cli::reasoning::state::{derive, AssessmentView};
143/// use majordomus_cli::reasoning::store::Loaded;
144///
145/// let mut loaded = Loaded::default();
146/// loaded.records.push(rec("assessment-1", 1, json!({
147///     "kind": "assessment", "subject": "lock scope", "materiality": "trivial",
148///     "questions": ["is it held across an await?"],
149/// })));
150/// let state = derive(&loaded, None);
151/// let view: &AssessmentView = &state.assessments[0];
152/// assert_eq!((view.id.as_str(), view.task.as_str()), ("assessment-1", "t"));
153/// assert_eq!(view.materiality, Materiality::Trivial);
154/// assert_eq!(view.conclusion, None);
155/// assert_eq!(view.questions, ["is it held across an await?"]);
156/// ```
157#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
158pub struct AssessmentView {
159    /// Record id.
160    pub id: String,
161    /// The task it was recorded under.
162    pub task: String,
163    /// What is uncertain.
164    pub subject: String,
165    /// How much it matters.
166    pub materiality: Materiality,
167    /// Where it stands.
168    pub standing: AssessmentStanding,
169    /// Its standing conclusion.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub conclusion: Option<String>,
172    /// Questions it left open.
173    #[serde(default, skip_serializing_if = "Vec::is_empty")]
174    pub questions: Vec<String>,
175}
176
177/// One consultation, as the state reports it.
178///
179/// A failed consultation is reported as plainly as a completed one: its status and the
180/// adapter's diagnostic stand where the advice would, so degradation is visible rather
181/// than silently absent.
182///
183/// ```
184/// # use majordomus_cli::reasoning::record::ReasoningRecord;
185/// # use serde_json::json;
186/// # fn rec(id: &str, n: u32, body: serde_json::Value) -> ReasoningRecord {
187/// #     serde_json::from_value(json!({
188/// #         "schema": "majordomus/reasoning-record", "version": 1, "id": id, "task": "t",
189/// #         "recorded_at": format!("2026-10-01T00:00:{n:02}Z"), "body": body,
190/// #     }))
191/// #     .unwrap()
192/// # }
193/// use majordomus_cli::reasoning::record::ConsultationStatus;
194/// use majordomus_cli::reasoning::state::{derive, ConsultationView};
195/// use majordomus_cli::reasoning::store::Loaded;
196///
197/// let mut loaded = Loaded::default();
198/// loaded.records.push(rec("consultation-1", 1, json!({
199///     "kind": "consultation", "plan": "plan-0", "advisor": "alpha",
200///     "status": "timeout", "diagnostic": "timeout: no answer in 60s",
201/// })));
202/// let state = derive(&loaded, None);
203/// let view: &ConsultationView = &state.consultations[0];
204/// assert_eq!(view.status, ConsultationStatus::Timeout);
205/// assert_eq!(view.conclusion, None);
206/// assert_eq!(view.diagnostic.as_deref(), Some("timeout: no answer in 60s"));
207/// assert_eq!(state.totals.consultations_failed, 1);
208/// ```
209#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
210pub struct ConsultationView {
211    /// Record id.
212    pub id: String,
213    /// The plan that selected it.
214    pub plan: String,
215    /// The advisor.
216    pub advisor: String,
217    /// How it ended.
218    pub status: ConsultationStatus,
219    /// Where the advice stands.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub stance: Option<Stance>,
222    /// The advice, summarised.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub conclusion: Option<String>,
225    /// The adapter's diagnostic, for a failure.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub diagnostic: Option<String>,
228}
229
230/// One conclusion, as the state reports it.
231///
232/// Besides the decision and its computed review, the view counts the validations
233/// recorded against the conclusion and names the later conclusion that superseded it,
234/// if any. A superseded conclusion stays in the list; the report leaves it out.
235///
236/// ```
237/// # use majordomus_cli::reasoning::record::ReasoningRecord;
238/// # use serde_json::json;
239/// # fn rec(id: &str, n: u32, body: serde_json::Value) -> ReasoningRecord {
240/// #     serde_json::from_value(json!({
241/// #         "schema": "majordomus/reasoning-record", "version": 1, "id": id, "task": "t",
242/// #         "recorded_at": format!("2026-10-01T00:00:{n:02}Z"), "body": body,
243/// #     }))
244/// #     .unwrap()
245/// # }
246/// use majordomus_cli::reasoning::state::{derive, ConclusionView};
247/// use majordomus_cli::reasoning::store::Loaded;
248///
249/// let conclusion = |decision: &str, supersedes: Option<&str>| {
250///     json!({
251///         "kind": "conclusion", "assessment": "assessment-0", "decision": decision,
252///         "rationale": "r", "supersedes": supersedes,
253///         "reviewed_by": [], "independent_review_count": 0,
254///     })
255/// };
256/// let mut loaded = Loaded::default();
257/// loaded.records.push(rec("conclusion-1", 1, conclusion("A", None)));
258/// loaded.records.push(rec("validation-2", 2, json!({
259///     "kind": "validation", "conclusion": "conclusion-1", "check": "x", "outcome": "fail",
260/// })));
261/// loaded.records.push(rec("conclusion-3", 3, conclusion("B", Some("conclusion-1"))));
262///
263/// let state = derive(&loaded, None);
264/// let first: &ConclusionView = &state.conclusions[0];
265/// assert_eq!((first.validations_passed, first.validations_failed), (0, 1));
266/// assert_eq!(first.superseded_by.as_deref(), Some("conclusion-3"));
267/// assert_eq!(state.conclusions[1].superseded_by, None);
268/// ```
269#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
270pub struct ConclusionView {
271    /// Record id.
272    pub id: String,
273    /// The assessment it concludes.
274    pub assessment: String,
275    /// The decision.
276    pub decision: String,
277    /// Advisors whose answers it weighed.
278    pub reviewed_by: Vec<String>,
279    /// How many.
280    pub independent_review_count: usize,
281    /// Validations recorded: passed, failed.
282    pub validations_passed: usize,
283    /// Validations failed.
284    pub validations_failed: usize,
285    /// Superseded by a later conclusion.
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub superseded_by: Option<String>,
288    /// Its validation plan.
289    pub validation_plan: Vec<String>,
290    /// Risks that remain.
291    #[serde(default, skip_serializing_if = "Vec::is_empty")]
292    pub risks: Vec<String>,
293}
294
295/// One disagreement, as the state reports it.
296///
297/// The view names the question that decides the disagreement and, once a resolution is
298/// recorded, the resolution and the position its evidence favoured. A view without a
299/// resolution is an unsettled disagreement that blocks its assessment.
300///
301/// ```
302/// # use majordomus_cli::reasoning::record::ReasoningRecord;
303/// # use serde_json::json;
304/// # fn rec(id: &str, n: u32, body: serde_json::Value) -> ReasoningRecord {
305/// #     serde_json::from_value(json!({
306/// #         "schema": "majordomus/reasoning-record", "version": 1, "id": id, "task": "t",
307/// #         "recorded_at": format!("2026-10-01T00:00:{n:02}Z"), "body": body,
308/// #     }))
309/// #     .unwrap()
310/// # }
311/// use majordomus_cli::reasoning::state::{derive, DisagreementView};
312/// use majordomus_cli::reasoning::store::Loaded;
313///
314/// let mut loaded = Loaded::default();
315/// loaded.records.push(rec("disagreement-1", 1, json!({
316///     "kind": "disagreement", "assessment": "assessment-0",
317///     "positions": [
318///         { "source": "primary", "position": "A" },
319///         { "source": "consultation-9", "position": "B" },
320///     ],
321///     "discriminating_question": "do callers retry?",
322/// })));
323/// loaded.records.push(rec("resolution-2", 2, json!({
324///     "kind": "resolution", "disagreement": "disagreement-1",
325///     "experiment": "read the callers", "outcome": "none retries",
326///     "favours": "consultation-9",
327///     "evidence": [{ "kind": "file", "reference": "src/caller.rs" }],
328/// })));
329/// let state = derive(&loaded, None);
330/// let view: &DisagreementView = &state.disagreements[0];
331/// assert_eq!(view.discriminating_question, "do callers retry?");
332/// assert_eq!(view.resolution.as_deref(), Some("resolution-2"));
333/// assert_eq!(view.favours.as_deref(), Some("consultation-9"));
334/// ```
335#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
336pub struct DisagreementView {
337    /// Record id.
338    pub id: String,
339    /// The assessment.
340    pub assessment: String,
341    /// The question that decides it.
342    pub discriminating_question: String,
343    /// The resolution, when settled.
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub resolution: Option<String>,
346    /// The position the evidence favoured.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub favours: Option<String>,
349}
350
351/// A phase of the reasoning timeline.
352///
353/// Each record maps to one phase: a plan to consulting, local or reuse by its outcome, a
354/// consultation to an advisor response or a degradation by its status, and every other
355/// kind to the phase of the same name.
356///
357/// ```
358/// # use majordomus_cli::reasoning::record::ReasoningRecord;
359/// # use serde_json::json;
360/// # fn rec(id: &str, n: u32, body: serde_json::Value) -> ReasoningRecord {
361/// #     serde_json::from_value(json!({
362/// #         "schema": "majordomus/reasoning-record", "version": 1, "id": id, "task": "t",
363/// #         "recorded_at": format!("2026-10-01T00:00:{n:02}Z"), "body": body,
364/// #     }))
365/// #     .unwrap()
366/// # }
367/// use majordomus_cli::reasoning::state::{derive, ReasoningPhase};
368/// use majordomus_cli::reasoning::store::Loaded;
369///
370/// let mut loaded = Loaded::default();
371/// loaded.records.push(rec("consultation-1", 1, json!({
372///     "kind": "consultation", "plan": "plan-0", "advisor": "alpha", "status": "unavailable",
373/// })));
374/// let phases: Vec<ReasoningPhase> =
375///     derive(&loaded, None).timeline.iter().map(|s| s.phase).collect();
376/// assert_eq!(phases, [ReasoningPhase::Degradation]);
377/// let word = serde_json::to_value(ReasoningPhase::AdvisorResponse).unwrap();
378/// assert_eq!(word, "advisor_response");
379/// ```
380#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
381#[serde(rename_all = "snake_case")]
382pub enum ReasoningPhase {
383    /// Uncertainty stated with evidence.
384    Uncertainty,
385    /// Review planned.
386    Consulting,
387    /// Decided locally, by plan.
388    Local,
389    /// A standing conclusion reused.
390    Reuse,
391    /// An advisor answered.
392    AdvisorResponse,
393    /// An advisor could not answer.
394    Degradation,
395    /// Positions diverged.
396    Disagreement,
397    /// An experiment settled a disagreement.
398    Experiment,
399    /// A conclusion.
400    Conclusion,
401    /// A validation step.
402    Validation,
403    /// A failed attempt.
404    Attempt,
405}
406
407/// One step of the timeline: when, which record, which phase, and one line saying what
408/// happened.
409///
410/// The summary is cut to a single line of at most 140 characters, so a timeline renders
411/// the same on a terminal, a page and a handover.
412///
413/// ```
414/// # use majordomus_cli::reasoning::record::ReasoningRecord;
415/// # use serde_json::json;
416/// # fn rec(id: &str, n: u32, body: serde_json::Value) -> ReasoningRecord {
417/// #     serde_json::from_value(json!({
418/// #         "schema": "majordomus/reasoning-record", "version": 1, "id": id, "task": "t",
419/// #         "recorded_at": format!("2026-10-01T00:00:{n:02}Z"), "body": body,
420/// #     }))
421/// #     .unwrap()
422/// # }
423/// use majordomus_cli::reasoning::state::{derive, ReasoningPhase, ReasoningStep};
424/// use majordomus_cli::reasoning::store::Loaded;
425///
426/// let mut loaded = Loaded::default();
427/// loaded.records.push(rec("attempt-1", 1, json!({
428///     "kind": "attempt", "attempt": "share the client\nacross threads",
429///     "observed_failure": "the handle is not Sync",
430/// })));
431/// let state = derive(&loaded, None);
432/// let step: &ReasoningStep = &state.timeline[0];
433/// assert_eq!(step.at, "2026-10-01T00:00:01Z");
434/// assert_eq!(step.phase, ReasoningPhase::Attempt);
435/// assert_eq!(step.summary, "share the client failed: the handle is not Sync");
436/// ```
437#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
438pub struct ReasoningStep {
439    /// When.
440    pub at: String,
441    /// The record.
442    pub id: String,
443    /// The phase.
444    pub phase: ReasoningPhase,
445    /// One line.
446    pub summary: String,
447}
448
449/// Totals over the records of the derived task: counts, reviewers and reported tokens.
450///
451/// Independent reviewers are distinct advisors with at least one completed consultation;
452/// token counts are summed only from what adapters reported, never estimated.
453///
454/// ```
455/// # use majordomus_cli::reasoning::record::ReasoningRecord;
456/// # use serde_json::json;
457/// # fn rec(id: &str, n: u32, body: serde_json::Value) -> ReasoningRecord {
458/// #     serde_json::from_value(json!({
459/// #         "schema": "majordomus/reasoning-record", "version": 1, "id": id, "task": "t",
460/// #         "recorded_at": format!("2026-10-01T00:00:{n:02}Z"), "body": body,
461/// #     }))
462/// #     .unwrap()
463/// # }
464/// use majordomus_cli::reasoning::state::{derive, ReasoningTotals};
465/// use majordomus_cli::reasoning::store::Loaded;
466///
467/// let answer = |plan: &str| {
468///     json!({
469///         "kind": "consultation", "plan": plan, "advisor": "alpha", "status": "completed",
470///         "conclusion": "A", "stance": "supports",
471///         "usage": { "input_tokens": 100, "output_tokens": 20 },
472///     })
473/// };
474/// let mut loaded = Loaded::default();
475/// loaded.records.push(rec("consultation-1", 1, answer("plan-a")));
476/// loaded.records.push(rec("consultation-2", 2, answer("plan-b")));
477/// let totals: ReasoningTotals = derive(&loaded, None).totals;
478/// assert_eq!(totals.consultations_completed, 2);
479/// assert_eq!(totals.independent_reviewers, 1, "one advisor, however often asked");
480/// assert_eq!((totals.input_tokens, totals.output_tokens), (200, 40));
481/// ```
482#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
483pub struct ReasoningTotals {
484    /// Records.
485    pub records: usize,
486    /// Consultations that completed.
487    pub consultations_completed: usize,
488    /// Consultations that did not.
489    pub consultations_failed: usize,
490    /// Distinct advisors that answered.
491    pub independent_reviewers: usize,
492    /// Input tokens the adapters reported.
493    pub input_tokens: u64,
494    /// Output tokens the adapters reported.
495    pub output_tokens: u64,
496}
497
498/// The reasoning state of a task (or of every task).
499///
500/// It is what every surface reports: the assessments, consultations, disagreements and
501/// conclusions of the task, the timeline of every record, the totals, and the store
502/// files that could not be read. Derived by [`derive()`], never stored.
503///
504/// ```
505/// # use majordomus_cli::reasoning::record::ReasoningRecord;
506/// # use serde_json::json;
507/// # fn rec(id: &str, task: &str, body: serde_json::Value) -> ReasoningRecord {
508/// #     serde_json::from_value(json!({
509/// #         "schema": "majordomus/reasoning-record", "version": 1, "id": id, "task": task,
510/// #         "recorded_at": "2026-10-01T00:00:00Z", "body": body,
511/// #     }))
512/// #     .unwrap()
513/// # }
514/// use majordomus_cli::reasoning::state::{derive, ReasoningState};
515/// use majordomus_cli::reasoning::store::Loaded;
516///
517/// let mut loaded = Loaded::default();
518/// let assessment = |subject: &str| json!({ "kind": "assessment", "subject": subject });
519/// loaded.records.push(rec("assessment-1", "t1", assessment("a")));
520/// loaded.records.push(rec("assessment-2", "t2", assessment("b")));
521/// loaded.unreadable.push("t2/broken.json".into());
522///
523/// let one: ReasoningState = derive(&loaded, Some("t1"));
524/// assert_eq!((one.task.as_str(), one.assessments.len()), ("t1", 1));
525/// let all = derive(&loaded, None);
526/// assert_eq!((all.task.as_str(), all.totals.records), ("all", 2));
527/// assert_eq!(all.unreadable, ["t2/broken.json"]);
528/// ```
529#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
530pub struct ReasoningState {
531    /// The task reported, or `all`.
532    pub task: String,
533    /// The assessments.
534    pub assessments: Vec<AssessmentView>,
535    /// The consultations.
536    pub consultations: Vec<ConsultationView>,
537    /// The disagreements.
538    pub disagreements: Vec<DisagreementView>,
539    /// The conclusions.
540    pub conclusions: Vec<ConclusionView>,
541    /// The timeline.
542    pub timeline: Vec<ReasoningStep>,
543    /// Totals.
544    pub totals: ReasoningTotals,
545    /// Store files that could not be read.
546    #[serde(default, skip_serializing_if = "Vec::is_empty")]
547    pub unreadable: Vec<String>,
548}
549
550fn one_line(s: &str) -> String {
551    let line = s.lines().next().unwrap_or_default().trim();
552    if line.chars().count() > 140 {
553        format!("{}…", line.chars().take(139).collect::<String>())
554    } else {
555        line.to_string()
556    }
557}
558
559/// Derive the state of `task` (`None` for every task).
560///
561/// Only the records of the task are reported, but references are resolved against every
562/// loaded record, so a conclusion recorded under another task still settles an
563/// assessment. The same records always give the same state.
564///
565/// ```
566/// # use majordomus_cli::reasoning::record::ReasoningRecord;
567/// # use serde_json::json;
568/// # fn rec(id: &str, task: &str, n: u32, body: serde_json::Value) -> ReasoningRecord {
569/// #     serde_json::from_value(json!({
570/// #         "schema": "majordomus/reasoning-record", "version": 1, "id": id, "task": task,
571/// #         "recorded_at": format!("2026-10-01T00:00:{n:02}Z"), "body": body,
572/// #     }))
573/// #     .unwrap()
574/// # }
575/// use majordomus_cli::reasoning::state::{derive, AssessmentStanding};
576/// use majordomus_cli::reasoning::store::Loaded;
577///
578/// let mut loaded = Loaded::default();
579/// let assessment = json!({ "kind": "assessment", "subject": "s" });
580/// loaded.records.push(rec("assessment-1", "t1", 1, assessment));
581/// loaded.records.push(rec("conclusion-2", "t2", 2, json!({
582///     "kind": "conclusion", "assessment": "assessment-1", "decision": "A", "rationale": "r",
583///     "reviewed_by": [], "independent_review_count": 0,
584/// })));
585/// let state = derive(&loaded, Some("t1"));
586/// assert_eq!(state.assessments[0].standing, AssessmentStanding::Concluded);
587/// assert_eq!(state.assessments[0].conclusion.as_deref(), Some("conclusion-2"));
588/// assert!(state.conclusions.is_empty(), "the conclusion is reported under its own task");
589/// ```
590pub fn derive(loaded: &Loaded, task: Option<&str>) -> ReasoningState {
591    let records: Vec<&ReasoningRecord> = loaded
592        .records
593        .iter()
594        .filter(|r| task.is_none_or(|t| r.task == t))
595        .collect();
596    let mut state = ReasoningState {
597        task: task.unwrap_or("all").to_string(),
598        assessments: Vec::new(),
599        consultations: Vec::new(),
600        disagreements: Vec::new(),
601        conclusions: Vec::new(),
602        timeline: Vec::new(),
603        totals: ReasoningTotals {
604            records: records.len(),
605            ..Default::default()
606        },
607        unreadable: loaded.unreadable.clone(),
608    };
609    let mut reviewers: Vec<&str> = Vec::new();
610    for r in &records {
611        let (phase, summary) = match &r.body {
612            ReasoningBody::Assessment(a) => {
613                let conclusion = standing_conclusion(loaded, &r.id);
614                let disputed = loaded.records.iter().any(|d| {
615                    matches!(&d.body, ReasoningBody::Disagreement(x) if x.assessment == r.id)
616                        && resolution_of(loaded, &d.id).is_none()
617                });
618                let planned = loaded
619                    .records
620                    .iter()
621                    .any(|p| matches!(&p.body, ReasoningBody::Plan(x) if x.assessment == r.id));
622                let standing = match conclusion {
623                    _ if disputed => AssessmentStanding::Disputed,
624                    Some(c) => {
625                        let (pass, fail) = validations(loaded, &c.id);
626                        if fail > 0 {
627                            AssessmentStanding::ValidationFailed
628                        } else if pass > 0 {
629                            AssessmentStanding::Validated
630                        } else {
631                            AssessmentStanding::Concluded
632                        }
633                    }
634                    None if planned => AssessmentStanding::Planned,
635                    None => AssessmentStanding::Open,
636                };
637                state.assessments.push(AssessmentView {
638                    id: r.id.clone(),
639                    task: r.task.clone(),
640                    subject: a.subject.clone(),
641                    materiality: a.materiality,
642                    standing,
643                    conclusion: conclusion.map(|c| c.id.clone()),
644                    questions: a.questions.clone(),
645                });
646                (
647                    ReasoningPhase::Uncertainty,
648                    format!(
649                        "{} ({}, {} evidence)",
650                        one_line(&a.subject),
651                        super::policy::materiality_word(a.materiality),
652                        a.evidence.len()
653                    ),
654                )
655            }
656            ReasoningBody::Plan(p) => {
657                let chosen: Vec<&str> =
658                    p.plan.selected.iter().map(|s| s.advisor.as_str()).collect();
659                match p.plan.outcome {
660                    PlanOutcome::Consult => (
661                        ReasoningPhase::Consulting,
662                        format!(
663                            "consult {} ({})",
664                            chosen.join(", "),
665                            one_line(&p.plan.reason)
666                        ),
667                    ),
668                    PlanOutcome::DecideLocally => (ReasoningPhase::Local, one_line(&p.plan.reason)),
669                    PlanOutcome::ReusePrior => (ReasoningPhase::Reuse, one_line(&p.plan.reason)),
670                }
671            }
672            ReasoningBody::Consultation(c) => {
673                state.consultations.push(ConsultationView {
674                    id: r.id.clone(),
675                    plan: c.plan.clone(),
676                    advisor: c.advisor.clone(),
677                    status: c.status,
678                    stance: c.stance,
679                    conclusion: c.conclusion.clone(),
680                    diagnostic: c.diagnostic.clone(),
681                });
682                if let Some(u) = c.usage {
683                    state.totals.input_tokens += u.input_tokens;
684                    state.totals.output_tokens += u.output_tokens;
685                }
686                if c.status == ConsultationStatus::Completed {
687                    state.totals.consultations_completed += 1;
688                    if !reviewers.contains(&c.advisor.as_str()) {
689                        reviewers.push(&c.advisor);
690                    }
691                    (
692                        ReasoningPhase::AdvisorResponse,
693                        format!(
694                            "{} {}: {}",
695                            c.advisor,
696                            c.stance.map(stance_word).unwrap_or("answered"),
697                            one_line(c.conclusion.as_deref().unwrap_or_default())
698                        ),
699                    )
700                } else {
701                    state.totals.consultations_failed += 1;
702                    (
703                        ReasoningPhase::Degradation,
704                        format!(
705                            "{} {}{}",
706                            c.advisor,
707                            c.status.as_str(),
708                            c.diagnostic
709                                .as_deref()
710                                .map(|d| format!(": {}", one_line(d)))
711                                .unwrap_or_default()
712                        ),
713                    )
714                }
715            }
716            ReasoningBody::Disagreement(d) => {
717                let res = resolution_of(loaded, &r.id);
718                state.disagreements.push(DisagreementView {
719                    id: r.id.clone(),
720                    assessment: d.assessment.clone(),
721                    discriminating_question: d.discriminating_question.clone(),
722                    resolution: res.map(|x| x.id.clone()),
723                    favours: res.and_then(|x| match &x.body {
724                        ReasoningBody::Resolution(y) => Some(y.favours.clone()),
725                        _ => None,
726                    }),
727                });
728                (
729                    ReasoningPhase::Disagreement,
730                    format!(
731                        "{} positions; decided by: {}",
732                        d.positions.len(),
733                        one_line(&d.discriminating_question)
734                    ),
735                )
736            }
737            ReasoningBody::Resolution(x) => (
738                ReasoningPhase::Experiment,
739                format!("{} → favours {}", one_line(&x.experiment), x.favours),
740            ),
741            ReasoningBody::Conclusion(c) => {
742                let (pass, fail) = validations(loaded, &r.id);
743                let superseded_by = loaded.records.iter().find_map(|o| match &o.body {
744                    ReasoningBody::Conclusion(x)
745                        if x.input.supersedes.as_deref() == Some(&r.id) =>
746                    {
747                        Some(o.id.clone())
748                    }
749                    _ => None,
750                });
751                state.conclusions.push(ConclusionView {
752                    id: r.id.clone(),
753                    assessment: c.input.assessment.clone(),
754                    decision: c.input.decision.clone(),
755                    reviewed_by: c.reviewed_by.clone(),
756                    independent_review_count: c.independent_review_count,
757                    validations_passed: pass,
758                    validations_failed: fail,
759                    superseded_by,
760                    validation_plan: c.input.validation_plan.clone(),
761                    risks: c.input.risks.clone(),
762                });
763                (
764                    ReasoningPhase::Conclusion,
765                    format!(
766                        "{} ({} independent review(s))",
767                        one_line(&c.input.decision),
768                        c.independent_review_count
769                    ),
770                )
771            }
772            ReasoningBody::Validation(v) => (
773                ReasoningPhase::Validation,
774                format!(
775                    "{}: {}",
776                    match v.outcome {
777                        ValidationOutcome::Pass => "pass",
778                        ValidationOutcome::Fail => "fail",
779                    },
780                    one_line(&v.check)
781                ),
782            ),
783            ReasoningBody::Attempt(a) => (
784                ReasoningPhase::Attempt,
785                format!(
786                    "{} failed: {}",
787                    one_line(&a.attempt),
788                    one_line(&a.observed_failure)
789                ),
790            ),
791        };
792        state.timeline.push(ReasoningStep {
793            at: r.recorded_at.clone(),
794            id: r.id.clone(),
795            phase,
796            summary,
797        });
798    }
799    state.totals.independent_reviewers = reviewers.len();
800    state
801}
802
803fn stance_word(s: Stance) -> &'static str {
804    match s {
805        Stance::Supports => "supports",
806        Stance::Opposes => "opposes",
807        Stance::Alternative => "proposes an alternative",
808        Stance::Inconclusive => "is inconclusive",
809    }
810}
811
812fn validations(loaded: &Loaded, conclusion: &str) -> (usize, usize) {
813    loaded
814        .records
815        .iter()
816        .fold((0, 0), |(p, f), r| match &r.body {
817            ReasoningBody::Validation(v) if v.conclusion == conclusion => match v.outcome {
818                ValidationOutcome::Pass => (p + 1, f),
819                ValidationOutcome::Fail => (p, f + 1),
820            },
821            _ => (p, f),
822        })
823}
824
825/// The state as Markdown: what a handover carries and a final report ends with. Empty
826/// when there is nothing to say.
827///
828/// The paragraph opens with the totals, then lists every standing decision with its
829/// review and validation, every assessment still open, planned or disputed with its open
830/// questions, and every consultation that degraded.
831///
832/// ```
833/// # use majordomus_cli::reasoning::record::ReasoningRecord;
834/// # use serde_json::json;
835/// # fn rec(id: &str, n: u32, body: serde_json::Value) -> ReasoningRecord {
836/// #     serde_json::from_value(json!({
837/// #         "schema": "majordomus/reasoning-record", "version": 1, "id": id, "task": "t",
838/// #         "recorded_at": format!("2026-10-01T00:00:{n:02}Z"), "body": body,
839/// #     }))
840/// #     .unwrap()
841/// # }
842/// use majordomus_cli::reasoning::state::{derive, render_markdown};
843/// use majordomus_cli::reasoning::store::Loaded;
844///
845/// let mut loaded = Loaded::default();
846/// assert_eq!(render_markdown(&derive(&loaded, None)), "");
847///
848/// loaded.records.push(rec("assessment-1", 1, json!({
849///     "kind": "assessment", "subject": "lock scope", "questions": ["held across an await?"],
850/// })));
851/// let report = render_markdown(&derive(&loaded, None));
852/// let lines: Vec<&str> = report.lines().collect();
853/// assert_eq!(
854///     lines,
855///     [
856///         "Reasoning: 1 record(s); 0 consultation(s) completed, 0 failed; \
857///          0 independent reviewer(s).",
858///         "- Unresolved assessment-1: lock scope (Open)",
859///         "  - open question: held across an await?",
860///     ]
861/// );
862/// ```
863pub fn render_markdown(state: &ReasoningState) -> String {
864    if state.totals.records == 0 {
865        return String::new();
866    }
867    let mut out = String::new();
868    let t = &state.totals;
869    out.push_str(&format!(
870        "Reasoning: {} record(s); {} consultation(s) completed, {} failed; {} independent reviewer(s).\n",
871        t.records, t.consultations_completed, t.consultations_failed, t.independent_reviewers
872    ));
873    for c in &state.conclusions {
874        if c.superseded_by.is_some() {
875            continue;
876        }
877        out.push_str(&format!(
878            "- Decision {}: {} — reviewed by {}; validation {} passed, {} failed.\n",
879            c.id,
880            one_line(&c.decision),
881            if c.reviewed_by.is_empty() {
882                "no independent advisor (local reasoning)".to_string()
883            } else {
884                c.reviewed_by.join(", ")
885            },
886            c.validations_passed,
887            c.validations_failed
888        ));
889        for r in &c.risks {
890            out.push_str(&format!("  - risk: {}\n", one_line(r)));
891        }
892    }
893    for a in &state.assessments {
894        if matches!(
895            a.standing,
896            AssessmentStanding::Open | AssessmentStanding::Planned | AssessmentStanding::Disputed
897        ) {
898            out.push_str(&format!(
899                "- Unresolved {}: {} ({:?})\n",
900                a.id,
901                one_line(&a.subject),
902                a.standing
903            ));
904            for q in &a.questions {
905                out.push_str(&format!("  - open question: {}\n", one_line(q)));
906            }
907        }
908    }
909    for c in &state.consultations {
910        if c.status != ConsultationStatus::Completed {
911            out.push_str(&format!(
912                "- Degradation: {} {} ({})\n",
913                c.advisor,
914                c.status.as_str(),
915                c.id
916            ));
917        }
918    }
919    out
920}
921
922/// A record with everything it rests on and everything that rests on it.
923///
924/// It carries the record itself, the assessment it belongs to, and that assessment's
925/// whole provenance chain as timeline steps in order. A record that belongs to no
926/// assessment has a chain of itself alone.
927///
928/// ```
929/// # use majordomus_cli::reasoning::record::ReasoningRecord;
930/// # use serde_json::json;
931/// # fn rec(id: &str, n: u32, body: serde_json::Value) -> ReasoningRecord {
932/// #     serde_json::from_value(json!({
933/// #         "schema": "majordomus/reasoning-record", "version": 1, "id": id, "task": "t",
934/// #         "recorded_at": format!("2026-10-01T00:00:{n:02}Z"), "body": body,
935/// #     }))
936/// #     .unwrap()
937/// # }
938/// use majordomus_cli::reasoning::state::{explain, ReasoningExplanation};
939/// use majordomus_cli::reasoning::store::Loaded;
940///
941/// let mut loaded = Loaded::default();
942/// loaded.records.push(rec("attempt-1", 1, json!({
943///     "kind": "attempt", "attempt": "inline it", "observed_failure": "still slow",
944/// })));
945/// let e: ReasoningExplanation = explain(&loaded, "attempt-1").unwrap();
946/// assert_eq!(e.record.id, "attempt-1");
947/// assert_eq!(e.assessment, None);
948/// assert_eq!(e.chain.len(), 1);
949/// ```
950#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
951pub struct ReasoningExplanation {
952    /// The record asked about.
953    pub record: ReasoningRecord,
954    /// The assessment it belongs to, when it belongs to one.
955    #[serde(default, skip_serializing_if = "Option::is_none")]
956    pub assessment: Option<String>,
957    /// Every record of that assessment, in order: its provenance chain.
958    pub chain: Vec<ReasoningStep>,
959}
960
961/// Explain one record: its assessment and the whole chain of that assessment.
962///
963/// The chain is derived over every task, so a record is explained with everything it
964/// rests on wherever that was recorded. An unknown id explains nothing.
965///
966/// ```
967/// # use majordomus_cli::reasoning::record::ReasoningRecord;
968/// # use serde_json::json;
969/// # fn rec(id: &str, n: u32, body: serde_json::Value) -> ReasoningRecord {
970/// #     serde_json::from_value(json!({
971/// #         "schema": "majordomus/reasoning-record", "version": 1, "id": id, "task": "t",
972/// #         "recorded_at": format!("2026-10-01T00:00:{n:02}Z"), "body": body,
973/// #     }))
974/// #     .unwrap()
975/// # }
976/// use majordomus_cli::reasoning::state::{explain, ReasoningPhase};
977/// use majordomus_cli::reasoning::store::Loaded;
978///
979/// let mut loaded = Loaded::default();
980/// loaded.records.push(rec("assessment-1", 1, json!({ "kind": "assessment", "subject": "s" })));
981/// loaded.records.push(rec("attempt-2", 2, json!({
982///     "kind": "attempt", "attempt": "unrelated", "observed_failure": "x",
983/// })));
984/// loaded.records.push(rec("conclusion-3", 3, json!({
985///     "kind": "conclusion", "assessment": "assessment-1", "decision": "A", "rationale": "r",
986///     "reviewed_by": [], "independent_review_count": 0,
987/// })));
988///
989/// let e = explain(&loaded, "conclusion-3").unwrap();
990/// assert_eq!(e.assessment.as_deref(), Some("assessment-1"));
991/// let phases: Vec<ReasoningPhase> = e.chain.iter().map(|s| s.phase).collect();
992/// assert_eq!(phases, [ReasoningPhase::Uncertainty, ReasoningPhase::Conclusion]);
993/// assert!(explain(&loaded, "conclusion-9").is_none());
994/// ```
995pub fn explain(loaded: &Loaded, id: &str) -> Option<ReasoningExplanation> {
996    let record = loaded.get(id)?.clone();
997    let assessment = assessment_of(loaded, &record);
998    // Derived over every record, so references resolve against everything; then the
999    // steps of this assessment (or the record alone, when it belongs to none).
1000    let chain = derive(loaded, None)
1001        .timeline
1002        .into_iter()
1003        .filter(|e| match &assessment {
1004            Some(a) => loaded
1005                .get(&e.id)
1006                .and_then(|r| assessment_of(loaded, r))
1007                .is_some_and(|x| &x == a),
1008            None => e.id == id,
1009        })
1010        .collect();
1011    Some(ReasoningExplanation {
1012        record,
1013        assessment,
1014        chain,
1015    })
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020    use super::*;
1021
1022    fn rec(id: &str, n: u32, body: ReasoningBody) -> ReasoningRecord {
1023        ReasoningRecord {
1024            schema: RECORD_SCHEMA.into(),
1025            version: RECORD_VERSION,
1026            id: id.into(),
1027            task: "t".into(),
1028            recorded_at: format!("2026-10-01T00:00:{n:02}Z"),
1029            head: None,
1030            redacted: vec![],
1031            body,
1032        }
1033    }
1034
1035    fn conclusion(assessment: &str) -> ReasoningBody {
1036        ReasoningBody::Conclusion(StoredConclusion {
1037            input: ConclusionInput {
1038                assessment: assessment.into(),
1039                decision: "A".into(),
1040                rationale: "r".into(),
1041                ..Default::default()
1042            },
1043            reviewed_by: vec![],
1044            independent_review_count: 0,
1045        })
1046    }
1047
1048    fn validation(conclusion: &str, outcome: ValidationOutcome) -> ReasoningBody {
1049        ReasoningBody::Validation(ValidationInput {
1050            conclusion: conclusion.into(),
1051            check: "x".into(),
1052            command: None,
1053            outcome,
1054            evidence: vec![],
1055        })
1056    }
1057
1058    #[test]
1059    fn one_failed_validation_outweighs_any_number_that_passed() {
1060        let mut loaded = Loaded::default();
1061        loaded.records.push(rec(
1062            "assessment-1",
1063            1,
1064            ReasoningBody::Assessment(AssessmentInput {
1065                subject: "s".into(),
1066                ..Default::default()
1067            }),
1068        ));
1069        loaded
1070            .records
1071            .push(rec("conclusion-2", 2, conclusion("assessment-1")));
1072        loaded.records.push(rec(
1073            "validation-3",
1074            3,
1075            validation("conclusion-2", ValidationOutcome::Pass),
1076        ));
1077        assert_eq!(
1078            derive(&loaded, None).assessments[0].standing,
1079            AssessmentStanding::Validated
1080        );
1081        loaded.records.push(rec(
1082            "validation-4",
1083            4,
1084            validation("conclusion-2", ValidationOutcome::Fail),
1085        ));
1086        assert_eq!(
1087            derive(&loaded, None).assessments[0].standing,
1088            AssessmentStanding::ValidationFailed
1089        );
1090    }
1091
1092    #[test]
1093    fn a_long_summary_is_cut_to_one_line() {
1094        let long = "x".repeat(200);
1095        assert_eq!(one_line(&long).chars().count(), 140);
1096        assert!(one_line(&long).ends_with('…'));
1097        assert_eq!(one_line("first\nsecond"), "first");
1098    }
1099
1100    #[test]
1101    fn a_degraded_consultation_is_reported() {
1102        let mut loaded = Loaded::default();
1103        loaded.records.push(rec(
1104            "consultation-1",
1105            1,
1106            ReasoningBody::Consultation(ConsultationInput {
1107                plan: "plan-0".into(),
1108                advisor: "alpha".into(),
1109                status: ConsultationStatus::RateLimited,
1110                question: None,
1111                evidence: vec![],
1112                conclusion: None,
1113                stance: None,
1114                assumptions: vec![],
1115                risks: vec![],
1116                actions: vec![],
1117                falsifiers: vec![],
1118                confidence: None,
1119                duration_ms: None,
1120                usage: None,
1121                diagnostic: None,
1122                retry_after_seconds: Some(30),
1123            }),
1124        ));
1125        let report = render_markdown(&derive(&loaded, None));
1126        assert!(
1127            report.contains("- Degradation: alpha rate_limited (consultation-1)"),
1128            "{report}"
1129        );
1130    }
1131}