Skip to content

Commit 2a24596

Browse files
Akkariclaude
andcommitted
feat: CSV audit reports — per-file detail and run summary with SHA-256, timing, cost breakdown
Adds machine-parseable audit output to validation runs (Milestone #17): - quorum/audit.py: generates audit-detail.csv (one row per file with SHA-256, timing, token counts, models used, verdict, findings, cost) and audit-summary.csv (single aggregate row with run totals, cost_by_model JSON, pass/fail counts, median/avg tokens-per-second) - pipeline.py: captures run_start/run_end timing, computes artifact SHA-256 and stores in run-manifest.json, calls audit generator when audit_report=True - cli.py: adds --audit-report flag, auto-enables at --depth thorough, prints path to audit-detail.csv on completion - tests/test_audit.py: 57 tests covering detail rows, summary aggregates, CSV round-trip parsability, numeric quoting, auto-enable at thorough depth, and pipeline integration (all mocked — no real API calls) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a09545a commit 2a24596

4 files changed

Lines changed: 1247 additions & 1 deletion

File tree

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
# SPDX-License-Identifier: MIT
2+
# Copyright 2026 SharedIntellect — https://github.com/SharedIntellect/quorum
3+
4+
"""
5+
CSV audit report generation for Quorum validation runs.
6+
7+
Generates two CSV files per run:
8+
- audit-detail.csv: one row per validated file with per-file metrics
9+
- audit-summary.csv: single row with run-level aggregates
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import csv
15+
import hashlib
16+
import json
17+
import logging
18+
import statistics
19+
from datetime import datetime, timezone
20+
from pathlib import Path
21+
from typing import TYPE_CHECKING, Any
22+
23+
if TYPE_CHECKING:
24+
from quorum.cost import CostTracker
25+
from quorum.models import FileResult
26+
27+
logger = logging.getLogger(__name__)
28+
29+
# CSV column definitions
30+
DETAIL_COLUMNS = [
31+
"filename",
32+
"sha256",
33+
"file_size_bytes",
34+
"file_token_estimate",
35+
"analysis_start",
36+
"analysis_end",
37+
"duration_seconds",
38+
"input_tokens",
39+
"output_tokens",
40+
"tokens_per_second",
41+
"models_used",
42+
"verdict",
43+
"finding_count",
44+
"cost_usd",
45+
]
46+
47+
SUMMARY_COLUMNS = [
48+
"total_files",
49+
"total_size_bytes",
50+
"total_token_estimate",
51+
"run_start",
52+
"run_end",
53+
"run_duration_seconds",
54+
"total_input_tokens",
55+
"total_output_tokens",
56+
"avg_tokens_per_second",
57+
"median_tokens_per_second",
58+
"total_critic_calls",
59+
"models_used",
60+
"total_cost_usd",
61+
"cost_by_model",
62+
"pass_count",
63+
"fail_count",
64+
]
65+
66+
# Verdict statuses that are considered "passing"
67+
_PASSING_STATUSES = {"PASS", "PASS_WITH_NOTES"}
68+
69+
70+
def _read_run_dir(run_dir: Path) -> dict[str, Any]:
71+
"""Read artifact, manifest, and verdict from a run directory."""
72+
artifact_text = ""
73+
artifact_path = run_dir / "artifact.txt"
74+
if artifact_path.exists():
75+
try:
76+
artifact_text = artifact_path.read_text(encoding="utf-8")
77+
except OSError:
78+
pass
79+
80+
manifest: dict[str, Any] = {}
81+
manifest_path = run_dir / "run-manifest.json"
82+
if manifest_path.exists():
83+
try:
84+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
85+
except (OSError, json.JSONDecodeError):
86+
pass
87+
88+
verdict_data: dict[str, Any] = {}
89+
verdict_path = run_dir / "verdict.json"
90+
if verdict_path.exists():
91+
try:
92+
verdict_data = json.loads(verdict_path.read_text(encoding="utf-8"))
93+
except (OSError, json.JSONDecodeError):
94+
pass
95+
96+
return {
97+
"artifact_text": artifact_text,
98+
"manifest": manifest,
99+
"verdict_data": verdict_data,
100+
}
101+
102+
103+
def _parse_duration(start_str: str, end_str: str) -> float:
104+
"""Parse ISO timestamps and return duration in seconds (>=0)."""
105+
if not start_str or not end_str:
106+
return 0.0
107+
try:
108+
start_dt = datetime.fromisoformat(start_str)
109+
end_dt = datetime.fromisoformat(end_str)
110+
return max(0.0, (end_dt - start_dt).total_seconds())
111+
except (ValueError, TypeError):
112+
return 0.0
113+
114+
115+
def _build_detail_row(
116+
run_dir: Path,
117+
file_path_str: str,
118+
all_records: list,
119+
) -> dict[str, Any]:
120+
"""Build a single audit-detail.csv row from a run directory."""
121+
data = _read_run_dir(run_dir)
122+
artifact_text = data["artifact_text"]
123+
manifest = data["manifest"]
124+
verdict_data = data["verdict_data"]
125+
126+
# File metrics
127+
artifact_bytes = artifact_text.encode("utf-8")
128+
sha256 = hashlib.sha256(artifact_bytes).hexdigest()
129+
file_size_bytes = len(artifact_bytes)
130+
file_token_estimate = len(artifact_text) // 4
131+
132+
# Timing from manifest (written by pipeline at start/end of run)
133+
analysis_start = manifest.get("started_at", "")
134+
analysis_end = manifest.get("completed_at", "")
135+
duration_seconds = _parse_duration(analysis_start, analysis_end)
136+
137+
# Token/cost data from records attributed to this file
138+
file_records = [r for r in all_records if r.file_path == file_path_str]
139+
input_tokens = sum(r.prompt_tokens for r in file_records)
140+
output_tokens = sum(r.completion_tokens for r in file_records)
141+
cost_usd = sum(r.cost_usd for r in file_records)
142+
143+
# Models used for this file (sorted for determinism)
144+
models_used_list = sorted(set(r.model for r in file_records))
145+
146+
# Tokens per second (output tokens / duration)
147+
tokens_per_second = output_tokens / duration_seconds if duration_seconds > 0 else 0.0
148+
149+
# Verdict and findings
150+
verdict_status = verdict_data.get("status", "UNKNOWN")
151+
report = verdict_data.get("report") or {}
152+
findings = report.get("findings") or []
153+
finding_count = len(findings)
154+
155+
return {
156+
"filename": file_path_str,
157+
"sha256": sha256,
158+
"file_size_bytes": file_size_bytes,
159+
"file_token_estimate": file_token_estimate,
160+
"analysis_start": analysis_start,
161+
"analysis_end": analysis_end,
162+
"duration_seconds": round(duration_seconds, 3),
163+
"input_tokens": input_tokens,
164+
"output_tokens": output_tokens,
165+
"tokens_per_second": round(tokens_per_second, 3),
166+
"models_used": ",".join(models_used_list),
167+
"verdict": verdict_status,
168+
"finding_count": finding_count,
169+
"cost_usd": round(cost_usd, 6),
170+
}
171+
172+
173+
def _build_summary_row(
174+
detail_rows: list[dict[str, Any]],
175+
all_records: list,
176+
run_start: datetime,
177+
run_end: datetime,
178+
) -> dict[str, Any]:
179+
"""Build the audit-summary.csv row from detail rows and cost records."""
180+
total_files = len(detail_rows)
181+
total_size_bytes = sum(r["file_size_bytes"] for r in detail_rows)
182+
total_token_estimate = sum(r["file_token_estimate"] for r in detail_rows)
183+
total_input_tokens = sum(r["input_tokens"] for r in detail_rows)
184+
total_output_tokens = sum(r["output_tokens"] for r in detail_rows)
185+
total_cost_usd = sum(r["cost_usd"] for r in detail_rows)
186+
total_critic_calls = len(all_records)
187+
188+
# Run timing
189+
run_start_str = run_start.isoformat()
190+
run_end_str = run_end.isoformat()
191+
run_duration_seconds = max(0.0, (run_end - run_start).total_seconds())
192+
193+
# Tokens per second stats (only for files with positive tps)
194+
tps_values = [r["tokens_per_second"] for r in detail_rows if r["tokens_per_second"] > 0]
195+
avg_tps = sum(tps_values) / len(tps_values) if tps_values else 0.0
196+
median_tps = statistics.median(tps_values) if tps_values else 0.0
197+
198+
# All unique models used across the run
199+
all_models = sorted(set(r.model for r in all_records))
200+
201+
# Cost aggregated by model
202+
cost_by_model: dict[str, float] = {}
203+
for rec in all_records:
204+
cost_by_model[rec.model] = round(
205+
cost_by_model.get(rec.model, 0.0) + rec.cost_usd, 6
206+
)
207+
208+
# Pass/fail counts
209+
pass_count = sum(1 for r in detail_rows if r["verdict"] in _PASSING_STATUSES)
210+
fail_count = total_files - pass_count
211+
212+
return {
213+
"total_files": total_files,
214+
"total_size_bytes": total_size_bytes,
215+
"total_token_estimate": total_token_estimate,
216+
"run_start": run_start_str,
217+
"run_end": run_end_str,
218+
"run_duration_seconds": round(run_duration_seconds, 3),
219+
"total_input_tokens": total_input_tokens,
220+
"total_output_tokens": total_output_tokens,
221+
"avg_tokens_per_second": round(avg_tps, 3),
222+
"median_tokens_per_second": round(median_tps, 3),
223+
"total_critic_calls": total_critic_calls,
224+
"models_used": ",".join(all_models),
225+
"total_cost_usd": round(total_cost_usd, 6),
226+
"cost_by_model": json.dumps(cost_by_model),
227+
"pass_count": pass_count,
228+
"fail_count": fail_count,
229+
}
230+
231+
232+
def _write_detail_csv(path: Path, rows: list[dict[str, Any]]) -> None:
233+
"""Write audit-detail.csv with header and one row per file."""
234+
path.parent.mkdir(parents=True, exist_ok=True)
235+
with open(path, "w", newline="", encoding="utf-8") as f:
236+
writer = csv.DictWriter(
237+
f,
238+
fieldnames=DETAIL_COLUMNS,
239+
quoting=csv.QUOTE_MINIMAL,
240+
extrasaction="ignore",
241+
)
242+
writer.writeheader()
243+
writer.writerows(rows)
244+
245+
246+
def _write_summary_csv(path: Path, row: dict[str, Any]) -> None:
247+
"""Write audit-summary.csv with header and single data row."""
248+
path.parent.mkdir(parents=True, exist_ok=True)
249+
with open(path, "w", newline="", encoding="utf-8") as f:
250+
writer = csv.DictWriter(
251+
f,
252+
fieldnames=SUMMARY_COLUMNS,
253+
quoting=csv.QUOTE_MINIMAL,
254+
extrasaction="ignore",
255+
)
256+
writer.writeheader()
257+
writer.writerow(row)
258+
259+
260+
def generate_audit_reports(
261+
run_dir: Path,
262+
file_path: str,
263+
cost_tracker: "CostTracker",
264+
run_start: datetime,
265+
run_end: datetime,
266+
) -> tuple[Path, Path]:
267+
"""
268+
Generate audit-detail.csv and audit-summary.csv for a single-file run.
269+
270+
Args:
271+
run_dir: Path to the per-file run output directory.
272+
file_path: Absolute/relative path to the validated file (for cost lookup).
273+
cost_tracker: CostTracker instance from the validation run.
274+
run_start: When the run started (UTC-aware datetime).
275+
run_end: When the run ended (UTC-aware datetime).
276+
277+
Returns:
278+
(detail_csv_path, summary_csv_path)
279+
"""
280+
summary = cost_tracker.summary()
281+
all_records = summary.records
282+
283+
detail_row = _build_detail_row(run_dir, file_path, all_records)
284+
detail_rows = [detail_row]
285+
summary_row = _build_summary_row(detail_rows, all_records, run_start, run_end)
286+
287+
detail_path = run_dir / "audit-detail.csv"
288+
summary_path = run_dir / "audit-summary.csv"
289+
290+
_write_detail_csv(detail_path, detail_rows)
291+
_write_summary_csv(summary_path, summary_row)
292+
293+
logger.info("Audit reports written: %s, %s", detail_path, summary_path)
294+
return detail_path, summary_path
295+
296+
297+
def generate_batch_audit_reports(
298+
batch_dir: Path,
299+
file_results: list["FileResult"],
300+
cost_tracker: "CostTracker",
301+
run_start: datetime,
302+
run_end: datetime,
303+
) -> tuple[Path, Path]:
304+
"""
305+
Generate audit-detail.csv and audit-summary.csv for a batch run.
306+
307+
Args:
308+
batch_dir: Path to the batch run output directory.
309+
file_results: List of FileResult objects from the batch run.
310+
cost_tracker: Shared CostTracker instance from the batch run.
311+
run_start: When the batch started (UTC-aware datetime).
312+
run_end: When the batch ended (UTC-aware datetime).
313+
314+
Returns:
315+
(detail_csv_path, summary_csv_path)
316+
"""
317+
summary = cost_tracker.summary()
318+
all_records = summary.records
319+
320+
detail_rows = []
321+
for result in file_results:
322+
run_dir = Path(result.run_dir)
323+
row = _build_detail_row(run_dir, result.file_path, all_records)
324+
detail_rows.append(row)
325+
326+
summary_row = _build_summary_row(detail_rows, all_records, run_start, run_end)
327+
328+
detail_path = batch_dir / "audit-detail.csv"
329+
summary_path = batch_dir / "audit-summary.csv"
330+
331+
_write_detail_csv(detail_path, detail_rows)
332+
_write_summary_csv(summary_path, summary_row)
333+
334+
logger.info("Batch audit reports written: %s, %s", detail_path, summary_path)
335+
return detail_path, summary_path

0 commit comments

Comments
 (0)