Skip to main content

clawft_kernel/
spatial_service.rs

1//! Spatial BVH as a kernel [`SystemService`] (ADR-035 / WEFT-718).
2//!
3//! Wraps an [`Arc<dyn SpatialBackend>`] (typically [`BvhBackend`]) with
4//! health/status counters. Mirrors [`crate::hnsw_service::HnswService`].
5
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use clawft_bvh::{
11    Aabb, BranchId, BranchMeta, DiffEntry, Frustum, Leaf, LeafId, Ray, RayHit, Vec3,
12};
13use clawft_types::config::SpatialConfig;
14use serde::{Deserialize, Serialize};
15
16use crate::health::HealthStatus;
17use crate::service::{ServiceType, SystemService};
18use crate::spatial_backend::{SpatialBackend, SpatialResult};
19use crate::spatial_bvh::{BvhBackend, KernelBvhChainSink};
20
21#[cfg(feature = "exochain")]
22use crate::chain::ChainManager;
23
24// ── Config bridge ────────────────────────────────────────────────────────
25
26/// Service-level config (mirrors types `SpatialConfig`).
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct SpatialServiceConfig {
29    /// Whether the service should be considered active.
30    pub enabled: bool,
31    /// Max leaves per branch.
32    pub max_leaves: usize,
33    /// Phase commit threshold (Phase D).
34    pub phase_commit_threshold: usize,
35}
36
37impl Default for SpatialServiceConfig {
38    fn default() -> Self {
39        Self::from_spatial_config(&SpatialConfig::default())
40    }
41}
42
43impl SpatialServiceConfig {
44    /// Build from `clawft-types` kernel spatial config.
45    pub fn from_spatial_config(cfg: &SpatialConfig) -> Self {
46        Self {
47            enabled: cfg.enabled,
48            max_leaves: cfg.max_leaves,
49            phase_commit_threshold: cfg.phase_commit_threshold,
50        }
51    }
52
53    /// Convert to `clawft_bvh::BvhStoreConfig`.
54    pub fn to_store_config(&self) -> clawft_bvh::BvhStoreConfig {
55        clawft_bvh::BvhStoreConfig {
56            max_leaves: self.max_leaves,
57            phase_commit_threshold: self.phase_commit_threshold,
58        }
59    }
60}
61
62// ── Status ───────────────────────────────────────────────────────────────
63
64/// Operator-facing status snapshot.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct SpatialServiceStatus {
67    /// Backend name (`"bvh"`).
68    pub backend: String,
69    /// Leaf count on active branch.
70    pub leaf_count: usize,
71    /// Monotonic epoch.
72    pub epoch: u64,
73    /// Number of branches.
74    pub branch_count: usize,
75    /// Lifetime insert ops.
76    pub insert_count: u64,
77    /// Lifetime query ops.
78    pub query_count: u64,
79    /// Configured max leaves (0 = unbounded).
80    pub max_leaves: usize,
81    /// Whether the service is enabled in config.
82    pub enabled: bool,
83}
84
85// ── Service ──────────────────────────────────────────────────────────────
86
87/// Kernel service for the spatial BVH index.
88pub struct SpatialService {
89    backend: Arc<dyn SpatialBackend>,
90    /// Concrete BVH handle for snapshot / seal helpers (optional for future
91    /// non-BVH backends).
92    bvh: Option<Arc<BvhBackend>>,
93    config: SpatialServiceConfig,
94    insert_count: AtomicU64,
95    query_count: AtomicU64,
96    started: std::sync::atomic::AtomicBool,
97}
98
99impl SpatialService {
100    /// Wrap an existing backend.
101    pub fn new(config: SpatialServiceConfig, backend: Arc<dyn SpatialBackend>) -> Self {
102        Self {
103            backend,
104            bvh: None,
105            config,
106            insert_count: AtomicU64::new(0),
107            query_count: AtomicU64::new(0),
108            started: std::sync::atomic::AtomicBool::new(false),
109        }
110    }
111
112    /// Construct the default BVH backend from config.
113    pub fn from_config(config: SpatialServiceConfig) -> Self {
114        let bvh = Arc::new(BvhBackend::new(config.to_store_config()));
115        let backend: Arc<dyn SpatialBackend> = bvh.clone();
116        Self {
117            backend,
118            bvh: Some(bvh),
119            config,
120            insert_count: AtomicU64::new(0),
121            query_count: AtomicU64::new(0),
122            started: std::sync::atomic::AtomicBool::new(false),
123        }
124    }
125
126    /// Construct from types-layer `SpatialConfig`.
127    pub fn from_spatial_config(cfg: &SpatialConfig) -> Self {
128        Self::from_config(SpatialServiceConfig::from_spatial_config(cfg))
129    }
130
131    /// Attach ExoChain sink to the underlying BVH backend.
132    #[cfg(feature = "exochain")]
133    pub fn attach_chain(&self, cm: Arc<ChainManager>) {
134        if let Some(ref bvh) = self.bvh {
135            bvh.set_chain_sink(Arc::new(KernelBvhChainSink::new(cm)));
136        }
137    }
138
139    /// Shared backend handle.
140    pub fn backend(&self) -> &Arc<dyn SpatialBackend> {
141        &self.backend
142    }
143
144    /// Concrete BVH backend when available.
145    pub fn bvh(&self) -> Option<&Arc<BvhBackend>> {
146        self.bvh.as_ref()
147    }
148
149    /// Insert a leaf.
150    pub fn insert(&self, leaf: Leaf) -> SpatialResult<LeafId> {
151        let id = self.backend.insert(leaf)?;
152        self.insert_count.fetch_add(1, Ordering::Relaxed);
153        Ok(id)
154    }
155
156    /// Remove a leaf.
157    pub fn remove(&self, id: LeafId) -> bool {
158        self.backend.remove(id)
159    }
160
161    /// Get a leaf.
162    pub fn get(&self, id: LeafId) -> Option<Leaf> {
163        self.backend.get(id)
164    }
165
166    /// Query point.
167    pub fn query_point(&self, p: Vec3) -> Vec<LeafId> {
168        self.query_count.fetch_add(1, Ordering::Relaxed);
169        self.backend.query_point(p)
170    }
171
172    /// Query AABB.
173    pub fn query_aabb(&self, bb: Aabb) -> Vec<LeafId> {
174        self.query_count.fetch_add(1, Ordering::Relaxed);
175        self.backend.query_aabb(bb)
176    }
177
178    /// Query sphere.
179    pub fn query_sphere(&self, center: Vec3, radius: f32) -> Vec<LeafId> {
180        self.query_count.fetch_add(1, Ordering::Relaxed);
181        self.backend.query_sphere(center, radius)
182    }
183
184    /// Query ray.
185    pub fn query_ray(&self, ray: Ray, max_t: f32) -> Vec<RayHit> {
186        self.query_count.fetch_add(1, Ordering::Relaxed);
187        self.backend.query_ray(ray, max_t)
188    }
189
190    /// Query frustum.
191    pub fn query_frustum(&self, frustum: &Frustum) -> Vec<LeafId> {
192        self.query_count.fetch_add(1, Ordering::Relaxed);
193        self.backend.query_frustum(frustum)
194    }
195
196    /// Query k-NN.
197    pub fn query_knn(&self, p: Vec3, k: usize) -> Vec<LeafId> {
198        self.query_count.fetch_add(1, Ordering::Relaxed);
199        self.backend.query_knn(p, k)
200    }
201
202    /// Derive a branch.
203    pub fn derive_branch(&self, meta: BranchMeta) -> SpatialResult<BranchId> {
204        self.backend.derive_branch(meta)
205    }
206
207    /// Branch diff.
208    pub fn branch_diff(&self, a: BranchId, b: BranchId, region: Aabb) -> Vec<DiffEntry> {
209        self.backend.branch_diff(a, b, region)
210    }
211
212    /// Status snapshot for CLI / health_detail.
213    pub fn status(&self) -> SpatialServiceStatus {
214        let branch_count = self.bvh.as_ref().map(|b| b.branch_count()).unwrap_or(1);
215        SpatialServiceStatus {
216            backend: self.backend.backend_name().to_string(),
217            leaf_count: self.backend.len(),
218            epoch: self.backend.epoch(),
219            branch_count,
220            insert_count: self.insert_count.load(Ordering::Relaxed),
221            query_count: self.query_count.load(Ordering::Relaxed),
222            max_leaves: self.config.max_leaves,
223            enabled: self.config.enabled,
224        }
225    }
226}
227
228#[async_trait]
229impl SystemService for SpatialService {
230    fn name(&self) -> &str {
231        "ecc.spatial"
232    }
233
234    fn service_type(&self) -> ServiceType {
235        ServiceType::Core
236    }
237
238    async fn start(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
239        self.started.store(true, Ordering::SeqCst);
240        Ok(())
241    }
242
243    async fn stop(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
244        self.started.store(false, Ordering::SeqCst);
245        Ok(())
246    }
247
248    async fn health_check(&self) -> HealthStatus {
249        if !self.config.enabled {
250            return HealthStatus::Healthy; // disabled is not unhealthy
251        }
252        if self.started.load(Ordering::SeqCst) {
253            HealthStatus::Healthy
254        } else {
255            HealthStatus::Degraded("spatial service not started".into())
256        }
257    }
258
259    fn health_detail(&self) -> Option<String> {
260        let s = self.status();
261        Some(format!(
262            "backend={} leaves={} epoch={} branches={} inserts={} queries={}",
263            s.backend, s.leaf_count, s.epoch, s.branch_count, s.insert_count, s.query_count
264        ))
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use clawft_bvh::IdentityKind;
272
273    #[tokio::test]
274    async fn service_lifecycle_and_status() {
275        let svc = SpatialService::from_config(SpatialServiceConfig {
276            enabled: true,
277            max_leaves: 100,
278            phase_commit_threshold: 16,
279        });
280        assert_eq!(svc.name(), "ecc.spatial");
281        svc.start().await.unwrap();
282        let id = svc
283            .insert(Leaf::empty_payload(
284                Aabb::unit(),
285                IdentityKind::Object,
286                1,
287            ))
288            .unwrap();
289        assert!(svc.query_point(Vec3::ZERO).contains(&id));
290        let st = svc.status();
291        assert_eq!(st.leaf_count, 1);
292        assert_eq!(st.insert_count, 1);
293        assert!(matches!(svc.health_check().await, HealthStatus::Healthy));
294        assert!(svc.health_detail().unwrap().contains("leaves=1"));
295        svc.stop().await.unwrap();
296    }
297}