Judge labels in the universality checker
The four conditions are set-membership statements over node maps, and
a relabel is the one edit that changes no membership: a merge that
ignored a rename — of code or a comment — passed the checker, both as
an external judge of other tools and in the pipeline self-check. A
fifth condition compares labels wherever the routes commute: a branch
that relabeled a node must see its label in M (missed relabel), M
must keep O's label when neither branch relabeled it (extra relabel),
and a branch's inserted nodes must arrive with labels verbatim —
otherwise the merge holds a label nobody wrote.

Nodes whose routes disagree are skipped rather than judged: that
disagreement is condition 3's extra deletion, and comparing labels
across unrelated twins (duplicated content splits i1 and i2 across
copies) would misfire.

Assisted-by: Claude Fable 5 via Claude Code
change rmkvwlsnorszusvzosvkspqoozqxzypr
commit a1c544fb9b85404346e2ef53b552f8b9bf287e23
author Alpha Chen <alpha@kejadlen.dev>
date
parent tnkutqvw
diff --git a/docs/notes/2026-07-14-mergiraf-comparison.md b/docs/notes/2026-07-14-mergiraf-comparison.md
index e649958..96f2063 100644
--- a/docs/notes/2026-07-14-mergiraf-comparison.md
+++ b/docs/notes/2026-07-14-mergiraf-comparison.md
@@ -310,10 +310,15 @@ between the pre- and post-comment builds is
 *silent* comment-dropping merge; the remaining BAD-MERGE is
 `conflicting_method_signatures`, the known signature-composition gap.
 
