daedalus_data/
convert.rs

1use std::cmp::Reverse;
2use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
3
4use serde::{Deserialize, Serialize};
5
6use crate::errors::{DataError, DataErrorCode, DataResult};
7use crate::model::{TypeExpr, Value};
8
9/// Trait implemented by converters between value types.
10///
11/// ```
12/// use daedalus_data::convert::{Converter, ConverterId};
13/// use daedalus_data::errors::DataResult;
14/// use daedalus_data::model::{TypeExpr, Value, ValueType};
15///
16/// struct Noop;
17/// impl Converter for Noop {
18///     fn id(&self) -> ConverterId { ConverterId("noop".into()) }
19///     fn input(&self) -> &TypeExpr { &TypeExpr::Scalar(ValueType::Int) }
20///     fn output(&self) -> &TypeExpr { &TypeExpr::Scalar(ValueType::Int) }
21///     fn cost(&self) -> u64 { 0 }
22///     fn convert(&self, value: Value) -> DataResult<Value> { Ok(value) }
23/// }
24/// ```
25pub trait Converter: Send + Sync {
26    fn id(&self) -> ConverterId;
27    fn input(&self) -> &TypeExpr;
28    fn output(&self) -> &TypeExpr;
29    fn cost(&self) -> u64;
30    fn feature_flags(&self) -> &[String] {
31        &[]
32    }
33    fn requires_gpu(&self) -> bool {
34        false
35    }
36    fn convert(&self, value: Value) -> DataResult<Value>;
37}
38
39/// Identifier for a converter edge in the graph.
40///
41/// ```
42/// use daedalus_data::convert::ConverterId;
43/// let id = ConverterId("rgb_to_rgba".into());
44/// assert_eq!(id.0, "rgb_to_rgba");
45/// ```
46#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
47pub struct ConverterId(pub String);
48
49/// Provenance for a conversion path.
50///
51/// ```
52/// use daedalus_data::convert::{ConversionProvenance, ConverterId};
53/// let prov = ConversionProvenance {
54///     steps: vec![ConverterId("a".into())],
55///     total_cost: 1,
56///     skipped_cycles: vec![],
57///     skipped_gpu: vec![],
58///     skipped_features: vec![],
59/// };
60/// assert_eq!(prov.total_cost, 1);
61/// ```
62#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
63pub struct ConversionProvenance {
64    pub steps: Vec<ConverterId>,
65    pub total_cost: u64,
66    pub skipped_cycles: Vec<ConverterId>,
67    pub skipped_gpu: Vec<ConverterId>,
68    pub skipped_features: Vec<ConverterId>,
69}
70
71/// Result of resolving a conversion path.
72///
73/// ```
74/// use daedalus_data::convert::{ConversionProvenance, ConversionResolution, ConverterId};
75/// let res = ConversionResolution {
76///     provenance: ConversionProvenance {
77///         steps: vec![ConverterId("noop".into())],
78///         total_cost: 0,
79///         skipped_cycles: vec![],
80///         skipped_gpu: vec![],
81///         skipped_features: vec![],
82///     },
83/// };
84/// assert!(res.notes().is_empty());
85/// ```
86#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
87pub struct ConversionResolution {
88    pub provenance: ConversionProvenance,
89}
90
91impl ConversionResolution {
92    /// Render human-readable notes about skipped edges during resolution.
93    pub fn notes(&self) -> Vec<String> {
94        let mut out = Vec::new();
95        if !self.provenance.skipped_cycles.is_empty() {
96            out.push(format!(
97                "skipped cycles: {:?}",
98                self.provenance.skipped_cycles
99            ));
100        }
101        if !self.provenance.skipped_gpu.is_empty() {
102            out.push(format!(
103                "skipped GPU-only converters: {:?}",
104                self.provenance.skipped_gpu
105            ));
106        }
107        if !self.provenance.skipped_features.is_empty() {
108            out.push(format!(
109                "skipped converters missing features: {:?}",
110                self.provenance.skipped_features
111            ));
112        }
113        out
114    }
115}
116
117#[derive(Clone, Debug)]
118struct Edge {
119    to: TypeExpr,
120    id: ConverterId,
121    cost: u64,
122    feature_flags: Vec<String>,
123    requires_gpu: bool,
124}
125
126/// Converter graph with deterministic resolver.
127///
128/// ```
129/// use daedalus_data::convert::{ConverterBuilder, ConverterGraph};
130/// use daedalus_data::model::{TypeExpr, Value, ValueType};
131///
132/// let mut graph = ConverterGraph::new();
133/// graph.register(
134///     ConverterBuilder::new(
135///         "bool-id",
136///         TypeExpr::Scalar(ValueType::Bool),
137///         TypeExpr::Scalar(ValueType::Bool),
138///         |v: Value| Ok(v),
139///     )
140///     .build_boxed(),
141/// );
142/// let res = graph.resolve(&TypeExpr::Scalar(ValueType::Bool), &TypeExpr::Scalar(ValueType::Bool));
143/// assert!(res.is_ok());
144/// ```
145#[derive(Default)]
146pub struct ConverterGraph {
147    converters: BTreeMap<ConverterId, Box<dyn Converter>>,
148    adjacency: BTreeMap<TypeExpr, BTreeSet<Edge>>,
149}
150
151/// Thread-safe wrapper type for concurrent registration/resolution.
152///
153/// ```
154/// use daedalus_data::convert::{ConverterBuilder, ConverterGraph, SharedConverterGraph};
155/// use daedalus_data::model::{TypeExpr, Value, ValueType};
156/// use std::sync::{Arc, RwLock};
157///
158/// let graph: SharedConverterGraph = Arc::new(RwLock::new(ConverterGraph::new()));
159/// {
160///     let mut g = graph.write().unwrap();
161///     g.register(
162///         ConverterBuilder::new(
163///             "id",
164///             TypeExpr::Scalar(ValueType::Bool),
165///             TypeExpr::Scalar(ValueType::Bool),
166///             |v| Ok(v),
167///         )
168///         .build_boxed(),
169///     );
170/// }
171/// {
172///     let g = graph.read().unwrap();
173///     let res = g.resolve(&TypeExpr::Scalar(ValueType::Bool), &TypeExpr::Scalar(ValueType::Bool)).unwrap();
174///     assert_eq!(res.provenance.total_cost, 0);
175/// }
176/// ```
177pub type SharedConverterGraph = std::sync::Arc<std::sync::RwLock<ConverterGraph>>;
178
179impl ConverterGraph {
180    /// Create an empty converter graph.
181    pub fn new() -> Self {
182        Self {
183            converters: BTreeMap::new(),
184            adjacency: BTreeMap::new(),
185        }
186    }
187
188    /// Register a converter into the graph.
189    pub fn register(&mut self, converter: Box<dyn Converter>) {
190        let id = converter.id();
191        let input = converter.input().clone().normalize();
192        let mut flags = converter.feature_flags().to_vec();
193        flags.sort();
194        let edge = Edge {
195            to: converter.output().clone().normalize(),
196            id: id.clone(),
197            cost: converter.cost(),
198            feature_flags: flags,
199            requires_gpu: converter.requires_gpu(),
200        };
201        self.adjacency.entry(input).or_default().insert(edge);
202        self.converters.insert(id, converter);
203    }
204
205    /// Resolve a conversion path using default context.
206    pub fn resolve(&self, from: &TypeExpr, to: &TypeExpr) -> DataResult<ConversionResolution> {
207        self.resolve_with_context(from, to, &[], true)
208    }
209
210    /// Resolve a conversion path with feature/GPU constraints.
211    pub fn resolve_with_context(
212        &self,
213        from: &TypeExpr,
214        to: &TypeExpr,
215        active_features: &[String],
216        allow_gpu: bool,
217    ) -> DataResult<ConversionResolution> {
218        let from = from.clone().normalize();
219        let to = to.clone().normalize();
220        if from == to {
221            return Ok(ConversionResolution {
222                provenance: ConversionProvenance {
223                    steps: Vec::new(),
224                    total_cost: 0,
225                    skipped_cycles: Vec::new(),
226                    skipped_gpu: Vec::new(),
227                    skipped_features: Vec::new(),
228                },
229            });
230        }
231
232        // Dijkstra with deterministic tie-breaking (BTree and Reverse heap).
233        #[allow(clippy::type_complexity)]
234        type HeapEntry = (
235            Reverse<u64>,
236            TypeExpr,
237            Vec<ConverterId>,
238            BTreeSet<TypeExpr>,
239            ConversionProvenance,
240        );
241        let mut dist: BTreeMap<TypeExpr, (u64, Vec<ConverterId>, ConversionProvenance)> =
242            BTreeMap::new();
243        let mut heap: BinaryHeap<HeapEntry> = BinaryHeap::new();
244
245        dist.insert(
246            from.clone(),
247            (
248                0,
249                Vec::new(),
250                ConversionProvenance {
251                    steps: Vec::new(),
252                    total_cost: 0,
253                    skipped_cycles: Vec::new(),
254                    skipped_gpu: Vec::new(),
255                    skipped_features: Vec::new(),
256                },
257            ),
258        );
259        heap.push((
260            Reverse(0),
261            from.clone(),
262            Vec::new(),
263            {
264                let mut set = BTreeSet::new();
265                set.insert(from.clone());
266                set
267            },
268            ConversionProvenance {
269                steps: Vec::new(),
270                total_cost: 0,
271                skipped_cycles: Vec::new(),
272                skipped_gpu: Vec::new(),
273                skipped_features: Vec::new(),
274            },
275        ));
276
277        while let Some((Reverse(cost), node, path, visited, provenance)) = heap.pop() {
278            if let Some((known, _, _)) = dist.get(&node)
279                && *known < cost
280            {
281                continue;
282            }
283            if node == to {
284                return Ok(ConversionResolution {
285                    provenance: ConversionProvenance {
286                        total_cost: cost,
287                        steps: path,
288                        skipped_cycles: provenance.skipped_cycles,
289                        skipped_gpu: provenance.skipped_gpu,
290                        skipped_features: provenance.skipped_features,
291                    },
292                });
293            }
294
295            if let Some(edges) = self.adjacency.get(&node) {
296                for edge in edges {
297                    if edge.requires_gpu && !allow_gpu {
298                        let mut prov = provenance.clone();
299                        prov.skipped_gpu.push(edge.id.clone());
300                        continue;
301                    }
302                    if !edge
303                        .feature_flags
304                        .iter()
305                        .all(|f| active_features.contains(f))
306                    {
307                        let mut prov = provenance.clone();
308                        prov.skipped_features.push(edge.id.clone());
309                        continue;
310                    }
311                    if visited.contains(&edge.to) {
312                        let mut prov = provenance.clone();
313                        prov.skipped_cycles.push(edge.id.clone());
314                        continue; // skip cycles, keep searching other paths
315                    }
316                    let next_cost = cost.saturating_add(edge.cost);
317                    let mut next_path = path.clone();
318                    next_path.push(edge.id.clone());
319                    let mut next_prov = provenance.clone();
320                    next_prov.steps = next_path.clone();
321                    next_prov.total_cost = next_cost;
322                    let entry = dist.get(&edge.to);
323                    let should_update = entry.map(|(c, _, _)| next_cost < *c).unwrap_or(true);
324                    if should_update {
325                        dist.insert(
326                            edge.to.clone(),
327                            (next_cost, next_path.clone(), next_prov.clone()),
328                        );
329                        let mut next_visited = visited.clone();
330                        next_visited.insert(edge.to.clone());
331                        heap.push((
332                            Reverse(next_cost),
333                            edge.to.clone(),
334                            next_path,
335                            next_visited,
336                            next_prov,
337                        ));
338                    }
339                }
340            }
341        }
342
343        Err(DataError::new(
344            DataErrorCode::UnknownConverter,
345            "no conversion path found",
346        ))
347    }
348
349    /// Async wrapper around `resolve_with_context`, feature-gated.
350    #[cfg(feature = "async")]
351    pub async fn resolve_with_context_async(
352        &self,
353        from: &TypeExpr,
354        to: &TypeExpr,
355        active_features: &[String],
356        allow_gpu: bool,
357    ) -> DataResult<ConversionResolution> {
358        self.resolve_with_context(from, to, active_features, allow_gpu)
359    }
360
361    /// Async wrapper around `resolve`, feature-gated.
362    #[cfg(feature = "async")]
363    pub async fn resolve_async(
364        &self,
365        from: &TypeExpr,
366        to: &TypeExpr,
367    ) -> DataResult<ConversionResolution> {
368        self.resolve(from, to)
369    }
370}
371
372/// Builder that turns a closure into a `Converter`.
373///
374/// ```
375/// use daedalus_data::convert::{ConverterBuilder, ConverterGraph};
376/// use daedalus_data::errors::{DataError, DataErrorCode};
377/// use daedalus_data::model::{TypeExpr, Value, ValueType};
378///
379/// let conv = ConverterBuilder::new(
380///     "int_to_string",
381///     TypeExpr::Scalar(ValueType::Int),
382///     TypeExpr::Scalar(ValueType::String),
383///     |v| match v {
384///         Value::Int(i) => Ok(Value::String(i.to_string().into())),
385///         _ => Err(DataError::new(DataErrorCode::InvalidType, "expected int")),
386///     },
387/// )
388/// .cost(2u64)
389/// .feature_flag("fmt")
390/// .build_boxed();
391///
392/// let mut graph = ConverterGraph::new();
393/// graph.register(conv);
394/// let res = graph
395///     .resolve_with_context(
396///         &TypeExpr::Scalar(ValueType::Int),
397///         &TypeExpr::Scalar(ValueType::String),
398///         &["fmt".into()],
399///         true,
400///     )
401///     .expect("resolution");
402/// assert_eq!(res.provenance.steps.len(), 1);
403/// ```
404pub struct ConverterBuilder<F>
405where
406    F: Fn(Value) -> DataResult<Value> + Send + Sync + 'static,
407{
408    id: ConverterId,
409    input: TypeExpr,
410    output: TypeExpr,
411    cost: u64,
412    feature_flags: Vec<String>,
413    requires_gpu: bool,
414    func: F,
415}
416
417impl<F> ConverterBuilder<F>
418where
419    F: Fn(Value) -> DataResult<Value> + Send + Sync + 'static,
420{
421    pub fn new(id: impl Into<String>, input: TypeExpr, output: TypeExpr, func: F) -> Self {
422        Self {
423            id: ConverterId(id.into()),
424            input: input.normalize(),
425            output: output.normalize(),
426            cost: 1,
427            feature_flags: Vec::new(),
428            requires_gpu: false,
429            func,
430        }
431    }
432
433    pub fn cost(mut self, cost: u64) -> Self {
434        self.cost = cost;
435        self
436    }
437
438    pub fn feature_flag(mut self, flag: impl Into<String>) -> Self {
439        self.feature_flags.push(flag.into());
440        self
441    }
442
443    pub fn requires_gpu(mut self, requires: bool) -> Self {
444        self.requires_gpu = requires;
445        self
446    }
447
448    pub fn build(self) -> FnConverter<F> {
449        let mut flags = self.feature_flags;
450        flags.sort();
451        FnConverter {
452            id: self.id,
453            input: self.input,
454            output: self.output,
455            cost: self.cost,
456            feature_flags: flags,
457            requires_gpu: self.requires_gpu,
458            func: self.func,
459        }
460    }
461
462    pub fn build_boxed(self) -> Box<dyn Converter> {
463        Box::new(self.build())
464    }
465}
466
467/// Converter implementation backed by a closure.
468///
469/// ```
470/// use daedalus_data::convert::{Converter, ConverterBuilder};
471/// use daedalus_data::model::{TypeExpr, Value, ValueType};
472/// let conv = ConverterBuilder::new(
473///     "noop",
474///     TypeExpr::Scalar(ValueType::Int),
475///     TypeExpr::Scalar(ValueType::Int),
476///     |v: Value| Ok(v),
477/// )
478/// .build();
479/// assert_eq!(conv.cost(), 1);
480/// ```
481pub struct FnConverter<F>
482where
483    F: Fn(Value) -> DataResult<Value> + Send + Sync + 'static,
484{
485    id: ConverterId,
486    input: TypeExpr,
487    output: TypeExpr,
488    cost: u64,
489    feature_flags: Vec<String>,
490    requires_gpu: bool,
491    func: F,
492}
493
494impl<F> Converter for FnConverter<F>
495where
496    F: Fn(Value) -> DataResult<Value> + Send + Sync + 'static,
497{
498    fn id(&self) -> ConverterId {
499        self.id.clone()
500    }
501
502    fn input(&self) -> &TypeExpr {
503        &self.input
504    }
505
506    fn output(&self) -> &TypeExpr {
507        &self.output
508    }
509
510    fn cost(&self) -> u64 {
511        self.cost
512    }
513
514    fn feature_flags(&self) -> &[String] {
515        &self.feature_flags
516    }
517
518    fn requires_gpu(&self) -> bool {
519        self.requires_gpu
520    }
521
522    fn convert(&self, value: Value) -> DataResult<Value> {
523        (self.func)(value)
524    }
525}
526
527impl Ord for Edge {
528    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
529        (self.cost, &self.id).cmp(&(other.cost, &other.id))
530    }
531}
532
533impl PartialOrd for Edge {
534    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
535        Some(self.cmp(other))
536    }
537}
538
539impl PartialEq for Edge {
540    fn eq(&self, other: &Self) -> bool {
541        self.id == other.id
542    }
543}
544
545impl Eq for Edge {}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550    use crate::model::{TypeExpr, Value, ValueType};
551    use once_cell::sync::Lazy;
552    use proptest::prelude::*;
553    #[cfg(feature = "async")]
554    use std::future::Future;
555    #[cfg(feature = "async")]
556    use std::pin::Pin;
557    #[cfg(feature = "async")]
558    use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
559
560    struct Identity {
561        id: ConverterId,
562        ty: TypeExpr,
563    }
564
565    impl Converter for Identity {
566        fn id(&self) -> ConverterId {
567            self.id.clone()
568        }
569        fn input(&self) -> &TypeExpr {
570            &self.ty
571        }
572        fn output(&self) -> &TypeExpr {
573            &self.ty
574        }
575        fn cost(&self) -> u64 {
576            0
577        }
578        fn convert(&self, v: Value) -> DataResult<Value> {
579            Ok(v)
580        }
581    }
582
583    struct BoolToInt;
584    impl Converter for BoolToInt {
585        fn id(&self) -> ConverterId {
586            ConverterId("bool_to_int".into())
587        }
588        fn input(&self) -> &TypeExpr {
589            static TY: Lazy<TypeExpr> = Lazy::new(|| TypeExpr::Scalar(ValueType::Bool));
590            &TY
591        }
592        fn output(&self) -> &TypeExpr {
593            static TY: Lazy<TypeExpr> = Lazy::new(|| TypeExpr::Scalar(ValueType::Int));
594            &TY
595        }
596        fn cost(&self) -> u64 {
597            1
598        }
599        fn convert(&self, v: Value) -> DataResult<Value> {
600            match v {
601                Value::Bool(b) => Ok(Value::Int(if b { 1 } else { 0 })),
602                _ => Err(DataError::new(DataErrorCode::InvalidType, "expected bool")),
603            }
604        }
605    }
606
607    struct IntToString;
608    impl Converter for IntToString {
609        fn id(&self) -> ConverterId {
610            ConverterId("int_to_string".into())
611        }
612        fn input(&self) -> &TypeExpr {
613            static TY: Lazy<TypeExpr> = Lazy::new(|| TypeExpr::Scalar(ValueType::Int));
614            &TY
615        }
616        fn output(&self) -> &TypeExpr {
617            static TY: Lazy<TypeExpr> = Lazy::new(|| TypeExpr::Scalar(ValueType::String));
618            &TY
619        }
620        fn cost(&self) -> u64 {
621            2
622        }
623        fn convert(&self, v: Value) -> DataResult<Value> {
624            match v {
625                Value::Int(i) => Ok(Value::String(i.to_string().into())),
626                _ => Err(DataError::new(DataErrorCode::InvalidType, "expected int")),
627            }
628        }
629    }
630
631    struct GpuOnly;
632    impl Converter for GpuOnly {
633        fn id(&self) -> ConverterId {
634            ConverterId("gpu_only".into())
635        }
636        fn input(&self) -> &TypeExpr {
637            static TY: Lazy<TypeExpr> = Lazy::new(|| TypeExpr::Scalar(ValueType::Int));
638            &TY
639        }
640        fn output(&self) -> &TypeExpr {
641            static TY: Lazy<TypeExpr> = Lazy::new(|| TypeExpr::Scalar(ValueType::Float));
642            &TY
643        }
644        fn cost(&self) -> u64 {
645            1
646        }
647        fn requires_gpu(&self) -> bool {
648            true
649        }
650        fn convert(&self, v: Value) -> DataResult<Value> {
651            match v {
652                Value::Int(i) => Ok(Value::Float(i as f64)),
653                _ => Err(DataError::new(DataErrorCode::InvalidType, "expected int")),
654            }
655        }
656    }
657
658    #[test]
659    fn resolves_trivial_path() {
660        let mut graph = ConverterGraph::new();
661        let ty = TypeExpr::Scalar(ValueType::Int);
662        graph.register(Box::new(Identity {
663            id: ConverterId("id".into()),
664            ty: ty.clone(),
665        }));
666        let res = graph.resolve(&ty, &ty).expect("resolve");
667        assert_eq!(res.provenance.total_cost, 0u64);
668        assert!(res.provenance.steps.is_empty());
669    }
670
671    #[test]
672    fn resolves_multi_step_path() {
673        let mut graph = ConverterGraph::new();
674        graph.register(Box::new(BoolToInt));
675        graph.register(Box::new(IntToString));
676        let from = TypeExpr::Scalar(ValueType::Bool);
677        let to = TypeExpr::Scalar(ValueType::String);
678        let res = graph.resolve(&from, &to).expect("resolve");
679        assert_eq!(res.provenance.total_cost, 3u64);
680        assert_eq!(
681            res.provenance.steps,
682            vec![
683                ConverterId("bool_to_int".into()),
684                ConverterId("int_to_string".into())
685            ]
686        );
687    }
688
689    #[test]
690    fn respects_gpu_flag() {
691        let mut graph = ConverterGraph::new();
692        graph.register(Box::new(GpuOnly));
693        let from = TypeExpr::Scalar(ValueType::Int);
694        let to = TypeExpr::Scalar(ValueType::Float);
695        let err = graph
696            .resolve_with_context(&from, &to, &[], false)
697            .unwrap_err();
698        assert_eq!(err.code(), DataErrorCode::UnknownConverter);
699        let res = graph
700            .resolve_with_context(&from, &to, &[], true)
701            .expect("resolve");
702        assert_eq!(res.provenance.steps, vec![ConverterId("gpu_only".into())]);
703    }
704
705    #[test]
706    fn cycles_do_not_hang() {
707        struct AtoB;
708        impl Converter for AtoB {
709            fn id(&self) -> ConverterId {
710                ConverterId("a_to_b".into())
711            }
712            fn input(&self) -> &TypeExpr {
713                static TY: Lazy<TypeExpr> = Lazy::new(|| TypeExpr::Scalar(ValueType::Bool));
714                &TY
715            }
716            fn output(&self) -> &TypeExpr {
717                static TY: Lazy<TypeExpr> = Lazy::new(|| TypeExpr::Scalar(ValueType::Int));
718                &TY
719            }
720            fn cost(&self) -> u64 {
721                1
722            }
723            fn convert(&self, v: Value) -> DataResult<Value> {
724                Ok(v)
725            }
726        }
727
728        struct BtoA;
729        impl Converter for BtoA {
730            fn id(&self) -> ConverterId {
731                ConverterId("b_to_a".into())
732            }
733            fn input(&self) -> &TypeExpr {
734                static TY: Lazy<TypeExpr> = Lazy::new(|| TypeExpr::Scalar(ValueType::Int));
735                &TY
736            }
737            fn output(&self) -> &TypeExpr {
738                static TY: Lazy<TypeExpr> = Lazy::new(|| TypeExpr::Scalar(ValueType::Bool));
739                &TY
740            }
741            fn cost(&self) -> u64 {
742                1
743            }
744            fn convert(&self, v: Value) -> DataResult<Value> {
745                Ok(v)
746            }
747        }
748
749        let mut graph = ConverterGraph::new();
750        graph.register(Box::new(AtoB));
751        graph.register(Box::new(BtoA));
752        let from = TypeExpr::Scalar(ValueType::Bool);
753        let to = TypeExpr::Scalar(ValueType::String);
754        let err = graph.resolve(&from, &to).unwrap_err();
755        assert!(matches!(
756            err.code(),
757            DataErrorCode::UnknownConverter | DataErrorCode::CycleDetected
758        ));
759    }
760
761    #[test]
762    fn skips_cycles_and_finds_alternate_path() {
763        struct AtoB;
764        impl Converter for AtoB {
765            fn id(&self) -> ConverterId {
766                ConverterId("a_to_b".into())
767            }
768            fn input(&self) -> &TypeExpr {
769                static TY: Lazy<TypeExpr> = Lazy::new(|| TypeExpr::Scalar(ValueType::Bool));
770                &TY
771            }
772            fn output(&self) -> &TypeExpr {
773                static TY: Lazy<TypeExpr> = Lazy::new(|| TypeExpr::Scalar(ValueType::Int));
774                &TY
775            }
776            fn cost(&self) -> u64 {
777                1
778            }
779            fn convert(&self, v: Value) -> DataResult<Value> {
780                Ok(v)
781            }
782        }
783
784        struct BtoA;
785        impl Converter for BtoA {
786            fn id(&self) -> ConverterId {
787                ConverterId("b_to_a".into())
788            }
789            fn input(&self) -> &TypeExpr {
790                static TY: Lazy<TypeExpr> = Lazy::new(|| TypeExpr::Scalar(ValueType::Int));
791                &TY
792            }
793            fn output(&self) -> &TypeExpr {
794                static TY: Lazy<TypeExpr> = Lazy::new(|| TypeExpr::Scalar(ValueType::Bool));
795                &TY
796            }
797            fn cost(&self) -> u64 {
798                1
799            }
800            fn convert(&self, v: Value) -> DataResult<Value> {
801                Ok(v)
802            }
803        }
804
805        struct BtoString;
806        impl Converter for BtoString {
807            fn id(&self) -> ConverterId {
808                ConverterId("b_to_string".into())
809            }
810            fn input(&self) -> &TypeExpr {
811                static TY: Lazy<TypeExpr> = Lazy::new(|| TypeExpr::Scalar(ValueType::Int));
812                &TY
813            }
814            fn output(&self) -> &TypeExpr {
815                static TY: Lazy<TypeExpr> = Lazy::new(|| TypeExpr::Scalar(ValueType::String));
816                &TY
817            }
818            fn cost(&self) -> u64 {
819                5
820            }
821            fn convert(&self, v: Value) -> DataResult<Value> {
822                Ok(v)
823            }
824        }
825
826        let mut graph = ConverterGraph::new();
827        graph.register(Box::new(AtoB));
828        graph.register(Box::new(BtoA));
829        graph.register(Box::new(BtoString));
830        let from = TypeExpr::Scalar(ValueType::Bool);
831        let to = TypeExpr::Scalar(ValueType::String);
832        let res = graph.resolve(&from, &to).expect("resolve");
833        assert_eq!(
834            res.provenance.steps,
835            vec![
836                ConverterId("a_to_b".into()),
837                ConverterId("b_to_string".into())
838            ]
839        );
840    }
841
842    #[test]
843    fn errors_when_no_path() {
844        let graph = ConverterGraph::new();
845        let from = TypeExpr::Scalar(ValueType::Bool);
846        let to = TypeExpr::Scalar(ValueType::String);
847        let err = graph.resolve(&from, &to).unwrap_err();
848        assert_eq!(err.code(), DataErrorCode::UnknownConverter);
849    }
850
851    #[test]
852    fn builder_sorts_feature_flags() {
853        let conv = ConverterBuilder::new(
854            "id",
855            TypeExpr::Scalar(ValueType::Int),
856            TypeExpr::Scalar(ValueType::String),
857            Ok,
858        )
859        .feature_flag("b")
860        .feature_flag("a")
861        .build();
862        assert_eq!(conv.feature_flags, vec!["a", "b"]);
863    }
864
865    proptest! {
866        #[test]
867        fn chain_costs_are_additive(len in 1usize..6) {
868            // Build a chain of unique type expressions using tuple arity to differentiate them.
869            let mut graph = ConverterGraph::new();
870            let types: Vec<TypeExpr> = (0..=len).map(|i| {
871                let v = vec![TypeExpr::Scalar(ValueType::Int); i];
872                TypeExpr::Tuple(v)
873            }).collect();
874            for i in 0..len {
875                let input = types[i].clone();
876                let output = types[i + 1].clone();
877                graph.register(ConverterBuilder::new(
878                    format!("c{i}"),
879                    input.clone(),
880                    output.clone(),
881                    Ok,
882                ).cost(1).build_boxed());
883            }
884            let res = graph.resolve(&types[0], &types[len]).expect("resolve chain");
885            prop_assert_eq!(res.provenance.steps.len(), len);
886            prop_assert_eq!(res.provenance.total_cost, len as u64);
887        }
888
889        #[test]
890        fn feature_flag_filtering(allows in proptest::bool::ANY) {
891            let mut graph = ConverterGraph::new();
892            graph.register(
893                ConverterBuilder::new(
894                    "flagged",
895                    TypeExpr::Scalar(ValueType::Int),
896                    TypeExpr::Scalar(ValueType::Float),
897                    Ok,
898                )
899                .feature_flag("feat")
900                .build_boxed(),
901            );
902            let from = TypeExpr::Scalar(ValueType::Int);
903            let to = TypeExpr::Scalar(ValueType::Float);
904            let features = if allows { vec!["feat".to_string()] } else { vec![] };
905            let res = graph.resolve_with_context(&from, &to, &features, true);
906            prop_assert_eq!(res.is_ok(), allows);
907        }
908    }
909
910    #[cfg(feature = "async")]
911    fn dummy_raw_waker() -> RawWaker {
912        fn no_op(_: *const ()) {}
913        fn clone(_: *const ()) -> RawWaker {
914            dummy_raw_waker()
915        }
916        static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, no_op, no_op, no_op);
917        RawWaker::new(std::ptr::null(), &VTABLE)
918    }
919
920    #[cfg(feature = "async")]
921    fn block_on<F: Future>(mut fut: F) -> F::Output {
922        let waker: Waker = unsafe { Waker::from_raw(dummy_raw_waker()) };
923        let mut cx = Context::from_waker(&waker);
924        let mut fut = unsafe { Pin::new_unchecked(&mut fut) };
925        loop {
926            match fut.as_mut().poll(&mut cx) {
927                Poll::Ready(val) => return val,
928                Poll::Pending => continue,
929            }
930        }
931    }
932
933    #[cfg(feature = "async")]
934    #[test]
935    fn async_resolve_matches_sync() {
936        let mut graph = ConverterGraph::new();
937        graph.register(
938            ConverterBuilder::new(
939                "id",
940                TypeExpr::Scalar(ValueType::Int),
941                TypeExpr::Scalar(ValueType::Int),
942                Ok,
943            )
944            .build_boxed(),
945        );
946        let from = TypeExpr::Scalar(ValueType::Int);
947        let to = TypeExpr::Scalar(ValueType::Int);
948        let sync = graph.resolve(&from, &to).unwrap();
949        let async_res = block_on(graph.resolve_async(&from, &to)).unwrap();
950        assert_eq!(sync.provenance, async_res.provenance);
951    }
952
953    #[test]
954    fn golden_resolution_is_stable() {
955        let mut graph = ConverterGraph::new();
956        graph.register(Box::new(BoolToInt));
957        graph.register(Box::new(IntToString));
958        let from = TypeExpr::Scalar(ValueType::Bool);
959        let to = TypeExpr::Scalar(ValueType::String);
960        let res = graph.resolve(&from, &to).expect("resolve");
961        let json = serde_json::to_string(&res).expect("serialize");
962        assert_eq!(
963            json,
964            r#"{"provenance":{"steps":["bool_to_int","int_to_string"],"total_cost":3,"skipped_cycles":[],"skipped_gpu":[],"skipped_features":[]}}"#
965        );
966    }
967}