Skip to main content

panproto_check/
classify.rs

1//! Classification of schema diffs into breaking vs. non-breaking changes.
2//!
3//! [`classify`] takes a [`SchemaDiff`] and a [`Protocol`] and determines
4//! which changes are backward-incompatible (breaking) and which are safe
5//! (non-breaking). The classification is protocol-aware: for example,
6//! removing a vertex that serves as the target of a required edge is
7//! always breaking.
8//!
9//! # Classification rules
10//!
11//! The tri-state verdict is summarised by [`Classification`]:
12//! *fully-compatible* (no changes in either direction),
13//! *backward-compatible* (non-breaking changes only), or *breaking*
14//! (at least one breaking change).
15//!
16//! Per category, the rules are:
17//!
18//! - **Vertices**: removals breaking, additions non-breaking. A detected
19//!   rename (see [`RenamedVertex`](BreakingChange::RenamedVertex)) is a
20//!   single breaking change that suppresses the removed/added pair.
21//! - **Edges**: removals breaking when the edge kind is governed by a
22//!   protocol edge rule, non-breaking otherwise; additions non-breaking.
23//! - **Required edges**: additions and removals both breaking (a newly
24//!   required edge rejects existing data; a removed requirement drops a
25//!   guarantee consumers relied on).
26//! - **Kind changes**: always breaking.
27//! - **Constraints**: additions breaking, removals non-breaking, value
28//!   changes tightening-breaking / relaxing-non-breaking. Sorts the
29//!   protocol does not recognise fall through to the conservative
30//!   tightening default rather than being dropped.
31//! - **Variants**: removals, modifications, and additions all breaking
32//!   (openness is not encoded in [`Protocol`], so additions default to
33//!   the closed-union reading).
34//! - **Orderings**: ordered-to-unordered, unordered-to-ordered, and
35//!   in-place reorderings all breaking.
36//! - **Recursion points**: additions, removals, and target
37//!   modifications all breaking.
38//! - **Usage modes**: tightening breaking, relaxing non-breaking.
39//! - **NSIDs**: additions non-breaking, changes and removals breaking.
40//! - **Hyper-edges / spans**: additions non-breaking, removals and
41//!   signature modifications breaking.
42//! - **Nominal identity**: any flip breaking in either direction.
43//! - **Enrichments** (coercions, mergers, defaults, policies):
44//!   additions non-breaking, removals and modifications breaking.
45//!   [`classify_with_schemas`] layers the schema-level coercion class
46//!   downgrade check on top.
47//!
48//! The [`classify`] function destructures [`SchemaDiff`] exhaustively so
49//! that a newly added diff field is a compile error until it is given a
50//! rule here; the [`UnclassifiedChange`](BreakingChange::UnclassifiedChange)
51//! bucket is the conservative fail-closed fallback for any residual
52//! sub-case that has no dedicated variant.
53
54use panproto_schema::Protocol;
55use rustc_hash::FxHashSet;
56use serde::{Deserialize, Serialize};
57
58use crate::diff::{ConstraintChange, SchemaDiff};
59
60/// The tri-state compatibility verdict for a [`CompatReport`].
61///
62/// - [`FullyCompatible`](Classification::FullyCompatible): no breaking
63///   and no non-breaking changes; the two schemas are equivalent for
64///   compatibility purposes and round-trip in both directions.
65/// - [`BackwardCompatible`](Classification::BackwardCompatible):
66///   non-breaking changes only; existing data and consumers keep
67///   working, but the reverse direction may not.
68/// - [`Breaking`](Classification::Breaking): at least one breaking
69///   change; existing data or consumers can be invalidated.
70#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum Classification {
73    /// No changes in either direction.
74    FullyCompatible,
75    /// Only non-breaking (backward-compatible) changes.
76    BackwardCompatible,
77    /// At least one breaking change. The default, so deserialising a
78    /// legacy report that predates this field fails closed.
79    #[default]
80    Breaking,
81}
82
83/// The result of classifying a schema diff.
84#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
85pub struct CompatReport {
86    /// Changes that break backward compatibility.
87    pub breaking: Vec<BreakingChange>,
88    /// Changes that are safe for existing consumers.
89    pub non_breaking: Vec<NonBreakingChange>,
90    /// `true` if the migration is fully backward-compatible.
91    pub compatible: bool,
92    /// The tri-state compatibility verdict.
93    #[serde(default)]
94    pub classification: Classification,
95}
96
97/// A change that breaks backward compatibility.
98#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
99#[non_exhaustive]
100pub enum BreakingChange {
101    /// A vertex was removed from the schema.
102    RemovedVertex {
103        /// The removed vertex ID.
104        vertex_id: String,
105    },
106
107    /// An edge was removed from the schema.
108    RemovedEdge {
109        /// Source vertex ID.
110        src: String,
111        /// Target vertex ID.
112        tgt: String,
113        /// Edge kind.
114        kind: String,
115        /// Edge name, if present.
116        name: Option<String>,
117    },
118
119    /// A required edge was added on an existing vertex (existing data
120    /// lacking the edge becomes invalid).
121    RequiredEdgeAdded {
122        /// The vertex the requirement is attached to.
123        vertex_id: String,
124        /// Source vertex ID.
125        src: String,
126        /// Target vertex ID.
127        tgt: String,
128        /// Edge kind.
129        kind: String,
130        /// Edge name, if present.
131        name: Option<String>,
132    },
133
134    /// A required edge was removed on an existing vertex (a guarantee
135    /// consumers relied on is gone).
136    RequiredEdgeRemoved {
137        /// The vertex the requirement was attached to.
138        vertex_id: String,
139        /// Source vertex ID.
140        src: String,
141        /// Target vertex ID.
142        tgt: String,
143        /// Edge kind.
144        kind: String,
145        /// Edge name, if present.
146        name: Option<String>,
147    },
148
149    /// A vertex's kind changed.
150    KindChanged {
151        /// The vertex ID.
152        vertex_id: String,
153        /// The old kind.
154        old_kind: String,
155        /// The new kind.
156        new_kind: String,
157    },
158
159    /// A constraint was tightened (made more restrictive).
160    ConstraintTightened {
161        /// The vertex ID.
162        vertex_id: String,
163        /// The constraint sort.
164        sort: String,
165        /// The old value.
166        old_value: String,
167        /// The new value.
168        new_value: String,
169    },
170
171    /// A new constraint was added to an existing vertex.
172    ConstraintAdded {
173        /// The vertex ID.
174        vertex_id: String,
175        /// The constraint sort.
176        sort: String,
177        /// The constraint value.
178        value: String,
179    },
180
181    /// A coproduct variant was added. Openness is not encoded in the
182    /// protocol, so this defaults to the closed-union reading where
183    /// existing consumers reject the unknown variant.
184    AddedVariant {
185        /// The parent coproduct vertex ID.
186        vertex_id: String,
187        /// The added variant ID.
188        variant_id: String,
189    },
190
191    /// A coproduct variant was removed (type error for existing data).
192    RemovedVariant {
193        /// The parent coproduct vertex ID.
194        vertex_id: String,
195        /// The removed variant ID.
196        variant_id: String,
197    },
198
199    /// A coproduct variant's tag changed.
200    ModifiedVariant {
201        /// The parent coproduct vertex ID.
202        vertex_id: String,
203        /// The variant ID.
204        variant_id: String,
205        /// The old tag.
206        old_tag: Option<String>,
207        /// The new tag.
208        new_tag: Option<String>,
209    },
210
211    /// An ordered collection became unordered (lossy).
212    OrderToUnordered {
213        /// The edge that lost its ordering.
214        edge: panproto_schema::Edge,
215    },
216
217    /// An unordered collection became ordered (consumers relying on set
218    /// semantics can break).
219    UnorderedToOrdered {
220        /// The edge that gained an ordering.
221        edge: panproto_schema::Edge,
222    },
223
224    /// A recursion point was added (the type became recursive).
225    RecursionPointAdded {
226        /// The added fixpoint marker ID.
227        mu_id: String,
228    },
229
230    /// A recursion point was removed (breaks recursive types).
231    RecursionBroken {
232        /// The removed fixpoint marker ID.
233        mu_id: String,
234    },
235
236    /// A recursion point's target vertex changed.
237    RecursionPointModified {
238        /// The fixpoint marker ID.
239        mu_id: String,
240        /// The old target vertex.
241        old_target: String,
242        /// The new target vertex.
243        new_target: String,
244    },
245
246    /// An edge's usage mode was tightened (e.g., structural → linear).
247    LinearityTightened {
248        /// The affected edge.
249        edge: panproto_schema::Edge,
250        /// The old usage mode.
251        old_mode: panproto_schema::UsageMode,
252        /// The new usage mode.
253        new_mode: panproto_schema::UsageMode,
254    },
255
256    /// A vertex's NSID mapping changed.
257    NsidChanged {
258        /// The vertex ID.
259        vertex_id: String,
260        /// The old NSID.
261        old_nsid: String,
262        /// The new NSID.
263        new_nsid: String,
264    },
265
266    /// A vertex's NSID mapping was removed.
267    NsidRemoved {
268        /// The vertex ID.
269        vertex_id: String,
270    },
271
272    /// A hyper-edge was removed from the schema.
273    HyperEdgeRemoved {
274        /// The removed hyper-edge ID.
275        id: String,
276    },
277
278    /// A hyper-edge's kind, signature, or parent label changed.
279    HyperEdgeModified {
280        /// The hyper-edge ID.
281        id: String,
282    },
283
284    /// A span was removed from the schema.
285    SpanRemoved {
286        /// The removed span ID.
287        id: String,
288    },
289
290    /// A span's left or right vertex changed.
291    SpanModified {
292        /// The span ID.
293        id: String,
294    },
295
296    /// A vertex's nominal-identity flag flipped in either direction.
297    NominalFlipped {
298        /// The vertex ID.
299        vertex_id: String,
300        /// The old nominal flag.
301        old_value: bool,
302        /// The new nominal flag.
303        new_value: bool,
304    },
305
306    /// An enrichment (coercion, merger, default, or policy) was removed.
307    EnrichmentRemoved {
308        /// The enrichment category (`"coercion"`, `"merger"`,
309        /// `"default"`, or `"policy"`).
310        category: String,
311        /// The enrichment key (vertex ID, sort, or `"from -> to"` pair).
312        key: String,
313    },
314
315    /// An enrichment (coercion, merger, default, or policy) was modified.
316    EnrichmentModified {
317        /// The enrichment category.
318        category: String,
319        /// The enrichment key.
320        key: String,
321    },
322
323    /// A coercion's round-trip class was downgraded (e.g., Iso to Retraction).
324    CoercionClassDowngraded {
325        /// The source kind of the coercion.
326        from_kind: String,
327        /// The target kind of the coercion.
328        to_kind: String,
329        /// The old coercion class.
330        old_class: String,
331        /// The new coercion class.
332        new_class: String,
333    },
334
335    /// A coercion was removed from the schema.
336    ///
337    /// Diff-level coercion removals are reported as
338    /// [`EnrichmentRemoved`](BreakingChange::EnrichmentRemoved); this
339    /// richer variant is retained for API stability.
340    CoercionRemoved {
341        /// The source kind of the removed coercion.
342        from_kind: String,
343        /// The target kind of the removed coercion.
344        to_kind: String,
345    },
346
347    /// A vertex was renamed (old ID to new ID), detected from a
348    /// removed/added pair.
349    RenamedVertex {
350        /// The old vertex ID.
351        old_id: String,
352        /// The new vertex ID.
353        new_id: String,
354    },
355
356    /// A residual, non-empty diff sub-case with no dedicated variant.
357    ///
358    /// This is the conservative fail-closed bucket: any change routed
359    /// here forces `compatible == false` rather than being dropped.
360    UnclassifiedChange {
361        /// A short label for the sub-case.
362        category: String,
363        /// The number of changes rolled into this bucket.
364        count: usize,
365    },
366}
367
368/// A non-breaking (backward-compatible) change.
369#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
370#[non_exhaustive]
371pub enum NonBreakingChange {
372    /// A new vertex was added.
373    AddedVertex {
374        /// The added vertex ID.
375        vertex_id: String,
376    },
377
378    /// A new edge was added.
379    AddedEdge {
380        /// Source vertex ID.
381        src: String,
382        /// Target vertex ID.
383        tgt: String,
384        /// Edge kind.
385        kind: String,
386        /// Edge name, if present.
387        name: Option<String>,
388    },
389
390    /// A constraint was relaxed (made less restrictive).
391    ConstraintRelaxed {
392        /// The vertex ID.
393        vertex_id: String,
394        /// The constraint sort.
395        sort: String,
396        /// The old value.
397        old_value: String,
398        /// The new value.
399        new_value: String,
400    },
401
402    /// A constraint was removed from a vertex.
403    ConstraintRemoved {
404        /// The vertex ID.
405        vertex_id: String,
406        /// The constraint sort.
407        sort: String,
408    },
409
410    /// An edge was removed but its kind is not governed by any protocol
411    /// edge rule, so it is considered non-breaking.
412    RemovedEdge {
413        /// Source vertex ID.
414        src: String,
415        /// Target vertex ID.
416        tgt: String,
417        /// Edge kind.
418        kind: String,
419        /// Edge name, if present.
420        name: Option<String>,
421    },
422
423    /// A vertex gained an NSID mapping.
424    AddedNsid {
425        /// The vertex ID.
426        vertex_id: String,
427        /// The added NSID.
428        nsid: String,
429    },
430
431    /// A hyper-edge was added to the schema.
432    AddedHyperEdge {
433        /// The added hyper-edge ID.
434        id: String,
435    },
436
437    /// A span was added to the schema.
438    AddedSpan {
439        /// The added span ID.
440        id: String,
441    },
442
443    /// An enrichment (coercion, merger, default, or policy) was added.
444    EnrichmentAdded {
445        /// The enrichment category.
446        category: String,
447        /// The enrichment key.
448        key: String,
449    },
450
451    /// An edge's usage mode was relaxed (e.g., linear → structural).
452    LinearityRelaxed {
453        /// The affected edge.
454        edge: panproto_schema::Edge,
455        /// The old usage mode.
456        old_mode: panproto_schema::UsageMode,
457        /// The new usage mode.
458        new_mode: panproto_schema::UsageMode,
459    },
460}
461
462/// Classify a [`SchemaDiff`] into breaking and non-breaking changes.
463///
464/// The classification depends on the protocol's edge rules to determine
465/// the severity of edge changes, and on the per-category rules
466/// documented at the module level.
467///
468/// [`SchemaDiff`] is destructured exhaustively (no `..` rest pattern) so
469/// that adding a diff field without a classification branch here is a
470/// compile error; the fail-closed rule routes any residual sub-case
471/// into [`UnclassifiedChange`](BreakingChange::UnclassifiedChange).
472#[must_use]
473#[allow(clippy::too_many_lines)]
474pub fn classify(diff: &SchemaDiff, protocol: &Protocol) -> CompatReport {
475    let mut breaking = Vec::new();
476    let mut non_breaking = Vec::new();
477
478    // Fail-closed exhaustive destructuring: a new SchemaDiff field must
479    // gain a branch below or this stops compiling.
480    let SchemaDiff {
481        added_vertices,
482        removed_vertices,
483        kind_changes,
484        added_edges,
485        removed_edges,
486        modified_constraints,
487        added_hyper_edges,
488        removed_hyper_edges,
489        modified_hyper_edges,
490        added_required,
491        removed_required,
492        added_nsids,
493        removed_nsids,
494        changed_nsids,
495        added_variants,
496        removed_variants,
497        modified_variants,
498        order_changes,
499        added_recursion_points,
500        removed_recursion_points,
501        modified_recursion_points,
502        usage_mode_changes,
503        added_spans,
504        removed_spans,
505        modified_spans,
506        nominal_changes,
507        added_coercions,
508        removed_coercions,
509        modified_coercions,
510        added_mergers,
511        removed_mergers,
512        modified_mergers,
513        added_defaults,
514        removed_defaults,
515        modified_defaults,
516        added_policies,
517        removed_policies,
518        modified_policies,
519        renamed_vertices,
520    } = diff;
521
522    // --- Renames --- (record and suppress the removed/added pair)
523    let mut renamed_old: FxHashSet<&str> = FxHashSet::default();
524    let mut renamed_new: FxHashSet<&str> = FxHashSet::default();
525    for (old_id, new_id) in renamed_vertices {
526        renamed_old.insert(old_id.as_str());
527        renamed_new.insert(new_id.as_str());
528        breaking.push(BreakingChange::RenamedVertex {
529            old_id: old_id.clone(),
530            new_id: new_id.clone(),
531        });
532    }
533
534    // --- Required edges --- (both directions breaking)
535    let required_added: FxHashSet<&panproto_schema::Edge> =
536        added_required.values().flatten().collect();
537    let required_removed: FxHashSet<&panproto_schema::Edge> =
538        removed_required.values().flatten().collect();
539    for (vid, edges) in added_required {
540        for e in edges {
541            breaking.push(BreakingChange::RequiredEdgeAdded {
542                vertex_id: vid.clone(),
543                src: e.src.to_string(),
544                tgt: e.tgt.to_string(),
545                kind: e.kind.to_string(),
546                name: e.name.as_ref().map(ToString::to_string),
547            });
548        }
549    }
550    for (vid, edges) in removed_required {
551        for e in edges {
552            breaking.push(BreakingChange::RequiredEdgeRemoved {
553                vertex_id: vid.clone(),
554                src: e.src.to_string(),
555                tgt: e.tgt.to_string(),
556                kind: e.kind.to_string(),
557                name: e.name.as_ref().map(ToString::to_string),
558            });
559        }
560    }
561
562    // Removed vertices are breaking (unless part of a detected rename).
563    for v in removed_vertices {
564        if renamed_old.contains(v.as_str()) {
565            continue;
566        }
567        breaking.push(BreakingChange::RemovedVertex {
568            vertex_id: v.clone(),
569        });
570    }
571
572    // Added vertices are non-breaking (unless part of a detected rename).
573    for v in added_vertices {
574        if renamed_new.contains(v.as_str()) {
575            continue;
576        }
577        non_breaking.push(NonBreakingChange::AddedVertex {
578            vertex_id: v.clone(),
579        });
580    }
581
582    // Removed edges: breaking if the edge kind is governed by a protocol
583    // edge rule. Edges also covered by a required-edge removal are
584    // suppressed to avoid a duplicate breaking entry.
585    for e in removed_edges {
586        if required_removed.contains(e) {
587            continue;
588        }
589        if protocol.find_edge_rule(&e.kind).is_some() {
590            breaking.push(BreakingChange::RemovedEdge {
591                src: e.src.to_string(),
592                tgt: e.tgt.to_string(),
593                kind: e.kind.to_string(),
594                name: e.name.as_ref().map(ToString::to_string),
595            });
596        } else {
597            non_breaking.push(NonBreakingChange::RemovedEdge {
598                src: e.src.to_string(),
599                tgt: e.tgt.to_string(),
600                kind: e.kind.to_string(),
601                name: e.name.as_ref().map(ToString::to_string),
602            });
603        }
604    }
605
606    // Added edges are non-breaking, unless the same edge is also a newly
607    // required edge (already reported as breaking above).
608    for e in added_edges {
609        if required_added.contains(e) {
610            continue;
611        }
612        non_breaking.push(NonBreakingChange::AddedEdge {
613            src: e.src.to_string(),
614            tgt: e.tgt.to_string(),
615            kind: e.kind.to_string(),
616            name: e.name.as_ref().map(ToString::to_string),
617        });
618    }
619
620    // Kind changes are always breaking.
621    for kc in kind_changes {
622        breaking.push(BreakingChange::KindChanged {
623            vertex_id: kc.vertex_id.clone(),
624            old_kind: kc.old_kind.clone(),
625            new_kind: kc.new_kind.clone(),
626        });
627    }
628
629    // Constraint changes are classified for every sort. Unrecognised
630    // sorts fall through to the conservative tightening default in
631    // `is_constraint_tightened` rather than being dropped.
632    for (vid, cdiff) in modified_constraints {
633        for c in &cdiff.added {
634            breaking.push(BreakingChange::ConstraintAdded {
635                vertex_id: vid.clone(),
636                sort: c.sort.to_string(),
637                value: c.value.clone(),
638            });
639        }
640        for c in &cdiff.removed {
641            non_breaking.push(NonBreakingChange::ConstraintRemoved {
642                vertex_id: vid.clone(),
643                sort: c.sort.to_string(),
644            });
645        }
646        for change in &cdiff.changed {
647            classify_constraint_change(vid, change, &mut breaking, &mut non_breaking);
648        }
649    }
650
651    // --- Variant changes ---
652    for v in added_variants {
653        breaking.push(BreakingChange::AddedVariant {
654            vertex_id: v.parent_vertex.to_string(),
655            variant_id: v.id.to_string(),
656        });
657    }
658    for v in removed_variants {
659        breaking.push(BreakingChange::RemovedVariant {
660            vertex_id: v.parent_vertex.to_string(),
661            variant_id: v.id.to_string(),
662        });
663    }
664    for vc in modified_variants {
665        breaking.push(BreakingChange::ModifiedVariant {
666            vertex_id: vc.parent_vertex.clone(),
667            variant_id: vc.id.clone(),
668            old_tag: vc.old_tag.clone(),
669            new_tag: vc.new_tag.clone(),
670        });
671    }
672
673    // --- Ordering changes ---
674    for (edge, old_pos, new_pos) in order_changes {
675        match (old_pos.is_some(), new_pos.is_some()) {
676            (true, false) => {
677                breaking.push(BreakingChange::OrderToUnordered { edge: edge.clone() });
678            }
679            (false, true) => {
680                breaking.push(BreakingChange::UnorderedToOrdered { edge: edge.clone() });
681            }
682            // Both positions present but different: an in-place reorder.
683            // No dedicated variant, so route through the conservative
684            // fail-closed bucket.
685            _ => {
686                breaking.push(BreakingChange::UnclassifiedChange {
687                    category: "reordered_edge".to_string(),
688                    count: 1,
689                });
690            }
691        }
692    }
693
694    // --- Recursion point changes ---
695    for (mu, _) in added_recursion_points {
696        breaking.push(BreakingChange::RecursionPointAdded {
697            mu_id: mu.to_string(),
698        });
699    }
700    for (mu, _) in removed_recursion_points {
701        breaking.push(BreakingChange::RecursionBroken {
702            mu_id: mu.to_string(),
703        });
704    }
705    for rpc in modified_recursion_points {
706        breaking.push(BreakingChange::RecursionPointModified {
707            mu_id: rpc.mu_id.clone(),
708            old_target: rpc.old_target.clone(),
709            new_target: rpc.new_target.clone(),
710        });
711    }
712
713    // --- Usage mode changes ---
714    for (edge, old_mode, new_mode) in usage_mode_changes {
715        if is_usage_tightened(old_mode, new_mode) {
716            breaking.push(BreakingChange::LinearityTightened {
717                edge: edge.clone(),
718                old_mode: old_mode.clone(),
719                new_mode: new_mode.clone(),
720            });
721        } else {
722            non_breaking.push(NonBreakingChange::LinearityRelaxed {
723                edge: edge.clone(),
724                old_mode: old_mode.clone(),
725                new_mode: new_mode.clone(),
726            });
727        }
728    }
729
730    // --- NSID changes ---
731    for (vid, nsid) in added_nsids {
732        non_breaking.push(NonBreakingChange::AddedNsid {
733            vertex_id: vid.clone(),
734            nsid: nsid.clone(),
735        });
736    }
737    for vid in removed_nsids {
738        breaking.push(BreakingChange::NsidRemoved {
739            vertex_id: vid.clone(),
740        });
741    }
742    for (vid, old_nsid, new_nsid) in changed_nsids {
743        breaking.push(BreakingChange::NsidChanged {
744            vertex_id: vid.clone(),
745            old_nsid: old_nsid.clone(),
746            new_nsid: new_nsid.clone(),
747        });
748    }
749
750    // --- Hyper-edge changes ---
751    for id in added_hyper_edges {
752        non_breaking.push(NonBreakingChange::AddedHyperEdge { id: id.clone() });
753    }
754    for id in removed_hyper_edges {
755        breaking.push(BreakingChange::HyperEdgeRemoved { id: id.clone() });
756    }
757    for hec in modified_hyper_edges {
758        breaking.push(BreakingChange::HyperEdgeModified { id: hec.id.clone() });
759    }
760
761    // --- Span changes ---
762    for id in added_spans {
763        non_breaking.push(NonBreakingChange::AddedSpan { id: id.clone() });
764    }
765    for id in removed_spans {
766        breaking.push(BreakingChange::SpanRemoved { id: id.clone() });
767    }
768    for sc in modified_spans {
769        breaking.push(BreakingChange::SpanModified { id: sc.id.clone() });
770    }
771
772    // --- Nominal identity changes ---
773    for (vid, old_val, new_val) in nominal_changes {
774        breaking.push(BreakingChange::NominalFlipped {
775            vertex_id: vid.clone(),
776            old_value: *old_val,
777            new_value: *new_val,
778        });
779    }
780
781    // --- Enrichment changes ---
782    classify_enrichment(
783        "coercion",
784        added_coercions.iter().map(coercion_key),
785        removed_coercions.iter().map(coercion_key),
786        modified_coercions.iter().map(coercion_key),
787        &mut breaking,
788        &mut non_breaking,
789    );
790    classify_enrichment(
791        "merger",
792        added_mergers.iter().cloned(),
793        removed_mergers.iter().cloned(),
794        modified_mergers.iter().cloned(),
795        &mut breaking,
796        &mut non_breaking,
797    );
798    classify_enrichment(
799        "default",
800        added_defaults.iter().cloned(),
801        removed_defaults.iter().cloned(),
802        modified_defaults.iter().cloned(),
803        &mut breaking,
804        &mut non_breaking,
805    );
806    classify_enrichment(
807        "policy",
808        added_policies.iter().cloned(),
809        removed_policies.iter().cloned(),
810        modified_policies.iter().cloned(),
811        &mut breaking,
812        &mut non_breaking,
813    );
814
815    finish_report(breaking, non_breaking)
816}
817
818/// Classify a schema diff with access to the old and new schemas for
819/// enrichment-level checks (coercion class downgrades).
820///
821/// This extends the basic [`classify`] with the schema-level coercion
822/// class downgrade check, which is not derivable from the structural
823/// diff alone. Diff-level coercion removals are already reported by
824/// [`classify`] as [`EnrichmentRemoved`](BreakingChange::EnrichmentRemoved).
825#[must_use]
826pub fn classify_with_schemas(
827    diff: &SchemaDiff,
828    protocol: &Protocol,
829    old_schema: &panproto_schema::Schema,
830    new_schema: &panproto_schema::Schema,
831) -> CompatReport {
832    let mut report = classify(diff, protocol);
833
834    // Check coercion class downgrades: if a coercion exists in both schemas
835    // but the new class is strictly greater (more lossy) than the old class,
836    // that is a breaking change.
837    for (key, new_spec) in &new_schema.coercions {
838        if let Some(old_spec) = old_schema.coercions.get(key) {
839            if new_spec.class > old_spec.class {
840                report
841                    .breaking
842                    .push(BreakingChange::CoercionClassDowngraded {
843                        from_kind: key.0.to_string(),
844                        to_kind: key.1.to_string(),
845                        old_class: format!("{:?}", old_spec.class),
846                        new_class: format!("{:?}", new_spec.class),
847                    });
848            }
849        }
850    }
851
852    report.compatible = report.breaking.is_empty();
853    report.classification = classify_verdict(&report.breaking, &report.non_breaking);
854    report
855}
856
857/// Build the tri-state verdict from the breaking/non-breaking lists.
858const fn classify_verdict(
859    breaking: &[BreakingChange],
860    non_breaking: &[NonBreakingChange],
861) -> Classification {
862    if !breaking.is_empty() {
863        Classification::Breaking
864    } else if non_breaking.is_empty() {
865        Classification::FullyCompatible
866    } else {
867        Classification::BackwardCompatible
868    }
869}
870
871/// Assemble a [`CompatReport`], deriving `compatible` and `classification`.
872fn finish_report(
873    breaking: Vec<BreakingChange>,
874    non_breaking: Vec<NonBreakingChange>,
875) -> CompatReport {
876    let compatible = breaking.is_empty();
877    let classification = classify_verdict(&breaking, &non_breaking);
878    CompatReport {
879        breaking,
880        non_breaking,
881        compatible,
882        classification,
883    }
884}
885
886/// Format a coercion `(from, to)` key as a display string.
887fn coercion_key(key: &(String, String)) -> String {
888    format!("{} -> {}", key.0, key.1)
889}
890
891/// Classify one enrichment category: additions non-breaking, removals
892/// and modifications breaking.
893fn classify_enrichment(
894    category: &str,
895    added: impl IntoIterator<Item = String>,
896    removed: impl IntoIterator<Item = String>,
897    modified: impl IntoIterator<Item = String>,
898    breaking: &mut Vec<BreakingChange>,
899    non_breaking: &mut Vec<NonBreakingChange>,
900) {
901    for key in added {
902        non_breaking.push(NonBreakingChange::EnrichmentAdded {
903            category: category.to_string(),
904            key,
905        });
906    }
907    for key in removed {
908        breaking.push(BreakingChange::EnrichmentRemoved {
909            category: category.to_string(),
910            key,
911        });
912    }
913    for key in modified {
914        breaking.push(BreakingChange::EnrichmentModified {
915            category: category.to_string(),
916            key,
917        });
918    }
919}
920
921/// Determine whether a usage-mode change is a tightening.
922///
923/// Tightening restricts how an edge may be used: `Structural` → `Affine`
924/// or `Linear`, or `Affine` → `Linear`. Everything else is a relaxation.
925const fn is_usage_tightened(
926    old_mode: &panproto_schema::UsageMode,
927    new_mode: &panproto_schema::UsageMode,
928) -> bool {
929    use panproto_schema::UsageMode::{Affine, Linear, Structural};
930    matches!(
931        (old_mode, new_mode),
932        (Structural | Affine, Linear) | (Structural, Affine)
933    )
934}
935
936/// Determine whether a constraint value change is tightening or relaxing.
937fn classify_constraint_change(
938    vertex_id: &str,
939    change: &ConstraintChange,
940    breaking: &mut Vec<BreakingChange>,
941    non_breaking: &mut Vec<NonBreakingChange>,
942) {
943    let is_tightened = is_constraint_tightened(&change.sort, &change.old_value, &change.new_value);
944
945    if is_tightened {
946        breaking.push(BreakingChange::ConstraintTightened {
947            vertex_id: vertex_id.to_string(),
948            sort: change.sort.clone(),
949            old_value: change.old_value.clone(),
950            new_value: change.new_value.clone(),
951        });
952    } else {
953        non_breaking.push(NonBreakingChange::ConstraintRelaxed {
954            vertex_id: vertex_id.to_string(),
955            sort: change.sort.clone(),
956            old_value: change.old_value.clone(),
957            new_value: change.new_value.clone(),
958        });
959    }
960}
961
962/// Determine if a constraint value change is a tightening.
963///
964/// For upper-bound constraints (`maxLength`, `maximum`, etc.), a smaller
965/// new value is tighter. For lower-bound constraints (`minLength`, `minimum`),
966/// a larger new value is tighter. For all others, any change is
967/// considered tightening.
968fn is_constraint_tightened(sort: &str, old_val: &str, new_val: &str) -> bool {
969    match sort {
970        "maxLength" | "maxSize" | "maximum" | "maxGraphemes" => {
971            let old_n: Result<i64, _> = old_val.parse();
972            let new_n: Result<i64, _> = new_val.parse();
973            if let (Ok(o), Ok(n)) = (old_n, new_n) {
974                return n < o;
975            }
976            // Non-numeric: any change is tightening.
977            true
978        }
979        "minLength" | "minimum" => {
980            let old_n: Result<i64, _> = old_val.parse();
981            let new_n: Result<i64, _> = new_val.parse();
982            if let (Ok(o), Ok(n)) = (old_n, new_n) {
983                return n > o;
984            }
985            true
986        }
987        _ => {
988            // For unknown constraint sorts, any change is tightening.
989            true
990        }
991    }
992}
993
994#[cfg(test)]
995mod tests {
996    use super::*;
997    use crate::diff::{
998        ConstraintDiff, HyperEdgeChange, KindChange, RecursionPointChange, SpanChange,
999        VariantChange,
1000    };
1001    use panproto_schema::{Constraint, Edge, EdgeRule, RecursionPoint, UsageMode, Variant};
1002    use std::collections::HashMap;
1003
1004    fn test_protocol() -> Protocol {
1005        Protocol {
1006            name: "test".into(),
1007            schema_theory: "ThTest".into(),
1008            instance_theory: "ThWType".into(),
1009            edge_rules: vec![EdgeRule {
1010                edge_kind: "prop".into(),
1011                src_kinds: vec!["object".into()],
1012                tgt_kinds: vec![],
1013            }],
1014            obj_kinds: vec!["object".into()],
1015            constraint_sorts: vec!["maxLength".into()],
1016            ..Protocol::default()
1017        }
1018    }
1019
1020    fn edge(src: &str, tgt: &str, kind: &str, name: Option<&str>) -> Edge {
1021        Edge {
1022            src: src.into(),
1023            tgt: tgt.into(),
1024            kind: kind.into(),
1025            name: name.map(Into::into),
1026        }
1027    }
1028
1029    #[test]
1030    fn classify_removed_required_field_as_breaking() {
1031        let diff = SchemaDiff {
1032            removed_vertices: vec!["body.text".into()],
1033            removed_edges: vec![edge("body", "body.text", "prop", Some("text"))],
1034            ..SchemaDiff::default()
1035        };
1036
1037        let report = classify(&diff, &test_protocol());
1038        assert!(!report.compatible, "removing a vertex should be breaking");
1039        assert_eq!(report.breaking.len(), 2); // vertex + edge
1040        assert_eq!(report.classification, Classification::Breaking);
1041    }
1042
1043    #[test]
1044    fn classify_added_optional_field_as_non_breaking() {
1045        let diff = SchemaDiff {
1046            added_vertices: vec!["body.newField".into()],
1047            added_edges: vec![edge("body", "body.newField", "prop", Some("newField"))],
1048            ..SchemaDiff::default()
1049        };
1050
1051        let report = classify(&diff, &test_protocol());
1052        assert!(report.compatible, "adding a vertex should be non-breaking");
1053        assert_eq!(report.non_breaking.len(), 2); // vertex + edge
1054        assert!(report.breaking.is_empty());
1055        assert_eq!(report.classification, Classification::BackwardCompatible);
1056    }
1057
1058    #[test]
1059    fn classify_empty_diff_is_fully_compatible() {
1060        let report = classify(&SchemaDiff::default(), &test_protocol());
1061        assert!(report.compatible);
1062        assert!(report.breaking.is_empty());
1063        assert!(report.non_breaking.is_empty());
1064        assert_eq!(report.classification, Classification::FullyCompatible);
1065    }
1066
1067    // -----------------------------------------------------------------------
1068    // Required edges
1069    // -----------------------------------------------------------------------
1070
1071    #[test]
1072    fn classify_added_required_edge_as_breaking() {
1073        let e = edge("body", "body.text", "prop", Some("text"));
1074        let diff = SchemaDiff {
1075            added_required: HashMap::from([("body".into(), vec![e.clone()])]),
1076            // An added-and-required edge also shows up as an added edge.
1077            added_edges: vec![e],
1078            ..SchemaDiff::default()
1079        };
1080
1081        let report = classify(&diff, &test_protocol());
1082        assert!(!report.compatible, "adding a required edge is breaking");
1083        assert!(
1084            report
1085                .breaking
1086                .iter()
1087                .any(|b| matches!(b, BreakingChange::RequiredEdgeAdded { .. }))
1088        );
1089        // The duplicate non-breaking AddedEdge is suppressed.
1090        assert!(
1091            !report
1092                .non_breaking
1093                .iter()
1094                .any(|nb| matches!(nb, NonBreakingChange::AddedEdge { .. }))
1095        );
1096    }
1097
1098    #[test]
1099    fn classify_removed_required_edge_as_breaking() {
1100        let e = edge("body", "body.text", "prop", Some("text"));
1101        let diff = SchemaDiff {
1102            removed_required: HashMap::from([("body".into(), vec![e])]),
1103            ..SchemaDiff::default()
1104        };
1105        let report = classify(&diff, &test_protocol());
1106        assert!(!report.compatible);
1107        assert!(
1108            report
1109                .breaking
1110                .iter()
1111                .any(|b| matches!(b, BreakingChange::RequiredEdgeRemoved { .. }))
1112        );
1113    }
1114
1115    // -----------------------------------------------------------------------
1116    // Variants
1117    // -----------------------------------------------------------------------
1118
1119    #[test]
1120    fn classify_added_variant_as_breaking_under_unknown_openness() {
1121        let diff = SchemaDiff {
1122            added_variants: vec![Variant {
1123                id: "v2".into(),
1124                parent_vertex: "u".into(),
1125                tag: Some("b".into()),
1126            }],
1127            ..SchemaDiff::default()
1128        };
1129        let report = classify(&diff, &test_protocol());
1130        assert!(!report.compatible, "added variant defaults to breaking");
1131        assert!(
1132            report
1133                .breaking
1134                .iter()
1135                .any(|b| matches!(b, BreakingChange::AddedVariant { .. }))
1136        );
1137    }
1138
1139    #[test]
1140    fn classify_modified_variant_as_breaking() {
1141        let diff = SchemaDiff {
1142            modified_variants: vec![VariantChange {
1143                id: "v1".into(),
1144                parent_vertex: "u".into(),
1145                old_tag: Some("a".into()),
1146                new_tag: Some("b".into()),
1147            }],
1148            ..SchemaDiff::default()
1149        };
1150        let report = classify(&diff, &test_protocol());
1151        assert!(!report.compatible, "modified variant is breaking");
1152        assert!(
1153            report
1154                .breaking
1155                .iter()
1156                .any(|b| matches!(b, BreakingChange::ModifiedVariant { .. }))
1157        );
1158    }
1159
1160    // -----------------------------------------------------------------------
1161    // Constraints
1162    // -----------------------------------------------------------------------
1163
1164    #[test]
1165    fn classify_constraint_tightening_as_breaking() {
1166        let diff = SchemaDiff {
1167            modified_constraints: std::iter::once((
1168                "body.text".into(),
1169                ConstraintDiff {
1170                    added: vec![],
1171                    removed: vec![],
1172                    changed: vec![ConstraintChange {
1173                        sort: "maxLength".into(),
1174                        old_value: "3000".into(),
1175                        new_value: "300".into(),
1176                    }],
1177                },
1178            ))
1179            .collect(),
1180            ..SchemaDiff::default()
1181        };
1182
1183        let report = classify(&diff, &test_protocol());
1184        assert!(
1185            !report.compatible,
1186            "tightening maxLength should be breaking"
1187        );
1188        assert!(
1189            report
1190                .breaking
1191                .iter()
1192                .any(|b| matches!(b, BreakingChange::ConstraintTightened { .. }))
1193        );
1194    }
1195
1196    #[test]
1197    fn classify_constraint_relaxing_as_non_breaking() {
1198        let diff = SchemaDiff {
1199            modified_constraints: std::iter::once((
1200                "body.text".into(),
1201                ConstraintDiff {
1202                    added: vec![],
1203                    removed: vec![],
1204                    changed: vec![ConstraintChange {
1205                        sort: "maxLength".into(),
1206                        old_value: "300".into(),
1207                        new_value: "3000".into(),
1208                    }],
1209                },
1210            ))
1211            .collect(),
1212            ..SchemaDiff::default()
1213        };
1214
1215        let report = classify(&diff, &test_protocol());
1216        assert!(
1217            report.compatible,
1218            "relaxing maxLength should be non-breaking"
1219        );
1220        assert!(
1221            report
1222                .non_breaking
1223                .iter()
1224                .any(|nb| matches!(nb, NonBreakingChange::ConstraintRelaxed { .. }))
1225        );
1226    }
1227
1228    #[test]
1229    fn classify_unlisted_sort_constraint_change_as_breaking() {
1230        // 'customSort' is not in the protocol's constraint_sorts, but its
1231        // change must still be classified via the conservative
1232        // tightening default rather than dropped.
1233        let diff = SchemaDiff {
1234            modified_constraints: std::iter::once((
1235                "body.text".into(),
1236                ConstraintDiff {
1237                    added: vec![],
1238                    removed: vec![],
1239                    changed: vec![ConstraintChange {
1240                        sort: "customSort".into(),
1241                        old_value: "a".into(),
1242                        new_value: "b".into(),
1243                    }],
1244                },
1245            ))
1246            .collect(),
1247            ..SchemaDiff::default()
1248        };
1249
1250        let report = classify(&diff, &test_protocol());
1251        assert!(
1252            !report.compatible,
1253            "a change on an unlisted constraint sort must be breaking"
1254        );
1255    }
1256
1257    #[test]
1258    fn classify_added_constraint_on_unlisted_sort_as_breaking() {
1259        let diff = SchemaDiff {
1260            modified_constraints: std::iter::once((
1261                "body.text".into(),
1262                ConstraintDiff {
1263                    added: vec![Constraint {
1264                        sort: "customSort".into(),
1265                        value: "v".into(),
1266                    }],
1267                    removed: vec![],
1268                    changed: vec![],
1269                },
1270            ))
1271            .collect(),
1272            ..SchemaDiff::default()
1273        };
1274        let report = classify(&diff, &test_protocol());
1275        assert!(!report.compatible);
1276        assert!(
1277            report
1278                .breaking
1279                .iter()
1280                .any(|b| matches!(b, BreakingChange::ConstraintAdded { .. }))
1281        );
1282    }
1283
1284    #[test]
1285    fn classify_kind_change_as_breaking() {
1286        let diff = SchemaDiff {
1287            kind_changes: vec![KindChange {
1288                vertex_id: "x".into(),
1289                old_kind: "string".into(),
1290                new_kind: "integer".into(),
1291            }],
1292            ..SchemaDiff::default()
1293        };
1294
1295        let report = classify(&diff, &test_protocol());
1296        assert!(!report.compatible, "kind change should be breaking");
1297    }
1298
1299    #[test]
1300    fn classify_removed_non_governed_edge_as_non_breaking() {
1301        let diff = SchemaDiff {
1302            removed_edges: vec![edge("body", "body.note", "annotation", Some("note"))],
1303            ..SchemaDiff::default()
1304        };
1305
1306        let report = classify(&diff, &test_protocol());
1307        assert!(report.compatible);
1308        assert_eq!(report.non_breaking.len(), 1);
1309        assert!(report.non_breaking.iter().any(
1310            |nb| matches!(nb, NonBreakingChange::RemovedEdge { kind, .. } if kind == "annotation")
1311        ),);
1312    }
1313
1314    #[test]
1315    fn classify_removed_governed_edge_as_breaking() {
1316        let diff = SchemaDiff {
1317            removed_edges: vec![edge("body", "body.text", "prop", Some("text"))],
1318            ..SchemaDiff::default()
1319        };
1320
1321        let report = classify(&diff, &test_protocol());
1322        assert!(!report.compatible);
1323        assert_eq!(report.breaking.len(), 1);
1324        assert!(
1325            report
1326                .breaking
1327                .iter()
1328                .any(|b| matches!(b, BreakingChange::RemovedEdge { kind, .. } if kind == "prop"))
1329        );
1330    }
1331
1332    // -----------------------------------------------------------------------
1333    // Per-category breaking/non-breaking coverage
1334    // -----------------------------------------------------------------------
1335
1336    #[test]
1337    fn nsid_add_non_breaking_change_remove_breaking() {
1338        let added = SchemaDiff {
1339            added_nsids: HashMap::from([("a".into(), "com.example.thing".into())]),
1340            ..SchemaDiff::default()
1341        };
1342        assert!(classify(&added, &test_protocol()).compatible);
1343
1344        let changed = SchemaDiff {
1345            changed_nsids: vec![("a".into(), "com.old".into(), "com.new".into())],
1346            ..SchemaDiff::default()
1347        };
1348        assert!(!classify(&changed, &test_protocol()).compatible);
1349
1350        let removed = SchemaDiff {
1351            removed_nsids: vec!["a".into()],
1352            ..SchemaDiff::default()
1353        };
1354        assert!(!classify(&removed, &test_protocol()).compatible);
1355    }
1356
1357    #[test]
1358    fn hyper_edge_add_non_breaking_remove_modify_breaking() {
1359        let added = SchemaDiff {
1360            added_hyper_edges: vec!["he1".into()],
1361            ..SchemaDiff::default()
1362        };
1363        assert!(classify(&added, &test_protocol()).compatible);
1364
1365        let removed = SchemaDiff {
1366            removed_hyper_edges: vec!["he1".into()],
1367            ..SchemaDiff::default()
1368        };
1369        assert!(!classify(&removed, &test_protocol()).compatible);
1370
1371        let modified = SchemaDiff {
1372            modified_hyper_edges: vec![HyperEdgeChange {
1373                id: "he1".into(),
1374                kind_change: Some(("join".into(), "merge".into())),
1375                signature_added: HashMap::new(),
1376                signature_removed: HashMap::new(),
1377                signature_changed: HashMap::new(),
1378                parent_label_change: None,
1379            }],
1380            ..SchemaDiff::default()
1381        };
1382        assert!(!classify(&modified, &test_protocol()).compatible);
1383    }
1384
1385    #[test]
1386    fn span_add_non_breaking_remove_modify_breaking() {
1387        let added = SchemaDiff {
1388            added_spans: vec!["s1".into()],
1389            ..SchemaDiff::default()
1390        };
1391        assert!(classify(&added, &test_protocol()).compatible);
1392
1393        let removed = SchemaDiff {
1394            removed_spans: vec!["s1".into()],
1395            ..SchemaDiff::default()
1396        };
1397        assert!(!classify(&removed, &test_protocol()).compatible);
1398
1399        let modified = SchemaDiff {
1400            modified_spans: vec![SpanChange {
1401                id: "s1".into(),
1402                left_change: Some(("a".into(), "b".into())),
1403                right_change: None,
1404            }],
1405            ..SchemaDiff::default()
1406        };
1407        assert!(!classify(&modified, &test_protocol()).compatible);
1408    }
1409
1410    #[test]
1411    fn nominal_flip_breaking_both_directions() {
1412        for (old, new) in [(false, true), (true, false)] {
1413            let diff = SchemaDiff {
1414                nominal_changes: vec![("a".into(), old, new)],
1415                ..SchemaDiff::default()
1416            };
1417            assert!(
1418                !classify(&diff, &test_protocol()).compatible,
1419                "nominal flip {old}->{new} must be breaking"
1420            );
1421        }
1422    }
1423
1424    #[test]
1425    fn recursion_point_add_remove_modify_breaking() {
1426        let added = SchemaDiff {
1427            added_recursion_points: vec![(
1428                "m".into(),
1429                RecursionPoint {
1430                    target_vertex: "t".into(),
1431                },
1432            )],
1433            ..SchemaDiff::default()
1434        };
1435        assert!(!classify(&added, &test_protocol()).compatible);
1436
1437        let removed = SchemaDiff {
1438            removed_recursion_points: vec![(
1439                "m".into(),
1440                RecursionPoint {
1441                    target_vertex: "t".into(),
1442                },
1443            )],
1444            ..SchemaDiff::default()
1445        };
1446        assert!(!classify(&removed, &test_protocol()).compatible);
1447
1448        let modified = SchemaDiff {
1449            modified_recursion_points: vec![RecursionPointChange {
1450                mu_id: "m".into(),
1451                old_target: "a".into(),
1452                new_target: "b".into(),
1453            }],
1454            ..SchemaDiff::default()
1455        };
1456        assert!(!classify(&modified, &test_protocol()).compatible);
1457    }
1458
1459    #[test]
1460    fn ordering_transitions_breaking() {
1461        let e = edge("a", "b", "prop", None);
1462        let to_unordered = SchemaDiff {
1463            order_changes: vec![(e.clone(), Some(0), None)],
1464            ..SchemaDiff::default()
1465        };
1466        assert!(!classify(&to_unordered, &test_protocol()).compatible);
1467
1468        let to_ordered = SchemaDiff {
1469            order_changes: vec![(e.clone(), None, Some(0))],
1470            ..SchemaDiff::default()
1471        };
1472        let report = classify(&to_ordered, &test_protocol());
1473        assert!(!report.compatible);
1474        assert!(
1475            report
1476                .breaking
1477                .iter()
1478                .any(|b| matches!(b, BreakingChange::UnorderedToOrdered { .. }))
1479        );
1480
1481        let reordered = SchemaDiff {
1482            order_changes: vec![(e, Some(0), Some(1))],
1483            ..SchemaDiff::default()
1484        };
1485        let report = classify(&reordered, &test_protocol());
1486        assert!(!report.compatible);
1487        assert!(
1488            report
1489                .breaking
1490                .iter()
1491                .any(|b| matches!(b, BreakingChange::UnclassifiedChange { .. }))
1492        );
1493    }
1494
1495    #[test]
1496    fn usage_mode_tighten_breaking_relax_non_breaking() {
1497        let e = edge("a", "b", "prop", None);
1498        let tighten = SchemaDiff {
1499            usage_mode_changes: vec![(e.clone(), UsageMode::Structural, UsageMode::Linear)],
1500            ..SchemaDiff::default()
1501        };
1502        assert!(!classify(&tighten, &test_protocol()).compatible);
1503
1504        let relax = SchemaDiff {
1505            usage_mode_changes: vec![(e, UsageMode::Linear, UsageMode::Structural)],
1506            ..SchemaDiff::default()
1507        };
1508        assert!(classify(&relax, &test_protocol()).compatible);
1509    }
1510
1511    #[test]
1512    fn enrichment_add_non_breaking_remove_modify_breaking() {
1513        let added = SchemaDiff {
1514            added_coercions: vec![("a".into(), "b".into())],
1515            added_mergers: vec!["m".into()],
1516            added_defaults: vec!["d".into()],
1517            added_policies: vec!["p".into()],
1518            ..SchemaDiff::default()
1519        };
1520        assert!(classify(&added, &test_protocol()).compatible);
1521
1522        let removed = SchemaDiff {
1523            removed_coercions: vec![("a".into(), "b".into())],
1524            ..SchemaDiff::default()
1525        };
1526        assert!(!classify(&removed, &test_protocol()).compatible);
1527
1528        let modified = SchemaDiff {
1529            modified_policies: vec!["p".into()],
1530            ..SchemaDiff::default()
1531        };
1532        assert!(!classify(&modified, &test_protocol()).compatible);
1533    }
1534
1535    /// Fail-closed guarantee: every diff category, populated
1536    /// alone, produces a classification (breaking for removals /
1537    /// modifications / tightenings, non-breaking for optional
1538    /// additions), never a silent "compatible" verdict on a non-empty
1539    /// diff.
1540    #[test]
1541    #[allow(clippy::too_many_lines)]
1542    fn fail_closed_every_category_is_classified() {
1543        let e = edge("a", "b", "prop", None);
1544        let breaking_cases: Vec<(&str, SchemaDiff)> = vec![
1545            (
1546                "removed_vertices",
1547                SchemaDiff {
1548                    removed_vertices: vec!["a".into()],
1549                    ..SchemaDiff::default()
1550                },
1551            ),
1552            (
1553                "kind_changes",
1554                SchemaDiff {
1555                    kind_changes: vec![KindChange {
1556                        vertex_id: "a".into(),
1557                        old_kind: "x".into(),
1558                        new_kind: "y".into(),
1559                    }],
1560                    ..SchemaDiff::default()
1561                },
1562            ),
1563            (
1564                "removed_hyper_edges",
1565                SchemaDiff {
1566                    removed_hyper_edges: vec!["he".into()],
1567                    ..SchemaDiff::default()
1568                },
1569            ),
1570            (
1571                "added_required",
1572                SchemaDiff {
1573                    added_required: HashMap::from([("a".into(), vec![e.clone()])]),
1574                    ..SchemaDiff::default()
1575                },
1576            ),
1577            (
1578                "removed_required",
1579                SchemaDiff {
1580                    removed_required: HashMap::from([("a".into(), vec![e.clone()])]),
1581                    ..SchemaDiff::default()
1582                },
1583            ),
1584            (
1585                "changed_nsids",
1586                SchemaDiff {
1587                    changed_nsids: vec![("a".into(), "x".into(), "y".into())],
1588                    ..SchemaDiff::default()
1589                },
1590            ),
1591            (
1592                "removed_nsids",
1593                SchemaDiff {
1594                    removed_nsids: vec!["a".into()],
1595                    ..SchemaDiff::default()
1596                },
1597            ),
1598            (
1599                "added_variants",
1600                SchemaDiff {
1601                    added_variants: vec![Variant {
1602                        id: "v".into(),
1603                        parent_vertex: "u".into(),
1604                        tag: None,
1605                    }],
1606                    ..SchemaDiff::default()
1607                },
1608            ),
1609            (
1610                "removed_variants",
1611                SchemaDiff {
1612                    removed_variants: vec![Variant {
1613                        id: "v".into(),
1614                        parent_vertex: "u".into(),
1615                        tag: None,
1616                    }],
1617                    ..SchemaDiff::default()
1618                },
1619            ),
1620            (
1621                "order_to_unordered",
1622                SchemaDiff {
1623                    order_changes: vec![(e.clone(), Some(0), None)],
1624                    ..SchemaDiff::default()
1625                },
1626            ),
1627            (
1628                "unordered_to_ordered",
1629                SchemaDiff {
1630                    order_changes: vec![(e, None, Some(0))],
1631                    ..SchemaDiff::default()
1632                },
1633            ),
1634            (
1635                "added_recursion_points",
1636                SchemaDiff {
1637                    added_recursion_points: vec![(
1638                        "m".into(),
1639                        RecursionPoint {
1640                            target_vertex: "t".into(),
1641                        },
1642                    )],
1643                    ..SchemaDiff::default()
1644                },
1645            ),
1646            (
1647                "modified_recursion_points",
1648                SchemaDiff {
1649                    modified_recursion_points: vec![RecursionPointChange {
1650                        mu_id: "m".into(),
1651                        old_target: "a".into(),
1652                        new_target: "b".into(),
1653                    }],
1654                    ..SchemaDiff::default()
1655                },
1656            ),
1657            (
1658                "removed_spans",
1659                SchemaDiff {
1660                    removed_spans: vec!["s".into()],
1661                    ..SchemaDiff::default()
1662                },
1663            ),
1664            (
1665                "nominal_changes",
1666                SchemaDiff {
1667                    nominal_changes: vec![("a".into(), false, true)],
1668                    ..SchemaDiff::default()
1669                },
1670            ),
1671            (
1672                "removed_coercions",
1673                SchemaDiff {
1674                    removed_coercions: vec![("a".into(), "b".into())],
1675                    ..SchemaDiff::default()
1676                },
1677            ),
1678            (
1679                "removed_mergers",
1680                SchemaDiff {
1681                    removed_mergers: vec!["m".into()],
1682                    ..SchemaDiff::default()
1683                },
1684            ),
1685            (
1686                "removed_defaults",
1687                SchemaDiff {
1688                    removed_defaults: vec!["d".into()],
1689                    ..SchemaDiff::default()
1690                },
1691            ),
1692            (
1693                "removed_policies",
1694                SchemaDiff {
1695                    removed_policies: vec!["p".into()],
1696                    ..SchemaDiff::default()
1697                },
1698            ),
1699        ];
1700
1701        for (label, diff) in &breaking_cases {
1702            let report = classify(diff, &test_protocol());
1703            assert!(
1704                !report.compatible,
1705                "category {label} must classify as breaking"
1706            );
1707            assert_eq!(report.classification, Classification::Breaking, "{label}");
1708        }
1709
1710        // Optional additions are backward-compatible, not dropped.
1711        let non_breaking_cases: Vec<(&str, SchemaDiff)> = vec![
1712            (
1713                "added_nsids",
1714                SchemaDiff {
1715                    added_nsids: HashMap::from([("a".into(), "x".into())]),
1716                    ..SchemaDiff::default()
1717                },
1718            ),
1719            (
1720                "added_hyper_edges",
1721                SchemaDiff {
1722                    added_hyper_edges: vec!["he".into()],
1723                    ..SchemaDiff::default()
1724                },
1725            ),
1726            (
1727                "added_spans",
1728                SchemaDiff {
1729                    added_spans: vec!["s".into()],
1730                    ..SchemaDiff::default()
1731                },
1732            ),
1733            (
1734                "added_coercions",
1735                SchemaDiff {
1736                    added_coercions: vec![("a".into(), "b".into())],
1737                    ..SchemaDiff::default()
1738                },
1739            ),
1740            (
1741                "added_mergers",
1742                SchemaDiff {
1743                    added_mergers: vec!["m".into()],
1744                    ..SchemaDiff::default()
1745                },
1746            ),
1747            (
1748                "added_defaults",
1749                SchemaDiff {
1750                    added_defaults: vec!["d".into()],
1751                    ..SchemaDiff::default()
1752                },
1753            ),
1754            (
1755                "added_policies",
1756                SchemaDiff {
1757                    added_policies: vec!["p".into()],
1758                    ..SchemaDiff::default()
1759                },
1760            ),
1761        ];
1762
1763        for (label, diff) in &non_breaking_cases {
1764            let report = classify(diff, &test_protocol());
1765            assert!(report.compatible, "category {label} should be non-breaking");
1766            assert_eq!(
1767                report.classification,
1768                Classification::BackwardCompatible,
1769                "{label}"
1770            );
1771            assert!(!report.non_breaking.is_empty(), "{label} produced no entry");
1772        }
1773    }
1774
1775    // -----------------------------------------------------------------------
1776    // Renames
1777    // -----------------------------------------------------------------------
1778
1779    #[test]
1780    fn classify_rename_suppresses_removed_added_pair() {
1781        let diff = SchemaDiff {
1782            removed_vertices: vec!["root.text".into()],
1783            added_vertices: vec!["root.body".into()],
1784            renamed_vertices: vec![("root.text".into(), "root.body".into())],
1785            ..SchemaDiff::default()
1786        };
1787
1788        let report = classify(&diff, &test_protocol());
1789        assert_eq!(
1790            report.breaking.len(),
1791            1,
1792            "only the rename should be breaking"
1793        );
1794        assert!(report.breaking.iter().any(
1795            |b| matches!(b, BreakingChange::RenamedVertex { old_id, new_id }
1796                    if old_id == "root.text" && new_id == "root.body")
1797        ));
1798        assert!(
1799            !report
1800                .breaking
1801                .iter()
1802                .any(|b| matches!(b, BreakingChange::RemovedVertex { .. })),
1803            "the removed vertex must be suppressed"
1804        );
1805        assert!(
1806            report.non_breaking.is_empty(),
1807            "the added vertex must be suppressed"
1808        );
1809    }
1810
1811    // -----------------------------------------------------------------------
1812    // Classification with schemas
1813    // -----------------------------------------------------------------------
1814
1815    #[test]
1816    fn classify_with_schemas_sets_classification() {
1817        let diff = SchemaDiff {
1818            added_vertices: vec!["x".into()],
1819            ..SchemaDiff::default()
1820        };
1821        let schema = empty_schema();
1822        let report = classify_with_schemas(&diff, &test_protocol(), &schema, &schema);
1823        assert_eq!(report.classification, Classification::BackwardCompatible);
1824    }
1825
1826    fn empty_schema() -> panproto_schema::Schema {
1827        panproto_schema::Schema {
1828            protocol: "test".into(),
1829            vertices: HashMap::new(),
1830            edges: HashMap::new(),
1831            hyper_edges: HashMap::new(),
1832            constraints: HashMap::new(),
1833            required: HashMap::new(),
1834            nsids: HashMap::new(),
1835            entries: Vec::new(),
1836            variants: HashMap::new(),
1837            orderings: HashMap::new(),
1838            recursion_points: HashMap::new(),
1839            spans: HashMap::new(),
1840            usage_modes: HashMap::new(),
1841            nominal: HashMap::new(),
1842            coercions: HashMap::new(),
1843            mergers: HashMap::new(),
1844            defaults: HashMap::new(),
1845            policies: HashMap::new(),
1846            outgoing: HashMap::new(),
1847            incoming: HashMap::new(),
1848            between: HashMap::new(),
1849        }
1850    }
1851}