-Sweeping also surfaced a checker limitation worth its own follow-up:
-`d3j check` cannot see a *dropped relabel* (comment or code — a merge
-that ignores A's rename of `a` to `b` passes). The four universality
-conditions are set-membership statements over node maps; none of them
-compares labels along the routes. The merge pipeline itself conflicts
-correctly via relabel-relabel, but `check` as an external judge of
-other tools' merges is blind to this class.
+Sweeping also surfaced a checker limitation, fixed the same day: `d3j
+check` could not see a *dropped relabel* (comment or code — a merge
+that ignored A's rename of `a` to `b` passed). The four universality
+conditions were set-membership statements over node maps; none of
+them compared labels along the routes. A fifth condition now judges
+labels wherever the routes commute: a branch that relabeled a node
+must see its label in M (missed relabel), M must keep O's label when
+neither branch relabeled (extra relabel), and a branch's inserted
+nodes must arrive with their labels verbatim. The merge pipeline
+already conflicted correctly via relabel-relabel; this closes the
+gap for `check` as an external judge and tightens the pipeline's own
+self-check.
diff --git a/src/bin/d3j/commands/check.rs b/src/bin/d3j/commands/check.rs
index e7d8244..5426057 100644
--- a/src/bin/d3j/commands/check.rs
+++ b/src/bin/d3j/commands/check.rs
@@ -55,16 +55,28 @@ fn witness<'t>(
     violation: &Violation,
 ) -> (&'t Tree, Range<usize>) {
     match violation {
-        Violation::ExtraInsertion { span, .. } => (m, span.clone()),
+        Violation::ExtraInsertion { span, .. } | Violation::ExtraRelabel { span, .. } => {
+            (m, span.clone())
+        }
         Violation::MissedInsertion {
             branch: Branch::A,
             span,
             ..
+        }
+        | Violation::MissedRelabel {
+            branch: Branch::A,
+            span,
+            ..
         } => (a, span.clone()),
         Violation::MissedInsertion {
             branch: Branch::B,
             span,
             ..
+        }
+        | Violation::MissedRelabel {
+            branch: Branch::B,
+            span,
+            ..
         } => (b, span.clone()),
         Violation::ExtraDeletion { span, .. } | Violation::MissedDeletion { span, .. } => {
             (o, span.clone())
diff --git a/src/check.rs b/src/check.rs
index 09fb643..1fb5432 100644
--- a/src/check.rs
+++ b/src/check.rs
@@ -3,8 +3,10 @@
 //! lands, nothing else does.
 //!
 //! Fidelity is bounded by diff quality (the paper carries the same
-//! caveat): the four conditions are set-membership statements over the
-//! diffs f: O→A, g: O→B, i1: A→M, i2: B→M.
+//! caveat): conditions 1–4 are set-membership statements over the
+//! diffs f: O→A, g: O→B, i1: A→M, i2: B→M; condition 5 additionally
+//! compares labels along the routes, since a merge that drops or
+//! invents a relabel is invisible to membership alone.
 
 use std::ops::Range;
 
@@ -37,6 +39,18 @@ pub enum Violation {
 
     #[error("missed deletion: a branch deleted the node at {span:?} but the merge kept it")]
     MissedDeletion { node: NodeId, span: Range<usize> },
+
+    #[error(
+        "missed relabel: branch {branch:?} relabeled the node at {span:?} but the merge kept a different label"
+    )]
+    MissedRelabel {
+        branch: Branch,
+        node: NodeId,
+        span: Range<usize>,
+    },
+
+    #[error("extra relabel: the merge node at {span:?} holds a label neither branch wrote")]
+    ExtraRelabel { node: NodeId, span: Range<usize> },
 }
 
 /// The checker's verdict on one (O, A, B, M) quadruple.
@@ -55,7 +69,7 @@ impl Report {
     }
 }
 
-/// Checks M against the four universality conditions.
+/// Checks M against the five universality conditions.
 pub fn check(o: &Tree, a: &Tree, b: &Tree, m: &Tree) -> Report {
     let f = diff(o, a);
     let g = diff(o, b);
@@ -119,14 +133,91 @@ pub fn check(o: &Tree, a: &Tree, b: &Tree, m: &Tree) -> Report {
         }
     }
 
+    // 5. Relabels land exactly once. Conditions 1–4 are membership
+    //    statements over node maps and never compare labels, so a
+    //    merge that ignored a rename — or invented one — would pass
+    //    them. For every O node reaching M through both routes: a
+    //    branch that relabeled it must see its label in M, and when
+    //    neither branch relabeled it, M must keep O's label.
+    for node in o.nodes() {
+        let (Some(in_a), Some(in_b)) = (f.image(node), g.image(node)) else {
+            continue;
+        };
+        // Broken or disagreeing routes are condition 3's extra
+        // deletion; labels are only judged where the routes commute.
+        let (Some(via_a), Some(via_b)) = (i1.image(in_a), i2.image(in_b)) else {
+            continue;
+        };
+        if via_a != via_b {
+            continue;
+        }
+        let merged = m.label(via_a);
+        let original = o.label(node);
+        let relabeled_a = a.label(in_a) != original;
+        let relabeled_b = b.label(in_b) != original;
+        if relabeled_a && merged != a.label(in_a) {
+            violations.push(Violation::MissedRelabel {
+                branch: Branch::A,
+                node: in_a,
+                span: a.span(in_a),
+            });
+        }
+        if relabeled_b && merged != b.label(in_b) {
+            violations.push(Violation::MissedRelabel {
+                branch: Branch::B,
+                node: in_b,
+                span: b.span(in_b),
+            });
+        }
+        if !relabeled_a && !relabeled_b && merged != original {
+            violations.push(Violation::ExtraRelabel {
+                node: via_a,
+                span: m.span(via_a),
+            });
+        }
+    }
+
+    //    The same for insertions: a branch's inserted node that
+    //    reaches M must arrive with its label intact — otherwise the
+    //    merge holds a label nobody wrote.
+    altered_insertions(a, &f, &i1, m, &mut violations);
+    altered_insertions(b, &g, &i2, m, &mut violations);
+
     Report {
         parsable: true,
         violations,
     }
 }
 
