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 = def.merge.identity.as_ref()?;
248
249        let identity_key = match identity {
250            IdentityRule::Key { key } => key,
251            IdentityRule::PrimitiveValue => return None, // primitive value IS the identity, can't change
252        };
253
254        // Check that all three values are arrays
255        let base_arr = base.as_array()?;
256        let current_arr = current.as_array()?;
257        let proposed_arr = proposed.as_array()?;
258
259        // Check current against base: compare identity keys at each position
260        for i in 0..base_arr.len().min(current_arr.len()) {
261            let base_id = base_arr[i].get(identity_key);
262            let cur_id = current_arr[i].get(identity_key);
263            if base_id != cur_id {
264                let base_id_str = base_id.and_then(|v| v.as_str()).unwrap_or("?");
265                let sub_path = format!("root.{path}[{base_id_str}]");
266                let proposed_val = proposed_arr.get(i).cloned().unwrap_or(Value::Null);
267                return Some(Conflict::identity_mutation(
268                    sub_path,
269                    base_arr[i].clone(),
270                    current_arr[i].clone(),
271                    proposed_val,
272                ));
273            }
274        }
275
276        // Check proposed against base: compare identity keys at each position
277        for i in 0..base_arr.len().min(proposed_arr.len()) {
278            let base_id = base_arr[i].get(identity_key);
279            let prop_id = proposed_arr[i].get(identity_key);
280            if base_id != prop_id {
281                let base_id_str = base_id.and_then(|v| v.as_str()).unwrap_or("?");
282                let sub_path = format!("root.{path}[{base_id_str}]");
283                let current_val = current_arr.get(i).cloned().unwrap_or(Value::Null);
284                return Some(Conflict::identity_mutation(
285                    sub_path,
286                    base_arr[i].clone(),
287                    current_val,
288                    proposed_arr[i].clone(),
289                ));
290            }
291        }
292
293        None
294    }
295
296    /// Apply the appropriate merge strategy for a path.
297    fn apply_strategy(
298        &self,
299        path: &str,
300        def: &PropertyDef,
301        base: &Value,
302        current: &Value,
303        proposed: &Value,
304    ) -> MergeResult {
305        let full_path = format!("root.{path}");
306
307        match def.merge.strategy_type {
308            MergeStrategyType::Replace => {
309                strategies::replace::merge_replace(&full_path, base, current, proposed)
310            }
311
312            MergeStrategyType::DeepMerge => {
313                strategies::deep_merge::merge_deep(&full_path, base, current, proposed)
314            }
315
316            MergeStrategyType::Atomic => {
317                strategies::atomic::merge_atomic(&full_path, base, current, proposed)
318            }
319
320            MergeStrategyType::OrderedUnique => {
321                let identity = def
322                    .merge
323                    .identity
324                    .clone()
325                    .unwrap_or(IdentityRule::PrimitiveValue);
326                strategies::ordered_unique::merge_ordered_unique(
327                    &full_path, base, current, proposed, &identity,
328                )
329            }
330
331            MergeStrategyType::SetUnion => {
332                let identity = def
333                    .merge
334                    .identity
335                    .clone()
336                    .unwrap_or(IdentityRule::PrimitiveValue);
337                strategies::set_union::merge_set_union(
338                    &full_path, base, current, proposed, &identity,
339                )
340            }
341
342            MergeStrategyType::EdgeList => {
343                let identity = def.merge.identity.clone().unwrap_or(IdentityRule::Key {
344                    key: "id".to_string(),
345                });
346                let source_key = def
347                    .merge
348                    .source_key
349                    .clone()
350                    .unwrap_or_else(|| "source".to_string());
351                let target_key = def
352                    .merge
353                    .target_key
354                    .clone()
355                    .unwrap_or_else(|| "target".to_string());
356                strategies::edge_list::merge_edge_list(
357                    &full_path,
358                    base,
359                    current,
360                    proposed,
361                    &identity,
362                    &source_key,
363                    &target_key,
364                )
365            }
366        }
367    }
368}
369
370/// Set a value at a given canonical path within a JSON document.
371///
372/// Creates intermediate objects as needed.
373/// Returns an error if the path cannot be set (e.g., type conflict).
374fn set_value_at_path(root: &mut Value, path: &str, value: Value) -> Result<(), String> {
375    let canonical =
376        CanonicalPath::parse(path).map_err(|e| format!("invalid path '{path}': {e}"))?;
377
378    let segments = canonical.segments().to_vec();
379    if segments.is_empty() {
380        return Err("empty path".to_string());
381    }
382
383    // Navigate to the parent of the final segment
384    let parent_segments = &segments[..segments.len() - 1];
385    let last_segment = &segments[segments.len() - 1];
386
387    let mut current = root;
388
389    // Navigate/build intermediate segments
390    for segment in parent_segments {
391        match segment {
392            crate::merge::path::PathSegment::Key(key) => {
393                if !current.is_object() {
394                    return Err(format!("cannot enter non-object at '{key}'"));
395                }
396                current = current
397                    .as_object_mut()
398                    .unwrap()
399                    .entry(key.clone())
400                    .or_insert_with(|| Value::Object(serde_json::Map::new()));
401            }
402            crate::merge::path::PathSegment::Identity(id) => {
403                // For identity segments, we assume the array already has the item
404                // (it was created during path map building)
405                if let Some(item) = find_item_by_identity(current, id) {
406                    current = item;
407                } else {
408                    return Err(format!("identity '{id}' not found in array"));
409                }
410            }
411        }
412    }
413
414    // Set the value at the final segment
415    match last_segment {
416        crate::merge::path::PathSegment::Key(key) => {
417            if let Value::Object(map) = current {
418                map.insert(key.clone(), value);
419                Ok(())
420            } else {
421                Err(format!("cannot set key '{key}' on non-object"))
422            }
423        }
424        crate::merge::path::PathSegment::Identity(id) => {
425            // Find the item by identity and replace it
426            if let Some(item) = find_item_by_identity(current, id) {
427                *item = value;
428                Ok(())
429            } else {
430                Err(format!("identity '{id}' not found in array"))
431            }
432        }
433    }
434}
435
436/// Find an item in an array by matching its identity value (any string field).
437fn find_item_by_identity<'a>(root: &'a mut Value, identity: &str) -> Option<&'a mut Value> {
438    match root {
439        Value::Array(arr) => {
440            for item in arr.iter_mut() {
441                if has_identity(item, identity) {
442                    return Some(item);
443                }
444            }
445            None
446        }
447        _ => None,
448    }
449}
450
451/// Check whether a JSON value matches a given identity string.
452fn has_identity(item: &Value, identity: &str) -> bool {
453    match item {
454        Value::Object(map) => map.values().any(|v| v.as_str() == Some(identity)),
455        Value::String(s) => s == identity,
456        Value::Number(n) => n.to_string() == identity,
457        Value::Bool(b) => b.to_string() == identity,
458        _ => false,
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use serde_json::json;
466
467    fn simple_sdl() -> SdlDocument {
468        SdlDocument::from_yaml(
469            r#"
470schema:
471  version: "1.0"
472  required: []
473  properties:
474    name:
475      type: string
476      merge:
477        type: replace
478    version:
479      type: number
480      merge:
481        type: atomic
482    tags:
483      type: array
484      merge:
485        type: ordered_unique
486        identity:
487          mode: primitive_value
488    characters:
489      type: array
490      merge:
491        type: ordered_unique
492        identity:
493          mode: key
494          key: id
495    edges:
496      type: array
497      merge:
498        type: edge_list
499        source_key: source
500        target_key: target
501        identity:
502          mode: key
503          key: id
504"#,
505        )
506        .unwrap()
507    }
508
509    #[test]
510    fn test_merge_simple_replace() {
511        let engine = MergeEngine::new(simple_sdl());
512
513        let base = json!({"name": "Luke"});
514        let current = json!({"name": "Luke Skywalker"});
515        let proposed = json!({"name": "Luke"});
516
517        let result = engine.merge(base, current, proposed);
518        assert!(result.is_merged());
519        assert_eq!(
520            result.unwrap_merged().get("name"),
521            Some(&json!("Luke Skywalker"))
522        );
523    }
524
525    #[test]
526    fn test_merge_replace_conflict() {
527        let engine = MergeEngine::new(simple_sdl());
528
529        let base = json!({"name": "Luke"});
530        let current = json!({"name": "Luke Skywalker"});
531        let proposed = json!({"name": "Anakin"});
532
533        let result = engine.merge(base, current, proposed);
534        assert!(result.is_conflict());
535    }
536
537    #[test]
538    fn test_merge_missing_field_preserved() {
539        let engine = MergeEngine::new(simple_sdl());
540
541        let base = json!({"name": "Obi Wan", "version": 1});
542        let current = json!({"name": "Obi Wan Kenobi"});
543        let proposed = json!({"name": "Obi Wan"});
544
545        // Normalization should fill in version from base
546        let result = engine.merge(base, current, proposed);
547        assert!(result.is_merged());
548        let merged = result.unwrap_merged();
549        assert_eq!(merged.get("name"), Some(&json!("Obi Wan Kenobi")));
550        assert_eq!(merged.get("version"), Some(&json!(1)));
551    }
552
553    #[test]
554    fn test_merge_null_is_deletion() {
555        let engine = MergeEngine::new(simple_sdl());
556
557        let base = json!({"name": "Luke", "version": 1});
558        let current = json!({"name": "Luke", "version": 1});
559        let proposed = json!({"name": "Luke", "version": null});
560
561        let result = engine.merge(base, current, proposed);
562        assert!(result.is_merged());
563        let merged = result.unwrap_merged();
564        // version should be null (explicit deletion)
565        assert_eq!(merged.get("version"), Some(&Value::Null));
566    }
567
568    #[test]
569    fn test_merge_ordered_unique_objects() {
570        let engine = MergeEngine::new(simple_sdl());
571
572        let base = json!({"characters": [{"id": "A", "name": "Alpha"}]});
573        let current =
574            json!({"characters": [{"id": "A", "name": "Alpha"}, {"id": "B", "name": "Beta"}]});
575        let proposed =
576            json!({"characters": [{"id": "A", "name": "Alpha"}, {"id": "C", "name": "Gamma"}]});
577
578        let result = engine.merge(base, current, proposed);
579        assert!(result.is_merged());
580        let merged = result.unwrap_merged();
581        let chars = merged["characters"].as_array().unwrap();
582        assert_eq!(chars.len(), 3);
583        assert_eq!(chars[0]["id"], json!("A"));
584        assert_eq!(chars[1]["id"], json!("B"));
585        assert_eq!(chars[2]["id"], json!("C"));
586    }
587
588    #[test]
589    fn test_merge_ordered_unique_primitives() {
590        let engine = MergeEngine::new(simple_sdl());
591
592        let base = json!({"tags": ["a", "b"]});
593        let current = json!({"tags": ["a", "b", "c"]});
594        let proposed = json!({"tags": ["a", "b", "d"]});
595
596        let result = engine.merge(base, current, proposed);
597        assert!(result.is_merged());
598        let merged = result.unwrap_merged();
599        assert_eq!(
600            merged["tags"].as_array().unwrap(),
601            &[json!("a"), json!("b"), json!("c"), json!("d")]
602        );
603    }
604
605    #[test]
606    fn test_merge_atomic_conflict() {
607        let engine = MergeEngine::new(simple_sdl());
608
609        let base = json!({"version": 1});
610        let current = json!({"version": 2});
611        let proposed = json!({"version": 3});
612
613        let result = engine.merge(base, current, proposed);
614        assert!(result.is_conflict());
615    }
616
617    #[test]
618    fn test_merge_edge_list() {
619        let engine = MergeEngine::new(simple_sdl());
620
621        let base = json!({"edges": [{"id": "e1", "source": "a", "target": "b"}]});
622        let current = json!({
623            "edges": [
624                {"id": "e1", "source": "a", "target": "b"},
625                {"id": "e2", "source": "b", "target": "c"}
626            ]
627        });
628        let proposed = json!({
629            "edges": [
630                {"id": "e1", "source": "a", "target": "b"},
631                {"id": "e3", "source": "c", "target": "a"}
632            ]
633        });
634
635        let result = engine.merge(base, current, proposed);
636        assert!(result.is_merged());
637        let merged = result.unwrap_merged();
638        assert_eq!(merged["edges"].as_array().unwrap().len(), 3);
639    }
640
641    #[test]
642    fn test_merge_identity_mutation_conflict() {
643        let engine = MergeEngine::new(simple_sdl());
644
645        let base = json!({"characters": [{"id": "obiwan", "name": "Obi-Wan"}]});
646        let current = json!({"characters": [{"id": "ben_kenobi", "name": "Obi-Wan"}]}); // id changed!
647        let proposed = json!({"characters": [{"id": "obiwan", "name": "Obi-Wan"}]});
648
649        let result = engine.merge(base, current, proposed);
650        assert!(result.is_conflict());
651    }
652
653    #[test]
654    fn test_merge_deterministic() {
655        let engine = MergeEngine::new(simple_sdl());
656
657        let base = json!({"name": "Luke", "version": 1, "tags": ["a"]});
658        let current = json!({"name": "Luke Skywalker", "version": 2, "tags": ["a", "b"]});
659        let proposed = json!({"name": "Luke", "version": 1, "tags": ["a", "c"]});
660
661        let result1 = engine.merge(base.clone(), current.clone(), proposed.clone());
662        let result2 = engine.merge(base, current, proposed);
663
664        // Both should produce identical results
665        match (result1, result2) {
666            (MergeResult::Merged(a), MergeResult::Merged(b)) => assert_eq!(a, b),
667            (MergeResult::Conflicts(a), MergeResult::Conflicts(b)) => {
668                assert_eq!(a.len(), b.len());
669                for (ca, cb) in a.iter().zip(b.iter()) {
670                    assert_eq!(ca.path, cb.path);
671                }
672            }
673            _ => panic!("results should be same variant"),
674        }
675    }
676
677    #[test]
678    fn test_merge_empty_documents() {
679        let engine = MergeEngine::new(simple_sdl());
680
681        let base = json!({});
682        let current = json!({});
683        let proposed = json!({});
684
685        let result = engine.merge(base, current, proposed);
686        assert!(result.is_merged());
687        assert_eq!(result.unwrap_merged(), json!({}));
688    }
689
690    #[test]
691    fn test_merge_additions_from_both_sides() {
692        let engine = MergeEngine::new(simple_sdl());
693
694        let base = json!({});
695        let current = json!({"name": "From Current"});
696        let proposed = json!({"version": 42});
697
698        let result = engine.merge(base, current, proposed);
699        assert!(result.is_merged());
700        let merged = result.unwrap_merged();
701        assert_eq!(merged.get("name"), Some(&json!("From Current")));
702        assert_eq!(merged.get("version"), Some(&json!(42)));
703    }
704
705    #[test]
706    fn test_merge_both_add_same_field_conflict() {
707        let engine = MergeEngine::new(simple_sdl());
708
709        let base = json!({});
710        let current = json!({"name": "From Current"});
711        let proposed = json!({"name": "From Proposed"});
712
713        let result = engine.merge(base, current, proposed);
714        // Both changed "name" differently from base → conflict
715        assert!(result.is_conflict());
716    }
717
718    #[test]
719    fn test_merge_ordered_unique_modification_accepted() {
720        let engine = MergeEngine::new(simple_sdl());
721
722        let base = json!({"characters": [{"id": "A", "val": 1}]});
723        let current = json!({"characters": [{"id": "A", "val": 2}]}); // modified
724        let proposed = json!({"characters": [{"id": "A", "val": 1}]}); // same as base
725
726        let result = engine.merge(base, current, proposed);
727        assert!(result.is_merged());
728        let merged = result.unwrap_merged();
729        assert_eq!(merged["characters"][0]["val"], json!(2));
730    }
731
732    // ── Property-style determinism test ────────────────────────────────
733
734    /// Generate random documents and verify that merge is deterministic.
735    #[test]
736    fn test_deterministic_random_iterations() {
737        use rand::Rng;
738        let engine = MergeEngine::new(simple_sdl());
739        let mut rng = rand::rng();
740
741        for _ in 0..50 {
742            // Build a simple random document
743            let mut base = serde_json::Map::new();
744            let mut current = serde_json::Map::new();
745            let mut proposed = serde_json::Map::new();
746
747            // Add random fields
748            let field_count = rng.random_range(0..6);
749            for i in 0..field_count {
750                let key = format!("field_{i}");
751                let base_val = rng.random_range(0..100);
752                let cur_val = if rng.random_bool(0.5) {
753                    base_val + rng.random_range(-5..=5)
754                } else {
755                    base_val
756                };
757                let prop_val = if rng.random_bool(0.5) {
758                    base_val + rng.random_range(-5..=5)
759                } else {
760                    base_val
761                };
762                base.insert(key.clone(), json!(base_val));
763                current.insert(key.clone(), json!(cur_val));
764                proposed.insert(key.clone(), json!(prop_val));
765            }
766
767            // Add random tags
768            let tag_count = rng.random_range(0..4);
769            let mut tags: Vec<Value> = (0..tag_count).map(|i| json!(format!("tag_{i}"))).collect();
770            if rng.random_bool(0.3) {
771                tags.push(json!("tag_0")); // intentional duplicate
772            }
773            if !tags.is_empty() {
774                base.insert("tags".to_string(), Value::Array(tags.clone()));
775                if rng.random_bool(0.5) {
776                    tags.push(json!("tag_new"));
777                }
778                current.insert("tags".to_string(), Value::Array(tags.clone()));
779                if rng.random_bool(0.5) {
780                    tags.push(json!("tag_extra"));
781                }
782                proposed.insert("tags".to_string(), Value::Array(tags));
783            }
784
785            let base_val = Value::Object(base);
786            let current_val = Value::Object(current);
787            let proposed_val = Value::Object(proposed);
788
789            // Run merge twice
790            let result1 = engine.merge(base_val.clone(), current_val.clone(), proposed_val.clone());
791            let result2 = engine.merge(base_val, current_val, proposed_val);
792
793            // Verify determinism
794            match (&result1, &result2) {
795                (MergeResult::Merged(a), MergeResult::Merged(b)) => {
796                    assert_eq!(a, b, "deterministic merge failed");
797                }
798                (MergeResult::Conflicts(a), MergeResult::Conflicts(b)) => {
799                    assert_eq!(a.len(), b.len(), "different conflict counts");
800                    for (ca, cb) in a.iter().zip(b.iter()) {
801                        assert_eq!(ca.path, cb.path, "different conflict paths");
802                    }
803                }
804                _ => panic!("merge results differ in type"),
805            }
806        }
807    }
808}