Skip to main content

sim_codec_chat/
helpers.rs

1//! Constructors and validators for chat transcript `Expr` values (model
2//! request, response, error, and card maps) plus the shared
3//! `validate_chat_transcript` shape check used by the codec.
4
5use std::collections::BTreeSet;
6
7use sim_kernel::{Error, Expr, Result, Symbol};
8use sim_value::access::{entry_field, entry_field_any, field as expr_field};
9
10/// Returns `true` when `expr` is a chat transcript map carrying a true
11/// `model-request` marker.
12pub fn is_model_request_expr(expr: &Expr) -> bool {
13    marker_is_true(expr, "model-request")
14}
15
16/// Returns the `messages` list of a model-request transcript, erroring if
17/// `expr` is not a valid request or its `messages` field is not a list.
18pub fn model_request_messages_expr(expr: &Expr) -> Result<&[Expr]> {
19    validate_chat_transcript(expr)?;
20    if !is_model_request_expr(expr) {
21        return Err(chat_eval("expr must be a model-request transcript"));
22    }
23    match expr_field(expr, "messages") {
24        Some(Expr::List(messages)) => Ok(messages),
25        _ => Err(chat_eval("model request messages field must be a list")),
26    }
27}
28
29/// Builds a model-response transcript map from a `runner`, `model` name,
30/// `content` parts, and `stop_reason`.
31///
32/// The result carries a true `model-response` marker and validates under
33/// [`validate_chat_transcript`].
34pub fn model_response_expr(
35    runner: Symbol,
36    model: impl Into<String>,
37    content: Vec<Expr>,
38    stop_reason: Symbol,
39) -> Expr {
40    Expr::Map(vec![
41        key_bool("model-response", true),
42        key_expr("runner", Expr::Symbol(runner)),
43        key_expr("model", Expr::String(model.into())),
44        key_expr("content", Expr::List(content)),
45        key_expr("stop-reason", Expr::Symbol(stop_reason)),
46    ])
47}
48
49/// Builds a model-response transcript carrying an error: a single text content
50/// part holding `message`, a `stop-reason` of `error`, and `text`/`shape-ok`
51/// fields recording the failure.
52pub fn model_error_expr(
53    runner: Symbol,
54    model: impl Into<String>,
55    message: impl Into<String>,
56) -> Expr {
57    let text = message.into();
58    let content = vec![Expr::Map(vec![
59        key_expr("type", Expr::Symbol(Symbol::new("text"))),
60        key_expr("text", Expr::String(text.clone())),
61    ])];
62    let mut response = match model_response_expr(runner, model, content, Symbol::new("error")) {
63        Expr::Map(entries) => entries,
64        _ => unreachable!("model_response_expr always returns a map"),
65    };
66    response.push(key_expr("text", Expr::String(text)));
67    response.push(key_bool("shape-ok", false));
68    Expr::Map(response)
69}
70
71/// Builds a model-card transcript map describing a model's identity and
72/// capabilities (provider, locality, modalities, and stream/tool/JSON/shape
73/// support), with conservative defaults the caller can override.
74///
75/// # Examples
76///
77/// ```
78/// use sim_codec_chat::{model_card_expr, validate_chat_transcript};
79/// use sim_kernel::Symbol;
80///
81/// let card = model_card_expr(
82///     Symbol::new("local-reasoner"),
83///     "qwen2.5-coder:14b",
84///     Symbol::new("ollama"),
85///     Symbol::new("local"),
86/// );
87/// assert!(validate_chat_transcript(&card).is_ok());
88/// ```
89pub fn model_card_expr(
90    runner: Symbol,
91    model: impl Into<String>,
92    provider: Symbol,
93    locality: Symbol,
94) -> Expr {
95    Expr::Map(vec![
96        key_bool("model-card", true),
97        key_expr("runner", Expr::Symbol(runner)),
98        key_expr("model", Expr::String(model.into())),
99        key_expr("provider", Expr::Symbol(provider)),
100        key_expr("locality", Expr::Symbol(locality)),
101        key_expr(
102            "modalities-in",
103            Expr::List(vec![Expr::Symbol(Symbol::new("text"))]),
104        ),
105        key_expr(
106            "modalities-out",
107            Expr::List(vec![Expr::Symbol(Symbol::new("text"))]),
108        ),
109        key_bool("supports-stream", false),
110        key_bool("supports-tools", false),
111        key_bool("supports-json", true),
112        key_bool("supports-shape", false),
113        key_expr("health", Expr::Symbol(Symbol::new("unknown"))),
114    ])
115}
116
117/// Validates that `expr` is a well-formed chat transcript and fails closed
118/// otherwise.
119///
120/// A transcript must be an `Expr::Map` with exactly one true marker among
121/// `model-request`, `model-response`, `model-event`, and `model-card`; the
122/// matching variant's required fields are then checked. This is the domain
123/// gate the codec runs on both decode and encode.
124///
125/// The validator owns a finite field set for each transcript variant and
126/// rejects duplicates in that set. Nested canonical records such as messages,
127/// content parts, tool-call arguments, usage, and provider raw projections are
128/// strict over their bare-symbol keys. Qualified symbols, non-symbol keys, and
129/// model-card advisory fields beyond runner/model/provider/locality stay open
130/// extension space.
131///
132/// # Examples
133///
134/// ```
135/// use sim_codec_chat::{model_response_expr, validate_chat_transcript};
136/// use sim_kernel::Symbol;
137///
138/// let response = model_response_expr(
139///     Symbol::new("local-reasoner"),
140///     "qwen2.5-coder:14b",
141///     Vec::new(),
142///     Symbol::new("stop"),
143/// );
144/// assert!(validate_chat_transcript(&response).is_ok());
145/// ```
146pub fn validate_chat_transcript(expr: &Expr) -> Result<()> {
147    let entries = require_map(expr, "chat transcript")?;
148    reject_duplicate_bare_fields(
149        entries,
150        "chat transcript",
151        &[
152            "model-request",
153            "model-response",
154            "model-event",
155            "model-card",
156        ],
157    )?;
158    let markers = [
159        marker_is_true_in(entries, "model-request"),
160        marker_is_true_in(entries, "model-response"),
161        marker_is_true_in(entries, "model-event"),
162        marker_is_true_in(entries, "model-card"),
163    ]
164    .into_iter()
165    .filter(|value| *value)
166    .count();
167
168    if markers != 1 {
169        return Err(chat_eval(
170            "chat transcript must have exactly one true model-request, model-response, model-event, or model-card marker",
171        ));
172    }
173
174    if marker_is_true_in(entries, "model-request") {
175        validate_request(entries)
176    } else if marker_is_true_in(entries, "model-response") {
177        validate_response(entries)
178    } else if marker_is_true_in(entries, "model-event") {
179        validate_event(entries)
180    } else {
181        validate_card(entries)
182    }
183}
184
185fn validate_request(entries: &[(Expr, Expr)]) -> Result<()> {
186    reject_duplicate_bare_fields(
187        entries,
188        "chat transcript",
189        &[
190            "model-request",
191            "task",
192            "messages",
193            "budget",
194            "max-tokens",
195            "tools",
196            "tool-choice",
197        ],
198    )?;
199    require_field(entries, "task")?;
200    for message in require_list_field(entries, "messages")? {
201        validate_message(message)?;
202    }
203    validate_optional_projected_field(entries, "budget")?;
204    validate_optional_projected_field(entries, "max-tokens")?;
205    validate_optional_projected_field(entries, "tools")?;
206    validate_optional_projected_field(entries, "tool-choice")?;
207    Ok(())
208}
209
210fn validate_response(entries: &[(Expr, Expr)]) -> Result<()> {
211    reject_duplicate_bare_fields(
212        entries,
213        "chat transcript",
214        &[
215            "model-response",
216            "runner",
217            "model",
218            "stop-reason",
219            "content",
220            "usage",
221            "raw-provider-response",
222        ],
223    )?;
224    require_symbol_field(entries, "runner")?;
225    require_string_field(entries, "model")?;
226    require_symbol_field(entries, "stop-reason")?;
227    validate_content_list(entries, "content")?;
228    validate_optional_usage(entries)?;
229    validate_optional_projected_field(entries, "raw-provider-response")
230}
231
232fn validate_event(entries: &[(Expr, Expr)]) -> Result<()> {
233    reject_duplicate_bare_fields(
234        entries,
235        "chat transcript",
236        &[
237            "model-event",
238            "event",
239            "runner",
240            "model",
241            "span-id",
242            "tool-call",
243            "tool-result",
244            "usage",
245            "response",
246            "raw-provider-response",
247        ],
248    )?;
249    require_symbol_field(entries, "event")?;
250    require_symbol_field(entries, "runner")?;
251    require_string_field(entries, "model")?;
252    require_field(entries, "span-id")?;
253    if let Some(tool_call) = entry_field(entries, "tool-call") {
254        validate_projected_expr(tool_call, "chat tool-call event")?;
255    }
256    if let Some(tool_result) = entry_field(entries, "tool-result") {
257        validate_projected_expr(tool_result, "chat tool-result event")?;
258    }
259    validate_optional_usage(entries)?;
260    if let Some(response) = entry_field(entries, "response") {
261        validate_chat_transcript(response)?;
262    }
263    validate_optional_projected_field(entries, "raw-provider-response")?;
264    Ok(())
265}
266
267fn validate_card(entries: &[(Expr, Expr)]) -> Result<()> {
268    reject_duplicate_bare_fields(
269        entries,
270        "chat transcript",
271        &["model-card", "runner", "model", "provider", "locality"],
272    )?;
273    require_symbol_field(entries, "runner")?;
274    require_string_field(entries, "model")?;
275    require_symbol_field(entries, "provider")?;
276    require_symbol_field(entries, "locality")?;
277    Ok(())
278}
279
280fn validate_message(expr: &Expr) -> Result<()> {
281    let entries = require_strict_map(expr, "chat message")?;
282    require_symbol_field(entries, "role")?;
283    validate_content_list(entries, "content")
284}
285
286fn validate_content_list(entries: &[(Expr, Expr)], field_name: &'static str) -> Result<()> {
287    let content = require_list_field(entries, field_name)?;
288    for part in content {
289        validate_content_part(part)?;
290    }
291    Ok(())
292}
293
294fn validate_content_part(expr: &Expr) -> Result<()> {
295    let entries = require_strict_map(expr, "chat content part")?;
296    let kind = require_symbol_field_any(entries, "type")?;
297    if kind.namespace.is_some() {
298        validate_optional_projected_field(entries, "raw-provider-part")
299    } else {
300        match kind.name.as_ref() {
301            "text" => {
302                require_string_field_any(entries, "text")?;
303                Ok(())
304            }
305            "tool-call" => validate_tool_call(entries),
306            "tool-result" => validate_tool_result(entries),
307            _ => validate_optional_projected_field(entries, "raw-provider-part"),
308        }
309    }
310}
311
312fn validate_tool_call(entries: &[(Expr, Expr)]) -> Result<()> {
313    require_string_field_any(entries, "id")?;
314    require_string_field_any(entries, "name")?;
315    validate_projected_expr(
316        require_field_any(entries, "arguments")?,
317        "chat tool-call arguments",
318    )
319}
320
321fn validate_tool_result(entries: &[(Expr, Expr)]) -> Result<()> {
322    require_string_field_any(entries, "tool-call-id")?;
323    require_symbol_field_any(entries, "status")?;
324    validate_projected_expr(
325        require_field_any(entries, "output")?,
326        "chat tool-result output",
327    )
328}
329
330fn validate_optional_usage(entries: &[(Expr, Expr)]) -> Result<()> {
331    if let Some(usage) = entry_field(entries, "usage") {
332        validate_projected_expr(usage, "chat usage")?;
333    }
334    Ok(())
335}
336
337fn validate_optional_projected_field(entries: &[(Expr, Expr)], name: &'static str) -> Result<()> {
338    if let Some(value) = entry_field(entries, name) {
339        validate_projected_expr(value, name)?;
340    }
341    Ok(())
342}
343
344fn validate_projected_expr(expr: &Expr, context: &str) -> Result<()> {
345    match expr {
346        Expr::Map(entries) => {
347            reject_duplicate_bare_keys(entries, context)?;
348            for (_, value) in entries {
349                validate_projected_expr(value, context)?;
350            }
351        }
352        Expr::List(items) | Expr::Vector(items) | Expr::Set(items) | Expr::Block(items) => {
353            for item in items {
354                validate_projected_expr(item, context)?;
355            }
356        }
357        _ => {}
358    }
359    Ok(())
360}
361
362fn require_map<'a>(expr: &'a Expr, context: &str) -> Result<&'a [(Expr, Expr)]> {
363    match expr {
364        Expr::Map(entries) => Ok(entries),
365        _ => Err(chat_eval(format!("{context} must be an Expr::Map"))),
366    }
367}
368
369fn require_strict_map<'a>(expr: &'a Expr, context: &str) -> Result<&'a [(Expr, Expr)]> {
370    match expr {
371        Expr::Map(entries) => {
372            reject_duplicate_bare_keys(entries, context)?;
373            Ok(entries)
374        }
375        _ => Err(chat_eval(format!("{context} must be an Expr::Map"))),
376    }
377}
378
379fn reject_duplicate_bare_fields(
380    entries: &[(Expr, Expr)],
381    context: &str,
382    owned: &[&str],
383) -> Result<()> {
384    let mut seen = BTreeSet::new();
385    for (key, _) in entries {
386        if let Expr::Symbol(symbol) = key
387            && symbol.namespace.is_none()
388            && owned.contains(&symbol.name.as_ref())
389            && !seen.insert(symbol.name.as_ref())
390        {
391            return Err(chat_eval(format!(
392                "{context} duplicate {} field",
393                symbol.name.as_ref()
394            )));
395        }
396    }
397    Ok(())
398}
399
400fn reject_duplicate_bare_keys(entries: &[(Expr, Expr)], context: &str) -> Result<()> {
401    let mut seen = BTreeSet::new();
402    for (key, _) in entries {
403        if let Expr::Symbol(symbol) = key
404            && symbol.namespace.is_none()
405            && !seen.insert(symbol.name.as_ref())
406        {
407            return Err(chat_eval(format!(
408                "{context} duplicate {} field",
409                symbol.name.as_ref()
410            )));
411        }
412    }
413    Ok(())
414}
415
416fn require_field<'a>(entries: &'a [(Expr, Expr)], name: &'static str) -> Result<&'a Expr> {
417    entry_field(entries, name)
418        .ok_or_else(|| chat_eval(format!("chat transcript missing {name} field")))
419}
420
421fn require_field_any<'a>(entries: &'a [(Expr, Expr)], name: &'static str) -> Result<&'a Expr> {
422    entry_field_any(entries, name)
423        .ok_or_else(|| chat_eval(format!("chat transcript missing {name} field")))
424}
425
426fn require_symbol_field<'a>(entries: &'a [(Expr, Expr)], name: &'static str) -> Result<&'a Symbol> {
427    match require_field(entries, name)? {
428        Expr::Symbol(symbol) => Ok(symbol),
429        _ => Err(chat_eval(format!(
430            "chat transcript {name} field must be a symbol"
431        ))),
432    }
433}
434
435fn require_symbol_field_any<'a>(
436    entries: &'a [(Expr, Expr)],
437    name: &'static str,
438) -> Result<&'a Symbol> {
439    match require_field_any(entries, name)? {
440        Expr::Symbol(symbol) => Ok(symbol),
441        _ => Err(chat_eval(format!(
442            "chat transcript {name} field must be a symbol"
443        ))),
444    }
445}
446
447fn require_string_field<'a>(entries: &'a [(Expr, Expr)], name: &'static str) -> Result<&'a str> {
448    match require_field(entries, name)? {
449        Expr::String(text) => Ok(text),
450        _ => Err(chat_eval(format!(
451            "chat transcript {name} field must be a string"
452        ))),
453    }
454}
455
456fn require_string_field_any<'a>(
457    entries: &'a [(Expr, Expr)],
458    name: &'static str,
459) -> Result<&'a str> {
460    match require_field_any(entries, name)? {
461        Expr::String(text) => Ok(text),
462        _ => Err(chat_eval(format!(
463            "chat transcript {name} field must be a string"
464        ))),
465    }
466}
467
468fn require_list_field<'a>(entries: &'a [(Expr, Expr)], name: &'static str) -> Result<&'a [Expr]> {
469    match require_field(entries, name)? {
470        Expr::List(items) => Ok(items),
471        _ => Err(chat_eval(format!(
472            "chat transcript {name} field must be a list"
473        ))),
474    }
475}
476
477fn marker_is_true(expr: &Expr, name: &str) -> bool {
478    matches!(expr_field(expr, name), Some(Expr::Bool(true)))
479}
480
481fn marker_is_true_in(entries: &[(Expr, Expr)], name: &str) -> bool {
482    matches!(entry_field(entries, name), Some(Expr::Bool(true)))
483}
484
485fn key_bool(name: &str, value: bool) -> (Expr, Expr) {
486    key_expr(name, Expr::Bool(value))
487}
488
489fn key_expr(name: &str, value: Expr) -> (Expr, Expr) {
490    (Expr::Symbol(Symbol::new(name.to_owned())), value)
491}
492
493fn chat_eval(message: impl Into<String>) -> Error {
494    Error::Eval(message.into())
495}