Skip to main content

nap_core/merge/
merge_engine.rs

1//! Three-way merge engine — path-map reconciliation.
2//!
3//! The engine operates on `serde_json::Value` (JSON AST), never on YAML
4//! text.  It implements the protocol invariants defined in
5//! `merge-semantics-v2.md`:
6//!
7//! 1. Normalize before merge
8//! 2. Missing ≠ null
9//! 3. Identity immutable
10//! 4. Merge over path union
11//! 5. Validate before persist (caller's responsibility)
12//! 6. Validate after merge (caller's responsibility)
13//! 7. Deterministic execution
14//!
15//! The engine does NOT depend on the `diff` module.
16
17use serde_json::Value;
18
19use crate::merge::conflict::{Conflict, MergeResult};
20use crate::merge::normalization::normalize;
21use crate::merge::path::{CanonicalPath, build_path_map, path_union};
22use crate::merge::sdl::{IdentityRule, MergeStrategyType, PropertyDef, SdlDocument};
23use crate::merge::strategies;
24
25/// The three-way merge engine.
26///
27/// Construct with an SDL document, then call `merge()` with
28/// base/current/proposed values.
29#[derive(Debug, Clone)]
30pub struct MergeEngine {
31    schema: SdlDocument,
32}
33
34impl MergeEngine {
35    /// Create a new merge engine from an SDL document.
36    ///
37    /// The SDL document must be valid (call `validate::sdl::validate_sdl()`
38    /// first if needed).
39    pub fn new(schema: SdlDocument) -> Self {
40        MergeEngine { schema }
41    }
42
43    /// Return a reference to the SDL schema.
44    pub fn schema(&self) -> &SdlDocument {
45        &self.schema
46    }
47
48    /// Perform a three-way merge.
49    ///
50    /// # Pipeline
51    ///
52    /// 1. Normalize current and proposed against base.
53    /// 2. Build path maps for all three documents.
54    /// 3. Compute the union of all paths.
55    /// 4. For each path, resolve values and apply merge strategy.
56    /// 5. Check for identity mutations (protocol invariant).
57    /// 6. Return merged result or conflicts.
58    ///
59    /// # Arguments
60    ///
61    /// * `base` - The base (reference) document.
62    /// * `current` - The current (our) document.
63    /// * `proposed` - The proposed (their) document.
64    ///
65    /// # Returns
66    ///
67    /// `MergeResult::Merged(Value)` on success, or
68    /// `MergeResult::Conflicts(Vec<Conflict>)` if conflicts were found.
69    pub fn merge(&self, base: Value, current: Value, proposed: Value) -> MergeResult {
70        // Step 1: Normalize
71        let normalized_current = normalize(&base, &current);
72        let normalized_proposed = normalize(&base, &proposed);
73
74        // Step 2: Build path maps
75        let base_paths = build_path_map(&base, &self.schema);
76        let current_paths = build_path_map(&normalized_current, &self.schema);
77        let proposed_paths = build_path_map(&normalized_proposed, &self.schema);
78
79        // Step 3: Path union
80        let all_paths = path_union(&[&base_paths, &current_paths, &proposed_paths]);
81
82        // Step 4: For each path, apply merge strategy
83        let mut merged = base.clone();
84        let mut all_conflicts = Vec::new();
85
86        for path in &all_paths {
87            // Skip sub-paths of identity-keyed arrays — the array-level
88            // strategy handles those items as a whole.
89            if self.is_identity_array_subpath(path) {
90                continue;
91            }
92
93            let base_val = base_paths.get(path).cloned().unwrap_or(Value::Null);
94            let current_val = current_paths.get(path).cloned().unwrap_or(Value::Null);
95            let proposed_val = proposed_paths.get(path).cloned().unwrap_or(Value::Null);
96
97            // Skip paths where all three are identical
98            if base_val == current_val && current_val == proposed_val {
99                continue;
100            }
101
102            // Determine the SDL property path (strip root. prefix if present)
103            let sdl_path = path.strip_prefix("root.").unwrap_or(path);
104
105            // Look up merge strategy from SDL
106            let result = match self.schema.property_def(sdl_path) {
107                Some(def) => {
108                    // Check for identity mutation first (protocol invariant)
109                    if let Some(mutation_conflict) = self.check_identity_mutation(
110                        sdl_path,
111                        def,
112                        &base_val,
113                        &current_val,
114                        &proposed_val,
115                    ) {
116                        MergeResult::Conflicts(vec![mutation_conflict])
117                    } else {
118                        // Apply the declared strategy
119                        self.apply_strategy(sdl_path, def, &base_val, &current_val, &proposed_val)
120                    }
121                }
122                None => {
123                    // No SDL definition for this path → treat as replace
124                    // (Validation layer should warn/error on schema-less properties,
125                    // but the engine must still produce a deterministic result.)
126                    strategies::replace::merge_replace(
127                        &format!("root.{sdl_path}"),
128                        &base_val,
129                        &current_val,
130                        &proposed_val,
131                    )
132                }
133            };
134
135            // Update the merged document or collect conflicts
136            match result {
137                MergeResult::Merged(val) => {
138                    // Insert the merged value into the result
139                    if let Err(e) = set_value_at_path(&mut merged, path, val) {
140                        // If we can't set the value at this path, it's a conflict
141                        all_conflicts.push(Conflict::structural(
142                            format!("root.{sdl_path}"),
143                            base_val,
144                            current_val,
145                            proposed_val,
146                        ));
147                        // Log the error for debugging
148                        tracing::debug!(
149                            "merge_engine: failed to set value at path '{}': {}",
150                            path,
151                            e
152                        );
153                    }
154                }
155                MergeResult::Conflicts(mut cs) => {
156                    all_conflicts.append(&mut cs);
157                }
158            }
159        }
160
161        // Step 5: Handle paths not covered by path map iteration
162        // This includes top-level deletions (when the entire document is stripped)
163        // which are handled implicitly by the iteration above.
164
165        if all_conflicts.is_empty() {
166            MergeResult::Merged(merged)
167        } else {
168            MergeResult::Conflicts(all_conflicts)
169        }
170    }
171
172    /// Check if a path is a sub-path of an identity-keyed array.
173    ///
174    /// For example, if `characters` is an `ordered_unique` array with
175    /// `identity: {key: id}`, then `characters[obiwan]` and
176    /// `characters[obiwan].name` are sub-paths that should be skipped
177    /// during merge — the array-level strategy handles them.
178    fn is_identity_array_subpath(&self, path: &str) -> bool {
179        let parsed = match CanonicalPath::parse(path) {
180            Ok(p) => p,
181            Err(_) => return false,
182        };
183
184        let segments = parsed.segments();
185        if segments.len() <= 1 {
186            return false;
187        }
188
189        // Check if any parent segment is an Identity segment
190        // (meaning we're inside a specific array item)
191        for (i, seg) in segments.iter().enumerate() {
192            if matches!(seg, crate::merge::path::PathSegment::Identity(_)) {
193                // Found an identity segment — check if it's not the last segment,
194                // or if the parent array path is an SDL-defined identity array.
195                let parent_path = segments[..i]
196                    .iter()
197                    .map(|s| match s {
198                        crate::merge::path::PathSegment::Key(k) => k.clone(),
199                        crate::merge::path::PathSegment::Identity(id) => format!("[{id}]"),
200                    })
201                    .collect::<Vec<_>>()
202                    .join(".");
203
204                // If the parent array path is defined in SDL with an identity strategy,
205                // this is a sub-path to be skipped.
206                if let Some(def) = self.schema.property_def(&parent_path)
207                    && matches!(
208                        def.merge.strategy_type,
209                        MergeStrategyType::OrderedUnique
210                            | MergeStrategyType::SetUnion
211                            | MergeStrategyType::EdgeList
212                    )
213                {
214                    return true;
215                }
216
217                // Also check if this is an array item path itself (identity is last segment)
218                // by checking if the path without the parent is just an identity.
219                if i == segments.len() - 1 {
220                    return true; // It's tags[a] or similar — skip individual item
221                }
222            }
223        }
224
225        false
226    }
227
228    /// Check for identity mutation conflicts.
229    ///
230    /// Returns `Some(Conflict)` if the identity of an array element was mutated,
231    /// which is forbidden by the protocol.
232    ///
233    /// Detection strategy: compare items at the same **position** in the array
234    /// and check whether their identity key values differ.  If the item at
235    /// position 0 in base has `id: "obiwan"` and the item at position 0 in
236    /// current has `id: "ben_kenobi"`, that is a mutation even though a
237    /// by-identity-indexed map would see two unrelated items.
238    fn check_identity_mutation(
239        &self,
240        path: &str,
241        def: &PropertyDef,
242        base: &Value,
243        current: &Value,
244        proposed: &Value,
245    ) -> Option<Conflict> {
246        // Only applicable for array strategies with identity rules
247        let identity = match &def.merge.identity {
248            Some(id) => id,
249            None => return None,
250        };
251
252        let identity_key = match identity {
253            IdentityRule::Key { key } => key,
254            IdentityRule::PrimitiveValue => return None, // primitive value IS the identity, can't change
255        };
256
257        // Check that all three values are arrays
258        let base_arr = base.as_array()?;
259        let current_arr = current.as_array()?;
260        let proposed_arr = proposed.as_array()?;
261
262        // Check current against base: compare identity keys at each position
263        for i in 0..base_arr.len().min(current_arr.len()) {
264            let base_id = base_arr[i].get(identity_key);
265            let cur_id = current_arr[i].get(identity_key);
266            if base_id != cur_id {
267                let base_id_str = base_id.and_then(|v| v.as_str()).unwrap_or("?");
268                let sub_path = format!("root.{path}[{base_id_str}]");
269                let proposed_val = proposed_arr.get(i).cloned().unwrap_or(Value::Null);
270                return Some(Conflict::identity_mutation(
271                    sub_path,
272                    base_arr[i].clone(),
273                    current_arr[i].clone(),
274                    proposed_val,
275                ));
276            }
277        }
278
279        // Check proposed against base: compare identity keys at each position
280        for i in 0..base_arr.len().min(proposed_arr.len()) {
281            let base_id = base_arr[i].get(identity_key);
282            let prop_id = proposed_arr[i].get(identity_key);
283            if base_id != prop_id {
284                let base_id_str = base_id.and_then(|v| v.as_str()).unwrap_or("?");
285                let sub_path = format!("root.{path}[{base_id_str}]");
286                let current_val = current_arr.get(i).cloned().unwrap_or(Value::Null);
287                return Some(Conflict::identity_mutation(
288                    sub_path,
289                    base_arr[i].clone(),
290                    current_val,
291                    proposed_arr[i].clone(),
292                ));
293            }
294        }
295
296        None
297    }
298
299    /// Apply the appropriate merge strategy for a path.
300    fn apply_strategy(
301        &self,
302        path: &str,
303        def: &PropertyDef,
304        base: &Value,
305        current: &Value,
306        proposed: &Value,
307    ) -> MergeResult {
308        let full_path = format!("root.{path}");
309
310        match def.merge.strategy_type {
311            MergeStrategyType::Replace => {
312                strategies::replace::merge_replace(&full_path, base, current, proposed)
313            }
314
315            MergeStrategyType::DeepMerge => {
316                strategies::deep_merge::merge_deep(&full_path, base, current, proposed)
317            }
318
319            MergeStrategyType::Atomic => {
320                strategies::atomic::merge_atomic(&full_path, base, current, proposed)
321            }
322
323            MergeStrategyType::OrderedUnique => {
324                let identity = def
325                    .merge
326                    .identity
327                    .clone()
328                    .unwrap_or(IdentityRule::PrimitiveValue);
329                strategies::ordered_unique::merge_ordered_unique(
330                    &full_path, base, current, proposed, &identity,
331                )
332            }
333
334            MergeStrategyType::SetUnion => {
335                let identity = def
336                    .merge
337                    .identity
338                    .clone()
339                    .unwrap_or(IdentityRule::PrimitiveValue);
340                strategies::set_union::merge_set_union(
341                    &full_path, base, current, proposed, &identity,
342                )
343            }
344
345            MergeStrategyType::EdgeList => {
346                let identity = def.merge.identity.clone().unwrap_or(IdentityRule::Key {
347                    key: "id".to_string(),
348                });
349                let source_key = def
350                    .merge
351                    .source_key
352                    .clone()
353                    .unwrap_or_else(|| "source".to_string());
354                let target_key = def
355                    .merge
356                    .target_key
357                    .clone()
358                    .unwrap_or_else(|| "target".to_string());
359                strategies::edge_list::merge_edge_list(
360                    &full_path,
361                    base,
362                    current,
363                    proposed,
364                    &identity,
365                    &source_key,
366                    &target_key,
367                )
368            }
369        }
370    }
371}
372
373/// Set a value at a given canonical path within a JSON document.
374///
375/// Creates intermediate objects as needed.
376/// Returns an error if the path cannot be set (e.g., type conflict).
377fn set_value_at_path(root: &mut Value, path: &str, value: Value) -> Result<(), String> {
378    let canonical =
379        CanonicalPath::parse(path).map_err(|e| format!("invalid path '{path}': {e}"))?;
380
381    let segments = canonical.segments().to_vec();
382    if segments.is_empty() {
383        return Err("empty path".to_string());
384    }
385
386    // Navigate to the parent of the final segment
387    let parent_segments = &segments[..segments.len() - 1];
388    let last_segment = &segments[segments.len() - 1];
389
390    let mut current = root;
391
392    // Navigate/build intermediate segments
393    for segment in parent_segments {
394        match segment {
395            crate::merge::path::PathSegment::Key(key) => {
396                if !current.is_object() {
397                    return Err(format!("cannot enter non-object at '{key}'"));
398                }
399                current = current
400                    .as_object_mut()
401                    .unwrap()
402                    .entry(key.clone())
403                    .or_insert_with(|| Value::Object(serde_json::Map::new()));
404            }
405            crate::merge::path::PathSegment::Identity(id) => {
406                // For identity segments, we assume the array already has the item
407                // (it was created during path map building)
408                if let Some(item) = find_item_by_identity(current, id) {
409                    current = item;
410                } else {
411                    return Err(format!("identity '{id}' not found in array"));
412                }
413            }
414        }
415    }
416
417    // Set the value at the final segment
418    match last_segment {
419        crate::merge::path::PathSegment::Key(key) => {
420            if let Value::Object(map) = current {
421                map.insert(key.clone(), value);
422                Ok(())
423            } else {
424                Err(format!("cannot set key '{key}' on non-object"))
425            }
426        }
427        crate::merge::path::PathSegment::Identity(id) => {
428            // Find the item by identity and replace it
429            if let Some(item) = find_item_by_identity(current, id) {
430                *item = value;
431                Ok(())
432            } else {
433                Err(format!("identity '{id}' not found in array"))
434            }
435        }
436    }
437}
438
439/// Find an item in an array by matching its identity value (any string field).
440fn find_item_by_identity<'a>(root: &'a mut Value, identity: &str) -> Option<&'a mut Value> {
441    match root {
442        Value::Array(arr) => {
443            for item in arr.iter_mut() {
444                if has_identity(item, identity) {
445                    return Some(item);
446                }
447            }
448            None
449        }
450        _ => None,
451    }
452}
453
454/// Check whether a JSON value matches a given identity string.
455fn has_identity(item: &Value, identity: &str) -> bool {
456    match item {
457        Value::Object(map) => map.values().any(|v| v.as_str() == Some(identity)),
458        Value::String(s) => s == identity,
459        Value::Number(n) => n.to_string() == identity,
460        Value::Bool(b) => b.to_string() == identity,
461        _ => false,
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468    use serde_json::json;
469
470    fn simple_sdl() -> SdlDocument {
471        SdlDocument::from_yaml(
472            r#"
473schema:
474  version: "1.0"
475  required: []
476  properties:
477    name:
478      type: string
479      merge:
480        type: replace
481    version:
482      type: number
483      merge:
484        type: atomic
485    tags:
486      type: array
487      merge:
488        type: ordered_unique
489        identity:
490          mode: primitive_value
491    characters:
492      type: array
493      merge:
494        type: ordered_unique
495        identity:
496          mode: key
497          key: id
498    edges:
499      type: array
500      merge:
501        type: edge_list
502        source_key: source
503        target_key: target
504        identity:
505          mode: key
506          key: id
507"#,
508        )
509        .unwrap()
510    }
511
512    #[test]
513    fn test_merge_simple_replace() {
514        let engine = MergeEngine::new(simple_sdl());
515
516        let base = json!({"name": "Luke"});
517        let current = json!({"name": "Luke Skywalker"});
518        let proposed = json!({"name": "Luke"});
519
520        let result = engine.merge(base, current, proposed);
521        assert!(result.is_merged());
522        assert_eq!(
523            result.unwrap_merged().get("name"),
524            Some(&json!("Luke Skywalker"))
525        );
526    }
527
528    #[test]
529    fn test_merge_replace_conflict() {
530        let engine = MergeEngine::new(simple_sdl());
531
532        let base = json!({"name": "Luke"});
533        let current = json!({"name": "Luke Skywalker"});
534        let proposed = json!({"name": "Anakin"});
535
536        let result = engine.merge(base, current, proposed);
537        assert!(result.is_conflict());
538    }
539
540    #[test]
541    fn test_merge_missing_field_preserved() {
542        let engine = MergeEngine::new(simple_sdl());
543
544        let base = json!({"name": "Obi Wan", "version": 1});
545        let current = json!({"name": "Obi Wan Kenobi"});
546        let proposed = json!({"name": "Obi Wan"});
547
548        // Normalization should fill in version from base
549        let result = engine.merge(base, current, proposed);
550        assert!(result.is_merged());
551        let merged = result.unwrap_merged();
552        assert_eq!(merged.get("name"), Some(&json!("Obi Wan Kenobi")));
553        assert_eq!(merged.get("version"), Some(&json!(1)));
554    }
555
556    #[test]
557    fn test_merge_null_is_deletion() {
558        let engine = MergeEngine::new(simple_sdl());
559
560        let base = json!({"name": "Luke", "version": 1});
561        let current = json!({"name": "Luke", "version": 1});
562        let proposed = json!({"name": "Luke", "version": null});
563
564        let result = engine.merge(base, current, proposed);
565        assert!(result.is_merged());
566        let merged = result.unwrap_merged();
567        // version should be null (explicit deletion)
568        assert_eq!(merged.get("version"), Some(&Value::Null));
569    }
570
571    #[test]
572    fn test_merge_ordered_unique_objects() {
573        let engine = MergeEngine::new(simple_sdl());
574
575        let base = json!({"characters": [{"id": "A", "name": "Alpha"}]});
576        let current =
577            json!({"characters": [{"id": "A", "name": "Alpha"}, {"id": "B", "name": "Beta"}]});
578        let proposed =
579            json!({"characters": [{"id": "A", "name": "Alpha"}, {"id": "C", "name": "Gamma"}]});
580
581        let result = engine.merge(base, current, proposed);
582        assert!(result.is_merged());
583        let merged = result.unwrap_merged();
584        let chars = merged["characters"].as_array().unwrap();
585        assert_eq!(chars.len(), 3);
586        assert_eq!(chars[0]["id"], json!("A"));
587        assert_eq!(chars[1]["id"], json!("B"));
588        assert_eq!(chars[2]["id"], json!("C"));
589    }
590
591    #[test]
592    fn test_merge_ordered_unique_primitives() {
593        let engine = MergeEngine::new(simple_sdl());
594
595        let base = json!({"tags": ["a", "b"]});
596        let current = json!({"tags": ["a", "b", "c"]});
597        let proposed = json!({"tags": ["a", "b", "d"]});
598
599        let result = engine.merge(base, current, proposed);
600        assert!(result.is_merged());
601        let merged = result.unwrap_merged();
602        assert_eq!(
603            merged["tags"].as_array().unwrap(),
604            &[json!("a"), json!("b"), json!("c"), json!("d")]
605        );
606    }
607
608    #[test]
609    fn test_merge_atomic_conflict() {
610        let engine = MergeEngine::new(simple_sdl());
611
612        let base = json!({"version": 1});
613        let current = json!({"version": 2});
614        let proposed = json!({"version": 3});
615
616        let result = engine.merge(base, current, proposed);
617        assert!(result.is_conflict());
618    }
619
620    #[test]
621    fn test_merge_edge_list() {
622        let engine = MergeEngine::new(simple_sdl());
623
624        let base = json!({"edges": [{"id": "e1", "source": "a", "target": "b"}]});
625        let current = json!({
626            "edges": [
627                {"id": "e1", "source": "a", "target": "b"},
628                {"id": "e2", "source": "b", "target": "c"}
629            ]
630        });
631        let proposed = json!({
632            "edges": [
633                {"id": "e1", "source": "a", "target": "b"},
634                {"id": "e3", "source": "c", "target": "a"}
635            ]
636        });
637
638        let result = engine.merge(base, current, proposed);
639        assert!(result.is_merged());
640        let merged = result.unwrap_merged();
641        assert_eq!(merged["edges"].as_array().unwrap().len(), 3);
642    }
643
644    #[test]
645    fn test_merge_identity_mutation_conflict() {
646        let engine = MergeEngine::new(simple_sdl());
647
648        let base = json!({"characters": [{"id": "obiwan", "name": "Obi-Wan"}]});
649        let current = json!({"characters": [{"id": "ben_kenobi", "name": "Obi-Wan"}]}); // id changed!
650        let proposed = json!({"characters": [{"id": "obiwan", "name": "Obi-Wan"}]});
651
652        let result = engine.merge(base, current, proposed);
653        assert!(result.is_conflict());
654    }
655
656    #[test]
657    fn test_merge_deterministic() {
658        let engine = MergeEngine::new(simple_sdl());
659
660        let base = json!({"name": "Luke", "version": 1, "tags": ["a"]});
661        let current = json!({"name": "Luke Skywalker", "version": 2, "tags": ["a", "b"]});
662        let proposed = json!({"name": "Luke", "version": 1, "tags": ["a", "c"]});
663
664        let result1 = engine.merge(base.clone(), current.clone(), proposed.clone());
665        let result2 = engine.merge(base, current, proposed);
666
667        // Both should produce identical results
668        match (result1, result2) {
669            (MergeResult::Merged(a), MergeResult::Merged(b)) => assert_eq!(a, b),
670            (MergeResult::Conflicts(a), MergeResult::Conflicts(b)) => {
671                assert_eq!(a.len(), b.len());
672                for (ca, cb) in a.iter().zip(b.iter()) {
673                    assert_eq!(ca.path, cb.path);
674                }
675            }
676            _ => panic!("results should be same variant"),
677        }
678    }
679
680    #[test]
681    fn test_merge_empty_documents() {
682        let engine = MergeEngine::new(simple_sdl());
683
684        let base = json!({});
685        let current = json!({});
686        let proposed = json!({});
687
688        let result = engine.merge(base, current, proposed);
689        assert!(result.is_merged());
690        assert_eq!(result.unwrap_merged(), json!({}));
691    }
692
693    #[test]
694    fn test_merge_additions_from_both_sides() {
695        let engine = MergeEngine::new(simple_sdl());
696
697        let base = json!({});
698        let current = json!({"name": "From Current"});
699        let proposed = json!({"version": 42});
700
701        let result = engine.merge(base, current, proposed);
702        assert!(result.is_merged());
703        let merged = result.unwrap_merged();
704        assert_eq!(merged.get("name"), Some(&json!("From Current")));
705        assert_eq!(merged.get("version"), Some(&json!(42)));
706    }
707
708    #[test]
709    fn test_merge_both_add_same_field_conflict() {
710        let engine = MergeEngine::new(simple_sdl());
711
712        let base = json!({});
713        let current = json!({"name": "From Current"});
714        let proposed = json!({"name": "From Proposed"});
715
716        let result = engine.merge(base, current, proposed);
717        // Both changed "name" differently from base → conflict
718        assert!(result.is_conflict());
719    }
720
721    #[test]
722    fn test_merge_ordered_unique_modification_accepted() {
723        let engine = MergeEngine::new(simple_sdl());
724
725        let base = json!({"characters": [{"id": "A", "val": 1}]});
726        let current = json!({"characters": [{"id": "A", "val": 2}]}); // modified
727        let proposed = json!({"characters": [{"id": "A", "val": 1}]}); // same as base
728
729        let result = engine.merge(base, current, proposed);
730        assert!(result.is_merged());
731        let merged = result.unwrap_merged();
732        assert_eq!(merged["characters"][0]["val"], json!(2));
733    }
734
735    // ── Property-style determinism test ────────────────────────────────
736
737    /// Generate random documents and verify that merge is deterministic.
738    #[test]
739    fn test_deterministic_random_iterations() {
740        use rand::Rng;
741        let engine = MergeEngine::new(simple_sdl());
742        let mut rng = rand::rng();
743
744        for _ in 0..50 {
745            // Build a simple random document
746            let mut base = serde_json::Map::new();
747            let mut current = serde_json::Map::new();
748            let mut proposed = serde_json::Map::new();
749
750            // Add random fields
751            let field_count = rng.random_range(0..6);
752            for i in 0..field_count {
753                let key = format!("field_{i}");
754                let base_val = rng.random_range(0..100);
755                let cur_val = if rng.random_bool(0.5) {
756                    base_val + rng.random_range(-5..=5)
757                } else {
758                    base_val
759                };
760                let prop_val = if rng.random_bool(0.5) {
761                    base_val + rng.random_range(-5..=5)
762                } else {
763                    base_val
764                };
765                base.insert(key.clone(), json!(base_val));
766                current.insert(key.clone(), json!(cur_val));
767                proposed.insert(key.clone(), json!(prop_val));
768            }
769
770            // Add random tags
771            let tag_count = rng.random_range(0..4);
772            let mut tags: Vec<Value> = (0..tag_count).map(|i| json!(format!("tag_{i}"))).collect();
773            if rng.random_bool(0.3) {
774                tags.push(json!("tag_0")); // intentional duplicate
775            }
776            if !tags.is_empty() {
777                base.insert("tags".to_string(), Value::Array(tags.clone()));
778                if rng.random_bool(0.5) {
779                    tags.push(json!("tag_new"));
780                }
781                current.insert("tags".to_string(), Value::Array(tags.clone()));
782                if rng.random_bool(0.5) {
783                    tags.push(json!("tag_extra"));
784                }
785                proposed.insert("tags".to_string(), Value::Array(tags));
786            }
787
788            let base_val = Value::Object(base);
789            let current_val = Value::Object(current);
790            let proposed_val = Value::Object(proposed);
791
792            // Run merge twice
793            let result1 = engine.merge(base_val.clone(), current_val.clone(), proposed_val.clone());
794            let result2 = engine.merge(base_val, current_val, proposed_val);
795
796            // Verify determinism
797            match (&result1, &result2) {
798                (MergeResult::Merged(a), MergeResult::Merged(b)) => {
799                    assert_eq!(a, b, "deterministic merge failed");
800                }
801                (MergeResult::Conflicts(a), MergeResult::Conflicts(b)) => {
802                    assert_eq!(a.len(), b.len(), "different conflict counts");
803                    for (ca, cb) in a.iter().zip(b.iter()) {
804                        assert_eq!(ca.path, cb.path, "different conflict paths");
805                    }
806                }
807                _ => panic!("merge results differ in type"),
808            }
809        }
810    }
811}