Skip to main content

dag_ml_core/
implementation_registry.rs

1//! Process-local executable implementation registry.
2//!
3//! DAG-ML owns descriptor validation and exact resolution while bindings own
4//! the executable object stored as `T`. The registry is deliberately generic:
5//! Rust can retain closures or trait objects, and bindings can retain native
6//! callable handles without serializing executable code into DAG contracts.
7
8use std::collections::BTreeMap;
9
10use crate::criteria::{
11    ImplementationDescriptor, ImplementationSemanticKind, LossReference, MetricReference,
12    PortabilityClass,
13};
14use crate::error::{DagMlError, Result};
15
16struct RegisteredImplementation<T> {
17    descriptor: ImplementationDescriptor,
18    implementation: T,
19}
20
21/// Generic process-local registry keyed by a validated implementation descriptor.
22///
23/// Resolution always requires the complete expected descriptor. A matching
24/// opaque registry key alone is insufficient because replay must also match the
25/// semantic, implementation and descriptor fingerprints.
26pub struct LocalImplementationRegistry<T> {
27    entries: BTreeMap<String, RegisteredImplementation<T>>,
28}
29
30impl<T> Default for LocalImplementationRegistry<T> {
31    fn default() -> Self {
32        Self {
33            entries: BTreeMap::new(),
34        }
35    }
36}
37
38impl<T> LocalImplementationRegistry<T> {
39    pub fn new() -> Self {
40        Self::default()
41    }
42
43    pub fn register(
44        &mut self,
45        descriptor: ImplementationDescriptor,
46        implementation: T,
47    ) -> Result<()> {
48        let key = implementation_dispatch_key(&descriptor)?;
49        if self.entries.contains_key(&key) {
50            return registration_error(format!(
51                "duplicate local implementation registry key `{key}`"
52            ));
53        }
54        self.entries.insert(
55            key,
56            RegisteredImplementation {
57                descriptor,
58                implementation,
59            },
60        );
61        Ok(())
62    }
63
64    pub fn register_loss(&mut self, loss: &LossReference, implementation: T) -> Result<()> {
65        loss.validate()?;
66        self.register(loss.implementation.clone(), implementation)
67    }
68
69    pub fn register_metric(&mut self, metric: &MetricReference, implementation: T) -> Result<()> {
70        metric.validate()?;
71        self.register(metric.implementation.clone(), implementation)
72    }
73
74    pub fn resolve(&self, descriptor: &ImplementationDescriptor) -> Result<&T> {
75        let key = implementation_dispatch_key(descriptor)?;
76        let registered = self.entries.get(&key).ok_or_else(|| {
77            DagMlError::RuntimeValidation(format!(
78                "local implementation registry has no implementation for `{key}`"
79            ))
80        })?;
81        if registered.descriptor != *descriptor {
82            return resolution_error(format!(
83                "local implementation registered for `{key}` does not match the requested descriptor"
84            ));
85        }
86        Ok(&registered.implementation)
87    }
88
89    pub fn resolve_loss(&self, loss: &LossReference) -> Result<&T> {
90        loss.validate()?;
91        if loss.implementation.semantic_kind != ImplementationSemanticKind::Loss {
92            return resolution_error("local loss resolution received a non-loss descriptor");
93        }
94        self.resolve(&loss.implementation)
95    }
96
97    pub fn resolve_metric(&self, metric: &MetricReference) -> Result<&T> {
98        metric.validate()?;
99        if metric.implementation.semantic_kind != ImplementationSemanticKind::Metric {
100            return resolution_error("local metric resolution received a non-metric descriptor");
101        }
102        self.resolve(&metric.implementation)
103    }
104
105    pub fn unregister(&mut self, descriptor: &ImplementationDescriptor) -> Result<T> {
106        let key = implementation_dispatch_key(descriptor)?;
107        let registered = self.entries.get(&key).ok_or_else(|| {
108            DagMlError::RuntimeValidation(format!(
109                "local implementation registry has no implementation for `{key}`"
110            ))
111        })?;
112        if registered.descriptor != *descriptor {
113            return resolution_error(format!(
114                "local implementation registered for `{key}` does not match the requested descriptor"
115            ));
116        }
117        Ok(self
118            .entries
119            .remove(&key)
120            .expect("entry checked above")
121            .implementation)
122    }
123
124    pub fn descriptors(&self) -> impl Iterator<Item = &ImplementationDescriptor> {
125        self.entries.values().map(|entry| &entry.descriptor)
126    }
127
128    pub fn len(&self) -> usize {
129        self.entries.len()
130    }
131
132    pub fn is_empty(&self) -> bool {
133        self.entries.is_empty()
134    }
135
136    pub fn clear(&mut self) {
137        self.entries.clear();
138    }
139}
140
141/// Return the canonical process-local dispatch key for an implementation.
142///
143/// Registered implementations use their opaque registry key. Built-ins have
144/// no registry key and are addressed by their descriptor fingerprint.
145pub fn implementation_dispatch_key(descriptor: &ImplementationDescriptor) -> Result<String> {
146    descriptor.validate()?;
147    match (&descriptor.registry_key, descriptor.portability) {
148        (Some(key), _) => Ok(key.clone()),
149        (None, PortabilityClass::PortableBuiltIn) => Ok(format!(
150            "portable_builtin:{}",
151            descriptor.descriptor_fingerprint
152        )),
153        (None, _) => {
154            registration_error("non-built-in implementation descriptor has no local registry key")
155        }
156    }
157}
158
159fn registration_error<T>(message: impl Into<String>) -> Result<T> {
160    Err(DagMlError::CampaignValidation(message.into()))
161}
162
163fn resolution_error<T>(message: impl Into<String>) -> Result<T> {
164    Err(DagMlError::RuntimeValidation(message.into()))
165}
166
167#[cfg(test)]
168mod tests {
169    use std::sync::{
170        atomic::{AtomicUsize, Ordering},
171        Arc,
172    };
173
174    use serde_json::Value;
175
176    use super::*;
177
178    fn custom_loss() -> LossReference {
179        let fixture: Value = serde_json::from_str(include_str!(
180            "../../../examples/fixtures/criteria/criteria_contracts.v1.json"
181        ))
182        .unwrap();
183        serde_json::from_value(fixture["valid"]["training_loss_role"]["loss"].clone()).unwrap()
184    }
185
186    #[test]
187    fn rust_closure_is_resolved_and_executed_as_a_local_loss() {
188        type LossFn = Box<dyn Fn(f64, f64) -> f64>;
189
190        let loss = custom_loss();
191        let mut registry = LocalImplementationRegistry::<LossFn>::new();
192        registry
193            .register_loss(
194                &loss,
195                Box::new(|target, prediction| (prediction - target).abs()),
196            )
197            .unwrap();
198
199        let callback = registry.resolve_loss(&loss).unwrap();
200        assert_eq!(callback(2.0, 5.5), 3.5);
201        assert_eq!(registry.len(), 1);
202        assert_eq!(registry.descriptors().next(), Some(&loss.implementation));
203    }
204
205    #[test]
206    fn resolution_requires_the_exact_descriptor_not_only_the_registry_key() {
207        let loss = custom_loss();
208        let mut registry = LocalImplementationRegistry::new();
209        registry.register_loss(&loss, "callable-a").unwrap();
210
211        let mut incompatible = loss.clone();
212        incompatible.implementation.implementation_version = "2.0.0".to_string();
213        incompatible.implementation.descriptor_fingerprint =
214            incompatible.implementation.compute_fingerprint().unwrap();
215        let error = registry
216            .resolve_loss(&incompatible)
217            .unwrap_err()
218            .to_string();
219        assert!(error.contains("does not match the requested descriptor"));
220    }
221
222    #[test]
223    fn duplicate_registry_keys_are_rejected_even_for_different_descriptors() {
224        let loss = custom_loss();
225        let mut second = loss.clone();
226        second.implementation.implementation_version = "2.0.0".to_string();
227        second.implementation.descriptor_fingerprint =
228            second.implementation.compute_fingerprint().unwrap();
229
230        let mut registry = LocalImplementationRegistry::new();
231        registry.register_loss(&loss, "callable-a").unwrap();
232        let error = registry
233            .register_loss(&second, "callable-b")
234            .unwrap_err()
235            .to_string();
236        assert!(error.contains("duplicate local implementation registry key"));
237    }
238
239    #[test]
240    fn unregister_checks_identity_and_returns_the_local_object() {
241        let loss = custom_loss();
242        let mut registry = LocalImplementationRegistry::new();
243        registry
244            .register_loss(&loss, String::from("callable-a"))
245            .unwrap();
246
247        assert_eq!(
248            registry.unregister(&loss.implementation).unwrap(),
249            "callable-a"
250        );
251        assert!(registry.is_empty());
252        assert!(registry.resolve_loss(&loss).is_err());
253    }
254
255    #[test]
256    fn clear_releases_registry_owned_implementations() {
257        struct DropProbe(Arc<AtomicUsize>);
258
259        impl Drop for DropProbe {
260            fn drop(&mut self) {
261                self.0.fetch_add(1, Ordering::SeqCst);
262            }
263        }
264
265        let loss = custom_loss();
266        let drops = Arc::new(AtomicUsize::new(0));
267        let mut registry = LocalImplementationRegistry::new();
268        registry
269            .register_loss(&loss, DropProbe(Arc::clone(&drops)))
270            .unwrap();
271
272        assert_eq!(drops.load(Ordering::SeqCst), 0);
273        registry.clear();
274        assert_eq!(drops.load(Ordering::SeqCst), 1);
275        assert!(registry.is_empty());
276    }
277}