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