1use std::collections::{BTreeMap, BTreeSet};
21
22use serde::{Deserialize, Serialize};
23
24use crate::adapter::ModelInputSpec;
25use crate::builtin_models::{
26 BuiltinDataModel, BUILTIN_DATA_MODELS, REPRESENTATION_FEATURE_BLOCK_SET,
27 REPRESENTATION_GRAY_IMAGE, REPRESENTATION_MC_IMAGE, REPRESENTATION_MULTISPECTRAL_IMAGE,
28 REPRESENTATION_RGB_IMAGE, REPRESENTATION_SAMPLE_METADATA, REPRESENTATION_SIGNAL_1D,
29 REPRESENTATION_SIGNAL_WITH_PROCESSINGS, REPRESENTATION_TARGET_CATEGORICAL,
30 REPRESENTATION_TARGET_CATEGORICAL_MATRIX, REPRESENTATION_TARGET_NUMERIC,
31 REPRESENTATION_TARGET_NUMERIC_MATRIX,
32};
33use crate::error::{DataError, Result};
34use crate::model::RepresentationSpec;
35
36pub const REPRESENTATION_REGISTRY_SCHEMA_VERSION: u32 = 1;
38
39pub const REPRESENTATION_REGISTRY_ID: &str = "dag-ml-data.representation_registry.v1";
41
42pub const REPRESENTATION_REGISTRY_SOURCE: &str = "crates/dag-ml-data-core/src/builtin_models.rs";
44
45pub const MVP_PROFILE_SPECTRA_IMAGE: &str = "spectra_image";
47
48const MVP_SPECTRA_IMAGE_EMITTED: &[&str] = &[
51 REPRESENTATION_SIGNAL_1D,
52 REPRESENTATION_SIGNAL_WITH_PROCESSINGS,
53 REPRESENTATION_FEATURE_BLOCK_SET,
54 REPRESENTATION_TARGET_NUMERIC,
55 REPRESENTATION_TARGET_CATEGORICAL,
56 REPRESENTATION_TARGET_NUMERIC_MATRIX,
57 REPRESENTATION_TARGET_CATEGORICAL_MATRIX,
58 REPRESENTATION_SAMPLE_METADATA,
59];
60
61const MVP_SPECTRA_IMAGE_PENDING: &[&str] = &[
64 REPRESENTATION_GRAY_IMAGE,
65 REPRESENTATION_RGB_IMAGE,
66 REPRESENTATION_MC_IMAGE,
67 REPRESENTATION_MULTISPECTRAL_IMAGE,
68];
69
70#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum MvpEmissionStatus {
79 Emitted,
82 LandedPendingEmit,
85}
86
87#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
89pub struct MvpStatus {
90 pub profile: String,
93 pub emission: MvpEmissionStatus,
95}
96
97#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
99pub struct RegisteredRepresentation {
100 pub representation_id: String,
103 pub builtin_key: String,
105 pub modality: String,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub mvp: Option<MvpStatus>,
110 pub representation: RepresentationSpec,
112}
113
114#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
119pub struct RepresentationRegistry {
120 pub schema_version: u32,
123 pub registry_id: String,
126 pub source: String,
129 pub representations: Vec<RegisteredRepresentation>,
131}
132
133pub fn representation_registry() -> RepresentationRegistry {
136 RepresentationRegistry {
137 schema_version: REPRESENTATION_REGISTRY_SCHEMA_VERSION,
138 registry_id: REPRESENTATION_REGISTRY_ID.to_string(),
139 source: REPRESENTATION_REGISTRY_SOURCE.to_string(),
140 representations: BUILTIN_DATA_MODELS
141 .iter()
142 .copied()
143 .map(registered_representation)
144 .collect(),
145 }
146}
147
148impl RepresentationRegistry {
149 pub fn validate(&self) -> Result<()> {
152 if self.schema_version != REPRESENTATION_REGISTRY_SCHEMA_VERSION {
153 return Err(DataError::Validation(format!(
154 "representation registry uses unsupported schema_version {}, expected {}",
155 self.schema_version, REPRESENTATION_REGISTRY_SCHEMA_VERSION
156 )));
157 }
158 if self.registry_id != REPRESENTATION_REGISTRY_ID {
159 return Err(DataError::Validation(format!(
160 "representation registry id `{}` does not match `{}`",
161 self.registry_id, REPRESENTATION_REGISTRY_ID
162 )));
163 }
164 if self.representations.is_empty() {
165 return Err(DataError::Validation(
166 "representation registry contains no representations".to_string(),
167 ));
168 }
169 let mut ids = BTreeSet::new();
170 for entry in &self.representations {
171 if entry.representation_id.trim().is_empty() {
172 return Err(DataError::Validation(
173 "representation registry contains an empty representation_id".to_string(),
174 ));
175 }
176 if !ids.insert(entry.representation_id.as_str()) {
177 return Err(DataError::Validation(format!(
178 "representation registry contains duplicate representation `{}`",
179 entry.representation_id
180 )));
181 }
182 if entry.representation_id != entry.representation.id.as_str() {
183 return Err(DataError::Validation(format!(
184 "representation registry entry `{}` does not match embedded representation id `{}`",
185 entry.representation_id, entry.representation.id
186 )));
187 }
188 entry.representation.validate()?;
189 }
190 Ok(())
191 }
192}
193
194pub fn validate_model_input_spec_against_registry(
198 model_input: &ModelInputSpec,
199 registry: &RepresentationRegistry,
200) -> Result<()> {
201 registry.validate()?;
202 model_input.validate()?;
203 let by_id = registry
204 .representations
205 .iter()
206 .map(|entry| (entry.representation_id.as_str(), entry))
207 .collect::<BTreeMap<_, _>>();
208 for port in &model_input.ports {
209 for representation_id in &port.accepted_representations {
210 let representation_key = representation_id.as_str();
211 let Some(entry) = by_id.get(representation_key) else {
212 return Err(DataError::Validation(format!(
213 "model input port `{}` accepts unknown representation `{representation_key}`",
214 port.name
215 )));
216 };
217 if !port
218 .accepted_types
219 .iter()
220 .any(|type_id| type_id == &entry.representation.type_id)
221 {
222 return Err(DataError::Validation(format!(
223 "model input port `{}` representation `{}` has type `{}` not listed in accepted_types",
224 port.name, representation_key, entry.representation.type_id
225 )));
226 }
227 if let Some(rank) = port.rank {
228 if entry.representation.rank != Some(rank) {
229 return Err(DataError::Validation(format!(
230 "model input port `{}` requires rank {rank} but representation `{}` has {:?}",
231 port.name, representation_key, entry.representation.rank
232 )));
233 }
234 }
235 }
236 }
237 Ok(())
238}
239
240fn registered_representation(model: BuiltinDataModel) -> RegisteredRepresentation {
241 let representation = model.representation();
242 let representation_id = representation.id.as_str().to_string();
243 let mvp = mvp_status(&representation_id);
244 RegisteredRepresentation {
245 representation_id,
246 builtin_key: model.key().to_string(),
247 modality: model.modality().to_string(),
248 mvp,
249 representation,
250 }
251}
252
253fn mvp_status(representation_id: &str) -> Option<MvpStatus> {
254 if MVP_SPECTRA_IMAGE_EMITTED.contains(&representation_id) {
255 Some(MvpStatus {
256 profile: MVP_PROFILE_SPECTRA_IMAGE.to_string(),
257 emission: MvpEmissionStatus::Emitted,
258 })
259 } else if MVP_SPECTRA_IMAGE_PENDING.contains(&representation_id) {
260 Some(MvpStatus {
261 profile: MVP_PROFILE_SPECTRA_IMAGE.to_string(),
262 emission: MvpEmissionStatus::LandedPendingEmit,
263 })
264 } else {
265 None
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use std::collections::BTreeSet;
272
273 use crate::adapter::InputPortSpec;
274 use crate::ids::{RepresentationId, TypeId};
275
276 use super::*;
277
278 const PUBLISHED: &str = include_str!("../../../docs/contracts/representation_registry.v1.json");
279
280 #[test]
281 fn published_registry_matches_builtin_models() {
282 let generated = serde_json::to_value(representation_registry()).unwrap();
283 let published: serde_json::Value = serde_json::from_str(PUBLISHED).unwrap();
284 assert_eq!(
285 published, generated,
286 "docs/contracts/representation_registry.v1.json drifted from builtin_models.rs; \
287 regenerate with `cargo run -p dag-ml-data-cli -- representation-registry`"
288 );
289 }
290
291 #[test]
292 fn registry_header_is_stable() {
293 let registry = representation_registry();
294 assert_eq!(
295 registry.schema_version,
296 REPRESENTATION_REGISTRY_SCHEMA_VERSION
297 );
298 assert_eq!(registry.registry_id, REPRESENTATION_REGISTRY_ID);
299 assert_eq!(registry.source, REPRESENTATION_REGISTRY_SOURCE);
300 }
301
302 #[test]
303 fn registry_covers_every_builtin_with_unique_ids() {
304 let registry = representation_registry();
305 assert_eq!(registry.representations.len(), BUILTIN_DATA_MODELS.len());
306
307 let mut ids = BTreeSet::new();
308 let mut keys = BTreeSet::new();
309 for entry in ®istry.representations {
310 assert!(
311 ids.insert(entry.representation_id.clone()),
312 "duplicate representation id {}",
313 entry.representation_id
314 );
315 assert!(
316 keys.insert(entry.builtin_key.clone()),
317 "duplicate builtin key {}",
318 entry.builtin_key
319 );
320 assert_eq!(entry.representation_id, entry.representation.id.as_str());
322 entry.representation.validate().unwrap();
324 }
325 }
326
327 #[test]
328 fn spectra_image_mvp_profile_is_consistent() {
329 let registry = representation_registry();
330 let frozen: BTreeSet<&str> = registry
331 .representations
332 .iter()
333 .map(|entry| entry.representation_id.as_str())
334 .collect();
335
336 let emitted: BTreeSet<&str> = registry
337 .representations
338 .iter()
339 .filter(|entry| {
340 matches!(&entry.mvp, Some(status) if status.emission == MvpEmissionStatus::Emitted)
341 })
342 .map(|entry| entry.representation_id.as_str())
343 .collect();
344 let pending: BTreeSet<&str> = registry
345 .representations
346 .iter()
347 .filter(|entry| {
348 matches!(
349 &entry.mvp,
350 Some(status) if status.emission == MvpEmissionStatus::LandedPendingEmit
351 )
352 })
353 .map(|entry| entry.representation_id.as_str())
354 .collect();
355
356 assert_eq!(emitted.len(), 8, "spectra MVP must publish 8 emitted IDs");
358 assert_eq!(pending.len(), 4, "image MVP must publish 4 pending IDs");
359 assert!(emitted.is_disjoint(&pending), "MVP groups must be disjoint");
360 for id in emitted.iter().chain(pending.iter()) {
361 assert!(
362 frozen.contains(id),
363 "MVP id {id} is not a frozen representation"
364 );
365 }
366
367 assert_eq!(
369 pending,
370 BTreeSet::from([
371 REPRESENTATION_GRAY_IMAGE,
372 REPRESENTATION_RGB_IMAGE,
373 REPRESENTATION_MC_IMAGE,
374 REPRESENTATION_MULTISPECTRAL_IMAGE,
375 ])
376 );
377
378 for entry in ®istry.representations {
380 if let Some(status) = &entry.mvp {
381 assert_eq!(status.profile, MVP_PROFILE_SPECTRA_IMAGE);
382 }
383 }
384 }
385
386 #[test]
387 fn registry_round_trips_through_json() {
388 let registry = representation_registry();
389 let json = serde_json::to_string(®istry).unwrap();
390 let decoded: RepresentationRegistry = serde_json::from_str(&json).unwrap();
391 assert_eq!(decoded, registry);
392 }
393
394 #[test]
395 fn model_input_spec_registry_validation_accepts_published_tabular_requirement() {
396 let spec = crate::tabular_numeric_model_input_spec();
397 validate_model_input_spec_against_registry(&spec, &representation_registry()).unwrap();
398 }
399
400 #[test]
401 fn model_input_spec_registry_validation_rejects_unknown_representation() {
402 let spec = ModelInputSpec {
403 ports: vec![InputPortSpec {
404 name: "x".to_string(),
405 accepted_representations: vec![RepresentationId::new("columnar_f64").unwrap()],
406 accepted_types: vec![TypeId::new("table").unwrap()],
407 rank: Some(2),
408 multi_source: false,
409 optional: false,
410 }],
411 default_fusion: None,
412 };
413 let error = validate_model_input_spec_against_registry(&spec, &representation_registry())
414 .unwrap_err();
415 assert!(
416 error.to_string().contains("unknown representation"),
417 "unexpected error: {error}"
418 );
419 }
420
421 #[test]
422 fn model_input_spec_registry_validation_rejects_type_drift() {
423 let mut spec = crate::tabular_numeric_model_input_spec();
424 spec.ports[0].accepted_types = vec![TypeId::new("f64").unwrap()];
425 let error = validate_model_input_spec_against_registry(&spec, &representation_registry())
426 .unwrap_err();
427 assert!(
428 error.to_string().contains("not listed in accepted_types"),
429 "unexpected error: {error}"
430 );
431 }
432}