1use serde::{Deserialize, Serialize};
2
3use crate::{Cursor, RequestSize, StepSource, Verdict};
4
5#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
28#[non_exhaustive]
29pub struct StepRow {
30 pub cursor: Cursor,
32 pub harness: String,
35 pub source: StepSource,
36 pub step_ref: String,
39 pub session: String,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub run_id: Option<String>,
43 pub occurred_at_ms: u64,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub provider: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub model: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub input: Option<u64>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub cache_read: Option<u64>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub cache_write: Option<u64>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub cache_write_5m: Option<u64>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub cache_write_1h: Option<u64>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub output: Option<u64>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub reasoning: Option<u64>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub finish: Option<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub context_limit: Option<u64>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub request_body_len: Option<u64>,
79 pub prompt: u64,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub retention: Option<f64>,
87 pub verdict: Verdict,
88 pub prompt_dropped: bool,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub request_size: Option<RequestSize>,
92}
93
94#[derive(Clone, Debug, PartialEq, Eq)]
97#[non_exhaustive]
98pub enum BadCounts {
99 ReasoningExceedsOutput {
101 harness: String,
102 step_ref: String,
103 reasoning: u64,
104 output: u64,
105 },
106}
107
108impl std::fmt::Display for BadCounts {
109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 match self {
111 Self::ReasoningExceedsOutput {
112 harness,
113 step_ref,
114 reasoning,
115 output,
116 } => write!(
117 f,
118 "row {harness} {step_ref}: reasoning {reasoning} exceeds output {output}"
119 ),
120 }
121 }
122}
123
124impl std::error::Error for BadCounts {}
125
126pub fn live_row_identity(
152 runner: &str,
153 harness: &str,
154 harness_step_ref: Option<&str>,
155 attempt_id: Option<&str>,
156) -> Option<(String, String)> {
157 match (harness_step_ref, attempt_id) {
158 (Some(host_record), _) => Some((harness.to_owned(), host_record.to_owned())),
159 (None, Some(attempt_id)) => Some((runner.to_owned(), attempt_id.to_owned())),
160 (None, None) => None,
161 }
162}
163
164#[derive(Clone, Debug, PartialEq, Eq)]
166#[non_exhaustive]
167pub enum NotClosed {
168 NotAnObject { object: &'static str },
170 UnknownMember {
172 object: &'static str,
173 member: String,
174 },
175}
176
177impl std::fmt::Display for NotClosed {
178 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179 match self {
180 Self::NotAnObject { object } => write!(f, "{object} is not a JSON object"),
181 Self::UnknownMember { object, member } => {
182 write!(f, "{object} carries unknown member `{member}`")
183 }
184 }
185 }
186}
187
188impl std::error::Error for NotClosed {}
189
190pub(crate) fn check_members(
191 object: &'static str,
192 value: &serde_json::Value,
193 allowed: &[&str],
194) -> Result<(), NotClosed> {
195 let members = value.as_object().ok_or(NotClosed::NotAnObject { object })?;
196 match members.keys().find(|key| !allowed.contains(&key.as_str())) {
197 Some(member) => Err(NotClosed::UnknownMember {
198 object,
199 member: member.clone(),
200 }),
201 None => Ok(()),
202 }
203}
204
205impl StepRow {
206 pub const REQUIRED_MEMBERS: &'static [&'static str] = &[
208 "cursor",
209 "harness",
210 "source",
211 "step_ref",
212 "session",
213 "occurred_at_ms",
214 "prompt",
215 "verdict",
216 "prompt_dropped",
217 ];
218
219 pub const OPTIONAL_MEMBERS: &'static [&'static str] = &[
221 "run_id",
222 "provider",
223 "model",
224 "input",
225 "cache_read",
226 "cache_write",
227 "cache_write_5m",
228 "cache_write_1h",
229 "output",
230 "reasoning",
231 "finish",
232 "context_limit",
233 "request_body_len",
234 "retention",
235 "request_size",
236 ];
237
238 #[allow(clippy::too_many_arguments)]
240 pub fn new(
241 cursor: impl Into<Cursor>,
242 harness: impl Into<String>,
243 source: StepSource,
244 step_ref: impl Into<String>,
245 session: impl Into<String>,
246 occurred_at_ms: u64,
247 prompt: u64,
248 verdict: Verdict,
249 prompt_dropped: bool,
250 ) -> Self {
251 Self {
252 cursor: cursor.into(),
253 harness: harness.into(),
254 source,
255 step_ref: step_ref.into(),
256 session: session.into(),
257 run_id: None,
258 occurred_at_ms,
259 provider: None,
260 model: None,
261 input: None,
262 cache_read: None,
263 cache_write: None,
264 cache_write_5m: None,
265 cache_write_1h: None,
266 output: None,
267 reasoning: None,
268 finish: None,
269 context_limit: None,
270 request_body_len: None,
271 prompt,
272 retention: None,
273 verdict,
274 prompt_dropped,
275 request_size: None,
276 }
277 }
278
279 pub fn identity(&self) -> (&str, &str) {
281 (&self.harness, &self.step_ref)
282 }
283
284 pub fn check_closed(value: &serde_json::Value) -> Result<(), NotClosed> {
289 let allowed: Vec<&str> = Self::REQUIRED_MEMBERS
290 .iter()
291 .chain(Self::OPTIONAL_MEMBERS)
292 .copied()
293 .collect();
294 check_members("row", value, &allowed)
295 }
296
297 pub fn check_counts(&self) -> Result<(), BadCounts> {
302 match (self.reasoning, self.output) {
303 (Some(reasoning), Some(output)) if reasoning > output => {
304 Err(BadCounts::ReasoningExceedsOutput {
305 harness: self.harness.clone(),
306 step_ref: self.step_ref.clone(),
307 reasoning,
308 output,
309 })
310 }
311 _ => Ok(()),
312 }
313 }
314
315 pub fn with_run_id(mut self, run_id: impl Into<String>) -> Self {
316 self.run_id = Some(run_id.into());
317 self
318 }
319 pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
320 self.provider = Some(provider.into());
321 self
322 }
323 pub fn with_model(mut self, model: impl Into<String>) -> Self {
324 self.model = Some(model.into());
325 self
326 }
327 pub fn with_input(mut self, input: u64) -> Self {
328 self.input = Some(input);
329 self
330 }
331 pub fn with_cache_read(mut self, cache_read: u64) -> Self {
332 self.cache_read = Some(cache_read);
333 self
334 }
335 pub fn with_cache_write(mut self, cache_write: u64) -> Self {
336 self.cache_write = Some(cache_write);
337 self
338 }
339 pub fn with_cache_write_5m(mut self, cache_write_5m: u64) -> Self {
340 self.cache_write_5m = Some(cache_write_5m);
341 self
342 }
343 pub fn with_cache_write_1h(mut self, cache_write_1h: u64) -> Self {
344 self.cache_write_1h = Some(cache_write_1h);
345 self
346 }
347 pub fn with_output(mut self, output: u64) -> Self {
348 self.output = Some(output);
349 self
350 }
351 pub fn with_reasoning(mut self, reasoning: u64) -> Self {
352 self.reasoning = Some(reasoning);
353 self
354 }
355 pub fn with_finish(mut self, finish: impl Into<String>) -> Self {
356 self.finish = Some(finish.into());
357 self
358 }
359 pub fn with_context_limit(mut self, context_limit: u64) -> Self {
360 self.context_limit = Some(context_limit);
361 self
362 }
363 pub fn with_request_body_len(mut self, request_body_len: u64) -> Self {
364 self.request_body_len = Some(request_body_len);
365 self
366 }
367 pub fn with_retention(mut self, retention: f64) -> Self {
368 self.retention = Some(retention);
369 self
370 }
371 pub fn with_request_size(mut self, request_size: RequestSize) -> Self {
372 self.request_size = Some(request_size);
373 self
374 }
375}