Skip to main content

qualia_core_db/domains/geospatial/
spatial_sync.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4/// A planted asset representation for the spatial sync protocol.
5/// Defines "who-planted-what-where" across a shared world, keyed by spatial index.
6#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
7pub struct PlantedAsset {
8    /// A unique identifier for this placement (UUID or similar).
9    pub asset_id: String,
10    /// The H3 cell (or quadtree node) index this asset was planted in.
11    pub cell_id: u64,
12    /// The exact world-space position.
13    pub position: [f64; 3],
14    /// Orientation/rotation (quaternion or euler angles), stubbed as [f64; 4] for quaternion.
15    pub rotation: [f64; 4],
16    /// DID of the participant who planted this.
17    pub creator_did: String,
18    /// Lamport clock tracking the latest update/deletion state.
19    pub lamport: u64,
20    /// True if the asset was deleted/uprooted by the creator.
21    pub deleted: bool,
22}
23
24/// A spatial sync cell keyed by H3/quadtree cell id, holding the merged planted assets.
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct SpatialSyncCell {
27    pub cell_id: u64,
28    pub planted_assets: Vec<PlantedAsset>,
29}
30
31impl SpatialSyncCell {
32    pub fn new(cell_id: u64, planted_assets: Vec<PlantedAsset>) -> Self {
33        Self {
34            cell_id,
35            planted_assets,
36        }
37    }
38
39    /// Build a cell view from merged assets, filtering to the target cell and dropping tombstones.
40    pub fn from_assets(cell_id: u64, assets: &[PlantedAsset]) -> Self {
41        let mut planted_assets: Vec<PlantedAsset> = assets
42            .iter()
43            .filter(|a| a.cell_id == cell_id && !a.deleted)
44            .cloned()
45            .collect();
46        planted_assets.sort_by(|a, b| a.asset_id.cmp(&b.asset_id));
47        Self {
48            cell_id,
49            planted_assets,
50        }
51    }
52
53    /// Merge incoming remote/local assets into this cell using LWW resolution.
54    pub fn merge(&mut self, incoming: &[PlantedAsset]) {
55        let merged = merge_planted_assets(&self.planted_assets, incoming);
56        self.planted_assets = merged
57            .into_iter()
58            .filter(|a| a.cell_id == self.cell_id && !a.deleted)
59            .collect();
60    }
61}
62
63/// Last-Write-Wins (LWW) conflict resolution for overlapping asset placements.
64/// Merges two sets of `PlantedAsset`s, resolving conflicts by `asset_id` using the Lamport clock.
65/// Higher Lamport wins. Deterministic tie-breaking on `asset_id` string if clocks match.
66pub fn merge_planted_assets(local: &[PlantedAsset], remote: &[PlantedAsset]) -> Vec<PlantedAsset> {
67    let mut state: HashMap<String, PlantedAsset> = HashMap::new();
68
69    for asset in local.iter().chain(remote.iter()) {
70        if let Some(existing) = state.get(&asset.asset_id) {
71            if asset.lamport > existing.lamport {
72                state.insert(asset.asset_id.clone(), asset.clone());
73            } else if asset.lamport == existing.lamport {
74                // Deterministic tie breaker using position string just in case
75                // but really should not happen for the exact same asset_id unless they are identical
76                // We'll prefer the one that is somehow "larger" in creator_did as a stable tie-break,
77                // or just leave it. If identical, leaving `existing` is fine.
78                if asset.creator_did > existing.creator_did {
79                    state.insert(asset.asset_id.clone(), asset.clone());
80                }
81            }
82        } else {
83            state.insert(asset.asset_id.clone(), asset.clone());
84        }
85    }
86
87    // Convert to sorted vec to ensure deterministic output
88    let mut merged: Vec<PlantedAsset> = state.into_values().collect();
89    merged.sort_by(|a, b| a.asset_id.cmp(&b.asset_id));
90    merged
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn test_merge_planted_assets() {
99        let asset1_v1 = PlantedAsset {
100            asset_id: "obj-1".into(),
101            cell_id: 1234,
102            position: [1.0, 2.0, 3.0],
103            rotation: [0.0, 0.0, 0.0, 1.0],
104            creator_did: "did:q42:alice".into(),
105            lamport: 1,
106            deleted: false,
107        };
108
109        let asset1_v2 = PlantedAsset {
110            asset_id: "obj-1".into(),
111            cell_id: 1234,
112            position: [1.0, 5.0, 3.0], // moved
113            rotation: [0.0, 0.0, 0.0, 1.0],
114            creator_did: "did:q42:alice".into(),
115            lamport: 2,
116            deleted: false,
117        };
118
119        let asset2 = PlantedAsset {
120            asset_id: "obj-2".into(),
121            cell_id: 1234,
122            position: [5.0, 5.0, 5.0],
123            rotation: [0.0, 0.0, 0.0, 1.0],
124            creator_did: "did:q42:bob".into(),
125            lamport: 1,
126            deleted: false,
127        };
128
129        let local = vec![asset1_v1.clone()];
130        let remote = vec![asset1_v2.clone(), asset2.clone()];
131
132        let merged = merge_planted_assets(&local, &remote);
133        assert_eq!(merged.len(), 2);
134
135        // V2 should overwrite V1 for obj-1
136        assert_eq!(merged[0].asset_id, "obj-1");
137        assert_eq!(merged[0].position[1], 5.0);
138
139        assert_eq!(merged[1].asset_id, "obj-2");
140    }
141
142    #[test]
143    fn spatial_sync_cell_filters_by_cell_and_tombstones() {
144        let active = PlantedAsset {
145            asset_id: "obj-a".into(),
146            cell_id: 42,
147            position: [0.0, 0.0, 0.0],
148            rotation: [0.0, 0.0, 0.0, 1.0],
149            creator_did: "did:q42:alice".into(),
150            lamport: 1,
151            deleted: false,
152        };
153        let tombstone = PlantedAsset {
154            asset_id: "obj-b".into(),
155            cell_id: 42,
156            position: [1.0, 0.0, 0.0],
157            rotation: [0.0, 0.0, 0.0, 1.0],
158            creator_did: "did:q42:bob".into(),
159            lamport: 2,
160            deleted: true,
161        };
162        let other_cell = PlantedAsset {
163            asset_id: "obj-c".into(),
164            cell_id: 99,
165            position: [2.0, 0.0, 0.0],
166            rotation: [0.0, 0.0, 0.0, 1.0],
167            creator_did: "did:q42:carol".into(),
168            lamport: 1,
169            deleted: false,
170        };
171
172        let cell = SpatialSyncCell::from_assets(42, &[active.clone(), tombstone, other_cell]);
173        assert_eq!(cell.cell_id, 42);
174        assert_eq!(cell.planted_assets.len(), 1);
175        assert_eq!(cell.planted_assets[0].asset_id, "obj-a");
176    }
177
178    #[test]
179    fn spatial_sync_cell_merge_applies_lww() {
180        let local = PlantedAsset {
181            asset_id: "obj-1".into(),
182            cell_id: 7,
183            position: [0.0, 0.0, 0.0],
184            rotation: [0.0, 0.0, 0.0, 1.0],
185            creator_did: "did:q42:alice".into(),
186            lamport: 1,
187            deleted: false,
188        };
189        let remote = PlantedAsset {
190            asset_id: "obj-1".into(),
191            cell_id: 7,
192            position: [5.0, 0.0, 0.0],
193            rotation: [0.0, 0.0, 0.0, 1.0],
194            creator_did: "did:q42:bob".into(),
195            lamport: 3,
196            deleted: false,
197        };
198
199        let mut cell = SpatialSyncCell::from_assets(7, &[local]);
200        cell.merge(&[remote]);
201        assert_eq!(cell.planted_assets.len(), 1);
202        assert_eq!(cell.planted_assets[0].position[0], 5.0);
203    }
204}