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}