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
17use core::fmt;
18use core::fmt::Write as _;
19
20use mcp_conformance_core::requirement::RegistrySet;
21use mcp_conformance_core::revision::ProtocolRevision;
22use mcp_conformance_core::trace::TraceEvent;
23use serde::{Deserialize, Serialize};
24
25use crate::engine;
26use crate::report::{Outcome, Report, Totals, Verdict};
27
28/// Error produced by a multi-revision run.
29#[derive(Debug, Clone, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum MultiError {
32    /// No revisions were requested; there is nothing to judge against.
33    NoRevisions,
34    /// A requested revision is not one the registry set describes.
35    UnknownRevision(ProtocolRevision),
36}
37
38impl fmt::Display for MultiError {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        match self {
41            Self::NoRevisions => f.write_str("no revisions requested for multi-revision judgment"),
42            Self::UnknownRevision(revision) => {
43                write!(f, "registry set does not describe revision {revision}")
44            }
45        }
46    }
47}
48
49impl core::error::Error for MultiError {}
50
51/// One revision's aggregate result within a [`MultiReport`].
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[non_exhaustive]
54pub struct RevisionSummary {
55    /// The protocol revision (`YYYY-MM-DD`).
56    pub revision: String,
57    /// Aggregate counts for this revision's projected registry.
58    pub totals: Totals,
59    /// This revision's standalone verdict.
60    pub verdict: Verdict,
61}
62
63/// One clause's row across every judged revision.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[non_exhaustive]
66pub struct MultiRow {
67    /// The requirement ID (`AREA-NNN`).
68    pub id: String,
69    /// The requirement's RFC 2119 level, as registry text (`"MUST"`, …).
70    pub level: String,
71    /// Outcome under each judged revision, aligned by index with
72    /// [`MultiReport::revisions`]. `None` means the clause does not exist at that
73    /// revision — *absent*, not [`Outcome::NotApplicable`].
74    pub outcomes: Vec<Option<Outcome>>,
75}
76
77impl MultiRow {
78    /// Whether this clause's presence-or-outcome is not uniform across the judged
79    /// revisions — the rows a migration review wants to look at first.
80    #[must_use]
81    pub fn differs(&self) -> bool {
82        self.outcomes.windows(2).any(|pair| pair[0] != pair[1])
83    }
84}
85
86/// A multi-revision report: the same trace judged against several revisions, aligned per
87/// clause.
88///
89/// Like [`Report`], it is an artifact — serialization order is fixed (revisions in the
90/// order requested; clauses in registry-union order) and nothing environment-dependent
91/// appears.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[non_exhaustive]
94pub struct MultiReport {
95    /// The revisions judged, in the order requested — the column order for every row.
96    pub revisions: Vec<String>,
97    /// Per-revision aggregate results, aligned by index with `revisions`.
98    pub summaries: Vec<RevisionSummary>,
99    /// Union of clauses across the judged revisions, in registry-union order. A clause is
100    /// included when it exists at one or more of the judged revisions.
101    pub requirements: Vec<MultiRow>,
102}
103
104impl MultiReport {
105    /// The overall verdict: the worst across revisions, by the same severity priority a
106    /// single [`Report`] uses (unsupported ≻ fail ≻ pass-with-warnings ≻ pass). A
107    /// multi-revision run is only as good as its weakest revision.
108    #[must_use]
109    pub fn verdict(&self) -> Verdict {
110        let any = |verdict: Verdict| self.summaries.iter().any(|s| s.verdict == verdict);
111        if any(Verdict::Unsupported) {
112            Verdict::Unsupported
113        } else if any(Verdict::Fail) {
114            Verdict::Fail
115        } else if any(Verdict::PassWithWarnings) {
116            Verdict::PassWithWarnings
117        } else {
118            Verdict::Pass
119        }
120    }
121
122    /// Renders the human-readable form.
123    #[must_use]
124    pub fn render_human(&self) -> String {
125        let mut out = String::new();
126        let _ = writeln!(
127            out,
128            "MCP multi-revision validation — revisions {}",
129            self.revisions.join(", ")
130        );
131        for row in &self.requirements {
132            let _ = write!(out, "  {:<10} ({})", row.id, row.level);
133            for (revision, outcome) in self.revisions.iter().zip(&row.outcomes) {
134                let _ = write!(out, "  {revision}={}", cell_token(*outcome));
135            }
136            if row.differs() {
137                let _ = write!(out, "  *differs");
138            }
139            let _ = writeln!(out);
140        }
141        let _ = writeln!(out, "per revision:");
142        for summary in &self.summaries {
143            let totals = summary.totals;
144            let _ = writeln!(
145                out,
146                "  {}: {} pass, {} fail, {} warn, {} excluded, {} unsupported, {} not applicable — verdict {}",
147                summary.revision,
148                totals.pass,
149                totals.fail,
150                totals.warn,
151                totals.excluded,
152                totals.unsupported,
153                totals.not_applicable,
154                summary.verdict
155            );
156        }
157        let _ = writeln!(out, "overall verdict: {}", self.verdict());
158        out
159    }
160}
161
162/// The per-cell token for a clause's outcome under one revision. Exhaustive on purpose
163/// (same-crate enum): a new [`Outcome`] variant must force a deliberate token here.
164const fn cell_token(outcome: Option<Outcome>) -> &'static str {
165    match outcome {
166        None => "absent",
167        Some(Outcome::Pass) => "pass",
168        Some(Outcome::Fail) => "fail",
169        Some(Outcome::Warn) => "warn",
170        Some(Outcome::Excluded) => "excluded",
171        Some(Outcome::Unsupported) => "unsupported",
172        Some(Outcome::NotApplicable) => "not-applicable",
173    }
174}
175
176/// Validates one trace against several protocol revisions in a single pass.
177///
178/// ```
179/// use mcp_conformance_core::requirement::RegistrySet;
180/// use mcp_trace_validator::multi;
181///
182/// // BASE-001 is present throughout; LIFE-009 is removed at 2026-07-28.
183/// let set = RegistrySet::from_json(r#"{
184///     "revisions": ["2025-11-25", "2026-07-28"],
185///     "requirements": [
186///         {"id": "BASE-001", "level": "MUST", "actor": "both",
187///          "source": {"section": "basic#x", "quote": "MUST jsonrpc 2.0"},
188///          "checks": ["base.jsonrpc-version"]},
189///         {"id": "LIFE-009", "level": "MUST", "actor": "server",
190///          "applies": {"removed": "2026-07-28"},
191///          "source": {"section": "life#y", "quote": "MUST jsonrpc 2.0"},
192///          "checks": ["base.jsonrpc-version"]}
193///     ]
194/// }"#)?;
195///
196/// let revisions = ["2025-11-25".parse()?, "2026-07-28".parse()?];
197/// let report = multi::validate_revisions(&set, &revisions, &[])?;
198///
199/// assert_eq!(report.revisions, ["2025-11-25", "2026-07-28"]);
200/// let life = report.requirements.iter().find(|r| r.id == "LIFE-009").unwrap();
201/// assert!(life.outcomes[0].is_some()); // present at 2025-11-25
202/// assert!(life.outcomes[1].is_none()); // absent at 2026-07-28
203/// assert!(life.differs());
204/// # Ok::<(), Box<dyn core::error::Error>>(())
205/// ```
206///
207/// # Errors
208///
209/// [`MultiError::NoRevisions`] when `revisions` is empty, and
210/// [`MultiError::UnknownRevision`] when a requested revision is not one `set` describes.
211pub fn validate_revisions(
212    set: &RegistrySet,
213    revisions: &[ProtocolRevision],
214    events: &[TraceEvent],
215) -> Result<MultiReport, MultiError> {
216    if revisions.is_empty() {
217        return Err(MultiError::NoRevisions);
218    }
219    let mut summaries = Vec::with_capacity(revisions.len());
220    let mut reports = Vec::with_capacity(revisions.len());
221    for &revision in revisions {
222        let registry = set
223            .registry(revision)
224            .ok_or(MultiError::UnknownRevision(revision))?;
225        let report = engine::validate(&registry, events);
226        summaries.push(RevisionSummary {
227            revision: revision.to_string(),
228            totals: report.totals,
229            verdict: report.verdict(),
230        });
231        reports.push(report);
232    }
233
234    // The union, in registry-union order: walk the set's requirements once and keep each
235    // clause that exists at one or more judged revisions. A projected report contains
236    // exactly the clauses in force at its revision, so a clause's outcome there is "found
237    // in that report" and its absence is "not found" — applicability needs no second
238    // source of truth.
239    let mut rows = Vec::new();
240    for requirement in set.requirements() {
241        let id = requirement.id.as_str();
242        let outcomes: Vec<Option<Outcome>> = reports
243            .iter()
244            .map(|report| outcome_in(report, id))
245            .collect();
246        if outcomes.iter().all(Option::is_none) {
247            continue;
248        }
249        rows.push(MultiRow {
250            id: id.to_owned(),
251            level: requirement.level.keyword().to_owned(),
252            outcomes,
253        });
254    }
255
256    Ok(MultiReport {
257        revisions: revisions.iter().map(ProtocolRevision::to_string).collect(),
258        summaries,
259        requirements: rows,
260    })
261}
262
263/// One clause's outcome within a single-revision report, by ID; `None` when the clause is
264/// not in that report (it does not exist at that revision).
265fn outcome_in(report: &Report, id: &str) -> Option<Outcome> {
266    report
267        .requirements
268        .iter()
269        .find(|row| row.id == id)
270        .map(|row| row.outcome)
271}
272
273#[cfg(test)]
274#[allow(clippy::unwrap_used)]
275mod tests {
276    use super::*;
277    use crate::reader::{Limits, parse_trace};
278
279    /// A two-revision set: BASE-001 throughout, LIFE-009 removed at 2026-07-28, DISC-001
280    /// introduced at 2026-07-28. All use a real check so outcomes are meaningful.
281    const SET: &str = r#"{
282        "revisions": ["2025-11-25", "2026-07-28"],
283        "requirements": [
284            {"id": "BASE-001", "level": "MUST", "actor": "both",
285             "source": {"section": "b#x", "quote": "MUST jsonrpc 2.0"},
286             "checks": ["base.jsonrpc-version"]},
287            {"id": "LIFE-009", "level": "MUST", "actor": "server",
288             "applies": {"removed": "2026-07-28"},
289             "source": {"section": "l#y", "quote": "MUST jsonrpc 2.0"},
290             "checks": ["base.jsonrpc-version"]},
291            {"id": "DISC-001", "level": "MUST", "actor": "server",
292             "applies": {"introduced": "2026-07-28"},
293             "source": {"section": "d#z", "quote": "MUST jsonrpc 2.0"},
294             "checks": ["base.jsonrpc-version"]}
295        ]
296    }"#;
297
298    fn set() -> RegistrySet {
299        RegistrySet::from_json(SET).unwrap()
300    }
301
302    fn revs() -> [ProtocolRevision; 2] {
303        ["2025-11-25".parse().unwrap(), "2026-07-28".parse().unwrap()]
304    }
305
306    #[test]
307    fn no_revisions_is_an_error() {
308        assert_eq!(
309            validate_revisions(&set(), &[], &[]),
310            Err(MultiError::NoRevisions)
311        );
312    }
313
314    #[test]
315    fn unknown_revision_names_itself() {
316        let unknown: ProtocolRevision = "2024-01-01".parse().unwrap();
317        assert_eq!(
318            validate_revisions(&set(), &[unknown], &[]),
319            Err(MultiError::UnknownRevision(unknown))
320        );
321        assert!(unknown.to_string().contains("2024-01-01"));
322    }
323
324    #[test]
325    fn rows_align_outcomes_with_revisions_and_mark_absence() {
326        let report = validate_revisions(&set(), &revs(), &[]).unwrap();
327        assert_eq!(report.revisions, ["2025-11-25", "2026-07-28"]);
328        assert_eq!(report.summaries.len(), 2);
329
330        let find = |id: &str| {
331            report
332                .requirements
333                .iter()
334                .find(|row| row.id == id)
335                .cloned()
336                .unwrap()
337        };
338
339        // Present throughout: an outcome in both columns, identical, not flagged.
340        let base = find("BASE-001");
341        assert!(base.outcomes[0].is_some() && base.outcomes[1].is_some());
342        assert!(!base.differs());
343
344        // Removed at the boundary: present, then absent.
345        let life = find("LIFE-009");
346        assert!(life.outcomes[0].is_some());
347        assert_eq!(life.outcomes[1], None);
348        assert!(life.differs());
349
350        // Introduced at the boundary: absent, then present.
351        let disc = find("DISC-001");
352        assert_eq!(disc.outcomes[0], None);
353        assert!(disc.outcomes[1].is_some());
354        assert!(disc.differs());
355    }
356
357    #[test]
358    fn union_order_follows_the_set_and_drops_clauses_in_no_judged_revision() {
359        // Judge only the older revision: DISC-001 (introduced later) appears in no judged
360        // revision and must be dropped entirely, not shown as an all-absent row.
361        let older: [ProtocolRevision; 1] = ["2025-11-25".parse().unwrap()];
362        let report = validate_revisions(&set(), &older, &[]).unwrap();
363        let ids: Vec<&str> = report.requirements.iter().map(|r| r.id.as_str()).collect();
364        assert_eq!(ids, ["BASE-001", "LIFE-009"]);
365        // A single judged revision can never "differ".
366        assert!(report.requirements.iter().all(|row| !row.differs()));
367    }
368
369    #[test]
370    fn differs_detects_a_non_adjacent_divergence() {
371        // Three identical-then-different columns: pins `any` against `all` and the row
372        // comparison against equality.
373        let uniform = MultiRow {
374            id: "X-001".to_owned(),
375            level: "MUST".to_owned(),
376            outcomes: vec![
377                Some(Outcome::Pass),
378                Some(Outcome::Pass),
379                Some(Outcome::Pass),
380            ],
381        };
382        assert!(!uniform.differs());
383        let diverges = MultiRow {
384            outcomes: vec![
385                Some(Outcome::Pass),
386                Some(Outcome::Pass),
387                Some(Outcome::Fail),
388            ],
389            ..uniform
390        };
391        assert!(diverges.differs());
392    }
393
394    #[test]
395    fn overall_verdict_is_the_worst_across_revisions() {
396        let mut report = validate_revisions(&set(), &revs(), &[]).unwrap();
397        // The synthetic trace is empty, so every real check passes vacuously.
398        assert_eq!(report.verdict(), Verdict::Pass);
399        // Worsen the second revision and confirm the fold tracks the priority order.
400        report.summaries[1].verdict = Verdict::PassWithWarnings;
401        assert_eq!(report.verdict(), Verdict::PassWithWarnings);
402        report.summaries[1].verdict = Verdict::Fail;
403        assert_eq!(report.verdict(), Verdict::Fail);
404        report.summaries[0].verdict = Verdict::Unsupported;
405        assert_eq!(report.verdict(), Verdict::Unsupported);
406    }
407
408    #[test]
409    fn human_render_shows_each_revision_cell_and_marks_divergence() {
410        let report = validate_revisions(&set(), &revs(), &[]).unwrap();
411        let text = report.render_human();
412        assert!(text.contains("revisions 2025-11-25, 2026-07-28"), "{text}");
413        // The removed clause reads present then absent, and is flagged.
414        assert!(text.contains("LIFE-009"), "{text}");
415        assert!(text.contains("2026-07-28=absent"), "{text}");
416        assert!(text.contains("*differs"), "{text}");
417        assert!(text.contains("overall verdict: pass"), "{text}");
418    }
419
420    #[test]
421    fn judges_a_real_trace_and_is_deterministic() {
422        // A real handshake, judged against both revisions, serializes identically twice.
423        let trace = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}}"#;
424        let events = parse_trace(trace, &Limits::default()).unwrap();
425        let a = validate_revisions(&set(), &revs(), &events).unwrap();
426        let b = validate_revisions(&set(), &revs(), &events).unwrap();
427        assert_eq!(
428            serde_json::to_string(&a).unwrap(),
429            serde_json::to_string(&b).unwrap()
430        );
431    }
432}