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[buzzlightyear]` and
176    /// `characters[buzzlightyear].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: "buzzlightyear"` and the item at position 0 in
236    /// current has `id: "buzzlightyear"`, 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": "Woody"});
514        let current = json!({"name": "Woody"});
515        let proposed = json!({"name": "Woody"});
516
517        let result = engine.merge(base, current, proposed);
518        assert!(result.is_merged());
519        assert_eq!(result.unwrap_merged().get("name"), Some(&json!("Woody")));
520    }
521
522    #[test]
523    fn test_merge_replace_conflict() {
524        let engine = MergeEngine::new(simple_sdl());
525
526        let base = json!({"name": "Woody"});
527        let current = json!({"name": "Sheriff Woody"});
528        let proposed = json!({"name": "Sid"});
529
530        let result = engine.merge(base, current, proposed);
531        assert!(result.is_conflict());
532    }
533
534    #[test]
535    fn test_merge_missing_field_preserved() {
536        let engine = MergeEngine::new(simple_sdl());
537
538        let base = json!({"name": "Buzz", "version": 1});
539        let current = json!({"name": "Buzz Lightyear"});
540        let proposed = json!({"name": "Buzz"});
541
542        // Normalization should fill in version from base
543        let result = engine.merge(base, current, proposed);
544        assert!(result.is_merged());
545        let merged = result.unwrap_merged();
546        assert_eq!(merged.get("name"), Some(&json!("Buzz Lightyear")));
547        assert_eq!(merged.get("version"), Some(&json!(1)));
548    }
549
550    #[test]
551    fn test_merge_null_is_deletion() {
552        let engine = MergeEngine::new(simple_sdl());
553
554        let base = json!({"name": "Woody", "version": 1});
555        let current = json!({"name": "Woody", "version": 1});
556        let proposed = json!({"name": "Woody", "version": null});
557
558        let result = engine.merge(base, current, proposed);
559        assert!(result.is_merged());
560        let merged = result.unwrap_merged();
561        // version should be null (explicit deletion)
562        assert_eq!(merged.get("version"), Some(&Value::Null));
563    }
564
565    #[test]
566    fn test_merge_ordered_unique_objects() {
567        let engine = MergeEngine::new(simple_sdl());
568
569        let base = json!({"characters": [{"id": "A", "name": "Alpha"}]});
570        let current =
571            json!({"characters": [{"id": "A", "name": "Alpha"}, {"id": "B", "name": "Beta"}]});
572        let proposed =
573            json!({"characters": [{"id": "A", "name": "Alpha"}, {"id": "C", "name": "Gamma"}]});
574
575        let result = engine.merge(base, current, proposed);
576        assert!(result.is_merged());
577        let merged = result.unwrap_merged();
578        let chars = merged["characters"].as_array().unwrap();
579        assert_eq!(chars.len(), 3);
580        assert_eq!(chars[0]["id"], json!("A"));
581        assert_eq!(chars[1]["id"], json!("B"));
582        assert_eq!(chars[2]["id"], json!("C"));
583    }
584
585    #[test]
586    fn test_merge_ordered_unique_primitives() {
587        let engine = MergeEngine::new(simple_sdl());
588
589        let base = json!({"tags": ["a", "b"]});
590        let current = json!({"tags": ["a", "b", "c"]});
591        let proposed = json!({"tags": ["a", "b", "d"]});
592
593        let result = engine.merge(base, current, proposed);
594        assert!(result.is_merged());
595        let merged = result.unwrap_merged();
596        assert_eq!(
597            merged["tags"].as_array().unwrap(),
598            &[json!("a"), json!("b"), json!("c"), json!("d")]
599        );
600    }
601
602    #[test]
603    fn test_merge_atomic_conflict() {
604        let engine = MergeEngine::new(simple_sdl());
605
606        let base = json!({"version": 1});
607        let current = json!({"version": 2});
608        let proposed = json!({"version": 3});
609
610        let result = engine.merge(base, current, proposed);
611        assert!(result.is_conflict());
612    }
613
614    #[test]
615    fn test_merge_edge_list() {
616        let engine = MergeEngine::new(simple_sdl());
617
618        let base = json!({"edges": [{"id": "e1", "source": "a", "target": "b"}]});
619        let current = json!({
620            "edges": [
621                {"id": "e1", "source": "a", "target": "b"},
622                {"id": "e2", "source": "b", "target": "c"}
623            ]
624        });
625        let proposed = json!({
626            "edges": [
627                {"id": "e1", "source": "a", "target": "b"},
628                {"id": "e3", "source": "c", "target": "a"}
629            ]
630        });
631
632        let result = engine.merge(base, current, proposed);
633        assert!(result.is_merged());
634        let merged = result.unwrap_merged();
635        assert_eq!(merged["edges"].as_array().unwrap().len(), 3);
636    }
637
638    #[test]
639    fn test_merge_identity_mutation_conflict() {
640        let engine = MergeEngine::new(simple_sdl());
641
642        let base = json!({"characters": [{"id": "buzzlightyear", "name": "Buzz"}]});
643        let current = json!({"characters": [{"id": "buzz", "name": "Buzz"}]}); // id changed!
644        let proposed = json!({"characters": [{"id": "buzzlightyear", "name": "Buzz"}]});
645
646        let result = engine.merge(base, current, proposed);
647        assert!(result.is_conflict());
648    }
649
650    #[test]
651    fn test_merge_deterministic() {
652        let engine = MergeEngine::new(simple_sdl());
653
654        let base = json!({"name": "Woody", "version": 1, "tags": ["a"]});
655        let current = json!({"name": "Woody", "version": 2, "tags": ["a", "b"]});
656        let proposed = json!({"name": "Woody", "version": 1, "tags": ["a", "c"]});
657
658        let result1 = engine.merge(base.clone(), current.clone(), proposed.clone());
659        let result2 = engine.merge(base, current, proposed);
660
661        // Both should produce identical results
662        match (result1, result2) {
663            (MergeResult::Merged(a), MergeResult::Merged(b)) => assert_eq!(a, b),
664            (MergeResult::Conflicts(a), MergeResult::Conflicts(b)) => {
665                assert_eq!(a.len(), b.len());
666                for (ca, cb) in a.iter().zip(b.iter()) {
667                    assert_eq!(ca.path, cb.path);
668                }
669            }
670            _ => panic!("results should be same variant"),
671        }
672    }
673
674    #[test]
675    fn test_merge_empty_documents() {
676        let engine = MergeEngine::new(simple_sdl());
677
678        let base = json!({});
679        let current = json!({});
680        let proposed = json!({});
681
682        let result = engine.merge(base, current, proposed);
683        assert!(result.is_merged());
684        assert_eq!(result.unwrap_merged(), json!({}));
685    }
686
687    #[test]
688    fn test_merge_additions_from_both_sides() {
689        let engine = MergeEngine::new(simple_sdl());
690
691        let base = json!({});
692        let current = json!({"name": "From Current"});
693        let proposed = json!({"version": 42});
694
695        let result = engine.merge(base, current, proposed);
696        assert!(result.is_merged());
697        let merged = result.unwrap_merged();
698        assert_eq!(merged.get("name"), Some(&json!("From Current")));
699        assert_eq!(merged.get("version"), Some(&json!(42)));
700    }
701
702    #[test]
703    fn test_merge_both_add_same_field_conflict() {
704        let engine = MergeEngine::new(simple_sdl());
705
706        let base = json!({});
707        let current = json!({"name": "From Current"});
708        let proposed = json!({"name": "From Proposed"});
709
710        let result = engine.merge(base, current, proposed);
711        // Both changed "name" differently from base → conflict
712        assert!(result.is_conflict());
713    }
714
715    #[test]
716    fn test_merge_ordered_unique_modification_accepted() {
717        let engine = MergeEngine::new(simple_sdl());
718
719        let base = json!({"characters": [{"id": "A", "val": 1}]});
720        let current = json!({"characters": [{"id": "A", "val": 2}]}); // modified
721        let proposed = json!({"characters": [{"id": "A", "val": 1}]}); // same as base
722
723        let result = engine.merge(base, current, proposed);
724        assert!(result.is_merged());
725        let merged = result.unwrap_merged();
726        assert_eq!(merged["characters"][0]["val"], json!(2));
727    }
728
729    // ── Property-style determinism test ────────────────────────────────
730
731    /// Generate random documents and verify that merge is deterministic.
732    #[test]
733    fn test_deterministic_random_iterations() {
734        use rand::Rng;
735        let engine = MergeEngine::new(simple_sdl());
736        let mut rng = rand::rng();
737
738        for _ in 0..50 {
739            // Build a simple random document
740            let mut base = serde_json::Map::new();
741            let mut current = serde_json::Map::new();
742            let mut proposed = serde_json::Map::new();
743
744            // Add random fields
745            let field_count = rng.random_range(0..6);
746            for i in 0..field_count {
747                let key = format!("field_{i}");
748                let base_val = rng.random_range(0..100);
749                let cur_val = if rng.random_bool(0.5) {
750                    base_val + rng.random_range(-5..=5)
751                } else {
752                    base_val
753                };
754                let prop_val = if rng.random_bool(0.5) {
755                    base_val + rng.random_range(-5..=5)
756                } else {
757                    base_val
758                };
759                base.insert(key.clone(), json!(base_val));
760                current.insert(key.clone(), json!(cur_val));
761                proposed.insert(key.clone(), json!(prop_val));
762            }
763
764            // Add random tags
765            let tag_count = rng.random_range(0..4);
766            let mut tags: Vec<Value> = (0..tag_count).map(|i| json!(format!("tag_{i}"))).collect();
767            if rng.random_bool(0.3) {
768                tags.push(json!("tag_0")); // intentional duplicate
769            }
770            if !tags.is_empty() {
771                base.insert("tags".to_string(), Value::Array(tags.clone()));
772                if rng.random_bool(0.5) {
773                    tags.push(json!("tag_new"));
774                }
775                current.insert("tags".to_string(), Value::Array(tags.clone()));
776                if rng.random_bool(0.5) {
777                    tags.push(json!("tag_extra"));
778                }
779                proposed.insert("tags".to_string(), Value::Array(tags));
780            }
781
782            let base_val = Value::Object(base);
783            let current_val = Value::Object(current);
784            let proposed_val = Value::Object(proposed);
785
786            // Run merge twice
787            let result1 = engine.merge(base_val.clone(), current_val.clone(), proposed_val.clone());
788            let result2 = engine.merge(base_val, current_val, proposed_val);
789
790            // Verify determinism
791            match (&result1, &result2) {
792                (MergeResult::Merged(a), MergeResult::Merged(b)) => {
793                    assert_eq!(a, b, "deterministic merge failed");
794                }
795                (MergeResult::Conflicts(a), MergeResult::Conflicts(b)) => {
796                    assert_eq!(a.len(), b.len(), "different conflict counts");
797                    for (ca, cb) in a.iter().zip(b.iter()) {
798                        assert_eq!(ca.path, cb.path, "different conflict paths");
799                    }
800                }
801                _ => panic!("merge results differ in type"),
802            }
803        }
804    }
805}