+/// Condition 5 for one branch's insertions: an inserted node's image
+/// in M must carry the inserted label verbatim.
+fn altered_insertions(
+    branch_tree: &Tree,
+    from_o: &Matching,
+    to_m: &Matching,
+    m: &Tree,
+    violations: &mut Vec<Violation>,
+) {
+    for node in branch_tree.nodes() {
+        if from_o.preimage(node).is_some() {
+            continue;
+        }
+        let Some(in_m) = to_m.image(node) else {
+            continue; // Condition 2's missed insertion, not a relabel.
+        };
+        if m.label(in_m) != branch_tree.label(node) {
+            violations.push(Violation::ExtraRelabel {
+                node: in_m,
+                span: m.span(in_m),
+            });
+        }
+    }
+}
+
 /// Anonymous tokens under a commutative parent are fungible
-/// separators, exempt from all four conditions. A cross-branch union
+/// separators, exempt from the membership conditions (the label
+/// condition is vacuous for them — anonymous nodes carry no label).
+/// A cross-branch union
 /// merge makes their attribution ambiguous — both branches' commas
 /// legitimately claim the same merged comma, orphaning its twin — and
 /// the parse itself (verified before the conditions run) already pins
@@ -276,6 +367,149 @@ mod tests {
         Ok(())
     }
 
