1use sim_kernel::{Error, Expr, Result, Symbol};
6
7pub fn is_model_request_expr(expr: &Expr) -> bool {
10 marker_is_true(expr, "model-request")
11}
12
13pub 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
23pub 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
43pub 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
65pub 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
111pub 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}