diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a6d33c..d2223ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- HTML and Markdown reports now identify the top-level duration as evaluation + wall time and show per-case tested-agent execution time plus input, output, + and total token usage. Benchmark cases include compact with-Skill, + without-Skill, and delta annotations beside the case heading. Agent-judge + time and tokens are reported separately and included in an explicit overall + token total. - Codex JSONL parsing now accepts records up to a configurable 16 MiB default while rejecting oversized records explicitly. The stdout stream is written to a bounded-download artifact instead of being buffered without limit in host diff --git a/internal/report/README.md b/internal/report/README.md index c45f075..d2db039 100644 --- a/internal/report/README.md +++ b/internal/report/README.md @@ -67,6 +67,8 @@ package "internal/report" { EndTime : time.Time CaseResults : []CaseResult TotalTokens : int + JudgeTokens : int + OverallTokens : int Benchmark : *BenchmarkResult +TotalDuration() time.Duration +OverallPassRate() float64 @@ -78,6 +80,11 @@ package "internal/report" { Status : judge.Status DurationMs : int64 Turns : int + InputTokens : int + OutputTokens : int + JudgeDurationMs : int64 + JudgeInputTokens : int + JudgeOutputTokens : int Error : string Grading : *judge.Result } @@ -160,9 +167,25 @@ end note - Rendered with the standard library's `html/template`; the template is loaded via `go:embed` from `templates/report.html` - Bundles responsive CSS styles -- Displays: skill name, engine, model, start time, execution time, pass rate +- Displays: skill name, engine, model, start time, evaluation wall time, pass rate, and separated tested-agent / judge / overall token totals - Summary cards: Total / Passed / Failed / Skipped / Errors / Pass Rate -- Per-case detail table: status icons, assertion results, evidence +- Per-case details: compact tested-agent and optional agent-judge metrics beside the case heading, plus status icons, assertion results, and evidence; input/output token counts remain available as hover details +- Benchmark cases show compact with-Skill, without-Skill, and delta metrics beside the case heading so execution cost remains secondary to the response and grading content + +### Metric semantics + +- `Input.TotalDuration()` is **evaluation wall time** (`EndTime - StartTime`). It + includes orchestration, tested-agent execution, judging, and other framework + overhead, so it is not expected to equal the sum of visible case execution + times, especially when cases run concurrently. +- `CaseResult.DurationMs` is **tested-agent execution time** for that case and + configuration. It is the primary duration for comparing Skill behavior. +- `CaseResult.InputTokens` and `OutputTokens` are tested-agent token usage. + `Input.TotalTokens` retains its existing JSON name for compatibility and is + the sum of those tested-agent tokens across all configurations. +- `JudgeDurationMs`, `JudgeInputTokens`, and `JudgeOutputTokens` are populated + when the judge runs a separate agent session. `Input.JudgeTokens` aggregates + those tokens, and `Input.OverallTokens` is tested-agent plus judge tokens. ### JUnitReporter (`junit.go`) diff --git a/internal/report/html.go b/internal/report/html.go index 37cc1c0..be3edd4 100644 --- a/internal/report/html.go +++ b/internal/report/html.go @@ -58,15 +58,17 @@ type htmlReportData struct { // -- Embedded JSON types for JavaScript consumption -- type embeddedReportData struct { - SkillName string `json:"skill_name"` - EngineName string `json:"engine_name"` - ModelName string `json:"model_name"` - StartTime string `json:"start_time"` - Duration string `json:"duration"` - TotalTokens int `json:"total_tokens"` - Summary embeddedSummary `json:"summary"` - Cases []embeddedCase `json:"cases"` - Benchmark *BenchmarkResult `json:"benchmark,omitempty"` + SkillName string `json:"skill_name"` + EngineName string `json:"engine_name"` + ModelName string `json:"model_name"` + StartTime string `json:"start_time"` + EvaluationWallTime string `json:"evaluation_wall_time"` + AgentTokens int `json:"agent_tokens"` + JudgeTokens int `json:"judge_tokens"` + OverallTokens int `json:"overall_tokens"` + Summary embeddedSummary `json:"summary"` + Cases []embeddedCase `json:"cases"` + Benchmark *BenchmarkResult `json:"benchmark,omitempty"` } type embeddedSummary struct { @@ -79,20 +81,28 @@ type embeddedSummary struct { } type embeddedCase struct { - ID string `json:"id"` - Title string `json:"title,omitempty"` - Status string `json:"status"` - DurationMs int64 `json:"duration_ms"` - Duration string `json:"duration"` - Turns int `json:"turns"` - Error string `json:"error,omitempty"` - Grading *embeddedGrading `json:"grading,omitempty"` - Configuration string `json:"configuration,omitempty"` - Prompt string `json:"prompt,omitempty"` - Response string `json:"response,omitempty"` - Baseline *embeddedCase `json:"baseline,omitempty"` - TurnResults []embeddedTurn `json:"turn_results,omitempty"` - JudgeSkills []judge.SkillInfo `json:"judge_skills,omitempty"` + ID string `json:"id"` + Title string `json:"title,omitempty"` + Status string `json:"status"` + AgentDurationMs int64 `json:"agent_duration_ms"` + AgentDuration string `json:"agent_duration"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + AgentTokens int `json:"agent_tokens"` + JudgeDurationMs int64 `json:"judge_duration_ms"` + JudgeDuration string `json:"judge_duration"` + JudgeInputTokens int `json:"judge_input_tokens"` + JudgeOutputTokens int `json:"judge_output_tokens"` + JudgeTokens int `json:"judge_tokens"` + Turns int `json:"turns"` + Error string `json:"error,omitempty"` + Grading *embeddedGrading `json:"grading,omitempty"` + Configuration string `json:"configuration,omitempty"` + Prompt string `json:"prompt,omitempty"` + Response string `json:"response,omitempty"` + Baseline *embeddedCase `json:"baseline,omitempty"` + TurnResults []embeddedTurn `json:"turn_results,omitempty"` + JudgeSkills []judge.SkillInfo `json:"judge_skills,omitempty"` } // embeddedTurn holds per-turn data for the HTML report JavaScript. @@ -136,18 +146,26 @@ type caseStatusCounts struct { func caseResultToEmbeddedCase(cr CaseResult) embeddedCase { ec := embeddedCase{ - ID: cr.CaseID, - Title: cr.Title, - Status: string(cr.Status), - DurationMs: cr.DurationMs, - Duration: fmt.Sprintf("%.1fs", float64(cr.DurationMs)/1000.0), - Turns: cr.Turns, - Error: cr.Error, - Configuration: cr.Configuration, - Prompt: cr.Prompt, - Response: cr.Response, - TurnResults: caseTurnResultsToEmbedded(cr.TurnResults), - JudgeSkills: cr.JudgeSkills, + ID: cr.CaseID, + Title: cr.Title, + Status: string(cr.Status), + AgentDurationMs: cr.DurationMs, + AgentDuration: fmt.Sprintf("%.1fs", float64(cr.DurationMs)/1000.0), + InputTokens: cr.InputTokens, + OutputTokens: cr.OutputTokens, + AgentTokens: cr.InputTokens + cr.OutputTokens, + JudgeDurationMs: cr.JudgeDurationMs, + JudgeDuration: fmt.Sprintf("%.1fs", float64(cr.JudgeDurationMs)/1000.0), + JudgeInputTokens: cr.JudgeInputTokens, + JudgeOutputTokens: cr.JudgeOutputTokens, + JudgeTokens: cr.JudgeInputTokens + cr.JudgeOutputTokens, + Turns: cr.Turns, + Error: cr.Error, + Configuration: cr.Configuration, + Prompt: cr.Prompt, + Response: cr.Response, + TurnResults: caseTurnResultsToEmbedded(cr.TurnResults), + JudgeSkills: cr.JudgeSkills, } if cr.Grading != nil { eg := &embeddedGrading{ @@ -252,12 +270,14 @@ func (r *HTMLReporter) buildTemplateData(in Input) (htmlReportData, error) { cases := buildEmbeddedCases(grouped, orderedIDs) ed := embeddedReportData{ - SkillName: in.SkillName, - EngineName: in.EngineName, - ModelName: in.ModelName, - StartTime: in.StartTime.Format(time.RFC3339), - Duration: fmt.Sprintf("%.1fs", in.TotalDuration().Seconds()), - TotalTokens: in.TotalTokens, + SkillName: in.SkillName, + EngineName: in.EngineName, + ModelName: in.ModelName, + StartTime: in.StartTime.Format(time.RFC3339), + EvaluationWallTime: fmt.Sprintf("%.1fs", in.TotalDuration().Seconds()), + AgentTokens: in.TotalTokens, + JudgeTokens: in.JudgeTokens, + OverallTokens: in.TotalTokens + in.JudgeTokens, Summary: embeddedSummary{ Total: len(cases), Passed: counts.passed, diff --git a/internal/report/json.go b/internal/report/json.go index 83af131..6bfbdf7 100644 --- a/internal/report/json.go +++ b/internal/report/json.go @@ -17,6 +17,9 @@ type JSONReporter struct { // Write implements the Reporter interface. func (r *JSONReporter) Write(_ context.Context, in Input) error { + // OverallTokens is derived so reports regenerated from an older result.json + // also receive a consistent explicit total. + in.OverallTokens = in.TotalTokens + in.JudgeTokens data, err := json.MarshalIndent(in, "", " ") if err != nil { return fmt.Errorf("json marshal: %w", err) diff --git a/internal/report/markdown.go b/internal/report/markdown.go index d763e74..9736dba 100644 --- a/internal/report/markdown.go +++ b/internal/report/markdown.go @@ -60,26 +60,66 @@ func writeMarkdownSummary(sb *strings.Builder, in Input) { fmt.Fprintf(sb, "| Errors | %d |\n", markdownCountByStatus(in, judge.StatusError)) fmt.Fprintf(sb, "| Skipped | %d |\n", markdownCountByStatus(in, judge.StatusSkip)) fmt.Fprintf(sb, "| Pass Rate | %.1f%% |\n", in.OverallPassRate()*100) - fmt.Fprintf(sb, "| Duration | %s |\n", markdownDuration(in.TotalDuration().Milliseconds())) - fmt.Fprintf(sb, "| Total Tokens | %d |\n\n", in.TotalTokens) + fmt.Fprintf(sb, "| Evaluation Wall Time | %s |\n", markdownDuration(in.TotalDuration().Milliseconds())) + fmt.Fprintf(sb, "| Tested Agent Tokens | %d |\n", in.TotalTokens) + fmt.Fprintf(sb, "| Judge Tokens | %d |\n", in.JudgeTokens) + fmt.Fprintf(sb, "| Overall Tokens | %d |\n\n", in.TotalTokens+in.JudgeTokens) } func writeMarkdownCases(sb *strings.Builder, in Input) { sb.WriteString("## Cases\n\n") - sb.WriteString("| Case | Title | Status | Duration | Turns |\n") - sb.WriteString("|---|---|---|---:|---:|\n") + if markdownHasJudgeMetrics(in) { + writeMarkdownCasesWithJudgeMetrics(sb, in) + return + } + sb.WriteString("| Case | Title | Configuration | Status | Agent Time | Input Tokens | Output Tokens | Agent Tokens | Turns |\n") + sb.WriteString("|---|---|---|---|---:|---:|---:|---:|---:|\n") + for _, cr := range in.CaseResults { + fmt.Fprintf(sb, "| %s | %s | %s | %s | %s | %d | %d | %d | %d |\n", + markdownTableCell(cr.CaseID), + markdownTableCell(cr.Title), + markdownTableCell(cr.Configuration), + markdownTableCell(string(cr.Status)), + markdownDuration(cr.DurationMs), + cr.InputTokens, + cr.OutputTokens, + cr.InputTokens+cr.OutputTokens, + cr.Turns, + ) + } + sb.WriteString("\n") +} + +func writeMarkdownCasesWithJudgeMetrics(sb *strings.Builder, in Input) { + sb.WriteString("| Case | Title | Configuration | Status | Agent Time | Input Tokens | Output Tokens | Agent Tokens | Judge Time | Judge Tokens | Turns |\n") + sb.WriteString("|---|---|---|---|---:|---:|---:|---:|---:|---:|---:|\n") for _, cr := range in.CaseResults { - fmt.Fprintf(sb, "| %s | %s | %s | %s | %d |\n", + fmt.Fprintf(sb, "| %s | %s | %s | %s | %s | %d | %d | %d | %s | %d | %d |\n", markdownTableCell(cr.CaseID), markdownTableCell(cr.Title), + markdownTableCell(cr.Configuration), markdownTableCell(string(cr.Status)), markdownDuration(cr.DurationMs), + cr.InputTokens, + cr.OutputTokens, + cr.InputTokens+cr.OutputTokens, + markdownDuration(cr.JudgeDurationMs), + cr.JudgeInputTokens+cr.JudgeOutputTokens, cr.Turns, ) } sb.WriteString("\n") } +func markdownHasJudgeMetrics(in Input) bool { + for _, cr := range in.CaseResults { + if cr.JudgeDurationMs != 0 || cr.JudgeInputTokens != 0 || cr.JudgeOutputTokens != 0 { + return true + } + } + return false +} + func writeMarkdownFailureDetails(sb *strings.Builder, in Input) { var details strings.Builder for _, cr := range in.CaseResults { diff --git a/internal/report/reporter.go b/internal/report/reporter.go index 00a0e38..9209f63 100644 --- a/internal/report/reporter.go +++ b/internal/report/reporter.go @@ -22,7 +22,9 @@ type Input struct { StartTime time.Time `json:"start_time"` EndTime time.Time `json:"end_time"` CaseResults []CaseResult `json:"case_results"` - TotalTokens int `json:"total_tokens"` + TotalTokens int `json:"total_tokens"` // tested-agent tokens across all configurations + JudgeTokens int `json:"judge_tokens"` + OverallTokens int `json:"overall_tokens"` Benchmark *BenchmarkResult `json:"benchmark,omitempty"` } @@ -99,20 +101,23 @@ func (in Input) PrimaryCaseResults() []CaseResult { // CaseResult represents the result of a single case execution. type CaseResult struct { - CaseID string `json:"case_id"` - Title string `json:"title"` - Status judge.Status `json:"status"` - DurationMs int64 `json:"duration_ms"` - Turns int `json:"turns"` - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - Error string `json:"error,omitempty"` - Grading *judge.Result `json:"grading"` - JudgeSkills []judge.SkillInfo `json:"judge_skills,omitempty"` - Configuration string `json:"configuration,omitempty"` // "with_skill" or "without_skill" - Prompt string `json:"prompt,omitempty"` // input prompt sent to the agent - Response string `json:"response,omitempty"` // agent final message - TurnResults []CaseTurnResult `json:"turn_results,omitempty"` // per-turn outcomes; nil for single-turn + CaseID string `json:"case_id"` + Title string `json:"title"` + Status judge.Status `json:"status"` + DurationMs int64 `json:"duration_ms"` + Turns int `json:"turns"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + JudgeDurationMs int64 `json:"judge_duration_ms,omitempty"` + JudgeInputTokens int `json:"judge_input_tokens,omitempty"` + JudgeOutputTokens int `json:"judge_output_tokens,omitempty"` + Error string `json:"error,omitempty"` + Grading *judge.Result `json:"grading"` + JudgeSkills []judge.SkillInfo `json:"judge_skills,omitempty"` + Configuration string `json:"configuration,omitempty"` // "with_skill" or "without_skill" + Prompt string `json:"prompt,omitempty"` // input prompt sent to the agent + Response string `json:"response,omitempty"` // agent final message + TurnResults []CaseTurnResult `json:"turn_results,omitempty"` // per-turn outcomes; nil for single-turn } // CaseTurnResult holds the outcome of a single turn for reporting purposes. diff --git a/internal/report/reporter_test.go b/internal/report/reporter_test.go index 32ef125..16fc5b1 100644 --- a/internal/report/reporter_test.go +++ b/internal/report/reporter_test.go @@ -25,13 +25,21 @@ func sampleInput() Input { ModelName: "openai/gpt-5.4", StartTime: start, EndTime: end, + TotalTokens: 1200, + JudgeTokens: 340, + OverallTokens: 1540, CaseResults: []CaseResult{ { - CaseID: "basic-success", - Title: "Agent should identify a missing null check", - Status: judge.StatusPass, - DurationMs: 45200, - Turns: 5, + CaseID: "basic-success", + Title: "Agent should identify a missing null check", + Status: judge.StatusPass, + DurationMs: 45200, + Turns: 5, + InputTokens: 1000, + OutputTokens: 200, + JudgeDurationMs: 12000, + JudgeInputTokens: 300, + JudgeOutputTokens: 40, JudgeSkills: []judge.SkillInfo{ { Source: "local_path", @@ -136,6 +144,34 @@ func TestJSONReporter_Write(t *testing.T) { } } +func TestJSONReporter_PreservesExecutionMetrics(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "result.json") + input := sampleInput() + input.OverallTokens = 0 // JSONReporter must derive this value, including for older input files. + if err := (&JSONReporter{OutputPath: path}).Write(context.Background(), input); err != nil { + t.Fatalf("JSONReporter.Write failed: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read json file: %v", err) + } + var parsed Input + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("invalid json: %v", err) + } + if parsed.TotalTokens != 1200 || parsed.JudgeTokens != 340 || parsed.OverallTokens != 1540 { + t.Fatalf("token summary not preserved in JSON: agent=%d judge=%d overall=%d", + parsed.TotalTokens, parsed.JudgeTokens, parsed.OverallTokens) + } + first := parsed.CaseResults[0] + if first.InputTokens != 1000 || first.OutputTokens != 200 || + first.JudgeInputTokens != 300 || first.JudgeOutputTokens != 40 || first.JudgeDurationMs != 12000 { + t.Fatalf("case metrics not preserved in JSON: %#v", first) + } +} + func TestJSONReporter_ContainsAssertions(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "result.json") @@ -266,6 +302,62 @@ func TestHTMLReporter_Write(t *testing.T) { if !strings.Contains(content, "evals/fixtures/judge-skill") { t.Fatal("missing judge skill path in embedded report data") } + for _, want := range []string{ + "Evaluation wall time", + "Tested agent tokens", + "case-header-metrics", + "renderCompactMetrics", + "Agent ", + "Judge ", + "Delta", + } { + if !strings.Contains(content, want) { + t.Fatalf("HTML missing metric label %q", want) + } + } + if strings.Contains(content, ">Execution Metrics<") { + t.Fatal("HTML should keep execution metrics inline instead of rendering a standalone section") + } +} + +func TestHTMLReporter_EmbedsPerConfigurationMetrics(t *testing.T) { + input := sampleInput() + input.CaseResults = []CaseResult{ + { + CaseID: "benchmark-case", Configuration: "with_skill", Status: judge.StatusPass, + DurationMs: 47618, InputTokens: 247863, OutputTokens: 2482, + JudgeDurationMs: 10000, JudgeInputTokens: 5000, JudgeOutputTokens: 200, + }, + { + CaseID: "benchmark-case", Configuration: "without_skill", Status: judge.StatusPass, + DurationMs: 14911, InputTokens: 59113, OutputTokens: 525, + JudgeDurationMs: 8000, JudgeInputTokens: 4000, JudgeOutputTokens: 100, + }, + } + + r := &HTMLReporter{} + data, err := r.buildTemplateData(input) + if err != nil { + t.Fatalf("buildTemplateData failed: %v", err) + } + var embedded embeddedReportData + if err := json.Unmarshal([]byte(data.EmbeddedDataJSON), &embedded); err != nil { + t.Fatalf("unmarshal embedded report data: %v", err) + } + if len(embedded.Cases) != 1 || embedded.Cases[0].Baseline == nil { + t.Fatalf("expected one case with baseline, got %#v", embedded.Cases) + } + withSkill := embedded.Cases[0] + withoutSkill := *withSkill.Baseline + if withSkill.AgentDurationMs != 47618 || withSkill.AgentTokens != 250345 { + t.Fatalf("with-skill metrics = %#v", withSkill) + } + if withoutSkill.AgentDurationMs != 14911 || withoutSkill.AgentTokens != 59638 { + t.Fatalf("without-skill metrics = %#v", withoutSkill) + } + if withSkill.JudgeDurationMs != 10000 || withSkill.JudgeTokens != 5200 { + t.Fatalf("with-skill judge metrics = %#v", withSkill) + } } func TestHTMLReporter_ContainsAssertionDetails(t *testing.T) { @@ -349,7 +441,11 @@ func TestMarkdownReporter_Write(t *testing.T) { "| Skipped | 1 |", "| Pass Rate | 25.0% |", "## Cases", - "| basic-success | Agent should identify a missing null check | PASS | 45.2s | 5 |", + "| Evaluation Wall Time | 245.0s |", + "| Tested Agent Tokens | 1200 |", + "| Judge Tokens | 340 |", + "| Overall Tokens | 1540 |", + "| basic-success | Agent should identify a missing null check | - | PASS | 45.2s | 1000 | 200 | 1200 | 12.0s | 340 | 5 |", "## Failure and Error Details", "### edge-case-null", "output_contains: 'graceful'", diff --git a/internal/report/templates/report.html b/internal/report/templates/report.html index b6ed43c..bc5e360 100644 --- a/internal/report/templates/report.html +++ b/internal/report/templates/report.html @@ -83,6 +83,12 @@ .case-info { display: flex; gap: 1.5rem; align-items: center; flex-wrap: wrap; font-size: 0.875rem; } .case-info .info-item { display: flex; align-items: center; gap: 0.25rem; } .case-info .info-label { color: var(--text-muted); font-size: 0.75rem; } + .case-header { flex-wrap: wrap; } + .case-header-metrics { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; font-family: 'Inter', system-ui, -apple-system, sans-serif; font-size: 0.7rem; font-weight: 400; text-transform: none; letter-spacing: normal; color: var(--text-muted); } + .compact-metrics { display: inline-flex; align-items: center; gap: 0.3rem; white-space: nowrap; } + .compact-metrics strong { color: var(--text); font-size: 0.7rem; font-weight: 600; } + .compact-metrics.delta { opacity: 0.8; } + .metrics-divider { opacity: 0.6; } .error-box { background: var(--error-bg); border: 1px solid var(--error-color); border-radius: 4px; padding: 0.75rem; color: var(--error-color); font-size: 0.875rem; white-space: pre-wrap; } @@ -197,7 +203,7 @@

Eval Report:

-
Case
+
Case