Skip to main content

lemma/evaluation/
run_data.rs

1use crate::computation::{OperationResult, VetoType};
2use crate::planning::execution_plan::{validate_value_against_type, ExecutionPlan};
3use crate::planning::semantics::{
4    number_with_unit_to_value_kind, parse_value_from_string, parser_value_to_value_kind,
5    DataDefinition, DataPath, LemmaType, LiteralValue, Source, TypeSpecification, TypedLiteral,
6    ValueKind,
7};
8use crate::planning::unit_index::UnitIndex;
9use crate::Error;
10use crate::ResourceLimits;
11use indexmap::IndexMap;
12use rust_decimal::Decimal;
13use std::collections::{BTreeMap, HashMap, HashSet};
14use std::str::FromStr;
15use std::sync::Arc;
16
17/// Typed data value from a client (CLI/WASM). JSON parsing stays outside [`parse_data_value`].
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum RunDataValue {
20    /// Raw string, parsed against the target type's specification by [`parse_value_from_string`].
21    String(String),
22    Boolean(bool),
23    MeasureMap(BTreeMap<String, Decimal>),
24    RatioMap(BTreeMap<String, Decimal>),
25}
26
27impl RunDataValue {
28    pub fn string(value: impl Into<String>) -> Self {
29        Self::String(value.into())
30    }
31
32    pub(crate) fn is_empty(&self) -> bool {
33        match self {
34            Self::String(s) => s.trim().is_empty(),
35            Self::MeasureMap(map) | Self::RatioMap(map) => map.is_empty(),
36            Self::Boolean(_) => false,
37        }
38    }
39}
40
41impl From<String> for RunDataValue {
42    fn from(value: String) -> Self {
43        Self::String(value)
44    }
45}
46
47impl From<&str> for RunDataValue {
48    fn from(value: &str) -> Self {
49        Self::String(value.to_string())
50    }
51}
52
53/// Parse one JSON value into a [`RunDataValue`] (SDK / MCP / WASM wire shape).
54///
55/// JSON `null` means omit: returns `Ok(None)`.
56pub fn run_data_value_from_json_value(
57    value: serde_json::Value,
58) -> Result<Option<RunDataValue>, String> {
59    match value {
60        serde_json::Value::Null => Ok(None),
61        serde_json::Value::String(s) => Ok(Some(RunDataValue::String(s))),
62        serde_json::Value::Bool(b) => Ok(Some(RunDataValue::Boolean(b))),
63        serde_json::Value::Number(n) => {
64            if n.is_i64() || n.is_u64() {
65                Ok(Some(RunDataValue::String(n.to_string())))
66            } else {
67                Err("decimal values must be passed as strings to preserve exactness".to_string())
68            }
69        }
70        serde_json::Value::Object(obj) => {
71            if obj.is_empty() {
72                return Err("data value object must not be empty".to_string());
73            }
74            if obj.len() == 2 && obj.contains_key("value") && obj.contains_key("unit") {
75                return Err(
76                    "the {value, unit} object shape is not supported; use a unit map like {\"eur\": \"84\"}"
77                        .to_string(),
78                );
79            }
80            if obj.values().all(|v| v.is_string()) {
81                let mut map = BTreeMap::new();
82                for (k, v) in obj {
83                    let text = v.as_str().expect("BUG: object values checked as strings");
84                    let decimal = Decimal::from_str(text.trim())
85                        .map_err(|error| format!("invalid decimal '{text}': {error}"))?;
86                    map.insert(k, decimal);
87                }
88                return Ok(Some(RunDataValue::MeasureMap(map)));
89            }
90            Err("data value object must be a unit map with string magnitudes".to_string())
91        }
92        serde_json::Value::Array(_) => Err("data value must not be an array".to_string()),
93    }
94}
95
96/// Convert SDK/MCP/WASM `data` object to string map for [`crate::Engine::run`].
97///
98/// Single-entry unit maps become convenience strings (`"84 eur"`). Multi-key maps
99/// are rejected until `Engine::run` accepts typed [`RunDataValue`] directly.
100/// JSON `null` field values are omitted (same as unbound).
101pub fn parse_run_data_object(
102    data: &Option<serde_json::Value>,
103) -> Result<HashMap<String, String>, String> {
104    let Some(value) = data else {
105        return Ok(HashMap::new());
106    };
107    if value.is_null() {
108        return Ok(HashMap::new());
109    }
110    let map: HashMap<String, serde_json::Value> = serde_json::from_value(value.clone())
111        .map_err(|e| format!("data must be a plain object: {e}"))?;
112    map.into_iter()
113        .filter_map(|(k, v)| match run_data_value_from_json_value(v) {
114            Ok(None) => None,
115            Ok(Some(input)) => Some(match input {
116                RunDataValue::String(s) => Ok((k, s)),
117                RunDataValue::Boolean(b) => Ok((k, b.to_string())),
118                RunDataValue::MeasureMap(m) | RunDataValue::RatioMap(m) => {
119                    if m.len() == 1 {
120                        let (unit, mag) = m.into_iter().next().expect("BUG: single entry map");
121                        Ok((k, format!("{mag} {unit}")))
122                    } else {
123                        Err(format!(
124                            "data value '{k}' must be a convenience string for run"
125                        ))
126                    }
127                }
128            }),
129            Err(e) => Some(Err(e)),
130        })
131        .collect()
132}
133
134/// Resolve SDK/MCP/WASM `rules` (string or string array) for [`crate::Engine::run`].
135pub fn resolve_run_rules(rules: &Option<serde_json::Value>) -> Result<Option<Vec<String>>, String> {
136    let Some(value) = rules else {
137        return Ok(None);
138    };
139    if value.is_null() {
140        return Ok(None);
141    }
142    if let Some(s) = value.as_str() {
143        let trimmed = s.trim();
144        if trimmed.is_empty() {
145            return Err("rules must not be empty".to_string());
146        }
147        return Ok(Some(vec![trimmed.to_string()]));
148    }
149    if let Some(arr) = value.as_array() {
150        if arr.is_empty() {
151            return Err("rules must not be empty".to_string());
152        }
153        let names: Vec<String> = arr
154            .iter()
155            .map(|v| {
156                v.as_str()
157                    .map(|s| s.to_string())
158                    .ok_or_else(|| "rules must be an array of strings".to_string())
159            })
160            .collect::<Result<_, _>>()?;
161        return Ok(Some(names));
162    }
163    Err("rules must be a string or array of strings".to_string())
164}
165
166pub fn parse_data_value(
167    input: &RunDataValue,
168    lemma_type: &Arc<LemmaType>,
169    source: &Source,
170    unit_index: &UnitIndex,
171    named_types: &IndexMap<String, Arc<LemmaType>>,
172) -> Result<TypedLiteral, Error> {
173    let to_err = |msg: String| Error::validation(msg, Some(source.clone()), None::<String>);
174    let type_spec = &lemma_type.specifications;
175
176    let (kind, binding_unit) = match (input, type_spec) {
177        (RunDataValue::String(s), _) => {
178            let parsed = parse_value_from_string(s, type_spec, source)?;
179            let resolve = |unit: &str| {
180                resolve_passed_unit(unit, lemma_type.as_ref(), unit_index, named_types)
181            };
182            let kind =
183                parser_value_to_value_kind(&parsed, type_spec, Some(&resolve)).map_err(to_err)?;
184            let binding = match binding_unit_from_parser_value(&parsed) {
185                Some(written) => Some(
186                    resolve_passed_unit(&written, lemma_type.as_ref(), unit_index, named_types)
187                        .map_err(to_err)?,
188                ),
189                None => None,
190            };
191            (kind, binding)
192        }
193        (RunDataValue::Boolean(b), TypeSpecification::Boolean { .. }) => {
194            (ValueKind::Boolean(*b), None)
195        }
196        (RunDataValue::Boolean(_), _) => {
197            return Err(to_err(format!(
198                "boolean input is only valid for boolean data, not {}",
199                type_spec
200            )));
201        }
202        (RunDataValue::MeasureMap(map), TypeSpecification::Measure { .. }) => {
203            let (kind, binding) =
204                measure_from_unit_map(map, lemma_type.as_ref(), unit_index, named_types)
205                    .map_err(to_err)?;
206            (kind, binding)
207        }
208        (
209            RunDataValue::MeasureMap(map) | RunDataValue::RatioMap(map),
210            TypeSpecification::Ratio { .. },
211        ) => {
212            let (kind, binding) =
213                ratio_from_unit_map(map, lemma_type.as_ref(), unit_index, named_types)
214                    .map_err(to_err)?;
215            (kind, binding)
216        }
217        (RunDataValue::MeasureMap(_), _) => {
218            return Err(to_err(format!(
219                "measure unit map is only valid for measure data, not {}",
220                type_spec
221            )));
222        }
223        (RunDataValue::RatioMap(_), _) => {
224            return Err(to_err(format!(
225                "ratio unit map is only valid for ratio data, not {}",
226                type_spec
227            )));
228        }
229    };
230
231    let typed_type = match binding_unit {
232        Some(unit)
233            if matches!(
234                &lemma_type.specifications,
235                TypeSpecification::Measure { .. } | TypeSpecification::Ratio { .. }
236            ) =>
237        {
238            Arc::new(lemma_type.as_ref().clone().with_unit(unit))
239        }
240        Some(_) | None => Arc::clone(lemma_type),
241    };
242    Ok(TypedLiteral {
243        value: kind,
244        lemma_type: typed_type,
245    })
246}
247
248fn binding_unit_from_parser_value(value: &crate::parsing::ast::Value) -> Option<String> {
249    use crate::parsing::ast::Value;
250    match value {
251        Value::NumberWithUnit(_, unit) => Some(unit.clone()),
252        Value::Range(left, right) => match (left.as_ref(), right.as_ref()) {
253            (Value::NumberWithUnit(_, left_unit), Value::NumberWithUnit(_, right_unit))
254                if left_unit == right_unit =>
255            {
256                Some(left_unit.clone())
257            }
258            _ => None,
259        },
260        _ => None,
261    }
262}
263
264/// Resolve a unit the caller actually wrote to a bare declared name.
265///
266/// Qualified paths go through [`UnitIndex::resolve_with_named_types`]. Bare names
267/// accepted only when this data type (or range element) declares that exact name.
268/// Never invents a unit the caller did not pass.
269fn resolve_passed_unit(
270    written: &str,
271    lemma_type: &LemmaType,
272    unit_index: &UnitIndex,
273    named_types: &IndexMap<String, Arc<LemmaType>>,
274) -> Result<String, String> {
275    match unit_index.resolve_with_named_types(written, named_types) {
276        Ok((bare, _)) => {
277            if unit_name_declared_on_type(lemma_type, &bare)
278                || range_element_declares_unit(lemma_type, &bare)
279            {
280                Ok(bare)
281            } else {
282                Err(format!(
283                    "Unit '{written}' resolves to '{bare}', which is not declared on type {}",
284                    lemma_type.name()
285                ))
286            }
287        }
288        Err(index_err) => {
289            if unit_name_declared_on_type(lemma_type, written)
290                || range_element_declares_unit(lemma_type, written)
291            {
292                Ok(written.to_string())
293            } else {
294                Err(index_err)
295            }
296        }
297    }
298}
299
300fn range_element_declares_unit(lemma_type: &LemmaType, unit_name: &str) -> bool {
301    let Some(element) = lemma_type.specifications.element_from_range() else {
302        return false;
303    };
304    unit_name_declared_on_type(&LemmaType::primitive(element), unit_name)
305}
306
307fn unit_name_declared_on_type(lemma_type: &LemmaType, unit_name: &str) -> bool {
308    match &lemma_type.specifications {
309        TypeSpecification::Measure { units, .. } => units.get(unit_name).is_ok(),
310        TypeSpecification::Ratio { units, .. } => units.get(unit_name).is_ok(),
311        _ => false,
312    }
313}
314
315fn measure_from_unit_map(
316    map: &BTreeMap<String, Decimal>,
317    lemma_type: &LemmaType,
318    unit_index: &UnitIndex,
319    named_types: &IndexMap<String, Arc<LemmaType>>,
320) -> Result<(ValueKind, Option<String>), String> {
321    if map.is_empty() {
322        return Err("measure input map must contain at least one unit key".to_string());
323    }
324    if lemma_type
325        .measure_unit_names()
326        .is_none_or(|names| names.is_empty())
327    {
328        unreachable!("BUG: measure type has no units at data input");
329    }
330
331    let mut kinds: Vec<ValueKind> = Vec::with_capacity(map.len());
332    let mut resolved_keys: Vec<String> = Vec::with_capacity(map.len());
333    for (unit_name, magnitude) in map {
334        let bare = resolve_passed_unit(unit_name, lemma_type, unit_index, named_types)?;
335        kinds.push(number_with_unit_to_value_kind(
336            *magnitude, &bare, lemma_type,
337        )?);
338        resolved_keys.push(bare);
339    }
340
341    let first = kinds.first().expect("BUG: map non-empty");
342    let ValueKind::Measure(first_magnitude) = first else {
343        return Err("expected measure value".to_string());
344    };
345    for kind in kinds.iter().skip(1) {
346        let ValueKind::Measure(magnitude) = kind else {
347            return Err("expected measure value".to_string());
348        };
349        if magnitude != first_magnitude {
350            return Err(
351                "measure unit map values disagree when converted to a common basis".to_string(),
352            );
353        }
354    }
355    let binding = (resolved_keys.len() == 1).then(|| {
356        resolved_keys
357            .into_iter()
358            .next()
359            .expect("BUG: single resolved key")
360    });
361    Ok((first.clone(), binding))
362}
363
364fn ratio_from_unit_map(
365    map: &BTreeMap<String, Decimal>,
366    lemma_type: &LemmaType,
367    unit_index: &UnitIndex,
368    named_types: &IndexMap<String, Arc<LemmaType>>,
369) -> Result<(ValueKind, Option<String>), String> {
370    if map.is_empty() {
371        return Err("ratio input map must contain at least one unit key".to_string());
372    }
373    match &lemma_type.specifications {
374        TypeSpecification::Ratio { units, .. } if !units.is_empty() => {}
375        _ => unreachable!("BUG: ratio type has no units at data input"),
376    }
377
378    let mut kinds: Vec<ValueKind> = Vec::with_capacity(map.len());
379    let mut resolved_keys: Vec<String> = Vec::with_capacity(map.len());
380    for (unit_name, magnitude) in map {
381        let bare = resolve_passed_unit(unit_name, lemma_type, unit_index, named_types)?;
382        kinds.push(number_with_unit_to_value_kind(
383            *magnitude, &bare, lemma_type,
384        )?);
385        resolved_keys.push(bare);
386    }
387
388    let first = kinds.first().expect("BUG: map non-empty");
389    let ValueKind::Ratio(first_canonical) = first else {
390        return Err("expected ratio value".to_string());
391    };
392    for kind in kinds.iter().skip(1) {
393        let ValueKind::Ratio(canonical) = kind else {
394            return Err("expected ratio value".to_string());
395        };
396        if canonical != first_canonical {
397            return Err(
398                "ratio unit map values disagree when converted to a common basis".to_string(),
399            );
400        }
401    }
402    let binding = (resolved_keys.len() == 1).then(|| {
403        resolved_keys
404            .into_iter()
405            .next()
406            .expect("BUG: single resolved key")
407    });
408    Ok((ValueKind::Ratio(first_canonical.clone()), binding))
409}
410
411/// User-provided data values resolved against a plan's type declarations.
412///
413/// Lightweight and cheap to construct — no plan cloning required. The
414/// [`ExecutionPlan`] stays immutable; callers pass `(&ExecutionPlan, &RunData)`
415/// to evaluation and show paths.
416#[derive(Debug, Clone, Default)]
417pub struct RunData {
418    /// Caller bindings: successful literals or Veto (bad override) per Data.
419    pub bindings: HashMap<DataPath, OperationResult>,
420    /// Input keys that did not match any plan Data (including Import aliases).
421    pub ignored_unknown: Vec<String>,
422}
423
424impl RunData {
425    /// Parse and validate caller-supplied values against the plan's data declarations.
426    ///
427    /// Unknown keys and Import aliases are ignored (recorded in [`Self::ignored_unknown`]).
428    /// Parse, constraint, options, decimals, and input-oversize failures bind that Data
429    /// as [`OperationResult::Veto`]; evaluation still runs. Duplicate canonical keys Error.
430    pub fn resolve(
431        plan: &ExecutionPlan,
432        raw_values: HashMap<String, RunDataValue>,
433        limits: &ResourceLimits,
434    ) -> Result<Self, Error> {
435        let mut run_data = Self::default();
436        let mut seen_canonical = HashSet::with_capacity(raw_values.len());
437
438        for (name, raw_value) in raw_values {
439            let canonical = crate::parsing::ast::ascii_lowercase_logical_name(name.clone());
440            if !seen_canonical.insert(canonical.clone()) {
441                return Err(Error::request(
442                    format!("Duplicate data key '{canonical}'"),
443                    Some("Data keys are case-insensitive; remove the duplicate"),
444                ));
445            }
446
447            let Some(data_path) = plan.input_key_index.get(canonical.as_str()) else {
448                run_data.ignored_unknown.push(name);
449                continue;
450            };
451
452            let data_definition = plan
453                .data
454                .get(data_path)
455                .expect("BUG: data_path was just resolved from plan.input_key_index, must exist");
456
457            let data_source = data_definition.source();
458            let type_arc = match data_definition {
459                DataDefinition::TypeDeclaration { resolved_type, .. }
460                | DataDefinition::Reference { resolved_type, .. }
461                | DataDefinition::Value { resolved_type, .. } => Arc::clone(resolved_type),
462                DataDefinition::Import { .. } => {
463                    run_data.ignored_unknown.push(name);
464                    continue;
465                }
466            };
467
468            let input_key = canonical;
469
470            if raw_value.is_empty() && type_arc.empty_runtime_input_vetoes() {
471                run_data.bindings.insert(
472                    data_path.clone(),
473                    OperationResult::Veto(VetoType::computation(
474                        type_arc.data_veto_message(&input_key, "cannot be empty."),
475                    )),
476                );
477                continue;
478            }
479
480            let typed = match parse_data_value(
481                &raw_value,
482                &type_arc,
483                data_source,
484                plan.expression_unit_index(),
485                &plan.resolved_types.resolved,
486            ) {
487                Ok(value) => value,
488                Err(error) => {
489                    run_data.bindings.insert(
490                        data_path.clone(),
491                        OperationResult::Veto(VetoType::computation(
492                            type_arc.data_veto_message(&input_key, error.message()),
493                        )),
494                    );
495                    continue;
496                }
497            };
498
499            let literal_value = LiteralValue {
500                value: typed.value.clone(),
501            };
502            let size = literal_value.byte_size();
503            if size > limits.max_data_value_bytes {
504                run_data.bindings.insert(
505                    data_path.clone(),
506                    OperationResult::Veto(VetoType::computation(
507                        type_arc.data_veto_message(&input_key, "exceeds the size limit."),
508                    )),
509                );
510                continue;
511            }
512
513            if let Err(message) = validate_value_against_type(
514                typed.lemma_type.as_ref(),
515                &literal_value,
516                plan.expression_unit_index(),
517            ) {
518                run_data.bindings.insert(
519                    data_path.clone(),
520                    OperationResult::Veto(VetoType::computation(
521                        type_arc.data_veto_message(&input_key, &message),
522                    )),
523                );
524                continue;
525            }
526
527            run_data.bindings.insert(
528                data_path.clone(),
529                OperationResult::from_bound(crate::planning::semantics::BoundValueKind {
530                    value: typed.value.clone(),
531                    unit: typed.lemma_type.unit().map(std::sync::Arc::from),
532                }),
533            );
534        }
535
536        Ok(run_data)
537    }
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543    use crate::computation::rational::{decimal_to_rational, rational_new, rational_one};
544    use crate::planning::semantics::{
545        primitive_number_arc, MeasureUnit, MeasureUnits, RatioUnit, RatioUnits, TypeExtends,
546    };
547
548    fn dummy_source() -> Source {
549        Source::new(
550            crate::parsing::source::SourceType::Volatile,
551            crate::parsing::ast::Span {
552                start: 0,
553                end: 0,
554                line: 1,
555                col: 1,
556            },
557        )
558    }
559
560    fn mass_measure_type() -> Arc<LemmaType> {
561        Arc::new(LemmaType::new(
562            "Mass".to_string(),
563            TypeSpecification::Measure {
564                minimum: None,
565                maximum: None,
566                decimals: None,
567                units: MeasureUnits::from(vec![
568                    MeasureUnit {
569                        name: "kilogram".to_string(),
570                        factor: rational_one(),
571                        derived_measure_factors: Vec::new(),
572                        decomposition: crate::literals::BaseMeasureVector::new(),
573                        minimum: None,
574                        maximum: None,
575                        suggestion_magnitude: None,
576                    },
577                    MeasureUnit {
578                        name: "gram".to_string(),
579                        factor: decimal_to_rational(Decimal::new(1, 3)).expect("factor"),
580                        derived_measure_factors: Vec::new(),
581                        decomposition: crate::literals::BaseMeasureVector::new(),
582                        minimum: None,
583                        maximum: None,
584                        suggestion_magnitude: None,
585                    },
586                ]),
587                traits: Vec::new(),
588                decomposition: None,
589                unit: None,
590                help: String::new(),
591            },
592            TypeExtends::Primitive,
593        ))
594    }
595
596    fn ratio_with_percent_type() -> Arc<LemmaType> {
597        Arc::new(LemmaType::new(
598            "Rate".to_string(),
599            TypeSpecification::Ratio {
600                minimum: None,
601                maximum: None,
602                decimals: None,
603                units: RatioUnits::from(vec![
604                    RatioUnit {
605                        name: "percent".to_string(),
606                        value: decimal_to_rational(Decimal::new(100, 0)).expect("factor"),
607                        minimum: None,
608                        maximum: None,
609                        suggestion_magnitude: None,
610                    },
611                    RatioUnit {
612                        name: "fraction".to_string(),
613                        value: rational_one(),
614                        minimum: None,
615                        maximum: None,
616                        suggestion_magnitude: None,
617                    },
618                ]),
619                unit: None,
620                help: String::new(),
621            },
622            TypeExtends::Primitive,
623        ))
624    }
625
626    #[test]
627    fn string_input_parsed_against_type() {
628        let ty = primitive_number_arc();
629        let lit = parse_data_value(
630            &RunDataValue::String("42".to_string()),
631            ty,
632            &dummy_source(),
633            &UnitIndex::new(),
634            &IndexMap::new(),
635        )
636        .unwrap();
637        assert!(matches!(lit.value, ValueKind::Number(_)));
638    }
639
640    #[test]
641    fn measure_map_agreeing_units_canonicalize() {
642        let ty = mass_measure_type();
643        let mut map = BTreeMap::new();
644        map.insert("kilogram".to_string(), Decimal::from(2));
645        map.insert("gram".to_string(), Decimal::from(2000));
646        let lit = parse_data_value(
647            &RunDataValue::MeasureMap(map),
648            &ty,
649            &dummy_source(),
650            &UnitIndex::new(),
651            &IndexMap::new(),
652        )
653        .unwrap();
654        let ValueKind::Measure(magnitude) = &lit.value else {
655            panic!("expected measure");
656        };
657        assert_eq!(magnitude, &rational_new(2, 1));
658        let signature = ty.measure_runtime_signature();
659        assert_eq!(signature.len(), 1);
660        assert_eq!(signature[0].1, 1);
661    }
662
663    #[test]
664    fn measure_map_disagreeing_units_rejected() {
665        let ty = mass_measure_type();
666        let mut map = BTreeMap::new();
667        map.insert("kilogram".to_string(), Decimal::from(2));
668        map.insert("gram".to_string(), Decimal::from(3000));
669        let err = parse_data_value(
670            &RunDataValue::MeasureMap(map),
671            &ty,
672            &dummy_source(),
673            &UnitIndex::new(),
674            &IndexMap::new(),
675        )
676        .unwrap_err();
677        assert!(err.message().contains("disagree"));
678    }
679
680    #[test]
681    fn ratio_map_percent_and_fraction_agree() {
682        let ty = ratio_with_percent_type();
683        let mut map = BTreeMap::new();
684        map.insert("percent".to_string(), Decimal::from(10));
685        map.insert("fraction".to_string(), Decimal::new(1, 1));
686        let lit = parse_data_value(
687            &RunDataValue::RatioMap(map),
688            &ty,
689            &dummy_source(),
690            &UnitIndex::new(),
691            &IndexMap::new(),
692        )
693        .unwrap();
694        let ValueKind::Ratio(canonical) = &lit.value else {
695            panic!("expected ratio");
696        };
697        assert_eq!(
698            *canonical,
699            decimal_to_rational(Decimal::new(1, 1)).expect("canonical")
700        );
701        assert!(ty.ratio_primary_unit().is_some());
702    }
703
704    #[test]
705    fn run_data_value_from_json_null_is_omitted() {
706        assert_eq!(
707            run_data_value_from_json_value(serde_json::Value::Null).expect("null is omit"),
708            None
709        );
710    }
711
712    #[test]
713    fn parse_run_data_object_null_field_is_omitted() {
714        let only_null = Some(serde_json::json!({ "x": null }));
715        let empty = parse_run_data_object(&only_null).expect("null field omits");
716        assert!(empty.is_empty(), "got: {empty:?}");
717
718        let mixed = Some(serde_json::json!({ "x": null, "y": "1" }));
719        let map = parse_run_data_object(&mixed).expect("null field omits");
720        assert_eq!(map.len(), 1);
721        assert_eq!(map.get("y").map(String::as_str), Some("1"));
722        assert!(!map.contains_key("x"));
723    }
724}