1use spatialrust_ai::{CopyPolicy, ModelSession, NamedTensors, RunOptions};
4use spatialrust_tensor::{Device, TensorBuffer, TensorDescriptor};
5
6use crate::{Embedding, SemanticError, SemanticResult};
7
8#[derive(Clone, Debug)]
16pub struct OnnxEntityEmbedder {
17 input_name: String,
18 output_name: String,
19 input_descriptor: TensorDescriptor,
21 output_descriptor: TensorDescriptor,
23 copy_policy: CopyPolicy,
24}
25
26impl OnnxEntityEmbedder {
27 pub fn try_new(
29 input_name: impl Into<String>,
30 output_name: impl Into<String>,
31 input_descriptor: TensorDescriptor,
32 output_descriptor: TensorDescriptor,
33 copy_policy: CopyPolicy,
34 ) -> SemanticResult<Self> {
35 if input_descriptor.device() != Device::CPU || output_descriptor.device() != Device::CPU {
36 return Err(SemanticError::InvalidConfiguration(
37 "entity embedder requires CPU-hosted input and output tensors".into(),
38 ));
39 }
40 if output_descriptor.shape().is_empty() || output_descriptor.shape().last() == Some(&0) {
41 return Err(SemanticError::InvalidConfiguration(
42 "embedding output must have a non-empty trailing dimension".into(),
43 ));
44 }
45 Ok(Self {
46 input_name: input_name.into(),
47 output_name: output_name.into(),
48 input_descriptor,
49 output_descriptor,
50 copy_policy,
51 })
52 }
53
54 pub fn embed_one(
60 &self,
61 session: &mut dyn ModelSession,
62 features: &[f32],
63 ) -> SemanticResult<Embedding> {
64 let expected = self.input_descriptor.element_count().map_err(|error| {
65 SemanticError::InvalidConfiguration(format!("input descriptor: {error}"))
66 })?;
67 if features.len() != expected {
68 return Err(SemanticError::InvalidConfiguration(format!(
69 "entity features have {} elements; expected {}",
70 features.len(),
71 expected
72 )));
73 }
74 let bytes = features_to_bytes(features)?;
75 let tensor = TensorBuffer::try_new(bytes, self.input_descriptor.clone())
76 .map_err(|error| SemanticError::InvalidConfiguration(error.to_string()))?;
77 let mut inputs = NamedTensors::new();
78 inputs
79 .insert(self.input_name.clone(), tensor)
80 .map_err(|error| SemanticError::InvalidConfiguration(error.to_string()))?;
81
82 let options = RunOptions { input_copy: self.copy_policy, output_copy: self.copy_policy };
83 let outputs = session
84 .run_with_options(inputs, options)
85 .map_err(|error| SemanticError::InvalidConfiguration(error.to_string()))?;
86 let output = outputs.get(&self.output_name).ok_or_else(|| {
87 SemanticError::InvalidConfiguration(format!(
88 "model output `{}` not found",
89 self.output_name
90 ))
91 })?;
92
93 let expected_output = self.output_descriptor.element_count().map_err(|error| {
94 SemanticError::InvalidConfiguration(format!("output descriptor: {error}"))
95 })?;
96 let output_shape = output.descriptor().shape();
97 if output_shape.iter().product::<usize>() != expected_output {
98 return Err(SemanticError::InvalidConfiguration(format!(
99 "model output `{}` has shape {output_shape:?}; expected {expected_output} elements",
100 self.output_name
101 )));
102 }
103 let bytes = output.allocation_bytes();
104 if bytes.len() != expected_output * 4 {
105 return Err(SemanticError::InvalidConfiguration(format!(
106 "model output `{}` has {} bytes; expected {}",
107 self.output_name,
108 bytes.len(),
109 expected_output * 4
110 )));
111 }
112 let mut values = Vec::with_capacity(expected_output);
113 for chunk in bytes.chunks_exact(4) {
114 values.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
115 }
116 Embedding::try_new(values)
117 }
118
119 #[must_use]
121 pub fn input_descriptor(&self) -> &TensorDescriptor {
122 &self.input_descriptor
123 }
124
125 #[must_use]
127 pub fn output_descriptor(&self) -> &TensorDescriptor {
128 &self.output_descriptor
129 }
130}
131
132fn features_to_bytes(features: &[f32]) -> SemanticResult<Vec<u8>> {
133 let mut bytes = Vec::with_capacity(features.len() * 4);
134 for value in features {
135 if !value.is_finite() {
136 return Err(SemanticError::InvalidConfiguration(
137 "entity features must contain finite values".into(),
138 ));
139 }
140 bytes.extend_from_slice(&value.to_le_bytes());
141 }
142 Ok(bytes)
143}
144
145#[cfg(test)]
146mod tests {
147 use super::OnnxEntityEmbedder;
148 use crate::Embedding;
149 use spatialrust_ai::{CopyPolicy, ModelInfo, ModelSession, NamedTensors, RunOptions};
150 use spatialrust_tensor::{DataType, Device, TensorDescriptor};
151
152 #[derive(Clone, Debug)]
153 struct IdentitySession {
154 info: ModelInfo,
155 }
156
157 impl Default for IdentitySession {
158 fn default() -> Self {
159 Self { info: ModelInfo { name: None, inputs: Vec::new(), outputs: Vec::new() } }
160 }
161 }
162
163 impl ModelSession for IdentitySession {
164 fn backend_name(&self) -> &str {
165 "test-identity"
166 }
167
168 fn model_info(&self) -> &ModelInfo {
169 &self.info
170 }
171
172 fn run_with_options(
173 &mut self,
174 inputs: NamedTensors,
175 _options: RunOptions,
176 ) -> spatialrust_ai::AiResult<NamedTensors> {
177 let mut outputs = NamedTensors::new();
178 for (name, tensor) in inputs.into_values() {
179 let output_name = if name == "input" { "output".to_owned() } else { name };
180 outputs.insert(output_name, tensor)?;
181 }
182 Ok(outputs)
183 }
184 }
185
186 fn descriptor(shape: &[usize]) -> TensorDescriptor {
187 TensorDescriptor::contiguous(DataType::F32, shape.to_vec(), Device::CPU)
188 }
189
190 #[test]
191 fn embeds_identity_features() {
192 let embedder = OnnxEntityEmbedder::try_new(
193 "input",
194 "output",
195 descriptor(&[1, 4]),
196 descriptor(&[1, 4]),
197 CopyPolicy::Allow,
198 )
199 .unwrap();
200 let mut session = IdentitySession::default();
201 let embedding = embedder.embed_one(&mut session, &[0.1, 0.2, 0.3, 0.4]).unwrap();
202 assert_eq!(embedding, Embedding::try_new(vec![0.1, 0.2, 0.3, 0.4]).unwrap());
203 }
204
205 #[test]
206 fn rejects_feature_count_mismatch() {
207 let embedder = OnnxEntityEmbedder::try_new(
208 "input",
209 "output",
210 descriptor(&[1, 4]),
211 descriptor(&[1, 4]),
212 CopyPolicy::Allow,
213 )
214 .unwrap();
215 let mut session = IdentitySession::default();
216 assert!(embedder.embed_one(&mut session, &[0.1, 0.2]).is_err());
217 }
218
219 #[test]
220 fn rejects_non_finite_features() {
221 let embedder = OnnxEntityEmbedder::try_new(
222 "input",
223 "output",
224 descriptor(&[1, 3]),
225 descriptor(&[1, 3]),
226 CopyPolicy::Allow,
227 )
228 .unwrap();
229 let mut session = IdentitySession::default();
230 assert!(embedder.embed_one(&mut session, &[f32::NAN, 0.0, 0.0]).is_err());
231 }
232
233 #[test]
234 fn rejects_device_mismatch() {
235 let gpu = TensorDescriptor::contiguous(
236 DataType::F32,
237 vec![1, 3],
238 Device { kind: spatialrust_tensor::DeviceKind::Cuda, id: 0 },
239 );
240 assert!(OnnxEntityEmbedder::try_new(
241 "input",
242 "output",
243 gpu,
244 descriptor(&[1, 3]),
245 CopyPolicy::Allow,
246 )
247 .is_err());
248 }
249
250 #[test]
251 fn rejects_zero_dim_output() {
252 assert!(OnnxEntityEmbedder::try_new(
253 "input",
254 "output",
255 descriptor(&[1, 3]),
256 descriptor(&[0]),
257 CopyPolicy::Allow,
258 )
259 .is_err());
260 }
261}