Skip to main content

spatialrust_semantic/
entity.rs

1//! Semantic spatial entities.
2
3use spatialrust_core::{FrameId, HasPositions3, Timestamp};
4use spatialrust_math::Vec3;
5use spatialrust_records::{RecordProvenance, SpatialRecord};
6
7use crate::{Embedding, SemanticError, SemanticResult};
8
9/// Stable entity identifier.
10#[derive(Clone, Debug, PartialEq, Eq, Hash)]
11pub struct EntityId(pub String);
12
13impl EntityId {
14    /// Creates an entity id.
15    #[must_use]
16    pub fn new(value: impl Into<String>) -> Self {
17        Self(value.into())
18    }
19}
20
21/// Open-vocabulary label with confidence.
22#[derive(Clone, Debug, PartialEq)]
23pub struct OpenVocabLabel {
24    /// Free-form text label.
25    pub text: String,
26    /// Confidence in `[0, 1]`.
27    pub confidence: f32,
28}
29
30/// One semantic spatial entity with optional embedding.
31#[derive(Clone, Debug, PartialEq)]
32pub struct SemanticEntity {
33    /// Entity id.
34    pub id: EntityId,
35    /// Optional centroid.
36    pub centroid: Option<Vec3<f32>>,
37    /// Open-vocabulary labels.
38    pub labels: Vec<OpenVocabLabel>,
39    /// Optional embedding for search/fusion.
40    pub embedding: Option<Embedding>,
41}
42
43/// A semantic entity derived from one versioned spatial record.
44///
45/// The embedded [`SemanticEntity`] remains compatible with the existing
46/// search index, while this wrapper keeps the record's lineage and spatial
47/// metadata alongside it. Model runtimes are intentionally not involved;
48/// callers may supply an embedding from any explicit adapter.
49#[derive(Clone, Debug, PartialEq)]
50pub struct SpatialRecordEntity {
51    /// Search/index payload.
52    pub entity: SemanticEntity,
53    /// Source lineage copied from the input record.
54    pub provenance: RecordProvenance,
55    /// Coordinate frame of the centroid.
56    pub frame_id: FrameId,
57    /// Observation timestamp copied from the input record.
58    pub timestamp: Timestamp,
59}
60
61impl SpatialRecordEntity {
62    /// Builds a deterministic semantic entity from a spatial record.
63    pub fn try_from_record(
64        record: &SpatialRecord,
65        labels: Vec<OpenVocabLabel>,
66        embedding: Option<Embedding>,
67    ) -> SemanticResult<Self> {
68        record
69            .provenance()
70            .validate()
71            .map_err(|error| SemanticError::InvalidConfiguration(error.to_string()))?;
72        validate_labels(&labels)?;
73        let centroid = centroid(record)?;
74        let provenance = record.provenance().clone();
75        let id = record_entity_id(&provenance);
76        Ok(Self {
77            entity: SemanticEntity { id, centroid, labels, embedding },
78            provenance,
79            frame_id: record.metadata().frame_id.clone(),
80            timestamp: record.metadata().timestamp,
81        })
82    }
83
84    /// Returns the search/index payload without losing the lineage wrapper.
85    #[must_use]
86    pub fn entity(&self) -> &SemanticEntity {
87        &self.entity
88    }
89}
90
91/// Derives a stable entity id from a record's protocol-independent lineage.
92#[must_use]
93pub fn record_entity_id(provenance: &RecordProvenance) -> EntityId {
94    let stream = provenance.stream_id.as_deref().unwrap_or("record");
95    let sequence =
96        provenance.sequence.map_or_else(|| "unsequenced".to_owned(), |value| value.to_string());
97    EntityId::new(format!("record:{}:{stream}:{sequence}", provenance.source_id))
98}
99
100fn centroid(record: &SpatialRecord) -> SemanticResult<Option<Vec3<f32>>> {
101    let cloud = record.cloud();
102    if cloud.is_empty() {
103        return Ok(None);
104    }
105    let (x, y, z) = cloud.positions3()?;
106    let mut sum = [0.0_f64; 3];
107    for index in 0..cloud.len() {
108        let values = [x[index], y[index], z[index]];
109        if values.iter().any(|value| !value.is_finite()) {
110            return Err(SemanticError::InvalidConfiguration(
111                "record positions must be finite for semantic centroid".into(),
112            ));
113        }
114        for axis in 0..3 {
115            sum[axis] += f64::from(values[axis]);
116        }
117    }
118    let count = cloud.len() as f64;
119    let values = [sum[0] / count, sum[1] / count, sum[2] / count];
120    if values.iter().any(|value| !value.is_finite()) {
121        return Err(SemanticError::InvalidConfiguration("record centroid is not finite".into()));
122    }
123    Ok(Some(Vec3::new(values[0] as f32, values[1] as f32, values[2] as f32)))
124}
125
126fn validate_labels(labels: &[OpenVocabLabel]) -> SemanticResult<()> {
127    for label in labels {
128        if label.text.trim().is_empty() {
129            return Err(SemanticError::InvalidConfiguration(
130                "semantic labels must be non-empty".into(),
131            ));
132        }
133        if !label.confidence.is_finite() || !(0.0..=1.0).contains(&label.confidence) {
134            return Err(SemanticError::InvalidConfiguration(
135                "semantic label confidence must be finite in [0, 1]".into(),
136            ));
137        }
138    }
139    Ok(())
140}
141
142#[cfg(test)]
143mod tests {
144    use super::{record_entity_id, OpenVocabLabel, SpatialRecordEntity};
145    use crate::Embedding;
146    use spatialrust_core::{
147        PointBuffer, PointBufferSet, PointCloud, SpatialMetadata, StandardSchemas, Timestamp,
148    };
149    use spatialrust_records::{RecordProvenance, SchemaVersion, SpatialRecord};
150
151    fn sample_record() -> SpatialRecord {
152        let mut buffers = PointBufferSet::new();
153        buffers.insert("x", PointBuffer::from_f32(vec![0.0, 2.0]));
154        buffers.insert("y", PointBuffer::from_f32(vec![1.0, 3.0]));
155        buffers.insert("z", PointBuffer::from_f32(vec![2.0, 4.0]));
156        let cloud = PointCloud::try_from_parts(
157            StandardSchemas::point_xyz(),
158            buffers,
159            SpatialMetadata::new("map", Timestamp::from_nanos(7)),
160        )
161        .unwrap();
162        SpatialRecord::try_from_cloud_with_provenance(
163            "point",
164            SchemaVersion::new(1, 0),
165            cloud,
166            RecordProvenance::try_new("bag")
167                .unwrap()
168                .with_stream_id("/lidar")
169                .with_sequence(Some(3)),
170        )
171        .unwrap()
172    }
173
174    #[test]
175    fn derives_centroid_and_lineage_stable_entity_id() {
176        let record = sample_record();
177        let entity = SpatialRecordEntity::try_from_record(
178            &record,
179            vec![OpenVocabLabel { text: "surface".into(), confidence: 0.8 }],
180            Some(Embedding::try_new(vec![1.0, 0.0]).unwrap()),
181        )
182        .unwrap();
183        assert_eq!(entity.entity.id, record_entity_id(record.provenance()));
184        assert_eq!(entity.entity.centroid, Some(spatialrust_math::Vec3::new(1.0, 2.0, 3.0)));
185        assert_eq!(entity.frame_id.0, "map");
186        assert_eq!(entity.timestamp.as_nanos(), 7);
187        assert_eq!(entity.provenance, *record.provenance());
188    }
189
190    #[test]
191    fn rejects_invalid_label_confidence() {
192        let error = SpatialRecordEntity::try_from_record(
193            &sample_record(),
194            vec![OpenVocabLabel { text: "surface".into(), confidence: 1.1 }],
195            None,
196        )
197        .unwrap_err();
198        assert!(error.to_string().contains("confidence"));
199    }
200}