rules: flag relabel-relabel and insert-insert conflicts
Merge now runs conflict rules over the two edit scripts before
building the pushout: both branches relabeling one O node differently,
or inserting different content at the same slot, stops the merge with
the overlapping origin spans.

The design sketched every rule as a pairwise (edit-A, edit-B) check,
but insert-insert had to be aggregate per slot: a branch inserts a
*sequence* of subtrees (a JSON insert is a comma and a value), and
pairwise hash comparison flags the A-comma against the B-value even
when both branches made the identical insertion. Slots compare their
whole inserted-sequence hashes instead; equal sequences dedupe.

Assisted-by: Claude Fable 5 via Claude Code
change ooypsrumuvummptusvnvlqpnlopwknlp
commit dcdfcc03f95bc602c3d337129801839f7a31c3a1
author Alpha Chen <alpha@kejadlen.dev>
date
parent wumsrxnz
diff --git a/src/lib.rs b/src/lib.rs
index a69caad..c94428d 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -4,6 +4,7 @@ pub mod error;
 pub mod hash;
 pub mod lang;
 pub mod merge;
+mod rules;
 pub mod tree;
 pub mod zs;
 
diff --git a/src/merge.rs b/src/merge.rs
index d126797..1939597 100644
--- a/src/merge.rs
+++ b/src/merge.rs
@@ -11,8 +11,9 @@
 use std::collections::{HashMap, HashSet};
 use std::ops::Range;
 
-use crate::diff::{Matching, Shape, diff};
+use crate::diff::{Matching, Shape, diff, edits};
 use crate::error::Error;
+use crate::rules;
 use crate::tree::{NodeId, Tree};
 
 /// Where a merge node's content comes from: a surviving O node, or a
