Skip to main content

bicmath_core/
schema.rs

1//! Typed parameter and output schemas with shared validation semantics.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::{EngineError, ErrorCode};
8use crate::limits::Limits;
9use crate::number::Number;
10use crate::value::{Bound, Dimension, RESERVED_KIND_KEY, Value};
11
12/// Accepted numeric kinds for a parameter.
13#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum NumberKind {
16    Integer,
17    Rational,
18    Decimal,
19    Float64,
20    /// Integer, rational, or decimal, but not float64.
21    Exact,
22    /// Any numeric representation, including float64.
23    Any,
24}
25
26impl NumberKind {
27    pub fn accepts(&self, number: &Number) -> bool {
28        match self {
29            NumberKind::Integer => matches!(number, Number::Integer(_)),
30            NumberKind::Rational => matches!(number, Number::Rational(_)),
31            NumberKind::Decimal => matches!(number, Number::Decimal(_)),
32            NumberKind::Float64 => matches!(number, Number::Float64(_)),
33            NumberKind::Exact => !matches!(number, Number::Float64(_)),
34            NumberKind::Any => true,
35        }
36    }
37
38    pub fn label(&self) -> &'static str {
39        match self {
40            NumberKind::Integer => "integer",
41            NumberKind::Rational => "rational",
42            NumberKind::Decimal => "decimal",
43            NumberKind::Float64 => "float64",
44            NumberKind::Exact => "exact number (integer, rational, or decimal)",
45            NumberKind::Any => "number",
46        }
47    }
48}
49
50/// A named field in a record schema.
51#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
52pub struct FieldSchema {
53    pub name: String,
54    pub description: String,
55    pub schema: ValueSchema,
56    #[serde(default)]
57    pub required: bool,
58}
59
60impl FieldSchema {
61    pub fn required(name: impl Into<String>, schema: ValueSchema) -> FieldSchema {
62        FieldSchema {
63            name: name.into(),
64            description: String::new(),
65            schema,
66            required: true,
67        }
68    }
69
70    pub fn optional(name: impl Into<String>, schema: ValueSchema) -> FieldSchema {
71        FieldSchema {
72            name: name.into(),
73            description: String::new(),
74            schema,
75            required: false,
76        }
77    }
78
79    pub fn with_description(mut self, description: impl Into<String>) -> FieldSchema {
80        self.description = description.into();
81        self
82    }
83}
84
85/// The schema of a single value.
86#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
87#[serde(tag = "type", rename_all = "snake_case")]
88pub enum ValueSchema {
89    Number {
90        kind: NumberKind,
91    },
92    Bool,
93    Text {
94        #[serde(default, skip_serializing_if = "Option::is_none")]
95        max_len: Option<usize>,
96    },
97    Enum {
98        variants: Vec<String>,
99    },
100    Array {
101        items: Box<ValueSchema>,
102        #[serde(default, skip_serializing_if = "Option::is_none")]
103        min_len: Option<usize>,
104        #[serde(default, skip_serializing_if = "Option::is_none")]
105        max_len: Option<usize>,
106    },
107    Record {
108        fields: Vec<FieldSchema>,
109        #[serde(default)]
110        allow_extra: bool,
111    },
112    Quantity {
113        #[serde(default, skip_serializing_if = "Option::is_none")]
114        dimension: Option<Dimension>,
115        /// Whether a temperature-difference style quantity is accepted.
116        #[serde(default)]
117        allow_delta: bool,
118    },
119    Money,
120    Matrix {
121        #[serde(default, skip_serializing_if = "Option::is_none")]
122        max_elems: Option<usize>,
123        #[serde(default)]
124        square: bool,
125    },
126    /// A restricted expression string, parsed by the engine before invocation.
127    Expression {
128        variables: Vec<String>,
129    },
130    Bound,
131    Any,
132}
133
134impl ValueSchema {
135    pub fn number(kind: NumberKind) -> ValueSchema {
136        ValueSchema::Number { kind }
137    }
138
139    pub fn exact() -> ValueSchema {
140        ValueSchema::Number {
141            kind: NumberKind::Exact,
142        }
143    }
144
145    pub fn float64() -> ValueSchema {
146        ValueSchema::Number {
147            kind: NumberKind::Float64,
148        }
149    }
150
151    pub fn text() -> ValueSchema {
152        ValueSchema::Text { max_len: None }
153    }
154
155    pub fn array(items: ValueSchema) -> ValueSchema {
156        ValueSchema::Array {
157            items: Box::new(items),
158            min_len: None,
159            max_len: None,
160        }
161    }
162
163    pub fn array_with_len(
164        items: ValueSchema,
165        min_len: usize,
166        max_len: Option<usize>,
167    ) -> ValueSchema {
168        ValueSchema::Array {
169            items: Box::new(items),
170            min_len: Some(min_len),
171            max_len,
172        }
173    }
174
175    /// Validate an already-typed value.
176    pub fn validate(&self, value: &Value, path: &str, limits: &Limits) -> Result<(), EngineError> {
177        value.check_limits(limits, 0)?;
178        self.validate_inner(value, path, limits)
179    }
180
181    fn validate_inner(
182        &self,
183        value: &Value,
184        path: &str,
185        limits: &Limits,
186    ) -> Result<(), EngineError> {
187        let at = |error: EngineError| {
188            if error.path.is_none() {
189                error.with_path(path.to_string())
190            } else {
191                error
192            }
193        };
194        match self {
195            ValueSchema::Number { kind } => match value {
196                Value::Number(number) if kind.accepts(number) => Ok(()),
197                Value::Number(number) => Err(at(EngineError::malformed(format!(
198                    "expected {}, found {}",
199                    kind.label(),
200                    number.kind_name()
201                )))),
202                other => Err(at(EngineError::malformed(format!(
203                    "expected {}, found {}",
204                    kind.label(),
205                    other.kind_name()
206                )))),
207            },
208            ValueSchema::Bool => match value {
209                Value::Bool(_) => Ok(()),
210                other => Err(at(EngineError::malformed(format!(
211                    "expected a boolean, found {}",
212                    other.kind_name()
213                )))),
214            },
215            ValueSchema::Text { max_len } => match value {
216                Value::Text(text) => {
217                    if let Some(max_len) = max_len
218                        && text.chars().count() > *max_len
219                    {
220                        return Err(at(EngineError::new(
221                            ErrorCode::ResourceLimit,
222                            format!(
223                                "text length {} exceeds the limit of {max_len}",
224                                text.chars().count()
225                            ),
226                        )));
227                    }
228                    Ok(())
229                }
230                other => Err(at(EngineError::malformed(format!(
231                    "expected text, found {}",
232                    other.kind_name()
233                )))),
234            },
235            ValueSchema::Enum { variants } => match value {
236                Value::Text(text) if variants.iter().any(|v| v == text) => Ok(()),
237                Value::Text(text) => Err(at(EngineError::domain(format!(
238                    "unknown variant {text:?}; expected one of {variants:?}"
239                )))),
240                other => Err(at(EngineError::malformed(format!(
241                    "expected one of {variants:?}, found {}",
242                    other.kind_name()
243                )))),
244            },
245            ValueSchema::Array {
246                items,
247                min_len,
248                max_len,
249            } => match value {
250                Value::Array(values) => {
251                    if let Some(min_len) = min_len
252                        && values.len() < *min_len
253                    {
254                        return Err(at(EngineError::new(
255                            ErrorCode::InsufficientObservations,
256                            format!(
257                                "array has {} elements; at least {min_len} are required",
258                                values.len()
259                            ),
260                        )));
261                    }
262                    let max = max_len.unwrap_or(limits.max_array_len);
263                    if values.len() > max {
264                        return Err(at(EngineError::new(
265                            ErrorCode::ResourceLimit,
266                            format!("array length {} exceeds the limit of {max}", values.len()),
267                        )));
268                    }
269                    for (index, item) in values.iter().enumerate() {
270                        items.validate_inner(item, &format!("{path}[{index}]"), limits)?;
271                    }
272                    Ok(())
273                }
274                other => Err(at(EngineError::malformed(format!(
275                    "expected an array, found {}",
276                    other.kind_name()
277                )))),
278            },
279            ValueSchema::Record {
280                fields,
281                allow_extra,
282            } => match value {
283                Value::Record(record) => {
284                    for field in fields {
285                        match record.get(&field.name) {
286                            Some(value) => {
287                                field.schema.validate_inner(
288                                    value,
289                                    &format!("{path}.{}", field.name),
290                                    limits,
291                                )?;
292                            }
293                            None if field.required => {
294                                return Err(at(EngineError::malformed(format!(
295                                    "missing required field {:?}",
296                                    field.name
297                                ))));
298                            }
299                            None => {}
300                        }
301                    }
302                    if !allow_extra {
303                        for key in record.keys() {
304                            if !fields.iter().any(|f| &f.name == key) {
305                                return Err(at(EngineError::malformed(format!(
306                                    "unknown field {key:?}"
307                                ))));
308                            }
309                        }
310                    }
311                    Ok(())
312                }
313                other => Err(at(EngineError::malformed(format!(
314                    "expected a record, found {}",
315                    other.kind_name()
316                )))),
317            },
318            ValueSchema::Quantity {
319                dimension,
320                allow_delta: _,
321            } => match value {
322                Value::Quantity {
323                    value,
324                    dimension: actual,
325                } => {
326                    if let Some(expected) = dimension
327                        && expected != actual
328                    {
329                        return Err(at(EngineError::new(
330                            ErrorCode::IncompatibleUnits,
331                            format!("expected dimension {expected}, found {actual}"),
332                        )));
333                    }
334                    value.check_limits(limits, 0)?;
335                    Ok(())
336                }
337                other => Err(at(EngineError::malformed(format!(
338                    "expected a quantity, found {}",
339                    other.kind_name()
340                )))),
341            },
342            ValueSchema::Money => match value {
343                Value::Money { .. } => Ok(()),
344                other => Err(at(EngineError::malformed(format!(
345                    "expected money, found {}",
346                    other.kind_name()
347                )))),
348            },
349            ValueSchema::Matrix { max_elems, square } => match value {
350                Value::Matrix { rows, cols, .. } => {
351                    if *square && rows != cols {
352                        return Err(at(EngineError::malformed(format!(
353                            "expected a square matrix, found {rows}x{cols}"
354                        ))));
355                    }
356                    let max = max_elems.unwrap_or(limits.max_matrix_elements);
357                    if (*rows as usize).saturating_mul(*cols as usize) > max {
358                        return Err(at(EngineError::new(
359                            ErrorCode::ResourceLimit,
360                            format!("matrix {rows}x{cols} exceeds the element limit of {max}"),
361                        )));
362                    }
363                    Ok(())
364                }
365                other => Err(at(EngineError::malformed(format!(
366                    "expected a matrix, found {}",
367                    other.kind_name()
368                )))),
369            },
370            ValueSchema::Expression { .. } => match value {
371                Value::Text(_) => Ok(()),
372                other => Err(at(EngineError::malformed(format!(
373                    "expected an expression string, found {}",
374                    other.kind_name()
375                )))),
376            },
377            ValueSchema::Bound => match value {
378                Value::Bound(_) => Ok(()),
379                other => Err(at(EngineError::malformed(format!(
380                    "expected a bound, found {}",
381                    other.kind_name()
382                )))),
383            },
384            ValueSchema::Any => Ok(()),
385        }
386    }
387
388    /// Coerce raw JSON into a typed value, applying the documented shorthand
389    /// rules: JSON integers/decimals are exact, and when `numeric_shorthand` is
390    /// true a string is parsed as a numeric literal for numeric schemas.
391    pub fn coerce(
392        &self,
393        raw: &serde_json::Value,
394        path: &str,
395        limits: &Limits,
396        numeric_shorthand: bool,
397    ) -> Result<Value, EngineError> {
398        match (self, raw) {
399            (ValueSchema::Number { kind }, serde_json::Value::String(text)) => {
400                if !numeric_shorthand {
401                    return Err(EngineError::malformed(
402                        "string shorthand is not allowed for this parameter",
403                    )
404                    .with_path(path.to_string()));
405                }
406                let number = Number::parse_literal(text, limits)
407                    .map_err(|e| e.with_path(path.to_string()))?;
408                if !kind.accepts(&number) {
409                    return Err(EngineError::malformed(format!(
410                        "expected {}, parsed shorthand is {}",
411                        kind.label(),
412                        number.kind_name()
413                    ))
414                    .with_path(path.to_string()));
415                }
416                Ok(Value::Number(number))
417            }
418            (ValueSchema::Array { items, .. }, serde_json::Value::Array(raw_items)) => {
419                let mut out = Vec::with_capacity(raw_items.len());
420                for (index, item) in raw_items.iter().enumerate() {
421                    out.push(items.coerce(
422                        item,
423                        &format!("{path}[{index}]"),
424                        limits,
425                        numeric_shorthand,
426                    )?);
427                }
428                let value = Value::Array(out);
429                self.validate(&value, path, limits)?;
430                Ok(value)
431            }
432            (
433                ValueSchema::Record {
434                    fields,
435                    allow_extra,
436                },
437                serde_json::Value::Object(object),
438            ) => {
439                let mut out = BTreeMap::new();
440                for (key, raw_value) in object {
441                    if key == RESERVED_KIND_KEY {
442                        return Err(EngineError::malformed(
443                            "record key \"kind\" is reserved for tagged values",
444                        )
445                        .with_path(path.to_string()));
446                    }
447                    let field_schema = fields
448                        .iter()
449                        .find(|f| &f.name == key)
450                        .map(|f| &f.schema)
451                        .or(if *allow_extra {
452                            Some(&ValueSchema::Any)
453                        } else {
454                            None
455                        });
456                    let Some(field_schema) = field_schema else {
457                        return Err(EngineError::malformed(format!("unknown field {key:?}"))
458                            .with_path(path.to_string()));
459                    };
460                    out.insert(
461                        key.clone(),
462                        field_schema.coerce(
463                            raw_value,
464                            &format!("{path}.{key}"),
465                            limits,
466                            numeric_shorthand,
467                        )?,
468                    );
469                }
470                let value = Value::Record(out);
471                self.validate(&value, path, limits)?;
472                Ok(value)
473            }
474            _ => {
475                let value =
476                    Value::from_json(raw, limits).map_err(|e| e.with_path(path.to_string()))?;
477                self.validate(&value, path, limits)?;
478                Ok(value)
479            }
480        }
481    }
482
483    /// True when this schema accepts exact numeric payloads and therefore may
484    /// receive string shorthand in expressions/requests.
485    pub fn is_numeric(&self) -> bool {
486        matches!(self, ValueSchema::Number { .. })
487    }
488
489    /// A short human-readable description, used in generated documentation.
490    pub fn summary(&self) -> String {
491        match self {
492            ValueSchema::Number { kind } => kind.label().to_string(),
493            ValueSchema::Bool => "boolean".to_string(),
494            ValueSchema::Text { .. } => "text".to_string(),
495            ValueSchema::Enum { variants } => format!("one of {variants:?}"),
496            ValueSchema::Array { items, .. } => format!("array of {}", items.summary()),
497            ValueSchema::Record { fields, .. } => {
498                let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
499                format!("record with fields {}", names.join(", "))
500            }
501            ValueSchema::Quantity { dimension, .. } => match dimension {
502                Some(dimension) => format!("quantity [{dimension}]"),
503                None => "quantity".to_string(),
504            },
505            ValueSchema::Money => "money".to_string(),
506            ValueSchema::Matrix { .. } => "matrix".to_string(),
507            ValueSchema::Expression { variables } => {
508                format!("restricted expression in {}", variables.join(", "))
509            }
510            ValueSchema::Bound => "bound".to_string(),
511            ValueSchema::Any => "any value".to_string(),
512        }
513    }
514
515    /// Convert to a JSON-schema-like description used in MCP tool definitions.
516    pub fn to_json_schema(&self) -> serde_json::Value {
517        use serde_json::json;
518        match self {
519            ValueSchema::Number { kind } => json!({
520                "description": kind.label(),
521                "oneOf": [
522                    {"type": "object", "properties": {"kind": {"const": "integer"}, "value": {"type": "string"}}, "required": ["kind", "value"]},
523                    {"type": "object", "properties": {"kind": {"const": "rational"}, "numerator": {"type": "string"}, "denominator": {"type": "string"}}, "required": ["kind", "numerator", "denominator"]},
524                    {"type": "object", "properties": {"kind": {"const": "decimal"}, "value": {"type": "string"}}, "required": ["kind", "value"]},
525                    {"type": "object", "properties": {"kind": {"const": "float64"}, "value": {"type": "string"}}, "required": ["kind", "value"]},
526                    {"type": "string", "description": "numeric shorthand string (for declared numeric parameters)"}
527                ]
528            }),
529            ValueSchema::Bool => json!({"type": "boolean"}),
530            ValueSchema::Text { .. } => json!({"type": "string"}),
531            ValueSchema::Enum { variants } => json!({"type": "string", "enum": variants}),
532            ValueSchema::Array {
533                items,
534                min_len,
535                max_len,
536            } => {
537                let mut schema = json!({"type": "array", "items": items.to_json_schema()});
538                if let Some(min_len) = min_len {
539                    schema["minItems"] = json!(min_len);
540                }
541                if let Some(max_len) = max_len {
542                    schema["maxItems"] = json!(max_len);
543                }
544                schema
545            }
546            ValueSchema::Record {
547                fields,
548                allow_extra,
549            } => {
550                let mut properties = serde_json::Map::new();
551                let mut required = Vec::new();
552                for field in fields {
553                    let mut schema = field.schema.to_json_schema();
554                    if !field.description.is_empty() {
555                        schema["description"] = json!(field.description);
556                    }
557                    properties.insert(field.name.clone(), schema);
558                    if field.required {
559                        required.push(serde_json::Value::String(field.name.clone()));
560                    }
561                }
562                json!({
563                    "type": "object",
564                    "properties": properties,
565                    "required": required,
566                    "additionalProperties": *allow_extra,
567                })
568            }
569            ValueSchema::Quantity { .. } => json!({
570                "type": "object",
571                "description": "quantity: {kind: quantity, value: number, dimension: {...}}",
572            }),
573            ValueSchema::Money => json!({
574                "type": "object",
575                "description": "money: {kind: money, amount: number, currency: \"USD\"}",
576            }),
577            ValueSchema::Matrix { .. } => json!({
578                "type": "object",
579                "description": "matrix: {kind: matrix, rows: n, cols: m, data: [numbers]}",
580            }),
581            ValueSchema::Expression { variables } => json!({
582                "type": "string",
583                "description": format!("restricted expression using variables {}", variables.join(", ")),
584            }),
585            ValueSchema::Bound => json!({
586                "type": "object",
587                "description": "bound: {kind: bound, unbounded: true} or {kind: bound, value: number}",
588            }),
589            ValueSchema::Any => json!({}),
590        }
591    }
592}
593
594/// Convenience: an exact-number array schema.
595pub fn exact_number_array() -> ValueSchema {
596    ValueSchema::array(ValueSchema::exact())
597}
598
599/// Convenience: a finite float64 array schema.
600pub fn float64_array() -> ValueSchema {
601    ValueSchema::array(ValueSchema::float64())
602}
603
604/// Convenience: a positive integer schema (validated by the function).
605pub fn integer() -> ValueSchema {
606    ValueSchema::number(NumberKind::Integer)
607}
608
609/// Convenience: a finite scalar schema.
610pub fn finite_scalar() -> ValueSchema {
611    ValueSchema::number(NumberKind::Any)
612}
613
614/// Convenience: an unbounded finite bound value.
615pub fn bound() -> ValueSchema {
616    ValueSchema::Bound
617}
618
619/// Extract a required dimension from a value, for unit-aware functions.
620pub fn expect_dimension(value: &Value, expected: Dimension) -> Result<(), EngineError> {
621    match value {
622        Value::Quantity { dimension, .. } if *dimension == expected => Ok(()),
623        Value::Quantity { dimension, .. } => Err(EngineError::new(
624            ErrorCode::IncompatibleUnits,
625            format!("expected dimension {expected}, found {dimension}"),
626        )),
627        other => Err(EngineError::malformed(format!(
628            "expected a quantity, found {}",
629            other.kind_name()
630        ))),
631    }
632}
633
634/// Extract a finite bound as a concrete endpoint, or `None` when unbounded.
635pub fn bound_endpoint(value: &Value) -> Result<Option<&Number>, EngineError> {
636    match value {
637        Value::Bound(Bound::Unbounded) => Ok(None),
638        Value::Bound(Bound::Finite(number)) => Ok(Some(number)),
639        other => Err(EngineError::malformed(format!(
640            "expected a bound, found {}",
641            other.kind_name()
642        ))),
643    }
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649
650    fn limits() -> Limits {
651        Limits::conservative()
652    }
653
654    #[test]
655    fn shorthand_is_only_allowed_when_declared() {
656        let schema = ValueSchema::exact();
657        let coerced = schema
658            .coerce(&serde_json::json!("0.10"), "x", &limits(), true)
659            .unwrap();
660        assert_eq!(
661            serde_json::to_value(&coerced).unwrap(),
662            serde_json::json!({"kind": "decimal", "value": "0.10"})
663        );
664        assert!(
665            schema
666                .coerce(&serde_json::json!("0.10"), "x", &limits(), false)
667                .is_err()
668        );
669    }
670
671    #[test]
672    fn float_is_rejected_for_exact_schema() {
673        let schema = ValueSchema::exact();
674        let raw = serde_json::json!({"kind": "float64", "value": "0.1"});
675        let err = schema.coerce(&raw, "x", &limits(), true).unwrap_err();
676        assert_eq!(err.code, ErrorCode::MalformedInput);
677    }
678
679    #[test]
680    fn unknown_record_fields_are_rejected() {
681        let schema = ValueSchema::Record {
682            fields: vec![FieldSchema::required("a", ValueSchema::exact())],
683            allow_extra: false,
684        };
685        let err = schema
686            .coerce(&serde_json::json!({"a": 1, "b": 2}), "r", &limits(), true)
687            .unwrap_err();
688        assert!(err.message.contains("unknown field"));
689    }
690}