Skip to main content

qualia_client_core/
canvas_world.rs

1//! Chora — spatio-temporal commons canvas world configuration.
2//!
3//! A "world" is a bounded projection over the canvas substrate (doc 02 §2):
4//! `{ temporal range, ordered layer-stack, permitted nquins, governance norms }`.
5//! Worlds are configurations, not engine forks.
6
7use serde::{Deserialize, Serialize};
8
9/// Ontological stratum for a layer (doc 02 §1).
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11pub enum WorldStratum {
12    /// Infrastructure, buildings, boundaries — authored human intent (a true digital twin).
13    WorldOfMan,
14    /// Biosphere, terrain, physics — computational approximation, never a twin.
15    WorldOfGod,
16    /// Records, claims, provenance — the informational environment.
17    Infosphere,
18    /// Human social relations — society as a stratum.
19    Sociosphere,
20}
21
22/// A dataset layer in the ordered stack (doc 02 §3).
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub enum CanvasLayer {
25    GeoSpatial {
26        endpoint: String,
27        stratum: WorldStratum,
28    },
29    Council {
30        endpoint: String,
31    },
32    Historical {
33        endpoint: String,
34    },
35    Celestial {
36        endpoint: String,
37    },
38    Infosphere {
39        endpoint: String,
40    },
41    MicroScale {
42        endpoint: String,
43    },
44    Biosphere {
45        endpoint: String,
46    },
47    Custom {
48        name: String,
49        endpoint: String,
50        stratum: WorldStratum,
51    },
52}
53
54impl CanvasLayer {
55    pub fn endpoint(&self) -> &str {
56        match self {
57            Self::GeoSpatial { endpoint, .. }
58            | Self::Council { endpoint }
59            | Self::Historical { endpoint }
60            | Self::Celestial { endpoint }
61            | Self::Infosphere { endpoint }
62            | Self::MicroScale { endpoint }
63            | Self::Biosphere { endpoint }
64            | Self::Custom { endpoint, .. } => endpoint,
65        }
66    }
67
68    pub fn stratum(&self) -> WorldStratum {
69        match self {
70            Self::GeoSpatial { stratum, .. } | Self::Custom { stratum, .. } => *stratum,
71            Self::Council { .. } | Self::Historical { .. } => WorldStratum::WorldOfMan,
72            Self::Celestial { .. } | Self::Biosphere { .. } | Self::MicroScale { .. } => {
73                WorldStratum::WorldOfGod
74            }
75            Self::Infosphere { .. } => WorldStratum::Infosphere,
76        }
77    }
78}
79
80/// A governed norm applied to this world (N3 rule URI or deontic contract hash).
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct CanvasNorm {
83    pub rule_uri: String,
84    #[serde(default)]
85    pub description: String,
86}
87
88/// A planted or referenced asset in the world (content-addressed `.10d` or remote hash).
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct CanvasAssetRef {
91    /// Content hash (hex) or `urn:qualia:asset:…` URI.
92    pub asset_id: String,
93    /// Optional geodetic anchor (degrees).
94    #[serde(default)]
95    pub lat: Option<f64>,
96    #[serde(default)]
97    pub lon: Option<f64>,
98    #[serde(default)]
99    pub alt_m: Option<f64>,
100    /// Valid-time interval (unix seconds). None = always visible.
101    #[serde(default)]
102    pub valid_from: Option<u64>,
103    #[serde(default)]
104    pub valid_until: Option<u64>,
105    /// Permissive licence (required for render gate).
106    pub licence: String,
107}
108
109/// A "world" configuration: a bounded projection over the canvas.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct CanvasWorldConfig {
112    /// Unique identifier (e.g. `q42:world:council-sydney`).
113    pub id: String,
114    /// Human-readable title shown in the Chora panel.
115    pub title: String,
116    /// Optional temporal range: (start_epoch, end_epoch). None = full timeline.
117    pub temporal_range: Option<(u64, u64)>,
118    /// Ordered stack of dataset layers to composite.
119    pub layer_stack: Vec<CanvasLayer>,
120    /// Offline or seeded assets rendered in this world.
121    #[serde(default)]
122    pub assets: Vec<CanvasAssetRef>,
123    /// Whitelist of permitted nquin subject hashes. Empty = layer-stack bounds only.
124    #[serde(default)]
125    pub permitted_nquins: Vec<u64>,
126    /// Deontic norms / governance rules applied to this world.
127    #[serde(default)]
128    pub norms: Vec<CanvasNorm>,
129    /// Reference-frame origin for local ENU coordinates (degrees).
130    #[serde(default)]
131    pub origin_lat: f64,
132    #[serde(default)]
133    pub origin_lon: f64,
134    #[serde(default)]
135    pub origin_alt_m: f64,
136}
137
138impl Default for CanvasWorldConfig {
139    fn default() -> Self {
140        Self {
141            id: "q42:world:default".to_string(),
142            title: "Default World".to_string(),
143            temporal_range: None,
144            layer_stack: vec![],
145            assets: vec![],
146            permitted_nquins: vec![],
147            norms: vec![],
148            origin_lat: -33.8688,
149            origin_lon: 151.2093,
150            origin_alt_m: 0.0,
151        }
152    }
153}
154
155#[derive(Debug, PartialEq, Eq)]
156pub enum WorldConfigError {
157    EmptyId,
158    InvertedTemporalRange,
159    AssetMissingLicence { asset_id: String },
160    AssetInvertedValidity { asset_id: String },
161}
162
163impl std::fmt::Display for WorldConfigError {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        match self {
166            Self::EmptyId => write!(f, "world id must not be empty"),
167            Self::InvertedTemporalRange => write!(f, "temporal_range start must be <= end"),
168            Self::AssetMissingLicence { asset_id } => {
169                write!(f, "asset {asset_id} requires a licence")
170            }
171            Self::AssetInvertedValidity { asset_id } => {
172                write!(f, "asset {asset_id} valid_from must be <= valid_until")
173            }
174        }
175    }
176}
177impl std::error::Error for WorldConfigError {}
178
179impl CanvasWorldConfig {
180    /// Validate the config contract (P0 freeze).
181    pub fn validate(&self) -> Result<(), WorldConfigError> {
182        if self.id.trim().is_empty() {
183            return Err(WorldConfigError::EmptyId);
184        }
185        if let Some((t0, t1)) = self.temporal_range {
186            if t0 > t1 {
187                return Err(WorldConfigError::InvertedTemporalRange);
188            }
189        }
190        for asset in &self.assets {
191            if asset.licence.trim().is_empty() {
192                return Err(WorldConfigError::AssetMissingLicence {
193                    asset_id: asset.asset_id.clone(),
194                });
195            }
196            if let (Some(vf), Some(vu)) = (asset.valid_from, asset.valid_until) {
197                if vf > vu {
198                    return Err(WorldConfigError::AssetInvertedValidity {
199                        asset_id: asset.asset_id.clone(),
200                    });
201                }
202            }
203        }
204        Ok(())
205    }
206
207    /// P0 demo world: one offline tile, Sydney origin, CC0 licence placeholder.
208    pub fn seed_demo() -> Self {
209        Self {
210            id: "q42:world:demo-offline".to_string(),
211            title: "Chora Demo (offline)".to_string(),
212            temporal_range: Some((1_700_000_000, 1_900_000_000)),
213            layer_stack: vec![CanvasLayer::GeoSpatial {
214                endpoint: "local://terrain/demo".to_string(),
215                stratum: WorldStratum::WorldOfGod,
216            }],
217            assets: vec![CanvasAssetRef {
218                asset_id: "local://assets/demo-tile.10d".to_string(),
219                lat: Some(-33.8688),
220                lon: Some(151.2093),
221                alt_m: Some(0.0),
222                valid_from: Some(1_700_000_000),
223                valid_until: None,
224                licence: "CC0".to_string(),
225            }],
226            norms: vec![CanvasNorm {
227                rule_uri: "urn:qualia:canvas:public-commons".to_string(),
228                description: "Permissive-commons read; planting requires placement right"
229                    .to_string(),
230            }],
231            origin_lat: -33.8688,
232            origin_lon: 151.2093,
233            origin_alt_m: 0.0,
234            ..Default::default()
235        }
236    }
237
238    /// Resolve layer endpoints in stack order (deduplicated).
239    pub fn layer_endpoints(&self) -> Vec<&str> {
240        let mut seen = std::collections::HashSet::new();
241        let mut out = Vec::new();
242        for layer in &self.layer_stack {
243            let ep = layer.endpoint();
244            if seen.insert(ep) {
245                out.push(ep);
246            }
247        }
248        out
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn demo_world_validates() {
258        let w = CanvasWorldConfig::seed_demo();
259        w.validate().unwrap();
260        assert_eq!(w.layer_stack.len(), 1);
261        assert_eq!(w.assets[0].licence, "CC0");
262    }
263
264    #[test]
265    fn rejects_inverted_temporal_range() {
266        let mut w = CanvasWorldConfig::default();
267        w.temporal_range = Some((2000, 1000));
268        assert_eq!(w.validate(), Err(WorldConfigError::InvertedTemporalRange));
269    }
270
271    #[test]
272    fn layer_endpoints_deduplicated() {
273        let w = CanvasWorldConfig {
274            layer_stack: vec![
275                CanvasLayer::GeoSpatial {
276                    endpoint: "a".into(),
277                    stratum: WorldStratum::WorldOfGod,
278                },
279                CanvasLayer::Council {
280                    endpoint: "b".into(),
281                },
282                CanvasLayer::GeoSpatial {
283                    endpoint: "a".into(),
284                    stratum: WorldStratum::WorldOfGod,
285                },
286            ],
287            ..Default::default()
288        };
289        assert_eq!(w.layer_endpoints(), vec!["a", "b"]);
290    }
291}