@@ -110,6 +111,19 @@ pub enum MergeOutcome {
 pub fn merge(o: &Tree, a: &Tree, b: &Tree) -> Result<MergeOutcome, Error> {
     let f = diff(o, a);
     let g = diff(o, b);
+
+    let ctx = rules::Ctx {
+        o,
+        a,
+        b,
+        f: &f,
+        g: &g,
+    };
+    let conflicts = rules::conflicts(&ctx, &edits(o, a, &f), &edits(o, b, &g));
+    if !conflicts.is_empty() {
+        return Ok(MergeOutcome::Conflicts(conflicts));
+    }
+
     let mut builder = Builder {
         o,
         a,
@@ -392,6 +406,74 @@ mod tests {
         assert_merges("json", "[1, 2]", "[1, 2]", "[2]", "[2]")
     }
 
+    fn assert_conflicts(
+        lang: &str,
+        o: &str,
+        a: &str,
+        b: &str,
+        rule: &str,
+    ) -> Result<Vec<Conflict>, Error> {
+        let o = parse(o, lang)?;
+        let a = parse(a, lang)?;
+        let b = parse(b, lang)?;
+        match merge(&o, &a, &b)? {
+            MergeOutcome::Merged(m) => {
+                panic!("expected conflicts, got a merge: {:?}", m.shape(&o, &a, &b));
+            }
+            MergeOutcome::Conflicts(conflicts) => {
+                assert!(!conflicts.is_empty());
+                assert!(
+                    conflicts.iter().all(|c| c.rule == rule),
+                    "expected only {rule}: {conflicts:?}"
+                );
+                Ok(conflicts)
+            }
+        }
+    }
+
+    #[test]
+    fn conflicting_renames_conflict() -> Result<(), Error> {
+        let conflicts = assert_conflicts(
+            "rust",
+            "fn a() {}",
+            "fn b() {}",
+            "fn c() {}",
+            "relabel-relabel",
+        )?;
+        let a = parse("fn b() {}", "rust")?;
+        let witnessed = conflicts.iter().any(|c| {
+            c.span_a
+                .clone()
+                .and_then(|span| a.source_slice(span))
+                .is_some_and(|text| text == "b")
+        });
+        assert!(witnessed);
+        Ok(())
+    }
+
+    #[test]
+    fn identical_renames_still_merge() -> Result<(), Error> {
+        assert_merges("rust", "fn a() {}", "fn b() {}", "fn b() {}", "fn b() {}")
+    }
+
+    #[test]
+    fn conflicting_insertions_at_one_slot_conflict() -> Result<(), Error> {
+        assert_conflicts(
+            "rust",
+            "fn f() { x(); }",
+            "fn f() { x(); y(); }",
+            "fn f() { x(); z(); }",
+            "insert-insert",
+        )?;
+        Ok(())
+    }
+
+    #[test]
+    fn different_value_insertions_at_one_slot_conflict() -> Result<(), Error> {
+        assert_conflicts("json", "[1]", "[1, 9]", "[1, 8]", "insert-insert")?;
+        Ok(())
+    }
+
     #[test]
     fn a_wrap_pulls_the_survivor_into_the_graft() -> Result<(), Error> {
         // A wraps the array one level deeper; B leaves it alone.
diff --git a/src/rules.rs b/src/rules.rs
new file mode 100644
index 0000000..da3c100
--- /dev/null
+++ b/src/rules.rs
@@ -0,0 +1,147 @@
+//! Conflict rules: each rule inspects an (edit-from-A, edit-from-B)
+//! pair with context and may yield a [`Conflict`]. The pushout applies
+//! common edits once; these rules name the combinations that cannot
+//! merge.
+
+use std::collections::HashMap;
+
+use crate::diff::{Edit, Matching};
+use crate::merge::Conflict;
+use crate::tree::{NodeId, Tree};
+
+/// Everything a rule may consult: the three trees and the two diffs.
+pub(crate) struct Ctx<'t> {
+    pub o: &'t Tree,
+    pub a: &'t Tree,
+    pub b: &'t Tree,
+    pub f: &'t Matching,
+    pub g: &'t Matching,
+}
+
+/// A conflict rule over one (edit-from-A, edit-from-B) pair.
+type PairRule = fn(&Edit, &Edit, &Ctx) -> Option<Conflict>;
+
+const PAIR_RULES: &[PairRule] = &[relabel_relabel];
+
+/// Runs the pairwise rules over every cross-branch edit pair and the
+/// aggregate rules over the whole scripts, deduplicating identical
+/// findings.
+///
+/// insert-insert is aggregate rather than pairwise: a branch's
+/// insertion at one slot is the *sequence* of subtrees it put there
+/// (a JSON insert is a comma and a value), and comparing elements
+/// cross-wise would flag two branches making the identical multi-node
+/// insertion.
+pub(crate) fn conflicts(ctx: &Ctx, edits_a: &[Edit], edits_b: &[Edit]) -> Vec<Conflict> {
+    let mut found: Vec<Conflict> = Vec::new();
+    for ea in edits_a {
+        for eb in edits_b {
+            for rule in PAIR_RULES {
+                if let Some(conflict) = rule(ea, eb, ctx)
+                    && !found.contains(&conflict)
+                {
+                    found.push(conflict);
+                }
+            }
+        }
+    }
+    insert_insert(ctx, edits_a, edits_b, &mut found);
+    found
+}
+
+/// relabel-relabel: both branches relabeled the same O node, to
+/// different labels. Identical relabels are one edit made twice and
+/// merge silently.
+fn relabel_relabel(ea: &Edit, eb: &Edit, ctx: &Ctx) -> Option<Conflict> {
+    let (Edit::Relabel(xa, ya), Edit::Relabel(xb, yb)) = (ea, eb) else {
+        return None;
+    };
+    if xa != xb || ctx.a.label(*ya) == ctx.b.label(*yb) {
+        return None;
+    }
+    Some(Conflict {
+        span_o: Some(ctx.o.span(*xa)),
+        span_a: Some(ctx.a.span(*ya)),
+        span_b: Some(ctx.b.span(*yb)),
+        rule: "relabel-relabel",
+    })
+}
+
+/// insert-insert: both branches inserted at the same slot — same
+/// parent image, same nearest preceding matched sibling — but the
+/// inserted subtree sequences differ. Equal sequences at the same
+/// slot are one edit made twice and dedupe instead.
+fn insert_insert(ctx: &Ctx, edits_a: &[Edit], edits_b: &[Edit], found: &mut Vec<Conflict>) {
+    let slots_a = insert_slots(ctx.a, ctx.f, edits_a);
+    let slots_b = insert_slots(ctx.b, ctx.g, edits_b);
+    for (anchor, seq_a) in &slots_a {
+        let Some(seq_b) = slots_b.get(anchor) else {
+            continue;
+        };
+        let hashes = |tree: &Tree, seq: &[NodeId]| -> Vec<u64> {
+            seq.iter().map(|&n| tree.hash(n)).collect()
+        };
+        if hashes(ctx.a, seq_a) == hashes(ctx.b, seq_b) {
+            continue;
+        }
+        let conflict = Conflict {
+            span_o: Some(ctx.o.span(anchor.0)),
+            span_a: covering_span(ctx.a, seq_a),
+            span_b: covering_span(ctx.b, seq_b),
+            rule: "insert-insert",
+        };
+        if !found.contains(&conflict) {
+            found.push(conflict);
+        }
+    }
+}
+
+/// Groups a branch's top-level insertions by slot, in sibling order
+/// (the edit script lists inserts in pre-order, so same-slot siblings
+/// arrive left to right).
+fn insert_slots(
+    tree: &Tree,
+    matching: &Matching,
+    script: &[Edit],
+) -> HashMap<(NodeId, Option<NodeId>), Vec<NodeId>> {
+    let mut slots: HashMap<(NodeId, Option<NodeId>), Vec<NodeId>> = HashMap::new();
+    for edit in script {
+        let Edit::Insert(node) = edit else {
+            continue;
+        };
+        if let Some(anchor) = insert_anchor(tree, matching, *node) {
+            slots.entry(anchor).or_default().push(*node);
+        }
+    }
+    slots
+}
+
+/// The byte range covering a sequence of nodes.
+fn covering_span(tree: &Tree, seq: &[NodeId]) -> Option<std::ops::Range<usize>> {
+    let start = seq.iter().map(|&n| tree.span(n).start).min()?;
+    let end = seq.iter().map(|&n| tree.span(n).end).max()?;
+    Some(start..end)
+}
+
+/// The slot an inserted node lands in: its parent's O preimage plus
+/// the O preimage of the nearest preceding matched sibling. `None`
+/// for nested insertions (their parent is inserted too — they ride
+/// along with the top-level graft).
+fn insert_anchor(
+    tree: &Tree,
+    matching: &Matching,
+    node: NodeId,
+) -> Option<(NodeId, Option<NodeId>)> {
+    let parent = tree.parent(node)?;
+    let parent_o = matching.preimage(parent)?;
+    let mut left = None;
+    for &sibling in tree.children(parent) {
+        if sibling == node {
+            break;
+        }
+        if let Some(preimage) = matching.preimage(sibling) {
+            left = Some(preimage);
+        }
+    }
+    Some((parent_o, left))
+}