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 kind = parser_value_to_value_kind(&parsed, type_spec).map_err(to_err)?;
180            let binding = match binding_unit_from_parser_value(&parsed) {
181                Some(written) => Some(
182                    resolve_passed_unit(&written, lemma_type.as_ref(), unit_index, named_types)
183                        .map_err(to_err)?,
184                ),
185                None => None,
186            };
187            (kind, binding)
188        }
189        (RunDataValue::Boolean(b), TypeSpecification::Boolean { .. }) => {
190            (ValueKind::Boolean(*b), None)
191        }
192        (RunDataValue::Boolean(_), _) => {
193            return Err(to_err(format!(
194                "boolean input is only valid for boolean data, not {}",
195                type_spec
196            )));
197        }
198        (RunDataValue::MeasureMap(map), TypeSpecification::Measure { .. }) => {
199            let (kind, binding) =
200                measure_from_unit_map(map, lemma_type.as_ref(), unit_index, named_types)
201                    .map_err(to_err)?;
202            (kind, binding)
203        }
204        (
205            RunDataValue::MeasureMap(map) | RunDataValue::RatioMap(map),
206            TypeSpecification::Ratio { .. },
207        ) => {
208            let (kind, binding) =
209                ratio_from_unit_map(map, lemma_type.as_ref(), unit_index, named_types)
210                    .map_err(to_err)?;
211            (kind, binding)
212        }
213        (RunDataValue::MeasureMap(_), _) => {
214            return Err(to_err(format!(
215                "measure unit map is only valid for measure data, not {}",
216                type_spec
217            )));
218        }
219        (RunDataValue::RatioMap(_), _) => {
220            return Err(to_err(format!(
221                "ratio unit map is only valid for ratio data, not {}",
222                type_spec
223            )));
224        }
225    };
226
227    let typed_type = match binding_unit {
228        Some(unit) => Arc::new(lemma_type.as_ref().clone().with_measure_binding_unit(unit)),
229        None => Arc::clone(lemma_type),
230    };
231    Ok(TypedLiteral {
232        value: kind,
233        lemma_type: typed_type,
234    })
235}
236
237fn binding_unit_from_parser_value(value: &crate::parsing::ast::Value) -> Option<String> {
238    use crate::parsing::ast::Value;
239    match value {
240        Value::NumberWithUnit(_, unit) => Some(unit.clone()),
241        Value::Range(left, right) => match (left.as_ref(), right.as_ref()) {
242            (Value::NumberWithUnit(_, left_unit), Value::NumberWithUnit(_, right_unit))
243                if left_unit == right_unit =>
244            {
245                Some(left_unit.clone())
246            }
247            _ => None,
248        },
249        _ => None,
250    }
251}
252
253/// Resolve a unit the caller actually wrote to a bare declared name.
254///
255/// Qualified paths go through [`UnitIndex::resolve_with_named_types`]. Bare names
256/// accepted only when this data type (or range element) declares that exact name.
257/// Never invents a unit the caller did not pass.
258fn resolve_passed_unit(
259    written: &str,
260    lemma_type: &LemmaType,
261    unit_index: &UnitIndex,
262    named_types: &IndexMap<String, Arc<LemmaType>>,
263) -> Result<String, String> {
264    match unit_index.resolve_with_named_types(written, named_types) {
265        Ok((bare, _)) => {
266            if unit_name_declared_on_type(lemma_type, &bare)
267                || range_element_declares_unit(lemma_type, &bare)
268            {
269                Ok(bare)
270            } else {
271                Err(format!(
272                    "Unit '{written}' resolves to '{bare}', which is not declared on type {}",
273                    lemma_type.name()
274                ))
275            }
276        }
277        Err(index_err) => {
278            if unit_name_declared_on_type(lemma_type, written)
279                || range_element_declares_unit(lemma_type, written)
280            {
281                Ok(written.to_string())
282            } else {
283                Err(index_err)
284            }
285        }
286    }
287}
288
289fn range_element_declares_unit(lemma_type: &LemmaType, unit_name: &str) -> bool {
290    let Some(element) = lemma_type.specifications.element_from_range() else {
291        return false;
292    };
293    unit_name_declared_on_type(&LemmaType::primitive(element), unit_name)
294}
295
296fn unit_name_declared_on_type(lemma_type: &LemmaType, unit_name: &str) -> bool {
297    match &lemma_type.specifications {
298        TypeSpecification::Measure { units, .. } => units.get(unit_name).is_ok(),
299        TypeSpecification::Ratio { units, .. } => units.get(unit_name).is_ok(),
300        _ => false,
301    }
302}
303
304fn measure_from_unit_map(
305    map: &BTreeMap<String, Decimal>,
306    lemma_type: &LemmaType,
307    unit_index: &UnitIndex,
308    named_types: &IndexMap<String, Arc<LemmaType>>,
309) -> Result<(ValueKind, Option<String>), String> {
310    if map.is_empty() {
311        return Err("measure input map must contain at least one unit key".to_string());
312    }
313    if lemma_type
314        .measure_unit_names()
315        .is_none_or(|names| names.is_empty())
316    {
317        unreachable!("BUG: measure type has no units at data input");
318    }
319
320    let mut kinds: Vec<ValueKind> = Vec::with_capacity(map.len());
321    let mut resolved_keys: Vec<String> = Vec::with_capacity(map.len());
322    for (unit_name, magnitude) in map {
323        let bare = resolve_passed_unit(unit_name, lemma_type, unit_index, named_types)?;
324        kinds.push(number_with_unit_to_value_kind(
325            *magnitude, &bare, lemma_type,
326        )?);
327        resolved_keys.push(bare);
328    }
329
330    let first = kinds.first().expect("BUG: map non-empty");
331    let ValueKind::Measure(first_magnitude) = first else {
332        return Err("expected measure value".to_string());
333    };
334    for kind in kinds.iter().skip(1) {
335        let ValueKind::Measure(magnitude) = kind else {
336            return Err("expected measure value".to_string());
337        };
338        if magnitude != first_magnitude {
339            return Err(
340                "measure unit map values disagree when converted to a common basis".to_string(),
341            );
342        }
343    }
344    let binding = (resolved_keys.len() == 1).then(|| {
345        resolved_keys
346            .into_iter()
347            .next()
348            .expect("BUG: single resolved key")
349    });
350    Ok((first.clone(), binding))
351}
352
353fn ratio_from_unit_map(
354    map: &BTreeMap<String, Decimal>,
355    lemma_type: &LemmaType,
356    unit_index: &UnitIndex,
357    named_types: &IndexMap<String, Arc<LemmaType>>,
358) -> Result<(ValueKind, Option<String>), String> {
359    if map.is_empty() {
360        return Err("ratio input map must contain at least one unit key".to_string());
361    }
362    match &lemma_type.specifications {
363        TypeSpecification::Ratio { units, .. } if !units.is_empty() => {}
364        _ => unreachable!("BUG: ratio type has no units at data input"),
365    }
366
367    let mut kinds: Vec<ValueKind> = Vec::with_capacity(map.len());
368    let mut resolved_keys: Vec<String> = Vec::with_capacity(map.len());
369    for (unit_name, magnitude) in map {
370        let bare = resolve_passed_unit(unit_name, lemma_type, unit_index, named_types)?;
371        kinds.push(number_with_unit_to_value_kind(
372            *magnitude, &bare, lemma_type,
373        )?);
374        resolved_keys.push(bare);
375    }
376
377    let first = kinds.first().expect("BUG: map non-empty");
378    let ValueKind::Ratio(first_canonical) = first else {
379        return Err("expected ratio value".to_string());
380    };
381    for kind in kinds.iter().skip(1) {
382        let ValueKind::Ratio(canonical) = kind else {
383            return Err("expected ratio value".to_string());
384        };
385        if canonical != first_canonical {
386            return Err(
387                "ratio unit map values disagree when converted to a common basis".to_string(),
388            );
389        }
390    }
391    let binding = (resolved_keys.len() == 1).then(|| {
392        resolved_keys
393            .into_iter()
394            .next()
395            .expect("BUG: single resolved key")
396    });
397    Ok((ValueKind::Ratio(first_canonical.clone()), binding))
398}
399
400/// User-provided data values resolved against a plan's type declarations.
401///
402/// Lightweight and cheap to construct — no plan cloning required. The
403/// [`ExecutionPlan`] stays immutable; callers pass `(&ExecutionPlan, &RunData)`
404/// to evaluation and show paths.
405#[derive(Debug, Clone, Default)]
406pub struct RunData {
407    /// Caller bindings: successful literals or Veto (bad override) per Data.
408    pub bindings: HashMap<DataPath, OperationResult>,
409    /// Input keys that did not match any plan Data (including Import aliases).
410    pub ignored_unknown: Vec<String>,
411}
412
413impl RunData {
414    /// Parse and validate caller-supplied values against the plan's data declarations.
415    ///
416    /// Unknown keys and Import aliases are ignored (recorded in [`Self::ignored_unknown`]).
417    /// Parse, constraint, options, decimals, and input-oversize failures bind that Data
418    /// as [`OperationResult::Veto`]; evaluation still runs. Duplicate canonical keys Error.
419    pub fn resolve(
420        plan: &ExecutionPlan,
421        raw_values: HashMap<String, RunDataValue>,
422        limits: &ResourceLimits,
423    ) -> Result<Self, Error> {
424        let mut run_data = Self::default();
425        let mut seen_canonical = HashSet::with_capacity(raw_values.len());
426
427        for (name, raw_value) in raw_values {
428            let canonical = crate::parsing::ast::ascii_lowercase_logical_name(name.clone());
429            if !seen_canonical.insert(canonical.clone()) {
430                return Err(Error::request(
431                    format!("Duplicate data key '{canonical}'"),
432                    Some("Data keys are case-insensitive; remove the duplicate"),
433                ));
434            }
435
436            let Some(data_path) = plan.input_key_index.get(canonical.as_str()) else {
437                run_data.ignored_unknown.push(name);
438                continue;
439            };
440
441            let data_definition = plan
442                .data
443                .get(data_path)
444                .expect("BUG: data_path was just resolved from plan.input_key_index, must exist");
445
446            let data_source = data_definition.source();
447            let type_arc = match data_definition {
448                DataDefinition::TypeDeclaration { resolved_type, .. }
449                | DataDefinition::Reference { resolved_type, .. }
450                | DataDefinition::Value { resolved_type, .. } => Arc::clone(resolved_type),
451                DataDefinition::Import { .. } => {
452                    run_data.ignored_unknown.push(name);
453                    continue;
454                }
455            };
456
457            let input_key = canonical;
458
459            if raw_value.is_empty() && type_arc.empty_runtime_input_vetoes() {
460                run_data.bindings.insert(
461                    data_path.clone(),
462                    OperationResult::Veto(VetoType::computation(
463                        type_arc.data_veto_message(&input_key, "cannot be empty."),
464                    )),
465                );
466                continue;
467            }
468
469            let typed = match parse_data_value(
470                &raw_value,
471                &type_arc,
472                data_source,
473                plan.expression_unit_index(),
474                &plan.resolved_types.resolved,
475            ) {
476                Ok(value) => value,
477                Err(error) => {
478                    run_data.bindings.insert(
479                        data_path.clone(),
480                        OperationResult::Veto(VetoType::computation(
481                            type_arc.data_veto_message(&input_key, error.message()),
482                        )),
483                    );
484                    continue;
485                }
486            };
487
488            let literal_value = LiteralValue {
489                value: typed.value.clone(),
490            };
491            let size = literal_value.byte_size();
492            if size > limits.max_data_value_bytes {
493                run_data.bindings.insert(
494                    data_path.clone(),
495                    OperationResult::Veto(VetoType::computation(
496                        type_arc.data_veto_message(&input_key, "exceeds the size limit."),
497                    )),
498                );
499                continue;
500            }
501
502            if let Err(message) = validate_value_against_type(
503                typed.lemma_type.as_ref(),
504                &literal_value,
505                plan.expression_unit_index(),
506            ) {
507                run_data.bindings.insert(
508                    data_path.clone(),
509                    OperationResult::Veto(VetoType::computation(
510                        type_arc.data_veto_message(&input_key, &message),
511                    )),
512                );
513                continue;
514            }
515
516            run_data.bindings.insert(
517                data_path.clone(),
518                OperationResult::from_bound(crate::planning::semantics::BoundValueKind {
519                    value: typed.value.clone(),
520                    measure_binding_unit: typed
521                        .lemma_type
522                        .measure_binding_unit
523                        .as_deref()
524                        .map(std::sync::Arc::from),
525                }),
526            );
527        }
528
529        Ok(run_data)
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use crate::computation::rational::{decimal_to_rational, rational_new, rational_one};
537    use crate::planning::semantics::{
538        primitive_number_arc, MeasureUnit, MeasureUnits, RatioUnit, RatioUnits, TypeExtends,
539    };
540
541    fn dummy_source() -> Source {
542        Source::new(
543            crate::parsing::source::SourceType::Volatile,
544            crate::parsing::ast::Span {
545                start: 0,
546                end: 0,
547                line: 1,
548                col: 1,
549            },
550        )
551    }
552
553    fn mass_measure_type() -> Arc<LemmaType> {
554        Arc::new(LemmaType::new(
555            "Mass".to_string(),
556            TypeSpecification::Measure {
557                minimum: None,
558                maximum: None,
559                decimals: None,
560                units: MeasureUnits::from(vec![
561                    MeasureUnit {
562                        name: "kilogram".to_string(),
563                        factor: rational_one(),
564                        derived_measure_factors: Vec::new(),
565                        decomposition: crate::literals::BaseMeasureVector::new(),
566                        minimum: None,
567                        maximum: None,
568                        suggestion_magnitude: None,
569                    },
570                    MeasureUnit {
571                        name: "gram".to_string(),
572                        factor: decimal_to_rational(Decimal::new(1, 3)).expect("factor"),
573                        derived_measure_factors: Vec::new(),
574                        decomposition: crate::literals::BaseMeasureVector::new(),
575                        minimum: None,
576                        maximum: None,
577                        suggestion_magnitude: None,
578                    },
579                ]),
580                traits: Vec::new(),
581                decomposition: None,
582                help: String::new(),
583            },
584            TypeExtends::Primitive,
585        ))
586    }
587
588    fn ratio_with_percent_type() -> Arc<LemmaType> {
589        Arc::new(LemmaType::new(
590            "Rate".to_string(),
591            TypeSpecification::Ratio {
592                minimum: None,
593                maximum: None,
594                decimals: None,
595                units: RatioUnits::from(vec![
596                    RatioUnit {
597                        name: "percent".to_string(),
598                        value: decimal_to_rational(Decimal::new(100, 0)).expect("factor"),
599                        minimum: None,
600                        maximum: None,
601                        suggestion_magnitude: None,
602                    },
603                    RatioUnit {
604                        name: "fraction".to_string(),
605                        value: rational_one(),
606                        minimum: None,
607                        maximum: None,
608                        suggestion_magnitude: None,
609                    },
610                ]),
611                help: String::new(),
612            },
613            TypeExtends::Primitive,
614        ))
615    }
616
617    #[test]
618    fn string_input_parsed_against_type() {
619        let ty = primitive_number_arc();
620        let lit = parse_data_value(
621            &RunDataValue::String("42".to_string()),
622            ty,
623            &dummy_source(),
624            &UnitIndex::new(),
625            &IndexMap::new(),
626        )
627        .unwrap();
628        assert!(matches!(lit.value, ValueKind::Number(_)));
629    }
630
631    #[test]
632    fn measure_map_agreeing_units_canonicalize() {
633        let ty = mass_measure_type();
634        let mut map = BTreeMap::new();
635        map.insert("kilogram".to_string(), Decimal::from(2));
636        map.insert("gram".to_string(), Decimal::from(2000));
637        let lit = parse_data_value(
638            &RunDataValue::MeasureMap(map),
639            &ty,
640            &dummy_source(),
641            &UnitIndex::new(),
642            &IndexMap::new(),
643        )
644        .unwrap();
645        let ValueKind::Measure(magnitude) = &lit.value else {
646            panic!("expected measure");
647        };
648        assert_eq!(magnitude, &rational_new(2, 1));
649        let signature = ty.measure_runtime_signature();
650        assert_eq!(signature.len(), 1);
651        assert_eq!(signature[0].1, 1);
652    }
653
654    #[test]
655    fn measure_map_disagreeing_units_rejected() {
656        let ty = mass_measure_type();
657        let mut map = BTreeMap::new();
658        map.insert("kilogram".to_string(), Decimal::from(2));
659        map.insert("gram".to_string(), Decimal::from(3000));
660        let err = parse_data_value(
661            &RunDataValue::MeasureMap(map),
662            &ty,
663            &dummy_source(),
664            &UnitIndex::new(),
665            &IndexMap::new(),
666        )
667        .unwrap_err();
668        assert!(err.message().contains("disagree"));
669    }
670
671    #[test]
672    fn ratio_map_percent_and_fraction_agree() {
673        let ty = ratio_with_percent_type();
674        let mut map = BTreeMap::new();
675        map.insert("percent".to_string(), Decimal::from(10));
676        map.insert("fraction".to_string(), Decimal::new(1, 1));
677        let lit = parse_data_value(
678            &RunDataValue::RatioMap(map),
679            &ty,
680            &dummy_source(),
681            &UnitIndex::new(),
682            &IndexMap::new(),
683        )
684        .unwrap();
685        let ValueKind::Ratio(canonical) = &lit.value else {
686            panic!("expected ratio");
687        };
688        assert_eq!(
689            *canonical,
690            decimal_to_rational(Decimal::new(1, 1)).expect("canonical")
691        );
692        assert!(ty.ratio_primary_unit().is_some());
693    }
694
695    #[test]
696    fn run_data_value_from_json_null_is_omitted() {
697        assert_eq!(
698            run_data_value_from_json_value(serde_json::Value::Null).expect("null is omit"),
699            None
700        );
701    }
702
703    #[test]
704    fn parse_run_data_object_null_field_is_omitted() {
705        let only_null = Some(serde_json::json!({ "x": null }));
706        let empty = parse_run_data_object(&only_null).expect("null field omits");
707        assert!(empty.is_empty(), "got: {empty:?}");
708
709        let mixed = Some(serde_json::json!({ "x": null, "y": "1" }));
710        let map = parse_run_data_object(&mixed).expect("null field omits");
711        assert_eq!(map.len(), 1);
712        assert_eq!(map.get("y").map(String::as_str), Some("1"));
713        assert!(!map.contains_key("x"));
714    }
715}