diff --git a/package.json b/package.json index 39f03f86b7..d903078d52 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "docs:keybindings": "tsx ./scripts/generate-keybindings-doc.ts", "eval:inventory": "tsx ./scripts/eval-inventory-cli.ts", "eval:inventory:json": "tsx ./scripts/eval-inventory-cli.ts --json", + "eval:coverage": "tsx ./scripts/eval-coverage-cli.ts", "build": "node scripts/build.js", "build-and-start": "npm run build && npm run start --", "build:vscode": "node scripts/build_vscode_companion.js", diff --git a/scripts/eval-coverage-cli.ts b/scripts/eval-coverage-cli.ts new file mode 100644 index 0000000000..5c82f6ed55 --- /dev/null +++ b/scripts/eval-coverage-cli.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env tsx + +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { collectInventory } from './utils/eval-inventory.js'; +import { buildToolRegistry } from './utils/tool-registry.js'; +import { + computeCoverage, + formatCoverageReport, +} from './utils/eval-coverage.js'; + +async function main() { + const rootFlagIndex = process.argv.indexOf('--root'); + const rootFlagValue = + rootFlagIndex !== -1 ? process.argv[rootFlagIndex + 1] : undefined; + + if (rootFlagIndex !== -1 && rootFlagValue === undefined) { + console.error( + 'Error: --root requires a directory path argument but none was provided.', + ); + process.exit(1); + } + if (rootFlagValue && rootFlagValue.startsWith('--')) { + console.error( + `Error: --root value "${rootFlagValue}" looks like a flag. Provide a valid directory path.`, + ); + process.exit(1); + } + + const repoRoot = rootFlagValue ?? process.cwd(); + const inventory = await collectInventory(repoRoot); + + if (inventory.totalFiles === 0) { + console.error('No eval files found under evals/.'); + process.exit(1); + } + + const registry = buildToolRegistry(); + const result = computeCoverage(inventory, registry); + + console.log(formatCoverageReport(result)); +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/scripts/tests/eval-coverage.test.ts b/scripts/tests/eval-coverage.test.ts new file mode 100644 index 0000000000..685c7a1cac --- /dev/null +++ b/scripts/tests/eval-coverage.test.ts @@ -0,0 +1,510 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + computeCoverage, + formatCoverageReport, + type CoverageResult, + type CoveredToolEntry, +} from '../utils/eval-coverage.js'; +import { buildToolRegistry } from '../utils/tool-registry.js'; +import { collectInventory } from '../utils/eval-inventory.js'; +import type { InventoryResult } from '../utils/eval-inventory.js'; +import type { + EvalCaseRecord, + EvalFileAnalysis, +} from '../utils/eval-analysis.js'; + +function makeCase(overrides: Partial = {}): EvalCaseRecord { + return { + filePath: '/repo/evals/test.eval.ts', + relativePath: 'evals/test.eval.ts', + helperName: 'evalTest', + baseHelperName: 'evalTest', + policy: 'USUALLY_PASSES', + name: 'test case', + hasFiles: false, + hasPrompt: true, + toolReferences: [], + location: { line: 1, column: 1 }, + ...overrides, + }; +} + +function makeInventory( + cases: EvalCaseRecord[], + overrides: Partial = {}, +): InventoryResult { + return { + totalFiles: 1, + totalCases: cases.length, + repoRoot: '/repo', + files: [] as EvalFileAnalysis[], + cases, + diagnostics: [], + ...overrides, + }; +} + +describe('eval-coverage', () => { + const registry = buildToolRegistry(); + + describe('computeCoverage', () => { + it('reports totals consistently with registry', () => { + const result = computeCoverage(makeInventory([]), registry); + + expect(result.totalTools).toBe(registry.totalTools); + expect(result.coveredCount + result.uncoveredCount).toBe( + result.totalTools, + ); + }); + + it('marks all tools uncovered when inventory is empty', () => { + const result = computeCoverage(makeInventory([]), registry); + + expect(result.coveredCount).toBe(0); + expect(result.uncoveredCount).toBe(registry.totalTools); + expect(result.covered).toEqual([]); + expect(result.coveragePercent).toBe(0); + }); + + it('marks all tools uncovered when no eval case has tool references', () => { + const result = computeCoverage( + makeInventory([ + makeCase({ toolReferences: [] }), + makeCase({ name: 'another', toolReferences: [] }), + ]), + registry, + ); + + expect(result.coveredCount).toBe(0); + expect(result.uncoveredCount).toBe(registry.totalTools); + }); + + it('marks a single referenced tool as covered', () => { + const result = computeCoverage( + makeInventory([makeCase({ toolReferences: ['grep_search'] })]), + registry, + ); + + expect(result.coveredCount).toBe(1); + expect(result.covered[0].name).toBe('grep_search'); + expect(result.covered[0].totalCaseCount).toBe(1); + }); + + it('counts multiple cases referencing the same tool', () => { + const result = computeCoverage( + makeInventory([ + makeCase({ + relativePath: 'evals/a.eval.ts', + toolReferences: ['grep_search'], + }), + makeCase({ + relativePath: 'evals/a.eval.ts', + toolReferences: ['grep_search'], + }), + makeCase({ + relativePath: 'evals/b.eval.ts', + toolReferences: ['grep_search'], + }), + ]), + registry, + ); + + const grepEntry = result.covered.find((t) => t.name === 'grep_search'); + expect(grepEntry).toBeDefined(); + expect(grepEntry!.totalCaseCount).toBe(3); + expect(grepEntry!.files).toHaveLength(2); + }); + + it('correctly builds per-file case counts', () => { + const result = computeCoverage( + makeInventory([ + makeCase({ + relativePath: 'evals/file-a.eval.ts', + toolReferences: ['glob'], + }), + makeCase({ + relativePath: 'evals/file-a.eval.ts', + toolReferences: ['glob'], + }), + makeCase({ + relativePath: 'evals/file-b.eval.ts', + toolReferences: ['glob'], + }), + ]), + registry, + ); + + const globEntry = result.covered.find((t) => t.name === 'glob'); + expect(globEntry).toBeDefined(); + + const fileA = globEntry!.files.find( + (f) => f.relativePath === 'evals/file-a.eval.ts', + ); + const fileB = globEntry!.files.find( + (f) => f.relativePath === 'evals/file-b.eval.ts', + ); + + expect(fileA?.caseCount).toBe(2); + expect(fileB?.caseCount).toBe(1); + }); + + it('computes correct policy distribution per tool', () => { + const result = computeCoverage( + makeInventory([ + makeCase({ + policy: 'ALWAYS_PASSES', + toolReferences: ['read_file'], + }), + makeCase({ + policy: 'USUALLY_PASSES', + toolReferences: ['read_file'], + }), + makeCase({ + policy: 'USUALLY_PASSES', + toolReferences: ['read_file'], + }), + ]), + registry, + ); + + const readEntry = result.covered.find((t) => t.name === 'read_file'); + expect(readEntry).toBeDefined(); + expect(readEntry!.policyDistribution.ALWAYS_PASSES).toBe(1); + expect(readEntry!.policyDistribution.USUALLY_PASSES).toBe(2); + expect(readEntry!.policyDistribution.USUALLY_FAILS).toBeUndefined(); + }); + + it('handles a case referencing multiple tools', () => { + const result = computeCoverage( + makeInventory([ + makeCase({ + toolReferences: ['glob', 'grep_search', 'read_file'], + }), + ]), + registry, + ); + + const names = result.covered.map((t) => t.name); + expect(names).toContain('glob'); + expect(names).toContain('grep_search'); + expect(names).toContain('read_file'); + expect(result.coveredCount).toBe(3); + }); + + it('resolves legacy aliases to canonical names', () => { + const result = computeCoverage( + makeInventory([makeCase({ toolReferences: ['search_file_content'] })]), + registry, + ); + + const names = result.covered.map((t) => t.name); + expect(names).toContain('grep_search'); + expect(names).not.toContain('search_file_content'); + }); + + it('ignores unrecognized tool names silently', () => { + const result = computeCoverage( + makeInventory([makeCase({ toolReferences: ['nonexistent_tool_xyz'] })]), + registry, + ); + + expect(result.covered.map((t) => t.name)).not.toContain( + 'nonexistent_tool_xyz', + ); + expect(result.coveredCount).toBe(0); + }); + + it('sorts covered tools alphabetically', () => { + const result = computeCoverage( + makeInventory([ + makeCase({ toolReferences: ['write_file'] }), + makeCase({ toolReferences: ['glob'] }), + makeCase({ toolReferences: ['grep_search'] }), + ]), + registry, + ); + + const names = result.covered.map((t) => t.name); + expect(names).toEqual([...names].sort()); + }); + + it('sorts uncovered tools alphabetically', () => { + const result = computeCoverage(makeInventory([]), registry); + const names = result.uncovered.map((t) => t.name); + expect(names).toEqual([...names].sort()); + }); + + it('sorts files within a covered entry alphabetically', () => { + const result = computeCoverage( + makeInventory([ + makeCase({ + relativePath: 'evals/z-last.eval.ts', + toolReferences: ['glob'], + }), + makeCase({ + relativePath: 'evals/a-first.eval.ts', + toolReferences: ['glob'], + }), + ]), + registry, + ); + + const globEntry = result.covered.find((t) => t.name === 'glob')!; + expect(globEntry.files[0].relativePath).toBe('evals/a-first.eval.ts'); + expect(globEntry.files[1].relativePath).toBe('evals/z-last.eval.ts'); + }); + + it('computes coverage percent correctly', () => { + const totalTools = registry.totalTools; + const halfTools = [...registry.tools.keys()].slice( + 0, + Math.floor(totalTools / 2), + ); + const cases = halfTools.map((name) => + makeCase({ toolReferences: [name] }), + ); + + const result = computeCoverage(makeInventory(cases), registry); + + const expected = Math.round((halfTools.length / totalTools) * 1000) / 10; + expect(result.coveragePercent).toBe(expected); + }); + + it('coveragePercent is 0 for empty inventory', () => { + const result = computeCoverage(makeInventory([]), registry); + expect(result.coveragePercent).toBe(0); + }); + + it('resolves absolute diagnostic file paths to relative paths', () => { + const diagnostic = { + severity: 'warning' as const, + message: 'Could not resolve policy', + filePath: '/repo/evals/bad.eval.ts', + location: { line: 5, column: 3 }, + }; + const result = computeCoverage( + makeInventory([], { diagnostics: [diagnostic], repoRoot: '/repo' }), + registry, + ); + + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]).toMatchObject({ + severity: 'warning', + message: 'Could not resolve policy', + filePath: 'evals/bad.eval.ts', + location: { line: 5, column: 3 }, + }); + }); + }); + + describe('formatCoverageReport', () => { + function makeCoverageResult( + overrides: Partial = {}, + ): CoverageResult { + return { + totalTools: 26, + coveredCount: 0, + uncoveredCount: 26, + coveragePercent: 0, + covered: [], + uncovered: [], + diagnostics: [], + ...overrides, + }; + } + + it('includes the title and summary line', () => { + const result = makeCoverageResult({ + totalTools: 26, + coveredCount: 10, + uncoveredCount: 16, + coveragePercent: 38.5, + }); + + const report = formatCoverageReport(result); + + expect(report).toContain('Eval Coverage Report'); + expect(report).toContain('10 / 26 tools covered (38.5%)'); + }); + + it('includes Covered Tools section header', () => { + const report = formatCoverageReport(makeCoverageResult()); + expect(report).toContain('Covered Tools'); + }); + + it('includes Uncovered Tools section header', () => { + const report = formatCoverageReport(makeCoverageResult()); + expect(report).toContain('Uncovered Tools'); + }); + + it('shows (none) when no tools are covered', () => { + const report = formatCoverageReport(makeCoverageResult()); + expect(report).toContain('(none)'); + }); + + it('shows full-coverage message when all tools are covered', () => { + const result = makeCoverageResult({ + coveredCount: 26, + uncoveredCount: 0, + uncovered: [], + }); + const report = formatCoverageReport(result); + expect(report).toContain('(none — full coverage!)'); + }); + + it('lists covered tools with case and file counts', () => { + const coveredEntry: CoveredToolEntry = { + name: 'grep_search', + category: 'file-system', + totalCaseCount: 5, + files: [ + { + relativePath: 'evals/grep_search_functionality.eval.ts', + caseCount: 5, + policyDistribution: { USUALLY_PASSES: 5 }, + }, + ], + policyDistribution: { USUALLY_PASSES: 5 }, + }; + + const result = makeCoverageResult({ + coveredCount: 1, + uncoveredCount: 25, + covered: [coveredEntry], + }); + + const report = formatCoverageReport(result); + + expect(report).toContain('grep_search'); + expect(report).toContain('5 cases'); + expect(report).toContain('1 file'); + expect(report).toContain('evals/grep_search_functionality.eval.ts'); + expect(report).toContain('5 USUALLY_PASSES'); + }); + + it('groups uncovered tools by category', () => { + const result = makeCoverageResult({ + uncovered: [ + { name: 'web_fetch', category: 'web' }, + { name: 'google_web_search', category: 'web' }, + { name: 'glob', category: 'file-system' }, + ], + }); + + const report = formatCoverageReport(result); + + expect(report).toContain('[web]'); + expect(report).toContain('[file-system]'); + expect(report).toContain('web_fetch'); + expect(report).toContain('google_web_search'); + expect(report).toContain('glob'); + }); + + it('does not crash when a tool has an undefined category', () => { + const result = makeCoverageResult({ + uncovered: [ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { name: 'mystery_tool', category: undefined as any }, + ], + }); + + expect(() => formatCoverageReport(result)).not.toThrow(); + const report = formatCoverageReport(result); + expect(report).toContain('mystery_tool'); + }); + + it('shows diagnostics section when diagnostics exist', () => { + const result = makeCoverageResult({ + diagnostics: [ + { + severity: 'warning', + message: 'Could not resolve policy', + filePath: 'evals/bad.eval.ts', + location: { line: 5, column: 3 }, + }, + ], + }); + + const report = formatCoverageReport(result); + + expect(report).toContain('Diagnostics'); + expect(report).toContain('⚠'); + expect(report).toContain('Could not resolve policy'); + }); + + it('omits diagnostics section when there are no diagnostics', () => { + const result = makeCoverageResult({ diagnostics: [] }); + const report = formatCoverageReport(result); + expect(report).not.toContain('Diagnostics'); + expect(report).not.toContain('⚠'); + }); + + it('shows policy distribution inside file entries', () => { + const coveredEntry: CoveredToolEntry = { + name: 'glob', + category: 'file-system', + totalCaseCount: 3, + files: [ + { + relativePath: 'evals/frugal.eval.ts', + caseCount: 3, + policyDistribution: { + ALWAYS_PASSES: 1, + USUALLY_PASSES: 2, + }, + }, + ], + policyDistribution: { ALWAYS_PASSES: 1, USUALLY_PASSES: 2 }, + }; + + const result = makeCoverageResult({ + coveredCount: 1, + covered: [coveredEntry], + }); + + const report = formatCoverageReport(result); + + expect(report).toContain('1 ALWAYS_PASSES'); + expect(report).toContain('2 USUALLY_PASSES'); + }); + }); + + describe('integration — real evals directory', () => { + it('produces a valid coverage result from the real eval suite', async () => { + const repoRoot = path.resolve(import.meta.dirname, '../../'); + const inventory = await collectInventory(repoRoot); + const result = computeCoverage(inventory, registry); + + expect(result.totalTools).toBe(registry.totalTools); + expect(result.coveredCount + result.uncoveredCount).toBe( + result.totalTools, + ); + expect(result.coveragePercent).toBeGreaterThanOrEqual(0); + expect(result.coveragePercent).toBeLessThanOrEqual(100); + expect(result.coveredCount).toBeGreaterThanOrEqual(5); + + const grepEntry = result.covered.find((t) => t.name === 'grep_search'); + expect(grepEntry).toBeDefined(); + expect(grepEntry!.totalCaseCount).toBeGreaterThanOrEqual(1); + }); + + it('formats the real coverage report without throwing', async () => { + const repoRoot = path.resolve(import.meta.dirname, '../../'); + const inventory = await collectInventory(repoRoot); + const result = computeCoverage(inventory, registry); + const report = formatCoverageReport(result); + + expect(typeof report).toBe('string'); + expect(report).toContain('Eval Coverage Report'); + expect(report).toContain('Covered Tools'); + expect(report).toContain('Uncovered Tools'); + }); + }); +}); diff --git a/scripts/utils/eval-coverage.ts b/scripts/utils/eval-coverage.ts new file mode 100644 index 0000000000..6a78b67891 --- /dev/null +++ b/scripts/utils/eval-coverage.ts @@ -0,0 +1,294 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import type { EvalAnalysisDiagnostic, EvalPolicy } from './eval-analysis.js'; +import type { InventoryResult } from './eval-inventory.js'; +import { type ToolCategory, type ToolRegistry } from './tool-registry.js'; + +const POLICY_ORDER: EvalPolicy[] = [ + 'ALWAYS_PASSES', + 'USUALLY_PASSES', + 'USUALLY_FAILS', + 'unknown', +]; + +const CATEGORY_ORDER: ToolCategory[] = [ + 'file-system', + 'shell', + 'web', + 'planning', + 'user-interaction', + 'skills', + 'task-tracker', + 'agent', + 'mcp', +]; + +export type PolicyDistribution = Partial>; + +export interface ToolEvalFileEntry { + relativePath: string; + caseCount: number; + policyDistribution: PolicyDistribution; +} + +export interface CoveredToolEntry { + name: string; + category: ToolCategory; + totalCaseCount: number; + files: ToolEvalFileEntry[]; + policyDistribution: PolicyDistribution; +} + +export interface UncoveredToolEntry { + name: string; + category: ToolCategory; +} + +export interface CoverageResult { + totalTools: number; + coveredCount: number; + uncoveredCount: number; + coveragePercent: number; + covered: CoveredToolEntry[]; + uncovered: UncoveredToolEntry[]; + diagnostics: readonly EvalAnalysisDiagnostic[]; +} + +/** + * Computes eval coverage by cross-referencing the inventory's tool references + * against the tool registry. + */ +export function computeCoverage( + inventory: InventoryResult, + registry: ToolRegistry, +): CoverageResult { + const toolFileMap = new Map< + string, + Map + >(); + + for (const toolName of registry.tools.keys()) { + toolFileMap.set(toolName, new Map()); + } + + for (const evalCase of inventory.cases) { + for (const toolName of evalCase.toolReferences) { + const canonicalName = registry.aliasLookup.get(toolName) ?? toolName; + if (!registry.tools.has(canonicalName)) { + continue; + } + + let fileMap = toolFileMap.get(canonicalName); + if (!fileMap) { + fileMap = new Map(); + toolFileMap.set(canonicalName, fileMap); + } + + const existingEntry = fileMap.get(evalCase.relativePath); + if (existingEntry) { + existingEntry.caseCount += 1; + existingEntry.policyDist[evalCase.policy] = + (existingEntry.policyDist[evalCase.policy] ?? 0) + 1; + } else { + const policyDist: PolicyDistribution = {}; + policyDist[evalCase.policy] = 1; + fileMap.set(evalCase.relativePath, { caseCount: 1, policyDist }); + } + } + } + + const covered: CoveredToolEntry[] = []; + const uncovered: UncoveredToolEntry[] = []; + + for (const [toolName, fileMap] of toolFileMap) { + const entry = registry.tools.get(toolName); + if (!entry) { + continue; + } + + if (fileMap.size === 0) { + uncovered.push({ name: toolName, category: entry.category }); + continue; + } + + const files: ToolEvalFileEntry[] = []; + const aggregateDist: PolicyDistribution = {}; + let totalCaseCount = 0; + + for (const relativePath of [...fileMap.keys()].sort()) { + const fileEntry = fileMap.get(relativePath)!; + files.push({ + relativePath, + caseCount: fileEntry.caseCount, + policyDistribution: fileEntry.policyDist, + }); + totalCaseCount += fileEntry.caseCount; + for (const policy of POLICY_ORDER) { + const count = fileEntry.policyDist[policy]; + if (count !== undefined) { + aggregateDist[policy] = (aggregateDist[policy] ?? 0) + count; + } + } + } + + covered.push({ + name: toolName, + category: entry.category, + totalCaseCount, + files, + policyDistribution: aggregateDist, + }); + } + + covered.sort((a, b) => a.name.localeCompare(b.name, 'en')); + uncovered.sort((a, b) => a.name.localeCompare(b.name, 'en')); + + const totalTools = registry.totalTools; + const coveredCount = covered.length; + const uncoveredCount = uncovered.length; + const coveragePercent = + totalTools === 0 ? 0 : Math.round((coveredCount / totalTools) * 1000) / 10; + + const filePathLookup = new Map(); + for (const f of inventory.files) { + filePathLookup.set(f.filePath, f.relativePath); + } + + const resolvedDiagnostics: EvalAnalysisDiagnostic[] = + inventory.diagnostics.map((d) => { + if (d.filePath === '') { + return d; + } + const relative = filePathLookup.get(d.filePath); + if (relative !== undefined) { + return { ...d, filePath: relative }; + } + if (path.isAbsolute(d.filePath) && inventory.repoRoot) { + return { + ...d, + filePath: path + .relative(inventory.repoRoot, d.filePath) + .replace(/\\/g, '/'), + }; + } + return d; + }); + + return { + totalTools, + coveredCount, + uncoveredCount, + coveragePercent, + covered, + uncovered, + diagnostics: resolvedDiagnostics, + }; +} + +/** + * Formats a CoverageResult as a human-readable report string. + */ +export function formatCoverageReport(result: CoverageResult): string { + const lines: string[] = []; + + lines.push('Eval Coverage Report'); + lines.push('════════════════════'); + lines.push(''); + lines.push( + `${result.coveredCount} / ${result.totalTools} tools covered (${result.coveragePercent}%)`, + ); + lines.push(''); + + lines.push('Covered Tools'); + lines.push('─────────────'); + + if (result.covered.length === 0) { + lines.push(' (none)'); + } else { + for (const tool of result.covered) { + const caseLabel = tool.totalCaseCount === 1 ? 'case' : 'cases'; + const fileLabel = tool.files.length === 1 ? 'file' : 'files'; + lines.push( + `${tool.name} (${tool.totalCaseCount} ${caseLabel} across ${tool.files.length} ${fileLabel})`, + ); + for (const file of tool.files) { + const policyParts = formatPolicyDistribution(file.policyDistribution); + lines.push(` ${file.relativePath} (${policyParts})`); + } + } + } + lines.push(''); + + lines.push('Uncovered Tools'); + lines.push('───────────────'); + + if (result.uncovered.length === 0) { + lines.push(' (none — full coverage!)'); + } else { + const byCategory = new Map(); + for (const tool of result.uncovered) { + const category = tool.category || 'unknown'; + const group = byCategory.get(category); + if (group) { + group.push(tool.name); + } else { + byCategory.set(category, [tool.name]); + } + } + + const maxCatLen = Math.max( + ...CATEGORY_ORDER.filter((c) => byCategory.has(c)).map((c) => c.length), + ...[...byCategory.keys()] + .filter((c) => !CATEGORY_ORDER.includes(c as ToolCategory)) + .map((c) => c.length), + ); + + const renderCategory = (category: string) => { + const names = byCategory.get(category); + if (!names || names.length === 0) { + return; + } + const padded = `[${category}]`.padEnd(maxCatLen + 2); + lines.push(`${padded} ${names.join(', ')}`); + }; + + for (const category of CATEGORY_ORDER) { + renderCategory(category); + } + for (const category of byCategory.keys()) { + if (!CATEGORY_ORDER.includes(category as ToolCategory)) { + renderCategory(category); + } + } + } + lines.push(''); + + if (result.diagnostics.length > 0) { + lines.push(`Diagnostics (${result.diagnostics.length})`); + lines.push('────────────────'); + for (const diagnostic of result.diagnostics) { + lines.push( + `⚠ ${diagnostic.filePath}:${diagnostic.location.line}:${diagnostic.location.column} — ${diagnostic.message}`, + ); + } + lines.push(''); + } + + return lines.join('\n'); +} + +function formatPolicyDistribution(dist: PolicyDistribution): string { + const parts: string[] = []; + for (const policy of POLICY_ORDER) { + const count = dist[policy]; + if (count !== undefined && count > 0) { + parts.push(`${count} ${policy}`); + } + } + return parts.length > 0 ? parts.join(', ') : '0 cases'; +}