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 sim_kernel::{Error, Expr, Result, Symbol};
6
7/// Returns `true` when `expr` is a chat transcript map carrying a true
8/// `model-request` marker.
9pub fn is_model_request_expr(expr: &Expr) -> bool {
10    marker_is_true(expr, "model-request")
11}
12
13/// Returns the `messages` list of a model-request transcript, erroring if
14/// `expr` is not a valid request or its `messages` field is not a list.
15pub fn model_request_messages_expr(expr: &Expr) -> Result<&[Expr]> {
16    validate_request(expr)?;
17    match field(expr, "messages") {
18        Some(Expr::List(messages)) => Ok(messages),
19        _ => Err(chat_eval("model request messages field must be a list")),
20    }
21}
22
23/// Builds a model-response transcript map from a `runner`, `model` name,
24/// `content` parts, and `stop_reason`.
25///
26/// The result carries a true `model-response` marker and validates under
27/// [`validate_chat_transcript`].
28pub fn model_response_expr(
29    runner: Symbol,
30    model: impl Into<String>,
31    content: Vec<Expr>,
32    stop_reason: Symbol,
33) -> Expr {
34    Expr::Map(vec![
35        key_bool("model-response", true),
36        key_expr("runner", Expr::Symbol(runner)),
37        key_expr("model", Expr::String(model.into())),
38        key_expr("content", Expr::List(content)),
39        key_expr("stop-reason", Expr::Symbol(stop_reason)),
40    ])
41}
42
43/// Builds a model-response transcript carrying an error: a single text content
44/// part holding `message`, a `stop-reason` of `error`, and `text`/`shape-ok`
45/// fields recording the failure.
46pub fn model_error_expr(
47    runner: Symbol,
48    model: impl Into<String>,
49    message: impl Into<String>,
50) -> Expr {
51    let text = message.into();
52    let content = vec![Expr::Map(vec![
53        key_expr("type", Expr::Symbol(Symbol::new("text"))),
54        key_expr("text", Expr::String(text.clone())),
55    ])];
56    let mut response = match model_response_expr(runner, model, content, Symbol::new("error")) {
57        Expr::Map(entries) => entries,
58        _ => unreachable!("model_response_expr always returns a map"),
59    };
60    response.push(key_expr("text", Expr::String(text)));
61    response.push(key_bool("shape-ok", false));
62    Expr::Map(response)
63}
64
65/// Builds a model-card transcript map describing a model's identity and
66/// capabilities (provider, locality, modalities, and stream/tool/JSON/shape
67/// support), with conservative defaults the caller can override.
68///
69/// # Examples
70///
71/// ```
72/// use sim_codec_chat::{model_card_expr, validate_chat_transcript};
73/// use sim_kernel::Symbol;
74///
75/// let card = model_card_expr(
76///     Symbol::new("local-reasoner"),
77///     "qwen2.5-coder:14b",
78///     Symbol::new("ollama"),
79///     Symbol::new("local"),
80/// );
81/// assert!(validate_chat_transcript(&card).is_ok());
82/// ```
83pub fn model_card_expr(
84    runner: Symbol,
85    model: impl Into<String>,
86    provider: Symbol,
87    locality: Symbol,
88) -> Expr {
89    Expr::Map(vec![
90        key_bool("model-card", true),
91        key_expr("runner", Expr::Symbol(runner)),
92        key_expr("model", Expr::String(model.into())),
93        key_expr("provider", Expr::Symbol(provider)),
94        key_expr("locality", Expr::Symbol(locality)),
95        key_expr(
96            "modalities-in",
97            Expr::List(vec![Expr::Symbol(Symbol::new("text"))]),
98        ),
99        key_expr(
100            "modalities-out",
101            Expr::List(vec![Expr::Symbol(Symbol::new("text"))]),
102        ),
103        key_bool("supports-stream", false),
104        key_bool("supports-tools", false),
105        key_bool("supports-json", true),
106        key_bool("supports-shape", false),
107        key_expr("health", Expr::Symbol(Symbol::new("unknown"))),
108    ])
109}
110
111/// Validates that `expr` is a well-formed chat transcript and fails closed
112/// otherwise.
113///
114/// A transcript must be an `Expr::Map` with exactly one true marker among
115/// `model-request`, `model-response`, `model-event`, and `model-card`; the
116/// matching variant's required fields are then checked. This is the domain
117/// gate the codec runs on both decode and encode.
118///
119/// # Examples
120///
121/// ```
122/// use sim_codec_chat::{model_response_expr, validate_chat_transcript};
123/// use sim_kernel::Symbol;
124///
125/// let response = model_response_expr(
126///     Symbol::new("local-reasoner"),
127///     "qwen2.5-coder:14b",
128///     Vec::new(),
129///     Symbol::new("stop"),
130/// );
131/// assert!(validate_chat_transcript(&response).is_ok());
132/// ```
133pub fn validate_chat_transcript(expr: &Expr) -> Result<()> {
134    let markers = [
135        marker_is_true(expr, "model-request"),
136        marker_is_true(expr, "model-response"),
137        marker_is_true(expr, "model-event"),
138        marker_is_true(expr, "model-card"),
139    ]
140    .into_iter()
141    .filter(|value| *value)
142    .count();
143
144    if markers != 1 {
145        return Err(chat_eval(
146            "chat transcript must have exactly one true model-request, model-response, model-event, or model-card marker",
147        ));
148    }
149
150    if marker_is_true(expr, "model-request") {
151        validate_request(expr)
152    } else if marker_is_true(expr, "model-response") {
153        validate_response(expr)
154    } else if marker_is_true(expr, "model-event") {
155        validate_event(expr)
156    } else {
157        validate_card(expr)
158    }
159}
160
161fn validate_request(expr: &Expr) -> Result<()> {
162    require_map(expr)?;
163    require_field(expr, "task")?;
164    require_list_field(expr, "messages")?;
165    if let Some(Expr::List(messages)) = field(expr, "messages") {
166        for message in messages {
167            validate_message(message)?;
168        }
169    }
170    Ok(())
171}
172
173fn validate_response(expr: &Expr) -> Result<()> {
174    require_symbol_field(expr, "runner")?;
175    require_string_field(expr, "model")?;
176    require_symbol_field(expr, "stop-reason")?;
177    validate_content_list(expr, "content")
178}
179
180fn validate_event(expr: &Expr) -> Result<()> {
181    require_symbol_field(expr, "event")?;
182    require_symbol_field(expr, "runner")?;
183    require_string_field(expr, "model")?;
184    require_field(expr, "span-id")?;
185    Ok(())
186}
187
188fn validate_card(expr: &Expr) -> Result<()> {
189    require_symbol_field(expr, "runner")?;
190    require_string_field(expr, "model")?;
191    require_symbol_field(expr, "provider")?;
192    require_symbol_field(expr, "locality")?;
193    Ok(())
194}
195
196fn validate_message(expr: &Expr) -> Result<()> {
197    require_symbol_field(expr, "role")?;
198    validate_content_list(expr, "content")
199}
200
201fn validate_content_list(expr: &Expr, field_name: &'static str) -> Result<()> {
202    let content = require_list_field(expr, field_name)?;
203    for part in content {
204        require_symbol_field(part, "type")?;
205    }
206    Ok(())
207}
208
209fn require_map(expr: &Expr) -> Result<&[(Expr, Expr)]> {
210    match expr {
211        Expr::Map(entries) => Ok(entries),
212        _ => Err(chat_eval("chat transcript must be an Expr::Map")),
213    }
214}
215
216fn require_field<'a>(expr: &'a Expr, name: &'static str) -> Result<&'a Expr> {
217    field(expr, name).ok_or_else(|| chat_eval(format!("chat transcript missing {name} field")))
218}
219
220fn require_symbol_field<'a>(expr: &'a Expr, name: &'static str) -> Result<&'a Symbol> {
221    match require_field(expr, name)? {
222        Expr::Symbol(symbol) => Ok(symbol),
223        _ => Err(chat_eval(format!(
224            "chat transcript {name} field must be a symbol"
225        ))),
226    }
227}
228
229fn require_string_field<'a>(expr: &'a Expr, name: &'static str) -> Result<&'a str> {
230    match require_field(expr, name)? {
231        Expr::String(text) => Ok(text),
232        _ => Err(chat_eval(format!(
233            "chat transcript {name} field must be a string"
234        ))),
235    }
236}
237
238fn require_list_field<'a>(expr: &'a Expr, name: &'static str) -> Result<&'a [Expr]> {
239    match require_field(expr, name)? {
240        Expr::List(items) => Ok(items),
241        _ => Err(chat_eval(format!(
242            "chat transcript {name} field must be a list"
243        ))),
244    }
245}
246
247use sim_value::access::field;
248
249fn marker_is_true(expr: &Expr, name: &str) -> bool {
250    matches!(field(expr, name), Some(Expr::Bool(true)))
251}
252
253fn key_bool(name: &str, value: bool) -> (Expr, Expr) {
254    key_expr(name, Expr::Bool(value))
255}
256
257fn key_expr(name: &str, value: Expr) -> (Expr, Expr) {
258    (Expr::Symbol(Symbol::new(name.to_owned())), value)
259}
260
261fn chat_eval(message: impl Into<String>) -> Error {
262    Error::Eval(message.into())
263}