1use 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
10pub fn is_model_request_expr(expr: &Expr) -> bool {
13 marker_is_true(expr, "model-request")
14}
15
16pub 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
29pub 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
49pub 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
71pub 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
117pub 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}