mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
**eval/runner/cat8-skill-compliance.ts** — Deterministic, judge-free Cat 8
scoring. Replays inbound signals through the agent adapter (Day 5) and
extracts four iron-law metrics directly from the tool-bridge state:
- brain_first_compliance: agent called search/get_page BEFORE producing
its final answer. Non-compliance = hallucinating from general knowledge.
- back_link_compliance: every dry_run_put_page intent has at least one
markdown [Name](slug) back-link in its compiled_truth.
- citation_format: timeline entries use canonical `- **YYYY-MM-DD** |
Source — Summary`; long final answers cite at least one slug.
- tier_escalation: simple probes use light tooling (≥1 brain call);
complex probes require ≥2 brain calls or a dry_run write when
expects_dry_run_write is set.
No judge call required — everything is computable from
`tool_bridge_state.made_dry_run_writes` + `count_by_tool` + final_answer
regex. Fast, deterministic, reproducible.
Bounded concurrency (p-limit style) worker pool at default 4 to keep
Sonnet rate limits comfortable across 100-probe batches.
**eval/runner/cat9-workflows.ts** — Rubric-graded Cat 9. 5 canonical
workflows (meeting_ingestion, email_to_brain, daily_task_prep, briefing,
sync) × ~10 scenarios each. Each scenario runs through the agent adapter,
then judge.ts scores the answer against a per-scenario rubric.
`buildEvidence(scenario, agentResult, pagesBySlug)` composes the
JudgeEvidence contract: resolves ground_truth_slugs to full
GroundTruthPage[] from a slug-map, pulls tool_call_summary directly from
tool_bridge_state (no raw tool_result content — Section-3 defense),
attaches rubric from the scenario.
Per-workflow rollup: each workflow gets its own pass_rate so the verdict
can fail one workflow without failing the whole Cat. Overall verdict
requires every populated workflow's pass_rate ≥ threshold (default 0.80)
when enableThreshold=true.
Both Cats default to verdict=baseline_only in v1 per codex fix #9: real
thresholds return after 10-probe Haiku-vs-hand-score calibration (κ > 0.7)
runs against the Day 3b amara-life-v1 corpus.
**Tests (23):** Cat 8 per-metric scorer unit tests covering every branch
(brain_first ordering, back-link compliance on mixed writes, long vs
short answer citation requirement, tier escalation for simple/complex/
writey probes, finalAnswerCiteCount dedups across syntaxes). Cat 9
buildEvidence contract shape — evidence_refs flow from agent, missing
slugs skip gracefully, no raw_transcript/tool_result leakage to judge.
Cat 9 runCat9 integration with stubbed agent + mixed-verdict judge
produces fractional pass rates correctly.
Total eval suite now: 273 pass, 0 fail.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
250 lines
7.4 KiB
TypeScript
250 lines
7.4 KiB
TypeScript
/**
|
|
* Cat 9 — End-to-End Workflows.
|
|
*
|
|
* Replays ~50 scripted scenarios across 5 workflows through the agent
|
|
* adapter, then scores each answer via judge.ts's rubric. Threshold
|
|
* (informational): >80% pass rate per workflow.
|
|
*
|
|
* Workflows (canonical per TODOS.md and design-doc):
|
|
* - meeting_ingestion
|
|
* - email_to_brain
|
|
* - daily_task_prep
|
|
* - briefing
|
|
* - sync
|
|
*
|
|
* Each scenario carries its own rubric (3-5 criteria, weights 1-2). The
|
|
* rubric lives in `eval/data/gold/personalization-rubric.json` alongside
|
|
* ground-truth slugs. The runner resolves slugs to full
|
|
* GroundTruthPage[] before handing evidence to the judge.
|
|
*
|
|
* v1 verdict is baseline_only. Thresholds flip on after the 10-probe
|
|
* Haiku-vs-hand-score calibration (κ > 0.7) lands alongside Day 3b corpus.
|
|
*/
|
|
|
|
import type { Anthropic } from '@anthropic-ai/sdk';
|
|
import {
|
|
runAgentLoop,
|
|
type AgentAdapterState,
|
|
type AgentRunConfig,
|
|
type AgentRunResult,
|
|
} from './adapters/claude-sonnet-with-tools.ts';
|
|
import {
|
|
scoreAnswer,
|
|
type JudgeEvidence,
|
|
type JudgeResult,
|
|
type JudgeConfig,
|
|
type RubricCriterion,
|
|
type GroundTruthPage,
|
|
} from './judge.ts';
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────
|
|
|
|
export type WorkflowId =
|
|
| 'meeting_ingestion'
|
|
| 'email_to_brain'
|
|
| 'daily_task_prep'
|
|
| 'briefing'
|
|
| 'sync';
|
|
|
|
export const ALL_WORKFLOWS: readonly WorkflowId[] = [
|
|
'meeting_ingestion',
|
|
'email_to_brain',
|
|
'daily_task_prep',
|
|
'briefing',
|
|
'sync',
|
|
] as const;
|
|
|
|
export interface WorkflowScenario {
|
|
id: string;
|
|
workflow: WorkflowId;
|
|
text: string;
|
|
/** Slugs that resolve to GroundTruthPage objects for the judge. */
|
|
ground_truth_slugs: string[];
|
|
rubric: RubricCriterion[];
|
|
}
|
|
|
|
export interface Cat9PerScenario {
|
|
scenario_id: string;
|
|
workflow: WorkflowId;
|
|
judge_result: JudgeResult;
|
|
pass: boolean;
|
|
agent_stop_reason: AgentRunResult['stop_reason'];
|
|
agent_cost_usd: number;
|
|
}
|
|
|
|
export interface WorkflowRollup {
|
|
workflow: WorkflowId;
|
|
total: number;
|
|
passed: number;
|
|
pass_rate: number;
|
|
}
|
|
|
|
export interface Cat9Report {
|
|
schema_version: 1;
|
|
ran_at: string;
|
|
total_scenarios: number;
|
|
overall_pass_rate: number;
|
|
per_workflow: WorkflowRollup[];
|
|
per_scenario: Cat9PerScenario[];
|
|
total_cost_usd: number;
|
|
verdict: 'pass' | 'fail' | 'baseline_only';
|
|
}
|
|
|
|
export interface Cat9Config extends Omit<AgentRunConfig, 'client'> {
|
|
agentClient?: Anthropic;
|
|
judgeClient?: Anthropic;
|
|
judge?: Omit<JudgeConfig, 'client'>;
|
|
/** Pass-rate threshold per workflow. Default 0.80. */
|
|
passRateThreshold?: number;
|
|
/** When false (default in v1), verdict is baseline_only. */
|
|
enableThreshold?: boolean;
|
|
concurrency?: number;
|
|
}
|
|
|
|
export interface RunCat9Options extends Cat9Config {
|
|
scenarios: WorkflowScenario[];
|
|
state: AgentAdapterState;
|
|
pagesBySlug: Map<string, GroundTruthPage>;
|
|
}
|
|
|
|
// ─── Runner ──────────────────────────────────────────────────────────
|
|
|
|
async function runConcurrently<T, R>(
|
|
items: T[],
|
|
concurrency: number,
|
|
fn: (item: T) => Promise<R>,
|
|
): Promise<R[]> {
|
|
const results: R[] = new Array(items.length);
|
|
let next = 0;
|
|
async function worker() {
|
|
while (true) {
|
|
const idx = next++;
|
|
if (idx >= items.length) return;
|
|
results[idx] = await fn(items[idx]);
|
|
}
|
|
}
|
|
const workerCount = Math.min(concurrency, items.length);
|
|
const workers: Promise<void>[] = [];
|
|
for (let i = 0; i < workerCount; i++) workers.push(worker());
|
|
await Promise.all(workers);
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Build a JudgeEvidence object for a scenario given the agent's run result.
|
|
* Exported for tests — they verify the contract assembly in isolation.
|
|
*/
|
|
export function buildEvidence(
|
|
scenario: WorkflowScenario,
|
|
runResult: AgentRunResult,
|
|
pagesBySlug: Map<string, GroundTruthPage>,
|
|
): JudgeEvidence {
|
|
const ground_truth_pages: GroundTruthPage[] = [];
|
|
for (const slug of scenario.ground_truth_slugs) {
|
|
const page = pagesBySlug.get(slug);
|
|
if (page) ground_truth_pages.push(page);
|
|
}
|
|
|
|
return {
|
|
schema_version: 1,
|
|
probe: {
|
|
id: scenario.id,
|
|
query: scenario.text,
|
|
category: 9,
|
|
},
|
|
final_answer_text: runResult.final_answer,
|
|
evidence_refs: runResult.evidence_refs,
|
|
tool_call_summary: {
|
|
count_by_tool: runResult.tool_bridge_state.count_by_tool,
|
|
saw_poison_items: runResult.tool_bridge_state.saw_poison_items,
|
|
brain_first_ordering: runResult.brain_first_ordering,
|
|
made_dry_run_writes: runResult.tool_bridge_state.made_dry_run_writes.map(w => ({
|
|
slug: w.slug,
|
|
has_back_links: w.has_back_links,
|
|
citation_format_ok: w.citation_format_ok,
|
|
tool_name: w.tool_name,
|
|
})),
|
|
},
|
|
ground_truth_pages,
|
|
rubric: scenario.rubric,
|
|
};
|
|
}
|
|
|
|
export async function runCat9(opts: RunCat9Options): Promise<Cat9Report> {
|
|
const concurrency = opts.concurrency ?? 4;
|
|
const passRateThreshold = opts.passRateThreshold ?? 0.8;
|
|
|
|
const perScenario = await runConcurrently(opts.scenarios, concurrency, async scenario => {
|
|
// Step 1: run the agent loop.
|
|
const agentResult = await runAgentLoop(scenario.id, scenario.text, opts.state, {
|
|
client: opts.agentClient,
|
|
model: opts.model,
|
|
maxTokens: opts.maxTokens,
|
|
turnCap: opts.turnCap,
|
|
systemPrompt: opts.systemPrompt,
|
|
maxRetries: opts.maxRetries,
|
|
});
|
|
|
|
// Step 2: build evidence, score with judge.
|
|
const evidence = buildEvidence(scenario, agentResult, opts.pagesBySlug);
|
|
const judgeResult = await scoreAnswer(evidence, {
|
|
...opts.judge,
|
|
client: opts.judgeClient,
|
|
});
|
|
|
|
const score: Cat9PerScenario = {
|
|
scenario_id: scenario.id,
|
|
workflow: scenario.workflow,
|
|
judge_result: judgeResult,
|
|
pass: judgeResult.verdict === 'pass',
|
|
agent_stop_reason: agentResult.stop_reason,
|
|
agent_cost_usd: agentResult.total_cost_usd,
|
|
};
|
|
return score;
|
|
});
|
|
|
|
// Per-workflow rollup
|
|
const byWorkflow = new Map<WorkflowId, Cat9PerScenario[]>();
|
|
for (const w of ALL_WORKFLOWS) byWorkflow.set(w, []);
|
|
for (const s of perScenario) byWorkflow.get(s.workflow)!.push(s);
|
|
|
|
const per_workflow: WorkflowRollup[] = [];
|
|
for (const [workflow, scenarios] of byWorkflow) {
|
|
const total = scenarios.length;
|
|
const passed = scenarios.filter(s => s.pass).length;
|
|
per_workflow.push({
|
|
workflow,
|
|
total,
|
|
passed,
|
|
pass_rate: total === 0 ? 0 : passed / total,
|
|
});
|
|
}
|
|
|
|
const totalPassed = perScenario.filter(s => s.pass).length;
|
|
const overallPassRate = perScenario.length === 0 ? 0 : totalPassed / perScenario.length;
|
|
|
|
let verdict: 'pass' | 'fail' | 'baseline_only';
|
|
if (!opts.enableThreshold) {
|
|
verdict = 'baseline_only';
|
|
} else {
|
|
const allWorkflowsPass = per_workflow
|
|
.filter(w => w.total > 0) // ignore empty workflows
|
|
.every(w => w.pass_rate >= passRateThreshold);
|
|
verdict = allWorkflowsPass ? 'pass' : 'fail';
|
|
}
|
|
|
|
const totalCost =
|
|
perScenario.reduce((sum, s) => sum + s.agent_cost_usd + s.judge_result.cost_usd, 0);
|
|
|
|
return {
|
|
schema_version: 1,
|
|
ran_at: new Date().toISOString(),
|
|
total_scenarios: perScenario.length,
|
|
overall_pass_rate: overallPassRate,
|
|
per_workflow,
|
|
per_scenario: perScenario,
|
|
total_cost_usd: totalCost,
|
|
verdict,
|
|
};
|
|
}
|