mcp_trace_validator/multi.rs
1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! Multi-revision judgment: one trace against several protocol revisions in a single
5//! pass, with per-clause applicability differences made visible.
6//!
7//! [`validate_revisions`] projects a [`RegistrySet`] to each requested revision, runs the
8//! ordinary [`engine::validate`] against each projection, and
9//! aligns the results into a [`MultiReport`]: one row per clause in the union, carrying
10//! its outcome under every judged revision. A clause that does not exist at a revision
11//! (its `applies` range excludes it) reports `None` there — *absent*, which the report
12//! keeps distinct from [`Outcome::NotApplicable`] (the clause exists at that revision but
13//! a capability gating it was never negotiated, ADR-0006). Seeing both side by side is
14//! what makes a migration's gains and losses legible: a clause removed in the newer
15//! revision reads `pass` then `absent`; one introduced there reads `absent` then `pass`.
16//!
17//! Against the two registries this build ships, those are the *only* patterns — see
18//! [`MultiRow::differs`] for why, and for what that costs the `*differs` marker.
19
20use core::fmt;
21use core::fmt::Write as _;
22
23use mcp_conformance_core::requirement::RegistrySet;
24use mcp_conformance_core::revision::ProtocolRevision;
25use mcp_conformance_core::trace::TraceEvent;
26use serde::{Deserialize, Serialize};
27
28use crate::engine;
29use crate::report::{Outcome, Report, Totals, Verdict};
30
31/// Error produced by a multi-revision run.
32#[derive(Debug, Clone, PartialEq, Eq)]
33#[non_exhaustive]
34pub enum MultiError {
35 /// No revisions were requested; there is nothing to judge against.
36 NoRevisions,
37 /// A requested revision is not one the registry set describes.
38 UnknownRevision(ProtocolRevision),
39}
40
41impl fmt::Display for MultiError {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 match self {
44 Self::NoRevisions => f.write_str("no revisions requested for multi-revision judgment"),
45 Self::UnknownRevision(revision) => {
46 write!(f, "registry set does not describe revision {revision}")
47 }
48 }
49 }
50}
51
52impl core::error::Error for MultiError {}
53
54/// One revision's aggregate result within a [`MultiReport`].
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[non_exhaustive]
57pub struct RevisionSummary {
58 /// The protocol revision (`YYYY-MM-DD`).
59 pub revision: String,
60 /// Aggregate counts for this revision's projected registry.
61 pub totals: Totals,
62 /// This revision's standalone verdict.
63 pub verdict: Verdict,
64}
65
66/// One clause's row across every judged revision.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[non_exhaustive]
69pub struct MultiRow {
70 /// The requirement ID (`AREA-NNN`).
71 pub id: String,
72 /// The requirement's RFC 2119 level, as registry text (`"MUST"`, …).
73 pub level: String,
74 /// Outcome under each judged revision, aligned by index with
75 /// [`MultiReport::revisions`]. `None` means the clause does not exist at that
76 /// revision — *absent*, not [`Outcome::NotApplicable`].
77 pub outcomes: Vec<Option<Outcome>>,
78}
79
80impl MultiRow {
81 /// Whether this clause's presence-or-outcome is not uniform across the judged
82 /// revisions.
83 ///
84 /// How much this discriminates depends on the registries judged, and against
85 /// the two this build ships it discriminates nothing: the registries are
86 /// extracted per revision rather than sharing entries, so a clause restated
87 /// with narrower text at the later revision gets its own ID — the reason
88 /// `2025-11-25`'s BASE-003 (no reuse within a session) and `2026-07-28`'s
89 /// BASE-045 (no reuse *while in flight*) are two clauses and not one. The ID
90 /// spaces are therefore disjoint, every row is `absent` on one side, and
91 /// `differs` is true for all of them. Read the *pattern* instead: `pass` then
92 /// `absent` is a clause the migration removes, `absent` then `pass` one it
93 /// adds. A row that differs in outcome while present at both revisions —
94 /// the one a review would want first — cannot occur here, and would only
95 /// arise for a revision pair that does share clauses.
96 #[must_use]
97 pub fn differs(&self) -> bool {
98 self.outcomes.windows(2).any(|pair| pair[0] != pair[1])
99 }
100}
101
102/// A multi-revision report: the same trace judged against several revisions, aligned per
103/// clause.
104///
105/// Like [`Report`], it is an artifact — serialization order is fixed (revisions in the
106/// order requested; clauses in registry-union order) and nothing environment-dependent
107/// appears.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[non_exhaustive]
110pub struct MultiReport {
111 /// The revisions judged, in the order requested — the column order for every row.
112 pub revisions: Vec<String>,
113 /// The revisions the *session* declared, when it declared some and none of
114 /// [`Self::revisions`] is among them. Named explicitly on the command line
115 /// or not, judging a recording against rules it was never playing by is the
116 /// same mistake, and the reader is told so either way
117 /// ([`crate::declared`]).
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub revision_mismatch: Option<Vec<String>>,
120 /// Per-revision aggregate results, aligned by index with `revisions`.
121 pub summaries: Vec<RevisionSummary>,
122 /// Union of clauses across the judged revisions, in registry-union order. A clause is
123 /// included when it exists at one or more of the judged revisions.
124 pub requirements: Vec<MultiRow>,
125}
126
127impl MultiReport {
128 /// The overall verdict: the worst across revisions, by the same severity priority a
129 /// single [`Report`] uses (unsupported ≻ fail ≻ pass-with-warnings ≻ pass). A
130 /// multi-revision run is only as good as its weakest revision.
131 #[must_use]
132 pub fn verdict(&self) -> Verdict {
133 let any = |verdict: Verdict| self.summaries.iter().any(|s| s.verdict == verdict);
134 if any(Verdict::Unsupported) {
135 Verdict::Unsupported
136 } else if any(Verdict::Fail) {
137 Verdict::Fail
138 } else if any(Verdict::PassWithWarnings) {
139 Verdict::PassWithWarnings
140 } else {
141 Verdict::Pass
142 }
143 }
144
145 /// Renders the human-readable form.
146 #[must_use]
147 pub fn render_human(&self) -> String {
148 let mut out = String::new();
149 let _ = writeln!(
150 out,
151 "MCP multi-revision validation — revisions {}",
152 self.revisions.join(", ")
153 );
154 self.write_revision_mismatch(&mut out);
155 for row in &self.requirements {
156 let _ = write!(out, " {:<10} ({})", row.id, row.level);
157 for (revision, outcome) in self.revisions.iter().zip(&row.outcomes) {
158 let _ = write!(out, " {revision}={}", cell_token(*outcome));
159 }
160 if row.differs() {
161 let _ = write!(out, " *differs");
162 }
163 let _ = writeln!(out);
164 }
165 let _ = writeln!(out, "per revision:");
166 for summary in &self.summaries {
167 // The same phrase the single-revision report prints, from the same
168 // source: this line used to name six of the seven outcomes, so a
169 // reader adding it up found fewer clauses than the revision has.
170 let _ = writeln!(
171 out,
172 " {}: {} — verdict {}",
173 summary.revision, summary.totals, summary.verdict
174 );
175 }
176 let _ = writeln!(out, "overall verdict: {}", self.verdict());
177 self.write_revision_mismatch(&mut out);
178 out
179 }
180
181 /// The revision-disagreement note, worded for a run that names its own
182 /// revisions: the fix is a different `--revision`, not the absence of one.
183 fn write_revision_mismatch(&self, out: &mut String) {
184 let Some(declared) = &self.revision_mismatch else {
185 return;
186 };
187 let (subject, tail) = if declared.len() == 1 {
188 ("revision", "which was not judged")
189 } else {
190 ("revisions", "none of which was judged")
191 };
192 let _ = writeln!(
193 out,
194 " NOTE this session declares protocol {subject} {}, {tail}.",
195 declared.join(", ")
196 );
197 let _ = writeln!(
198 out,
199 " Every outcome here judges it against rules it was not playing by."
200 );
201 }
202}
203
204/// The per-cell token for a clause's outcome under one revision. Exhaustive on purpose
205/// (same-crate enum): a new [`Outcome`] variant must force a deliberate token here.
206const fn cell_token(outcome: Option<Outcome>) -> &'static str {
207 match outcome {
208 None => "absent",
209 Some(Outcome::Pass) => "pass",
210 Some(Outcome::Fail) => "fail",
211 Some(Outcome::Warn) => "warn",
212 Some(Outcome::Excluded) => "excluded",
213 Some(Outcome::Unsupported) => "unsupported",
214 Some(Outcome::NotApplicable) => "not-applicable",
215 Some(Outcome::NotObserved) => "not-observed",
216 }
217}
218
219/// Validates one trace against several protocol revisions in a single pass.
220///
221/// ```
222/// use mcp_conformance_core::requirement::RegistrySet;
223/// use mcp_trace_validator::multi;
224///
225/// // BASE-001 is present throughout; LIFE-009 is removed at 2026-07-28.
226/// let set = RegistrySet::from_json(r#"{
227/// "revisions": ["2025-11-25", "2026-07-28"],
228/// "requirements": [
229/// {"id": "BASE-001", "level": "MUST", "actor": "both",
230/// "source": {"section": "basic#x", "quote": "MUST jsonrpc 2.0"},
231/// "checks": ["base.jsonrpc-version"]},
232/// {"id": "LIFE-009", "level": "MUST", "actor": "server",
233/// "applies": {"removed": "2026-07-28"},
234/// "source": {"section": "life#y", "quote": "MUST jsonrpc 2.0"},
235/// "checks": ["base.jsonrpc-version"]}
236/// ]
237/// }"#)?;
238///
239/// let revisions = ["2025-11-25".parse()?, "2026-07-28".parse()?];
240/// let report = multi::validate_revisions(&set, &revisions, &[])?;
241///
242/// assert_eq!(report.revisions, ["2025-11-25", "2026-07-28"]);
243/// let life = report.requirements.iter().find(|r| r.id == "LIFE-009").unwrap();
244/// assert!(life.outcomes[0].is_some()); // present at 2025-11-25
245/// assert!(life.outcomes[1].is_none()); // absent at 2026-07-28
246/// assert!(life.differs());
247/// # Ok::<(), Box<dyn core::error::Error>>(())
248/// ```
249///
250/// # Errors
251///
252/// [`MultiError::NoRevisions`] when `revisions` is empty, and
253/// [`MultiError::UnknownRevision`] when a requested revision is not one `set` describes.
254pub fn validate_revisions(
255 set: &RegistrySet,
256 revisions: &[ProtocolRevision],
257 events: &[TraceEvent],
258) -> Result<MultiReport, MultiError> {
259 if revisions.is_empty() {
260 return Err(MultiError::NoRevisions);
261 }
262 let mut summaries = Vec::with_capacity(revisions.len());
263 let mut reports = Vec::with_capacity(revisions.len());
264 for &revision in revisions {
265 let registry = set
266 .registry(revision)
267 .ok_or(MultiError::UnknownRevision(revision))?;
268 let report = engine::validate(®istry, events);
269 summaries.push(RevisionSummary {
270 revision: revision.to_string(),
271 totals: report.totals,
272 verdict: report.verdict(),
273 });
274 reports.push(report);
275 }
276
277 // The union, in registry-union order: walk the set's requirements once and keep each
278 // clause that exists at one or more judged revisions. A projected report contains
279 // exactly the clauses in force at its revision, so a clause's outcome there is "found
280 // in that report" and its absence is "not found" — applicability needs no second
281 // source of truth.
282 let mut rows = Vec::new();
283 for requirement in set.requirements() {
284 let id = requirement.id.as_str();
285 let outcomes: Vec<Option<Outcome>> = reports
286 .iter()
287 .map(|report| outcome_in(report, id))
288 .collect();
289 if outcomes.iter().all(Option::is_none) {
290 continue;
291 }
292 rows.push(MultiRow {
293 id: id.to_owned(),
294 level: requirement.level.keyword().to_owned(),
295 outcomes,
296 });
297 }
298
299 Ok(MultiReport {
300 revisions: revisions.iter().map(ProtocolRevision::to_string).collect(),
301 revision_mismatch: crate::declared::mismatch_any(revisions, events),
302 summaries,
303 requirements: rows,
304 })
305}
306
307/// One clause's outcome within a single-revision report, by ID; `None` when the clause is
308/// not in that report (it does not exist at that revision).
309fn outcome_in(report: &Report, id: &str) -> Option<Outcome> {
310 report
311 .requirements
312 .iter()
313 .find(|row| row.id == id)
314 .map(|row| row.outcome)
315}
316
317#[cfg(test)]
318mod tests;