Skip to main content

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(&registry, 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;