+    #[test]
+    fn a_dropped_relabel_is_flagged() -> Result<(), Error> {
+        // A renames 1 to 2; a merge that keeps 1 used to pass — the
+        // membership conditions never compare labels.
+        let report = check_json("[1]", "[2]", "[1]", "[1]")?;
+        assert!(report.violations.iter().any(|v| matches!(
+            v,
+            Violation::MissedRelabel {
+                branch: Branch::A,
+                ..
+            }
+        )));
+        // The witness is A's new label.
+        let a = parse_json("[2]")?;
+        assert!(report.violations.iter().any(|v| match v {
+            Violation::MissedRelabel { span, .. } => a.source_slice(span.clone()) == Some("2"),
+            _ => false,
+        }));
+        // The merge that lands the rename passes.
+        let report = check_json("[1]", "[2]", "[1]", "[2]")?;
+        assert!(report.is_correct());
+        Ok(())
+    }
+
+    #[test]
+    fn a_dropped_relabel_is_flagged_for_branch_b() -> Result<(), Error> {
+        let report = check_json("[1]", "[1]", "[2]", "[1]")?;
+        assert!(report.violations.iter().any(|v| matches!(
+            v,
+            Violation::MissedRelabel {
+                branch: Branch::B,
+                ..
+            }
+        )));
+        Ok(())
+    }
+
+    #[test]
+    fn a_dropped_comment_edit_is_flagged() -> Result<(), Error> {
+        // The comment-nodes probe: A rewrites a comment, the candidate
+        // merge keeps the old text.
+        let lang = Lang::by_name("rust").ok_or(Error::UnknownLanguage {
+            path: "rust".into(),
+        })?;
+        let o = Tree::parse("// old\nfn f() {}", lang)?;
+        let a = Tree::parse("// new\nfn f() {}", lang)?;
+        let b = Tree::parse("// old\nfn f() {}", lang)?;
+        let m = Tree::parse("// old\nfn f() {}", lang)?;
+        let report = check(&o, &a, &b, &m);
+        assert!(report.violations.iter().any(|v| matches!(
+            v,
+            Violation::MissedRelabel {
+                branch: Branch::A,
+                ..
+            }
+        )));
+        Ok(())
+    }
+
+    #[test]
+    fn an_invented_relabel_is_flagged() -> Result<(), Error> {
+        // Nobody renamed 1, but the merge holds 9: condition 1 passes
+        // (the 9 has a preimage via the relabel matching), so only a
+        // label comparison catches it.
+        let report = check_json("[1]", "[1]", "[1]", "[9]")?;
+        assert!(!report.violations.is_empty());
+        let m = parse_json("[9]")?;
+        assert!(report.violations.iter().any(|v| match v {
+            Violation::ExtraRelabel { span, .. } => m.source_slice(span.clone()) == Some("9"),
+            _ => false,
+        }));
+        Ok(())
+    }
+
+    #[test]
+    fn an_altered_insertion_is_flagged() -> Result<(), Error> {
+        // A inserted 2; the merge holds 3 where the insertion landed —
+        // a label nobody wrote, on a node with no O preimage.
+        let report = check_json("[1]", "[1, 2]", "[1]", "[1, 3]")?;
+        assert!(
+            report
+                .violations
+                .iter()
+                .any(|v| matches!(v, Violation::ExtraRelabel { .. })),
+            "{:?}",
+            report.violations
+        );
+        // The faithful merge passes.
+        let report = check_json("[1]", "[1, 2]", "[1]", "[1, 2]")?;
+        assert!(report.is_correct());
+        Ok(())
+    }
+
+    #[test]
+    fn concurrent_distinct_relabels_flag_whichever_side_lost() -> Result<(), Error> {
+        // A and B rename 1 differently; no M satisfies both, so the
+        // checker must reject either resolution.
+        let report = check_json("[1]", "[2]", "[3]", "[2]")?;
+        assert!(report.violations.iter().any(|v| matches!(
+            v,
+            Violation::MissedRelabel {
+                branch: Branch::B,
+                ..
+            }
+        )));
+        let report = check_json("[1]", "[2]", "[3]", "[3]")?;
+        assert!(report.violations.iter().any(|v| matches!(
+            v,
+            Violation::MissedRelabel {
+                branch: Branch::A,
+                ..
+            }
+        )));
+        // The identical rename made twice passes with the new label.
+        let report = check_json("[1]", "[2]", "[2]", "[2]")?;
+        assert!(report.is_correct());
+        Ok(())
+    }
+
+    #[test]
+    fn routes_that_disagree_are_not_judged_for_labels() -> Result<(), Error> {
+        // O's 8 reaches M through both routes, but they land on
+        // different twins: i1 pairs A's 8 with the first (greedy LCS),
+        // while B's 0 anchors uniquely and drags i2's 8 to the third
+        // element. Condition 3 owns that disagreement (extra
+        // deletion); condition 5 must skip the node rather than
+        // compare labels across unrelated twins.
+        let report = check_json("[8]", "[8]", "[0, 8]", "[8, 0, 8]")?;
+        assert!(
+            report
+                .violations
+                .iter()
+                .any(|v| matches!(v, Violation::ExtraDeletion { .. })),
+            "{:?}",
+            report.violations
+        );
+        assert!(report.violations.iter().all(|v| !matches!(
+            v,
+            Violation::MissedRelabel { .. } | Violation::ExtraRelabel { .. }
+        )));
+        Ok(())
+    }
+
     #[test]
     fn a_universal_merge_passes() -> Result<(), Error> {
         // The paper's Figures 2/3: disjoint edits, including B's 2→6
diff --git a/src/merge.rs b/src/merge.rs
index 3e69d21..2d63f0e 100644
--- a/src/merge.rs
+++ b/src/merge.rs
@@ -172,14 +172,26 @@ fn merge_to_text_with(
                         branch: crate::check::Branch::A,
                         span,
                         ..
+                    }
+                    | Violation::MissedRelabel {
+                        branch: crate::check::Branch::A,
+                        span,
+                        ..
                     } => (None, Some(span.clone()), None),
                     Violation::MissedInsertion {
                         branch: crate::check::Branch::B,
                         span,
                         ..
+                    }
+                    | Violation::MissedRelabel {
+                        branch: crate::check::Branch::B,
+                        span,
+                        ..
                     } => (None, None, Some(span.clone())),
                     // The witness lives in M, which has no span slot.
-                    Violation::ExtraInsertion { .. } => (None, None, None),
+                    Violation::ExtraInsertion { .. } | Violation::ExtraRelabel { .. } => {
+                        (None, None, None)
+                    }
                 };
                 Conflict {
                     span_o,