2424with empty bodies are unrecoverable and land in the residual. Enable
2525attribution at build time for complete coverage.
2626
27- Usage: per_trial_tokens.py SESSION_DIR [--requests-dir DIR] [--tasks-dir DIR]
28- [--json]
27+ Pass one session dir for a single run, or several to roll up a grid; ``--csv``
28+ writes a flat per-trial table across all of them (one row per
29+ run/evaluation/task) for spreadsheet analysis. Tokens are the reported unit;
30+ dollars are a downstream linear function of the (input, cached, output) triple
31+ with a per-model rate vector, so they are deliberately not computed here.
32+
33+ Usage: per_trial_tokens.py SESSION_DIR [SESSION_DIR ...] [--requests-dir DIR]
34+ [--tasks-dir DIR] [--json] [--csv OUT.csv]
2935"""
3036
3137from __future__ import annotations
3238
3339import argparse
40+ import csv
3441import json
3542import re
3643import sys
@@ -209,30 +216,27 @@ def assign_thread(
209216 return None
210217
211218
212- def main () -> int :
213- parser = argparse .ArgumentParser (description = __doc__ )
214- parser .add_argument ("session" , type = Path )
215- parser .add_argument ("--requests-dir" , type = Path , default = None )
216- parser .add_argument ("--tasks-dir" , type = Path , default = None )
217- parser .add_argument ("--json" , action = "store_true" , dest = "as_json" )
218- arguments = parser .parse_args ()
219+ def trial_wall_seconds (trial : dict ) -> float | None :
220+ started , finished = trial .get ("started" ), trial .get ("finished" )
221+ if started is None or finished is None :
222+ return None
223+ return round ((finished - started ).total_seconds (), 1 )
219224
220- session = arguments .session
221- requests_dir = (
222- arguments .requests_dir or session / "artifacts" / "inference" / "requests"
223- )
224- if not requests_dir .is_dir ():
225- print (f"no request log at { requests_dir } " , file = sys .stderr )
226- return 1
227225
226+ def analyze_session (
227+ session : Path , requests_dir : Path , task_texts : list [tuple [str , str ]]
228+ ) -> dict :
229+ """Per evaluation id: per-trial token triples + latency + wall, with the
230+ trusted gateway envelope, the attributed sum, the independent agent-reported
231+ sum (for reconciliation), and the unattributed residual."""
228232 trials_by_evaluation = load_trials (session )
229233 threads_by_evaluation = load_threads (requests_dir )
230- task_texts = load_task_texts (arguments .tasks_dir )
231234
232- report = {}
235+ report : dict [ str , dict ] = {}
233236 for evaluation_id , threads in sorted (threads_by_evaluation .items ()):
234237 trials = trials_by_evaluation .get (evaluation_id , [])
235238 per_trial : dict [str , dict ] = {}
239+ seen : dict [str , set ] = defaultdict (set )
236240 residual = {"requests" : 0 , ** {key : 0 for key in TOKEN_KEYS }}
237241 for thread in threads .values ():
238242 trial = assign_thread (thread , trials , task_texts )
@@ -241,11 +245,13 @@ def main() -> int:
241245 for key in TOKEN_KEYS :
242246 residual [key ] += thread [key ]
243247 continue
248+ task_name = trial ["task_name" ]
244249 entry = per_trial .setdefault (
245- trial [ " task_name" ] ,
250+ task_name ,
246251 {
247252 "requests" : 0 ,
248253 "latency_ms" : 0.0 ,
254+ "wall_s" : 0.0 ,
249255 "agent_reported" : trial ["agent_reported" ],
250256 ** {key : 0 for key in TOKEN_KEYS },
251257 },
@@ -254,53 +260,182 @@ def main() -> int:
254260 entry ["latency_ms" ] += thread ["latency_ms" ]
255261 for key in TOKEN_KEYS :
256262 entry [key ] += thread [key ]
257- total = {
263+ # Wall is a per-trial property, so add each contributing trial once:
264+ # repeated passes of one task sum, but a trial's many threads don't
265+ # double-count its wall.
266+ trial_name = trial .get ("trial_name" )
267+ if trial_name not in seen [task_name ]:
268+ seen [task_name ].add (trial_name )
269+ wall = trial_wall_seconds (trial )
270+ if wall is not None :
271+ entry ["wall_s" ] = round (entry ["wall_s" ] + wall , 1 )
272+ gateway_total = {
258273 key : sum (thread [key ] for thread in threads .values ()) for key in TOKEN_KEYS
259274 }
260275 attributed = {
261276 key : sum (entry [key ] for entry in per_trial .values ()) for key in TOKEN_KEYS
262277 }
278+ agent_reported_total = {
279+ key : sum (
280+ entry ["agent_reported" ].get (key , 0 ) for entry in per_trial .values ()
281+ )
282+ for key in ("n_input_tokens" , "n_cache_tokens" , "n_output_tokens" )
283+ }
263284 report [evaluation_id ] = {
264285 "trials" : per_trial ,
265- "gateway_total" : total ,
286+ "gateway_total" : gateway_total ,
266287 "attributed" : attributed ,
288+ "agent_reported_total" : agent_reported_total ,
267289 "residual" : residual ,
268290 "coverage_pct" : (
269- round (100.0 * attributed ["total_tokens" ] / total ["total_tokens" ], 1 )
270- if total ["total_tokens" ]
291+ round (
292+ 100.0 * attributed ["total_tokens" ] / gateway_total ["total_tokens" ],
293+ 1 ,
294+ )
295+ if gateway_total ["total_tokens" ]
271296 else None
272297 ),
273298 }
299+ return report
274300
275- if arguments .as_json :
276- print (json .dumps (report , indent = 1 , default = str ))
277- return 0
278301
302+ _CSV_FIELDS = [
303+ "run" ,
304+ "evaluation" ,
305+ "task" ,
306+ "requests" ,
307+ "input_tokens" ,
308+ "cached_input_tokens" ,
309+ "output_tokens" ,
310+ "total_tokens" ,
311+ "latency_ms" ,
312+ "wall_s" ,
313+ "agent_reported_input_tokens" ,
314+ "agent_reported_cache_tokens" ,
315+ "agent_reported_output_tokens" ,
316+ ]
317+
318+
319+ def csv_rows (run_label : str , report : dict ):
320+ """Flat per-trial rows (plus one residual row per evaluation) for the grid."""
279321 for evaluation_id , data in report .items ():
280- print (f"\n === evaluation { evaluation_id } ===" )
281- header = f"{ 'task' :<44} { 'req' :>5} { 'input' :>10} { 'cached' :>10} { 'output' :>8} { 'agent-reported in/cache/out' :>28} "
282- print (header )
322+ for task_name , entry in sorted (data ["trials" ].items ()):
323+ reported = entry ["agent_reported" ]
324+ yield {
325+ "run" : run_label ,
326+ "evaluation" : evaluation_id ,
327+ "task" : task_name ,
328+ "requests" : entry ["requests" ],
329+ "input_tokens" : entry ["input_tokens" ],
330+ "cached_input_tokens" : entry ["cached_input_tokens" ],
331+ "output_tokens" : entry ["output_tokens" ],
332+ "total_tokens" : entry ["total_tokens" ],
333+ "latency_ms" : round (entry ["latency_ms" ], 1 ),
334+ "wall_s" : entry ["wall_s" ],
335+ "agent_reported_input_tokens" : reported .get ("n_input_tokens" ),
336+ "agent_reported_cache_tokens" : reported .get ("n_cache_tokens" ),
337+ "agent_reported_output_tokens" : reported .get ("n_output_tokens" ),
338+ }
339+ residual = data ["residual" ]
340+ if residual ["requests" ]:
341+ yield {
342+ "run" : run_label ,
343+ "evaluation" : evaluation_id ,
344+ "task" : "(unattributed)" ,
345+ "requests" : residual ["requests" ],
346+ "input_tokens" : residual ["input_tokens" ],
347+ "cached_input_tokens" : residual ["cached_input_tokens" ],
348+ "output_tokens" : residual ["output_tokens" ],
349+ "total_tokens" : residual ["total_tokens" ],
350+ "latency_ms" : "" ,
351+ "wall_s" : "" ,
352+ "agent_reported_input_tokens" : "" ,
353+ "agent_reported_cache_tokens" : "" ,
354+ "agent_reported_output_tokens" : "" ,
355+ }
356+
357+
358+ def print_report (run_label : str , report : dict ) -> None :
359+ for evaluation_id , data in report .items ():
360+ print (f"\n === { run_label } / evaluation { evaluation_id } ===" )
361+ print (
362+ f"{ 'task' :<40} { 'req' :>5} { 'input' :>10} { 'cached' :>10} "
363+ f"{ 'output' :>8} { 'wall_s' :>8} { 'agent in/cache/out' :>24} "
364+ )
283365 for task_name , entry in sorted (data ["trials" ].items ()):
284366 reported = entry ["agent_reported" ]
285367 reported_text = "/" .join (
286368 str (reported .get (key , "-" ))
287369 for key in ("n_input_tokens" , "n_cache_tokens" , "n_output_tokens" )
288370 )
289371 print (
290- f"{ str (task_name )[:44 ]:<44 } { entry ['requests' ]:>5} "
372+ f"{ str (task_name )[:40 ]:<40 } { entry ['requests' ]:>5} "
291373 f"{ entry ['input_tokens' ]:>10} { entry ['cached_input_tokens' ]:>10} "
292- f"{ entry ['output_tokens' ]:>8} { reported_text :>28 } "
374+ f"{ entry ['output_tokens' ]:>8} { entry [ 'wall_s' ]:>8 } { reported_text :>24 } "
293375 )
294376 residual = data ["residual" ]
295377 print (
296- f"{ '(unattributed)' :<44 } { residual ['requests' ]:>5} "
378+ f"{ '(unattributed)' :<40 } { residual ['requests' ]:>5} "
297379 f"{ residual ['input_tokens' ]:>10} { residual ['cached_input_tokens' ]:>10} "
298380 f"{ residual ['output_tokens' ]:>8} "
299381 )
382+ gateway , attributed = data ["gateway_total" ], data ["attributed" ]
383+ agent = data ["agent_reported_total" ]
300384 print (
301- f"coverage: { data ['coverage_pct' ]} % of "
302- f"{ data [ 'gateway_total' ][ 'total_tokens' ] } gateway-metered tokens attributed"
385+ f"coverage: { data ['coverage_pct' ]} % of { gateway [ 'total_tokens' ] } "
386+ f"gateway-metered tokens attributed to trials "
303387 )
388+ # Reconcile the trusted envelope against the two independent token sources.
389+ print (
390+ f" input tokens gateway { gateway ['input_tokens' ]} | "
391+ f"attributed { attributed ['input_tokens' ]} | "
392+ f"agent-reported { agent ['n_input_tokens' ]} "
393+ )
394+
395+
396+ def main () -> int :
397+ parser = argparse .ArgumentParser (description = __doc__ )
398+ parser .add_argument ("sessions" , type = Path , nargs = "+" , help = "one or more session dirs" )
399+ parser .add_argument (
400+ "--requests-dir" , type = Path , default = None , help = "single session only"
401+ )
402+ parser .add_argument ("--tasks-dir" , type = Path , default = None )
403+ parser .add_argument ("--json" , action = "store_true" , dest = "as_json" )
404+ parser .add_argument ("--csv" , type = Path , default = None , help = "write a flat per-trial CSV" )
405+ arguments = parser .parse_args ()
406+
407+ task_texts = load_task_texts (arguments .tasks_dir )
408+ grid : dict [str , dict ] = {}
409+ for session in arguments .sessions :
410+ if arguments .requests_dir is not None and len (arguments .sessions ) == 1 :
411+ requests_dir = arguments .requests_dir
412+ else :
413+ requests_dir = session / "artifacts" / "inference" / "requests"
414+ if not requests_dir .is_dir ():
415+ print (f"no request log at { requests_dir } " , file = sys .stderr )
416+ continue
417+ grid [session .name ] = analyze_session (session , requests_dir , task_texts )
418+
419+ if not grid :
420+ return 1
421+
422+ if arguments .csv is not None :
423+ with open (arguments .csv , "w" , newline = "" , encoding = "utf-8" ) as handle :
424+ writer = csv .DictWriter (handle , fieldnames = _CSV_FIELDS )
425+ writer .writeheader ()
426+ for run_label , report in grid .items ():
427+ writer .writerows (csv_rows (run_label , report ))
428+ print (f"wrote { arguments .csv } " , file = sys .stderr )
429+
430+ if arguments .as_json :
431+ # One session keeps the original flat {evaluation: ...} schema; several
432+ # nest under their run label.
433+ payload = grid [arguments .sessions [0 ].name ] if len (grid ) == 1 else grid
434+ print (json .dumps (payload , indent = 1 , default = str ))
435+ return 0
436+
437+ for run_label , report in grid .items ():
438+ print_report (run_label , report )
304439 return 0
305440
306441
0 commit comments