Skip to main content

sim_codec_json/
projection.rs

1//! Mode-aware Expr <-> JSON projections.
2//!
3//! `sim-codec-json` offers more than one way to map the kernel `Expr` graph
4//! onto `serde_json::Value`. The canonical, lossless projection lives in
5//! [`crate::expr_json`] and uses `$expr` tags so that every `Expr` round-trips
6//! exactly. Some interop surfaces (notably third-party LLM tool schemas) need a
7//! plain, untagged JSON shape instead. [`JsonProjectionMode`] selects which
8//! projection to apply.
9
10use serde_json::{Map, Value as JsonValue};
11use sim_codec::DecodeBudget;
12use sim_kernel::{CodecId, Expr, NumberLiteral, Result, Symbol};
13
14use crate::expr_json;
15
16/// Selects which `Expr <-> JSON` projection to apply.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum JsonProjectionMode {
19    /// The canonical, lossless projection: `$expr`-tagged forms that round-trip
20    /// every `Expr` exactly. Delegates to [`crate::expr_to_json`] /
21    /// [`crate::json_to_expr`].
22    TaggedCanonical,
23    /// A lossy interop projection that emits/accepts plain, untagged JSON.
24    ///
25    /// This is intended for foreign surfaces (for example LLM tool schemas and
26    /// tool-call arguments) that expect ordinary JSON without kernel tags. It is
27    /// NOT the canonical codec: structure such as symbols vs strings, number
28    /// domains, vectors vs lists, and most non-collection forms is collapsed and
29    /// cannot be recovered. Use [`JsonProjectionMode::TaggedCanonical`] whenever
30    /// faithful round-tripping is required.
31    UntaggedInterop,
32    /// A one-way, lossy text projection: `Expr -> JSON` produces a JSON string
33    /// containing the debug rendering of the expression. There is no faithful
34    /// inverse; `JSON -> Expr` reuses the [`JsonProjectionMode::UntaggedInterop`]
35    /// decoder.
36    TextLossy,
37}
38
39/// Reads a JSON number as `u64`, accepting an unsigned literal directly or a
40/// non-negative signed literal. This is the one home for the provider
41/// token-count parser that the OpenAI server, chat codec, and HTTP runner each
42/// re-grew.
43pub fn json_number_to_u64(value: &JsonValue) -> Option<u64> {
44    value
45        .as_u64()
46        .or_else(|| value.as_i64().and_then(|n| u64::try_from(n).ok()))
47}
48
49/// Projects an `Expr` to a `serde_json::Value` using the given mode.
50pub fn project_expr_to_json(expr: &Expr, mode: JsonProjectionMode) -> JsonValue {
51    match mode {
52        JsonProjectionMode::TaggedCanonical => expr_json::expr_to_json(expr),
53        JsonProjectionMode::UntaggedInterop => untagged_expr_to_json(expr),
54        JsonProjectionMode::TextLossy => JsonValue::String(format!("{expr:?}")),
55    }
56}
57
58/// Projects a `serde_json::Value` to an `Expr` using the given mode.
59///
60/// [`JsonProjectionMode::TaggedCanonical`] requires `$expr`-tagged input and is
61/// fallible at the codec layer; this infallible entry point is only defined for
62/// the lossy projections. For tagged canonical decoding use
63/// [`crate::json_to_expr`] directly (it threads a decode budget and returns a
64/// `Result`).
65///
66/// Both [`JsonProjectionMode::UntaggedInterop`] and
67/// [`JsonProjectionMode::TextLossy`] decode plain JSON via the untagged
68/// projection.
69pub fn project_json_to_expr(value: &JsonValue, mode: JsonProjectionMode) -> Expr {
70    match mode {
71        // All infallible decode modes share the untagged decoder. Tagged
72        // canonical decoding is fallible and lives behind `crate::json_to_expr`.
73        JsonProjectionMode::TaggedCanonical
74        | JsonProjectionMode::UntaggedInterop
75        | JsonProjectionMode::TextLossy => untagged_json_to_expr(value),
76    }
77}
78
79/// Budget-aware variant of [`project_json_to_expr`].
80///
81/// Decodes through the untagged projection like [`project_json_to_expr`] but
82/// charges every produced node, collection length, and string length against
83/// `budget`, so a hostile provider response cannot exhaust host resources. The
84/// `mode` is accepted for signature parity; every decode mode shares the
85/// untagged decoder.
86pub fn project_json_to_expr_budgeted(
87    value: &JsonValue,
88    _mode: JsonProjectionMode,
89    codec: CodecId,
90    budget: &mut DecodeBudget,
91    depth: usize,
92) -> Result<Expr> {
93    budget.enter_node(codec, depth)?;
94    match value {
95        JsonValue::Null => Ok(Expr::Nil),
96        JsonValue::Bool(value) => Ok(Expr::Bool(*value)),
97        JsonValue::Number(number) => Ok(Expr::Number(NumberLiteral {
98            domain: Symbol::qualified("numbers", "f64"),
99            canonical: number.to_string(),
100        })),
101        JsonValue::String(value) => {
102            budget.check_string_bytes(codec, value.len())?;
103            Ok(Expr::String(value.clone()))
104        }
105        JsonValue::Array(items) => {
106            budget.check_collection_len(codec, items.len())?;
107            let items = items
108                .iter()
109                .map(|item| {
110                    project_json_to_expr_budgeted(
111                        item,
112                        JsonProjectionMode::UntaggedInterop,
113                        codec,
114                        budget,
115                        depth + 1,
116                    )
117                })
118                .collect::<Result<Vec<_>>>()?;
119            Ok(Expr::List(items))
120        }
121        JsonValue::Object(entries) => {
122            budget.check_collection_len(codec, entries.len())?;
123            let entries = entries
124                .iter()
125                .map(|(key, value)| {
126                    Ok((
127                        Expr::Symbol(Symbol::new(key.clone())),
128                        project_json_to_expr_budgeted(
129                            value,
130                            JsonProjectionMode::UntaggedInterop,
131                            codec,
132                            budget,
133                            depth + 1,
134                        )?,
135                    ))
136                })
137                .collect::<Result<Vec<_>>>()?;
138            Ok(Expr::Map(entries))
139        }
140    }
141}
142
143// The bodies below produce the OpenAI server projection
144// byte-for-byte. Do not "improve" them: foreign tool-schema golden tests depend
145// on this exact untagged shape.
146
147fn untagged_expr_to_json(expr: &Expr) -> JsonValue {
148    match expr {
149        Expr::Nil => JsonValue::Null,
150        Expr::Bool(value) => JsonValue::Bool(*value),
151        Expr::Number(number) => serde_json::from_str(&number.canonical)
152            .unwrap_or_else(|_| JsonValue::String(number.canonical.clone())),
153        Expr::String(value) => JsonValue::String(value.clone()),
154        Expr::Symbol(symbol) | Expr::Local(symbol) => JsonValue::String(symbol.to_string()),
155        Expr::List(items) | Expr::Vector(items) => {
156            JsonValue::Array(items.iter().map(untagged_expr_to_json).collect())
157        }
158        Expr::Map(entries) => {
159            let mut object = Map::new();
160            for (key, value) in entries {
161                object.insert(untagged_expr_key(key), untagged_expr_to_json(value));
162            }
163            JsonValue::Object(object)
164        }
165        other => JsonValue::String(format!("{other:?}")),
166    }
167}
168
169fn untagged_expr_key(expr: &Expr) -> String {
170    match expr {
171        Expr::String(value) => value.clone(),
172        Expr::Symbol(symbol) | Expr::Local(symbol) => symbol.as_qualified_str(),
173        other => format!("{other:?}"),
174    }
175}
176
177fn untagged_json_to_expr(value: &JsonValue) -> Expr {
178    match value {
179        JsonValue::Null => Expr::Nil,
180        JsonValue::Bool(value) => Expr::Bool(*value),
181        JsonValue::Number(number) => Expr::Number(NumberLiteral {
182            domain: Symbol::qualified("numbers", "f64"),
183            canonical: number.to_string(),
184        }),
185        JsonValue::String(value) => Expr::String(value.clone()),
186        JsonValue::Array(items) => Expr::List(items.iter().map(untagged_json_to_expr).collect()),
187        JsonValue::Object(entries) => Expr::Map(
188            entries
189                .iter()
190                .map(|(key, value)| {
191                    (
192                        Expr::Symbol(Symbol::new(key.clone())),
193                        untagged_json_to_expr(value),
194                    )
195                })
196                .collect(),
197        ),
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn untagged_roundtrips_plain_object() {
207        let expr = Expr::Map(vec![
208            (
209                Expr::Symbol(Symbol::new("name")),
210                Expr::String("ada".to_owned()),
211            ),
212            (Expr::Symbol(Symbol::new("flag")), Expr::Bool(true)),
213        ]);
214        let json = project_expr_to_json(&expr, JsonProjectionMode::UntaggedInterop);
215        assert_eq!(json["name"], JsonValue::String("ada".to_owned()));
216        assert_eq!(json["flag"], JsonValue::Bool(true));
217    }
218
219    #[test]
220    fn text_lossy_is_debug_string() {
221        let expr = Expr::Bool(true);
222        let json = project_expr_to_json(&expr, JsonProjectionMode::TextLossy);
223        assert_eq!(json, JsonValue::String(format!("{expr:?}")));
224    }
225}