diff --git a/.agents/skills/create-pr/SKILL.md b/.agents/skills/create-pr/SKILL.md new file mode 100644 index 0000000..fb98a08 --- /dev/null +++ b/.agents/skills/create-pr/SKILL.md @@ -0,0 +1,146 @@ +--- +name: create-pr +description: Prepare or update vieval pull requests with source-backed disclosures of CLI, eval DSL / package export, and report / viewer API changes, before-and-after usage, and migration notes. Also use for retrospective API audits of specified PRs or revisions. +--- + +# Create PR + + + +Make the PR useful to both reviewers and downstream callers: eval authors, report readers, and tools that read +vieval's files. Explain the final behavior, show exact calling changes, and preserve enough source context to audit +those claims later. Follow the repository's AGENTS.md. + +## Establish the comparison + +- Identify the repository, PR or branch, base, and head. Record resolved commit + SHAs in the disclosure. Use the actual PR diff, not every change since `main`. +- For an open PR, compare its merge base with its head. For a merged or historical + change, identify the reviewed or landed revision range and state which one you + used. Do not infer historical behavior from today's working tree. A commit in + Git is not proof of merge or release; use “head revision” unless that status + was verified. +- For stacked PRs, distinguish changes introduced here from changes inherited + from the base. State dependencies and merge order. Recheck the base after an + upstream merge; do not claim independence merely because a PR has its own URL. +- Start with changed files, then inspect definitions, exports, and real callers + at both revisions. Use `git show`, `git diff`, and targeted `rg` searches. + PR prose and commit messages are context, not proof of an API contract. + +## Inventory caller-visible changes + +Trace each changed contract across the layers that actually expose it: the eval DSL +(`describeTask`, `caseOf`, `recordScore`, `defineMetric`, ...), package exports in +`packages/vieval/package.json` (`vieval`, `vieval/config`, `vieval/core/*`, `vieval/events`, ...), config types, +the CLI, the report files, and the viewer (`@vieval/ui`). Do not scan unrelated modules or invent obligations for +untouched layers. + +Always resolve these two surfaces explicitly for a caller-visible PR, even when +one is unchanged: + +- **CLI:** inspect command definitions and dispatch (`packages/vieval/src/cli`), then show the exact command + line before and after. Include subcommands, flags, defaults, terminal output that people read (summary lines, + leaderboards), exit codes, and stdout/stderr behavior when relevant. + A prose feature name such as “adds report support” is not a CLI disclosure. +- **Report files and viewer API:** inspect `events.jsonl` (`RUN_EVENT_SCHEMA_VERSION`, event names and payload + schemas in `packages/vieval/src/events`), `run-summary.json`, `cases.jsonl`, `metrics-summary.json`, `otlp/*`, + and the viewer HTTP contract (`packages/ui/src/shared/contract.ts`, `packages/ui/API.md`). Show exact event, + field, schema version, and route changes, and whether older files are still read. If the diff does not change + this surface, say **No report file or viewer API changes**; do not omit the surface or infer a file change from + CLI behavior. + +Classify findings as added, removed, changed, or internal-only. Include: + +- Exported functions, types, fields, and re-exports. +- Parameter order and types, required arguments, defaults, options, + return values, error messages, and serialization shapes. +- Accepted values, aggregation and scoring rules, and file layouts when existing evals or reports behave + differently. + +Check these distinctions before describing a migration: + +- A new path or re-export is not a removal if the old path still exists. +- A rename of an unexported helper is not a public API break. +- A new public type does not mean the functions that run evals accept that type. Read their + parameter types; show actual accepted inputs, not an intended future API. +- Source compatibility, file compatibility, and runtime behavior are different. + An optional payload field keeps old logs readable; a renamed or retyped one needs a schema version bump. + A reader that ignores a new field does not implement the behavior it asks for. +- The ability to parse or compile an input does not prove it is scored or shown. +- An unchanged signature can require migration when defaults, scoring semantics, + file versions, or what the viewer reads change. + +Report removals explicitly, including when none were found in the inspected +public surfaces. Mention unchanged APIs only when readers could reasonably +mistake them for removed or changed APIs. Do not list every untouched function. + +## Write the disclosure + +Lead with the concrete problem and resulting behavior. Use an English +Conventional Commit title with the relevant scope (`feat(vieval): ...`, `feat(ui): ...`). Use `chore(docs): ...` for +primarily documentation changes. Keep committed PR text in clear, simple English unless the user requests another +language. + +For API changes, include a Markdown table with side-by-side inline code: + +| Surface / change | Before | After | Caller action | +|---|---|---|---| +| DSL method — changed | Exact old call | Exact new call | Required migration | +| Public type — added | Not available | Exact construction or use | Optional use or new requirement | +| Export — removed | Exact old import | Supported replacement, or none | Required migration | + +Replace the illustrative rows with verified findings; omit categories with no +rows. Put short code in backticks inside cells. Escape pipe characters as needed +for Markdown tables. Put longer compilable examples in fenced blocks below the +table and refer to them from the relevant row. Do not put fenced blocks inside +table cells. Define setup when a snippet would otherwise be ambiguous. Label partial snippets and placeholders; do +not imply they were run. + +For caller-visible PRs, give CLI and report files / viewer API their own headings in the +published body. Under **CLI changes**, use a before/after table plus copyable +command examples (and the changed output lines) for every added or changed command family. Use `Not available` +for a genuinely new command rather than hiding the before state. Under +**Report file and viewer API changes**, use the same before/after form for file or +route changes, or the explicit unchanged statement required above. Other DSL or export changes may follow in the +general API table. Do not substitute source links or a list of command names for concrete caller syntax. + +For viewer UI changes, add screenshots of the changed pages when the user asks for them or they were taken while +verifying the change. + +Then add only the migration notes that matter: + +- List added APIs with usable examples, and removed APIs with replacements. + The comparison table can serve as this inventory; do not duplicate it. +- State required edits versus optional adoption. Preserve meaningful before/after behavior differences. +- Name package version requirements when supported by the code. Do not + invent released versions or compatibility negotiation. +- Link each group of claims to commit-pinned source definitions, callers, tests, + or durable contract notes. Include both revisions when explaining a removal or + signature change. Local revision/path references are acceptable for offline + drafts; replace them with permalinks when publishing to GitHub. +- Report validation commands and outcomes at the tested revision (`pnpm typecheck`, `pnpm lint`, + `pnpm test:run`, builds, real runs, viewer checks). Distinguish compilation, automated tests, real runs, and + pending CI. Do not claim examples were executed when only their signatures were inspected. + +For changes with no caller-visible impact, use a short explicit statement and +relevant validation instead of an empty table. For a retrospective audit, produce +the same disclosure with the historical comparison range; do not create a new PR +or rewrite old PRs unless requested. + +## Deliver within the requested scope + +Before publishing, compare the final diff with the table and migration notes. +Update obsolete rows when scope changes; do not append contradictory follow-ups. +Confirm that the final body contains the **CLI changes** and **Report file and viewer API changes** headings and +that their claims match the inspected definitions. +Use a structured body argument or `gh ... --body-file` to preserve code and +newlines. Read the published body back to verify it reflects the intended text. + +Creating or updating a PR must follow the user's existing authorization. A draft +or audit request alone does not authorize publishing, merging, or changing other +PRs. When publication is already requested, complete the preparation and publish +without an extra confirmation. Keep unrelated working-tree changes out of the PR. +The skill does not require raw logs, screenshots, or temporary probe scripts to +be committed; concise validation results and source links are sufficient unless +the task specifically requires those artifacts. diff --git a/.agents/skills/create-pr/agents/openai.yaml b/.agents/skills/create-pr/agents/openai.yaml new file mode 100644 index 0000000..6c6c942 --- /dev/null +++ b/.agents/skills/create-pr/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Create PR" + short_description: "Disclose CLI, DSL, and report changes and migration steps in PRs" + default_prompt: "Use $create-pr to prepare a PR with API changes, before-and-after examples, and migration notes." diff --git a/README.md b/README.md index 18e4653..b7cbba8 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,33 @@ describeTask('arithmetic-quality', ({ casesFromInputs }) => { }) ``` +Declare the scores a task measures with `defineScore`. Each score is a named value in `0..1` where higher is better; one score per task is `primary` and becomes the headline, shown next to the completion rate. A derived score (`combine: { all | mean | weighted }`) is computed by the runtime when the case try settles: + +```ts +import { caseOf, defineScore, describeTask } from 'vieval' + +const correct = defineScore('formula.correct', { label: { 'en': 'Correct', 'zh-Hans': '正确' } }) +const natural = defineScore('formula.natural', { grader: { id: 'judge', kind: 'model' } }) +const pass = defineScore('formula.pass', { + combine: { all: [correct, { atLeast: 0.5, score: natural }] }, + label: 'Pass', + primary: true, +}) + +describeTask('read-formula', () => { + caseOf('fraction', async (context) => { + const { listenerScore, verdict } = await readAloud('1/2') + context.recordScore(correct, listenerScore) + if (verdict == null) + context.recordUnscored(natural, 'invalid_response') + else + context.recordScore(natural, verdict.score, { rationale: verdict.reason }) + }) +}, { scores: [correct, natural, pass] }) +``` + +A failed case counts `0` in every score. An errored or timed-out case is left out (`errors: 'exclude'`, the default) or counts `0` (`errors: 'zero'`), and lowers the completion rate either way. An unscored value never counts. Latency and cost are metrics (`defineMetric` with `better: 'lower'`), not scores. + `describeEval` remains exported as an alias of `describeTask`, but new examples should prefer `describeTask` because task/case semantics are the primary runtime model. Every `describeTask` call in an eval file is its own task: @@ -182,7 +209,8 @@ Each scheduled task receives stable matrix metadata: - `project`: the complete unit of work: discovery rules (`include`, `exclude`, `root`), `runMatrix` / `evalMatrix`, `models`, `inferenceExecutors`, optional `executor`, `plugins`, `reporters`, and `scheduler`. - `experiment`: run metadata derived from explicit `--experiment` or, when omitted, stable matrix row metadata. It does not create an extra scheduler layer. - `task`: one eval definition discovered from files and expanded across inference executors plus run/eval matrix rows. -- `case`: the scoring and evidence source inside a task. `context.recordScore(...)` contributes normalized score evidence; `context.recordMetric(...)` emits benchmark metadata for reports. +- `case`: the scoring and evidence source inside a task. `context.recordScore(score, value)` records a named score declared with `defineScore` (a value in `0..1`, higher is better; one score per task is the primary score); `context.recordUnscored(score, reason)` records that the grader could not produce one; `context.recordMetric(...)` emits benchmark data such as latency or cost for reports. +- `completion rate`: the share of cases that produced a result (`passed` or `failed`, not `errored` or `timeout`). It is shown next to the primary score everywhere. - `attempt`: a full task rerun used to estimate reliability. With `autoAttempt`, each full attempt contributes evidence, so a fail-then-pass pair scores as a success rate rather than replacing the earlier failure. - `retry`: an in-case retry. With `autoRetry`, a case can recover inside one attempt; a retry pass still counts as that attempt passing. @@ -260,6 +288,7 @@ If a project provides no `executor`, `vieval run` still discovers eval files, sc ```ts import { defineConfig } from 'vieval' +import { scoreCases } from 'vieval/core/scoring' export default defineConfig({ projects: [ @@ -282,7 +311,8 @@ export default defineConfig({ id: task.id, inferenceExecutorId: task.inferenceExecutor.id, matrix: task.matrix, - scores: [{ kind: 'exact', score: success ? 1 : 0 }], + // One measurement as one passed outcome; `scoreCases` builds `scores`, `primary`, `completion`, and `passed`. + ...scoreCases([{ scores: { 'motion.success': success ? 1 : 0 }, state: 'passed', unscored: {} }]), } }, include: ['evals/*.eval.ts'], @@ -363,7 +393,7 @@ export default defineConfig({ - One live terminal window shows `Workspaces`, `Projects`, `Tasks`, `Cases`, `Concurrency`, and `Time` rows for all workspaces. - A failing workspace does not stop the others. -- At the end, a leaderboard prints one row per workspace and project, sorted by hybrid score, then the failed workspaces with the last lines of their stderr. +- At the end, a leaderboard prints one row per workspace and project, ranked by the primary score with the completion rate in its own column, then the failed workspaces with the last lines of their stderr. If the workspaces have different primary scores, the leaderboard says so and ranks by passed cases. - The exit code is `1` when any workspace failed or had failing cases. - `--json` prints the comparison output. - Reports go to `.vieval/reports` next to the comparison config. The experiment defaults to the comparison id, and all workspaces share one attempt. @@ -393,7 +423,7 @@ vieval viewer [reportRoot...] [--port ] [--host ] [--open] vieval report analyze vieval report index [--output ] [--format table|json|jsonl] vieval report cases [--where ] [--group-by ] [--format table|json|jsonl] -vieval report compare [--case-key ] [--score-kind ] [--format table|json] +vieval report compare [--case-key ] [--score ] [--format table|json] ``` `vieval run --config ` runs a comparison; there is no separate `vieval compare` command. `vieval report compare` diffs two existing reports. @@ -427,11 +457,12 @@ A concurrency value is shared by the work where it is declared: top-level `sched ## Public Entrypoints -- `vieval`: `defineConfig`, `loadEnv`, `requiredEnvFrom`, `describeTask`, `describeEval`, `caseOf`, `casesFromInputs`, and `expect`. +- `vieval`: `defineConfig`, `loadEnv`, `requiredEnvFrom`, `describeTask`, `describeEval`, `caseOf`, `casesFromInputs`, `defineScore`, `defineMetric`, and `expect`. - `vieval/config`: lower-level `defineEval`, `defineTask`, matrix types, task context types, model definitions, and plugin contracts. - `vieval/plugins/chat-models`: `ChatModels`, `ChatProviders`, `chatModelFrom`, `chatProviderFrom`, `chatModelMatrix`, runtime config helpers, and chat telemetry helpers. - `vieval/core/runner`: collection, scheduling, task context, cache runtime, scheduler, execution, and aggregation utilities. -- `vieval/core/assertions`: assertion primitives and pipeline helpers. +- `vieval/core/assertions`: assertion primitives and pipeline helpers. Outcomes carry a `score`; record it with `context.recordScore(definition, outcome.score)`. +- `vieval/core/scoring`: the one scoring rule (`scoreCases`), derived scores (`deriveScores`), pooling (`poolScoreSummaries`), and headline resolution (`resolveHeadline`). Side-effect free. - `vieval/core/inference-executors`: env helpers and remote provider executors. - `vieval/testing/expect-extensions`: Vitest expect extensions for testing eval behavior. diff --git a/docs/.vitepress/theme/home/blocks/EvalCode.vue b/docs/.vitepress/theme/home/blocks/EvalCode.vue index 2cb943f..6a0002e 100644 --- a/docs/.vitepress/theme/home/blocks/EvalCode.vue +++ b/docs/.vitepress/theme/home/blocks/EvalCode.vue @@ -8,22 +8,23 @@ type Tone = 'comment' | 'function' | 'keyword' | 'plain' | 'string' /** Tokens per line of the sample eval file; the shape follows `docs/content/en/guide/learn/tasks-cases-and-inputs.md`. */ const lines: [string, Tone][][] = [ - [['import', 'keyword'], [' { describeTask, expect } ', 'plain'], ['from', 'keyword'], [' \'vieval\'', 'string']], + [['import', 'keyword'], [' { defineScore, describeTask, expect } ', 'plain'], ['from', 'keyword'], [' \'vieval\'', 'string']], [], [['const', 'keyword'], [' inputs = ', 'plain'], ['await', 'keyword'], [' loadEpisodes', 'function'], ['(', 'plain'], ['\'s3://evals\'', 'string'], [')', 'plain']], + [['const', 'keyword'], [' solved = ', 'plain'], ['defineScore', 'function'], ['(', 'plain'], ['\'desktop.solved\'', 'string'], [', { primary: ', 'plain'], ['true', 'keyword'], [' })', 'plain']], [], [['describeTask', 'function'], ['(', 'plain'], ['\'desktop\'', 'string'], [', ({ casesFromInputs }) => {', 'plain']], [[' casesFromInputs', 'function'], ['(', 'plain'], ['\'episode\'', 'string'], [', inputs, ', 'plain'], ['async', 'keyword'], [' (ctx) => {', 'plain']], [[' const', 'keyword'], [' { task, goal } = ctx.matrix.inputs', 'plain']], [[' const', 'keyword'], [' run = ', 'plain'], ['await', 'keyword'], [' agent.', 'plain'], ['play', 'function'], ['(task)', 'plain']], [[' expect', 'function'], ['(run.state).', 'plain'], ['toEqual', 'function'], ['(goal)', 'plain']], - [[' ctx.', 'plain'], ['recordScore', 'function'], ['(', 'plain'], ['await', 'keyword'], [' judge', 'function'], ['(run))', 'plain']], + [[' ctx.', 'plain'], ['recordScore', 'function'], ['(solved, ', 'plain'], ['await', 'keyword'], [' judge', 'function'], ['(run))', 'plain']], [[' })', 'plain']], [['})', 'plain']], ] /** Lines each case passes through, in order: read inputs, run the agent, assert, score. */ -const CASE_LINES = [6, 7, 8, 9] +const CASE_LINES = [7, 8, 9, 10] const LINE_HEIGHT_EM = 1.8 const BEAT = 420 diff --git a/docs/content/en/api/index.md b/docs/content/en/api/index.md index d0dc5df..00bd1a1 100644 --- a/docs/content/en/api/index.md +++ b/docs/content/en/api/index.md @@ -6,11 +6,11 @@ Vieval publishes focused entrypoints so application code can import the narrowes | Import | Import when | | --- | --- | -| `vieval` | Author eval files or config with the task DSL, config helpers, and the preconfigured assertion API; representative imports are `describeTask`, `defineConfig`, and `expect`. | -| `vieval/core/assertions` | Build and evaluate structured assertion pipelines and convert their outcomes into runner scores with exports such as `evaluateAssertions`, `expectRubric`, and `toRunScores`. | +| `vieval` | Author eval files or config with the task DSL, config helpers, and the preconfigured assertion API; representative imports are `describeTask`, `defineScore`, `defineMetric`, `defineConfig`, and `expect`. | +| `vieval/core/assertions` | Build and evaluate structured assertion pipelines with exports such as `evaluateAssertions`, `expectRubric`, and `expectCustom`. Each outcome carries a `score` in `0..1`; the case records it with `context.recordScore(definition, outcome.score)`. | | `vieval/expect` | Import the standalone `expect` with Vieval matchers pre-installed without importing the rest of the main authoring surface. | -Start with [Tasks, Cases, and Inputs](/en/guide/learn/tasks-cases-and-inputs) and [Assertions, Scores, and Metrics](/en/guide/learn/assertions-scores-and-metrics). The task DSL and the structured core assertion pipeline are both public, but outcomes from `vieval/core/assertions` are not connected to task cases automatically. +Start with [Tasks, Cases, and Inputs](/en/guide/learn/tasks-cases-and-inputs) and [Assertions, Scores, and Metrics](/en/guide/learn/assertions-scores-and-metrics). The task DSL and the structured core assertion pipeline are both public, but outcomes from `vieval/core/assertions` are not connected to task cases automatically: record each outcome's `score` into the score it measures. ## Config and integration @@ -30,8 +30,7 @@ These are public integration surfaces for hosts that need to assemble or drive e | `vieval/cli` | Embed the import-safe top-level CLI parser and command dispatcher through `parseTopLevelCliArguments` and `runTopLevelCli`. | | `vieval/core/runner` | Assemble collection, scheduling, and execution directly with exports such as `collectEvalEntries`, `createRunnerSchedule`, and `runScheduledTasks`. | | `vieval/core/scheduler` | Admit projects, tasks, and cases through concurrency pools and middleware without the runner's collection APIs, using `createScheduler` and `Scheduler.child`. | -| `vieval/core/scoring` | Score case outcomes with the same rule as tasks and the viewer, using `scoreCaseOutcomes`, `strictExactOf`, and `isMeasuredState`. It has no side effects, so browser code can import it. | -| `vieval/core/processors/results` | Apply built-in result policies and inspect the gate through `processRunResults` and the `ResultGateDecision` type. | +| `vieval/core/scoring` | Score case outcomes with the same rule as tasks, reports, and the viewer. `scoreCases(outcomes, definitions)` returns the score summaries, the completion rate, the share of passed cases, and the primary score name; a custom `defineTask` can return its result as `TaskRunOutput`. `deriveScores` computes the derived (`combine`) scores of one case try, `poolScoreSummaries` pools summaries of the same name across task runs, `resolveHeadline` resolves the headline of a project or run, and `isMeasuredState` tells whether a state produced a result. It has no side effects, so browser code can import it. | | `vieval/core/inference-executors` | Build custom inference integrations with exports such as `createProviderAdapter`, `createRetryPolicy`, and `createOpenAIFromEnv`. | Read [Reliable Execution](/en/guide/learn/reliable-execution) before composing runner, scheduler, retry, or cache behavior. For aggregation, gating, and persisted evidence, continue with [Reports and Comparisons](/en/guide/learn/reports-and-comparisons). diff --git a/docs/content/en/guide/core-concepts.md b/docs/content/en/guide/core-concepts.md index c3dd652..3d53257 100644 --- a/docs/content/en/guide/core-concepts.md +++ b/docs/content/en/guide/core-concepts.md @@ -25,7 +25,7 @@ run --json -> machine-readable stdout run -> report artifacts (.vieval/reports by default) -> analyze / compare ``` -A **workspace** is a config that lists `projects`, inline or collected from other configs such as `packages/*`. A **comparison** is a config that lists `workspaces` and runs each one in its own process, side by side. A **project** defines a discovery boundary and project-level configuration. A discovered eval definition may carry a **task**, while DSL definitions such as `describeTask` register tasks directly. Matrix rows expand the registered tasks into concrete scheduled work. A task contains one or more **cases**, where assertions, normalized scores, and custom metrics provide execution evidence. +A **workspace** is a config that lists `projects`, inline or collected from other configs such as `packages/*`. A **comparison** is a config that lists `workspaces` and runs each one in its own process, side by side. A **project** defines a discovery boundary and project-level configuration. A discovered eval definition may carry a **task**, while DSL definitions such as `describeTask` register tasks directly. Matrix rows expand the registered tasks into concrete scheduled work. A task contains one or more **cases**, where assertions, named scores, and custom metrics provide execution evidence. One score per task is its **primary** score; reports show it as the headline, next to the **completion rate**, the share of cases that produced a result. Execution reliability has two scopes. An **attempt** reruns a full task and retains the evidence from every attempt. A **retry** lets a failed case run again within an attempt. Output is explicit: `--json` prints a machine-readable result to standard output, and every run writes the report artifacts consumed by analysis and comparison commands, by default to `.vieval/reports` next to the config. `--report-out ` changes that root and `--no-report` turns reports off. diff --git a/docs/content/en/guide/learn/assertions-scores-and-metrics.md b/docs/content/en/guide/learn/assertions-scores-and-metrics.md index 2e363f1..aff07ac 100644 --- a/docs/content/en/guide/learn/assertions-scores-and-metrics.md +++ b/docs/content/en/guide/learn/assertions-scores-and-metrics.md @@ -10,15 +10,15 @@ next: # Assertions, Scores, and Metrics -A case becomes useful evidence when it makes success observable. Start with deterministic assertions, then add normalized scores or report metadata only when the evaluation needs them. +A case becomes useful evidence when it makes success observable. Start with deterministic assertions, then declare named scores for the qualities you measure, and record metrics for everything else that reports should keep. ## Start with a deterministic assertion -Vieval exports a Vitest-compatible `expect`. A failed matcher throws an `AssertionError` inside the case, so the case ends `failed`: it produced a wrong result and contributes an `exact` score of `0`. A case that completes without a custom score contributes `1`. +Vieval exports a Vitest-compatible `expect`. A failed matcher throws an `AssertionError` inside the case, so the case ends `failed`: it produced a wrong result. -Any other error, such as a provider rate limit, a dropped connection, or a bug, ends the case `errored`: it produced no result. A timeout ends it `timeout`, which also has no result. These cases stay out of the `exact` score. Instead they lower the coverage, the share of settled cases that produced a result. The run summary keeps the older every-case score as `strictExactAverage`, where cases without a result count as `0`. Read the score together with its coverage: a high score over a low coverage measured only part of the dataset. +Any other error, such as a provider rate limit, a dropped connection, or a bug, ends the case `errored`: it produced no result. A timeout ends it `timeout`, which also has no result. These cases lower the **completion rate**, the share of cases that produced a result. Vieval shows the completion rate next to the headline score everywhere, so read the two together: a high score over a low completion rate measured only part of the dataset. -To mark a wrong result, use `expect` or `recordScore(0)`. Do not throw a plain `Error`, which reads as "no result". If your test object can crash on hard inputs and a crash should count as a wrong result, set `classifyError` on the task or case: +To mark a wrong result, use `expect`. Do not throw a plain `Error`, which reads as "no result". If your test object can crash on hard inputs and a crash should count as a wrong result, set `classifyError` on the task or case: ```ts import { isRetriableProviderError } from 'vieval/core/inference-executors' @@ -40,51 +40,192 @@ describeTask('normalized answer', ({ caseOf }) => { }) ``` -This is the clearest starting point for exact values, schemas, required fields, and other rules that code can decide reliably. +This is the clearest starting point for exact values, schemas, required fields, and other rules that code can decide reliably. A task that records no score has no score: its headline is the share of passed cases, labeled "Passed cases". ## Separate the evidence types | Term | Meaning in Vieval | Effect on a task case | | --- | --- | --- | | Assertion | A check that produces a pass/fail decision. `expect` controls the case by throwing on failure; `vieval/core/assertions` also exposes assertion functions that return structured outcomes. | A thrown matcher fails the case. Core assertion outcomes are not connected to the task DSL automatically. | -| Score | A normalized number in `0..1`, recorded with `context.recordScore(value, kind)`. Public score kinds are `exact` and `judge`; `exact` is the default. | Contributes to task-result aggregation when the case passes. Its emitted event may also be persisted in a report case record. | -| Metric | Named benchmark metadata recorded with `context.recordMetric(name, value)`. Values may be strings, numbers, booleans, `null`, or arrays of those telemetry values. | Appears in report events and case records, but does not change pass/fail state or score aggregation. | -| Rubric | An assertion whose `judge` callback returns a reason and normalized score, then compares that score with `minScore` (default `0.7`). | Produces an `AssertionOutcome` with score kind `judge`; the author decides how to bridge that outcome into a task case. | +| Score | A named value in `0..1` where higher is better, declared with `defineScore` and recorded with `context.recordScore(score, value)`. One score per task is the primary score. | Aggregated per name over the task's cases. A failed case counts as `0` in every score. | +| Completion rate | The built-in share of cases that produced a result: `(passed + failed) / (passed + failed + errored + timeout)`. Skipped cases are left out. | Always reported next to the primary score. An eval cannot turn it off. | +| Metric | Named benchmark data recorded with `context.recordMetric(name, value)`: latency, cost, token counts, categories. Values may be strings, numbers, booleans, `null`, or arrays of those. | Appears in report events and case records, but does not change the case state or any score. | +| Rubric | An assertion whose `judge` callback returns a reason and normalized score, then compares that score with `minScore` (default `0.7`). | Produces an `AssertionOutcome`; the case records its `score` into a score of its choice. | -The `recordScore` and `recordMetric` methods are available on the case callback context received by `caseOf` and `casesFromInputs`. +The `recordScore`, `recordUnscored`, and `recordMetric` methods are available on the case callback context received by `caseOf` and `casesFromInputs`. -## Record scores and metrics from a case +## Declare a score -Use `recordScore` for a value that should be averaged. Use `recordMetric` for dimensions or observations that reports should retain without treating them as the result itself. +`defineScore(name, metadata)` declares a score once, as `defineMetric` does for metrics. The name is the stable identifier written to reports. `label` and `description` take one string, or one string per BCP 47 locale; the viewer picks the reader's language and falls back to `en`, then to the first entry. Hovering the score in the viewer shows the description. ```ts [evals/retrieval.eval.ts] -import { describeTask, expect } from 'vieval' +import { defineScore, describeTask, expect } from 'vieval' + +const recall = defineScore('retrieval.recall', { + description: { + 'en': 'Share of the expected documents that the retriever returned.', + 'zh-Hans': '检索结果中包含的期望文档所占的比例。', + }, + label: { 'en': 'Recall', 'zh-Hans': '召回率' }, +}) describeTask('retrieval', ({ caseOf }) => { caseOf('finds the expected documents', ({ recordMetric, recordScore }) => { const expected = new Set(['doc-a', 'doc-b']) const retrieved = ['doc-a', 'doc-c'] const matches = retrieved.filter(id => expected.has(id)).length - const recall = matches / expected.size - recordScore(recall, 'exact') + recordScore(recall, matches / expected.size) recordMetric('benchmark.case.id', 'retrieval-basic') recordMetric('retrieved.count', retrieved.length) - expect(recall).toBeGreaterThan(0) + expect(matches).toBeGreaterThan(0) }) +}, { scores: [recall] }) +``` + +- The value must be finite and inside `0..1`. Within one case try, the last `recordScore` call per name wins. +- `recordScore` also accepts a bare name, for example `recordScore('retrieval.recall', 0.5)`. The score then has no label or description. +- A definition is announced to the report the first time a case records it. List the task's scores in the `scores` option of `describeTask` to announce them when the task starts. A listed score is known before the first case, and it counts as missing for a passed case that never records it. + +## Choose the primary score + +Pages, leaderboards, comparisons, and the CLI summary line show one score per task first, with its label next to its value. That is the **primary** score. + +- Mark it with `primary: true`. At most one score of a task is primary; a second one throws when it is announced. +- A task with exactly one score uses that score as primary without the flag. +- A task with several scores and none marked primary has no primary score. Its headline falls back to the share of passed cases. +- When the tasks of a run have different primary scores, the run has no run-level headline score. Each task's primary score is listed with its task name, and the completion rate stays as the run headline. + +## Combine scores + +A derived score is computed from other scores of the same case try. Use it when the headline answers a question that combines several measurements, for example "correct and natural": + +```ts [evals/read-aloud.eval.ts] +import { defineScore, describeTask } from 'vieval' + +const correct = defineScore('tts.correct', { + label: { 'en': 'Written back correctly', 'zh-Hans': '听写正确' }, +}) +const natural = defineScore('tts.natural', { + description: 'The judge median: natural 1, mixed 0.5, spelled 0.', + label: { 'en': 'Natural reading', 'zh-Hans': '读法自然' }, }) +const pass = defineScore('tts.pass', { + // A bare input means `atLeast: 1`, so a mixed reading (0.5) does not pass. + combine: { all: [correct, natural] }, + label: { 'en': 'Pass', 'zh-Hans': '通过' }, + primary: true, +}) + +describeTask('read aloud', ({ caseOf }) => { + caseOf('reads a fraction', async ({ recordScore }) => { + const { listenerScore, readingScore } = await readAloud('1/2') + recordScore(correct, listenerScore) + recordScore(natural, readingScore) + // `pass` is computed when the try settles; recording it throws. + }) +}, { scores: [correct, natural, pass] }) +``` + +`combine` takes one of three forms, so the viewer can show the formula, for example "all of: correct, natural": + +| Form | Value | +| --- | --- | +| `{ all: [a, { score: b, atLeast: 0.5 }] }` | `1` when every input reaches its threshold, else `0`. A bare input passes at `1`. `all` is always binary. | +| `{ mean: [a, b] }` | The mean of the inputs. | +| `{ weighted: [{ score: a, weight: 2 }, { score: b, weight: 1 }] }` | The mean weighted by `weight`. Weights are normalized. | + +- A derived score must be listed in the `scores` option of `describeTask`, because no case records it. +- When a try settles, the runtime computes every derived score whose inputs were all recorded in that try. If an input is missing, the derived score is missing for that case. +- A case cannot record a derived score; `recordScore` throws. +- When an eval needs another rule, compute the value in the case and record a plain score whose `description` explains the rule. + +## How a case state counts + +One rule decides how each case counts in each score. The task result, `run-summary.json`, `cases.jsonl`, and the viewer all use it: + +| Case state | Every score of the task | +| --- | --- | +| `passed` | The recorded value, or missing when the case did not record it. | +| `failed` | `0`. An assertion failed, so the case measured a wrong result. The recorded values are kept as details, but they are not counted. | +| `errored`, `timeout` | Left out by default (`errors: 'exclude'`). With `errors: 'zero'` the case counts as `0`. | +| `skipped` | Left out. | + +Set `errors` per score. `exclude` follows the view that a provider outage is not a result of the test object; the completion rate then shows how many cases were left out. `zero` matches benchmarks that count every error as a wrong answer, for example Harbor and Terminal-Bench: + +```ts +const solved = defineScore('bench.solved', { errors: 'zero', primary: true }) ``` -Scores must be finite and inside `0..1`. Within one case, another `recordScore` call with the same kind replaces the earlier value; use one final value per score family. +Each score summary reports `mean` (over the counted cases, or `null`), `n` (cases that counted), and `total` (settled cases that were not skipped). An empty case selection, for example a `--case` filter that matches nothing, returns no scores. -Task-result aggregation and persisted report records have different timing. If a case later fails, task-result aggregation ignores its custom score contributions and adds an exact `0`. If it errors or times out, task-result aggregation leaves it out of the exact score. Each `recordScore` call also emits an event immediately, however, so a score emitted before the failure may remain in the persisted case record. For a `failed` case, the case-end event supplies an exact `0` only when that record does not already have an exact score. An `errored` or `timeout` record gets no default exact score, so `vieval report` means leave it out. When diagnosing a report, read the case `state` together with its recorded scores. +## Record a score the grader could not produce -Metrics are evidence for filtering, grouping, and diagnosing reports. A metric alone never makes a case pass, fail, or receive a higher aggregate score. +Sometimes a case runs to the end, but its grader cannot produce a value: the judge's answer cannot be parsed, the grader fails, or the output is empty. That is neither a wrong result nor an execution error. Record it with `recordUnscored`: + +```ts +caseOf('reads a fraction', async ({ recordScore, recordUnscored }) => { + const verdict = await judgeReading(audio) + if (verdict == null) { + recordUnscored(natural, 'invalid_response') + return + } + recordScore(natural, verdict.score) +}) +``` + +- `reason` is a string. The viewer shows its own labels for `grader_failed`, `invalid_response`, `no_response`, and `refusal`. Any other string is shown as written. +- An unscored value never counts, whatever `errors` says. It is not `0`, because it says nothing about the test object. +- An unscored record and a value of the same name replace each other in one try. The last call wins. +- A derived score with an unscored input is unscored with the reason `input_unscored`. +- The case state does not change. A passed case with an unscored value stays passed, and the completion rate does not change. +- Each score summary counts unscored values per reason in `unscored`, for example `{ grader_failed: 2 }`. They are part of `total`, not of `n`. + +## Explain a value: rationale and grader + +A recorded value can say why it has its value, and what produced it. The viewer shows both on the case page, next to the score badge: + +```ts +recordScore(natural, median(verdicts.map(verdict => verdict.score)), { + grader: { id: 'judge', kind: 'model', model: judgeModel.model, reducer: 'median', votes: verdicts.length }, + metadata: { verdicts }, + rationale: verdicts.map(verdict => verdict.reason).join('\n'), +}) +``` + +- `rationale` is a judge's reasoning or the rule that matched. +- `grader` says what produced the value. `kind` is `rule` (deterministic code), `model` (an LLM or another model), `human` (a reviewer), or `external` (another harness's verifier). `id`, `model`, `version`, `votes`, and `reducer` are optional. +- `metadata` holds other details, shown as an object tree. +- Declare the usual grader once on the definition, `defineScore(name, { grader: { kind: 'model', id: 'judge' } })`. A record overrides it, for example when the judge model is a matrix axis. +- A derived score has no grader of its own. The viewer shows the graders of its inputs. +- `grader.id` matches the `role` of the judge's `inference.*` events, so the viewer can link a score to its judge calls. + +The grader is part of what a score means. Two runs whose graders differ in `kind`, `model`, or `version` do not measure the same thing, so a comparison of such runs shows a warning on that score. + +## Scores versus metrics + +A score answers "how good was this case?" with a value in `0..1` where higher is better. Everything else is a metric: ranges, directions, units, counts, and categories belong to metrics. + +Latency is a metric, not a score. Give it a direction so that reports know lower is better: + +```ts +import { defineMetric } from 'vieval' + +const latency = defineMetric('read_aloud.latency', { + better: 'lower', + label: { 'en': 'Read-aloud latency', 'zh-Hans': '朗读耗时' }, + unit: 'ms', +}) + +recordMetric(latency, elapsedMs) +``` + +Metrics are evidence for filtering, grouping, and diagnosing reports. A metric alone never makes a case pass, fail, or change a score. ## Give metrics a readable name -The viewer shows metric names such as `osworld.model.usage.output_tokens` as they are. Declare the metric once with `defineMetric` to give it a label, a description, a unit, and a direction. Label and description take one string, or one string per BCP 47 locale; the viewer picks the reader's language and falls back to `en`, then to the first entry. +The viewer shows metric names such as `osworld.model.usage.output_tokens` as they are. Declare the metric once with `defineMetric` to give it a label, a description, a unit, and a direction. Label and description use the same localized shape as scores. ```ts [evals/agent.eval.ts] import { defineMetric, describeTask } from 'vieval' @@ -109,7 +250,7 @@ describeTask('agent', ({ caseOf }) => { }) ``` -The first value that carries metadata emits a `metric.defined` event for the task; the same metadata is not emitted again, and changed metadata is emitted again. Keep one set of metadata per metric name: the viewer shows the latest one. In the viewer, hovering a metric shows its label, name, description, unit, direction, and the distribution of its values across the task's cases. +The first value that carries metadata emits a `metric.defined` event for the task; the same metadata is not emitted again, and changed metadata is emitted again. Keep one set of metadata per metric name: the viewer shows the latest one. In the viewer, hovering a metric shows its label, name, description, unit, direction, and the distribution of its values across the task's cases. Scores appear in the same metric panels, before the metrics, always as percentages where higher is better. `recordArtifact` takes `label` and `description` in the same shape, for example `recordArtifact('final.webp', { contentType: 'image/webp', path, label: { 'en': 'Final screen', 'zh-Hans': '最终画面' } })`. @@ -136,12 +277,21 @@ const [outcome] = await evaluateAssertions([ This example is deterministic and makes no model call. `expectRubric` does not select a model or invoke a provider by itself. A `judge` callback may use local logic, a human result, or model inference. -The core assertion pipeline and the task DSL are currently separate public surfaces. `evaluateAssertions` returns outcomes; it does not automatically call the case callback context's `recordScore`, `recordMetric`, or `expect`. If you bridge an outcome manually, decide explicitly whether you are recording its numeric score, failing the case from `outcome.pass`, retaining its reason as a metric, or some combination. Throwing an `AssertionError` (for example through `expect`) makes the task result treat the case as an exact failure; throwing another error makes it `errored` unless `classifyError` says otherwise. Score events emitted before the throw may still appear in the persisted case record. +The core assertion pipeline and the task DSL are separate public surfaces. `evaluateAssertions` returns outcomes; it does not call the case context's `recordScore`, `recordMetric`, or `expect`. Bridge an outcome explicitly into the score it measures: + +```ts +const concise = defineScore('answer.concise', { grader: { id: 'concise-answer', kind: 'rule' } }) + +recordScore(concise, outcome.score, { rationale: outcome.reason }) +expect(outcome.pass).toBe(true) +``` + +Decide deliberately whether the outcome also fails the case. Throwing an `AssertionError` (for example through `expect`) makes the case `failed`, so every score of the task counts it as `0`; throwing another error makes it `errored` unless `classifyError` says otherwise. ::: warning Model-backed judging has runtime consequences If your `judge` callback invokes a model, it needs the relevant provider configuration and credentials and adds latency and cost. Case concurrency can fan out judge requests, so apply limits that match provider capacity. Review the provider's data policy and choose deliberately which case inputs, outputs, judge prompts, reasons, metrics, or events you retain in report artifacts. ::: -A common boundary error is treating a metric as a score or assuming a rubric outcome is wired into the case automatically. Keep the decision, numeric evidence, and diagnostic metadata separate in the code. +A common boundary error is treating a metric as a score or assuming a rubric outcome is wired into the case automatically. Keep the decision, the scores, and the diagnostic metrics separate in the code. Next, learn how model registrations and runtime adapters fit this flow in [Models and Inference Executors](/en/guide/learn/models-and-inference-executors). The assertion primitives are listed under the public [`vieval/core/assertions` entrypoint](/en/api/). diff --git a/docs/content/en/guide/learn/matrices-and-datasets.md b/docs/content/en/guide/learn/matrices-and-datasets.md index 0aab051..dd96c6f 100644 --- a/docs/content/en/guide/learn/matrices-and-datasets.md +++ b/docs/content/en/guide/learn/matrices-and-datasets.md @@ -100,6 +100,7 @@ Within every layer, Vieval applies controls in this order: ```ts [evals/layered.eval.ts] import { defineEval, defineTask } from 'vieval/config' +import { scoreCases } from 'vieval/core/scoring' export default defineEval({ description: 'Shows matrix layering.', @@ -125,12 +126,12 @@ export default defineEval({ }, }, run(context) { - return { - scores: [{ - kind: 'exact', - score: context.task.matrix.eval.rubric === 'strict' ? 1 : 0, - }], - } + // A custom task returns score summaries; `scoreCases` builds them from case outcomes. + return scoreCases([{ + scores: { 'layered.strict': context.task.matrix.eval.rubric === 'strict' ? 1 : 0 }, + state: 'passed', + unscored: {}, + }]) }, }), }) diff --git a/docs/content/en/guide/learn/reliable-execution.md b/docs/content/en/guide/learn/reliable-execution.md index e674d25..7f70914 100644 --- a/docs/content/en/guide/learn/reliable-execution.md +++ b/docs/content/en/guide/learn/reliable-execution.md @@ -138,7 +138,7 @@ const providerRetry = { A case retry replays the whole case. To retry only the failed provider call, wrap that call in `runWithRetry` from `vieval/core/inference-executors`. Its default delay waits for the provider's `Retry-After` or an exponential backoff with jitter, whichever is longer, and passing `{ signal }` stops the wait when the run is interrupted. -`autoAttempt: 1` permits one additional full task attempt. It starts only when a case still fails after its retries and that case has remaining attempts. Because the full registered case set runs again, attempt evidence includes both the earlier and later outcomes. For example, one failed attempt followed by one passing attempt contributes `0.5`, not a replacement pass of `1`. +`autoAttempt: 1` permits one additional full task attempt. It starts only when a case still fails after its retries and that case has remaining attempts. Because the full registered case set runs again, attempt evidence includes both the earlier and later outcomes. For example, one failed attempt followed by one passing attempt that records `1` gives that case a mean of `0.5` in the score, not a replacement pass of `1`. ::: tip Use repetition for the question it answers Attempts measure nondeterministic reliability because every completed attempt contributes evidence. Retries recover transient case failures inside one attempt; a retry that eventually passes makes that attempt pass. Retries therefore hide the earlier transient failure from task scoring, although lifecycle events still record the tries. Use retries only when recovery, rather than failure frequency, is the intended measurement. @@ -166,7 +166,7 @@ its slot back while it waits, and each later run gets its own `autoRetry` retrie be next, and without a delay a requeue would be an immediate retry. Until its last run ends, the case has not ended. It emits `case.requeued` instead of `case.ended`, the viewer shows it -as waiting to rerun (not done yet), and it does not count in scores or coverage. Only the final run is scored. A case +as waiting to rerun (not done yet), and it does not count in scores or in the completion rate. Only the final run is scored. A case whose retries and requeues all ran out ends `errored`; resume it later with `--resume`. ### Resume an attempt @@ -234,13 +234,13 @@ export default defineConfig({ Task aggregation and report artifacts answer related but different questions: -- A failed or timed-out case adds exact score evidence of `0`; a passing case without a custom score adds `1`. -- A passing case contributes its custom `exact` and `judge` scores. A failed case's custom score is not used by task aggregation. +- A failed case counts as `0` in every score of its task. An errored or timed-out case is left out of a score with `errors: 'exclude'` (the default) and counts as `0` with `errors: 'zero'`; either way it lowers the completion rate. +- A passing case contributes the values it recorded. A passing case that did not record a score is missing from that score, not counted as `1`. - Every completed auto attempt contributes another set of case outcomes to the task's aggregated score. - Reporter lifecycle, score, and metric events are emitted as execution happens. Unless the run uses `--no-report`, `events.jsonl` retains those events. -- `cases.jsonl` is a final normalized projection keyed by task and case, so repeated retries or automatic attempts do not become independent case rows. For a task with automatic attempts, the record lists every attempt in `attempts`. Read raw events when the exact sequence matters (`case.started` and `case.ended` carry `attemptIndex`), and read the task aggregate in `run-summary.json` when attempt-weighted scoring matters. +- `cases.jsonl` is a final normalized projection keyed by task and case, so repeated retries or automatic attempts do not become independent case rows. Its `scores` hold the counted value of each score under the same rule as the task result, so a failed case shows `0`. For a task with automatic attempts, the record lists every attempt in `attempts`. Read raw events when the exact sequence matters (`case.started` and `case.ended` carry `attemptIndex`), and read the task aggregate in `run-summary.json` when attempt-weighted scoring matters. -As described in [Assertions, Scores, and Metrics](/en/guide/learn/assertions-scores-and-metrics), a score event emitted before a later failure can remain in a persisted case record even though task aggregation counts that outcome as a failure. Diagnose state and score together. +As described in [Assertions, Scores, and Metrics](/en/guide/learn/assertions-scores-and-metrics), a value recorded before a later failure stays in `events.jsonl` and in the case record's `scoreDetails`, but it is not counted: the case's `scores` show `0`. Diagnose state and score together. A common failure is using `autoRetry` to estimate reliability: the recovered case appears as one passing attempt. Use `autoAttempt` when earlier failures should remain in the aggregate, and remember that it reruns cases that already passed. Also avoid treating `--attempt` as a request for more automatic attempts: it only labels the run. diff --git a/docs/content/en/guide/learn/reports-and-comparisons.md b/docs/content/en/guide/learn/reports-and-comparisons.md index 9d46615..15ae4c6 100644 --- a/docs/content/en/guide/learn/reports-and-comparisons.md +++ b/docs/content/en/guide/learn/reports-and-comparisons.md @@ -29,11 +29,11 @@ Every project is its own run. Vieval places each run under the report root as `< | Artifact | Confirmed contents | | --- | --- | -| `run-summary.json` | The run output of exactly one project, with `schemaVersion: 1`, `runId`, `reportDirectory`, identities, project status, case summaries and failures, matrix summaries, and aggregated run scores. `vieval report` commands reject other versions. | -| `events.jsonl` | Ordered run, task, case, score, metric, and custom event envelopes captured during execution. | -| `cases.jsonl` | One normalized final record per observed task/case, with identities, state, timing, a derived retry count, scores, metrics, optional input/output, and, for tasks with automatic attempts, `attemptIndex`, `autoAttempt`, and every attempt in `attempts`. | -| `metrics-summary.json` | Overall score counts, sums, and averages derived from normalized case records. | -| `otlp/traces.json`, `otlp/logs.json`, `otlp/metrics.json` | Local OTLP-shaped projections derived from the case records. Each resource and the `vieval.run` span carry `vieval.workspace.id`, `vieval.experiment.id`, `vieval.attempt.id`, and `vieval.run.id`. | +| `run-summary.json` | The run output of exactly one project, with `schemaVersion: 2`, `runId`, `reportDirectory`, identities, project status, case summaries and failures, matrix summaries, the score summaries of every task run, the `scoreDefinitions` the tasks announced, and the resolved headline (see below). `vieval report` commands reject other versions. | +| `events.jsonl` | Ordered run, task, case, score (`score.defined`, `score.recorded`), metric, and custom event envelopes captured during execution. | +| `cases.jsonl` | One normalized final record per observed task/case (`schemaVersion: 2`), with identities, state, timing, a derived retry count, scores, metrics, optional input/output, and, for tasks with automatic attempts, `attemptIndex`, `autoAttempt`, and every attempt in `attempts`. `scores` maps score names to the counted value, `unscored` maps score names to the reason the grader gave none, and `scoreDetails` keeps the `rationale`, `grader`, and `metadata` of each record. | +| `metrics-summary.json` | Overall score counts, sums, and averages per score name, derived from normalized case records. | +| `otlp/traces.json`, `otlp/logs.json`, `otlp/metrics.json` | Local OTLP-shaped projections derived from the case records. Score gauges are named `vieval.score.`. Each resource and the `vieval.run` span carry `vieval.workspace.id`, `vieval.experiment.id`, `vieval.attempt.id`, and `vieval.run.id`. | `vieval report index` creates another artifact, `index/runs.jsonl` by default. It is not written by `vieval run`. @@ -43,12 +43,30 @@ Automatic attempts require one reading caveat. `run-summary.json` contains the t The `retryCount` field counts retries within the final attempt. For tasks with automatic attempts it uses `attemptIndex`, so additional attempts are not counted as retries. Reports written before `attemptIndex` existed have no such field; for them, repeated starts from additional attempts may still increase `retryCount`. -Event lines keep `schemaVersion: 2` and case records keep `schemaVersion: 1`: new optional fields and new event names do not change the version, and readers ignore fields they do not know. A renamed, removed, or retyped field would increment the version. +Event lines have `schemaVersion: 3` and case records have `schemaVersion: 2`. Version 3 of the events replaced the fixed `exact` and `judge` score kinds with named scores. New optional fields and new event names do not change the version, and readers ignore fields they do not know. A renamed, removed, or retyped field increments the version. Readers reject reports written with an older version ("This report was written by an older vieval. Run it again."), and the viewer lists such a run as unsupported; there is no migration command. ::: warning Treat report artifacts as potentially sensitive Artifacts may contain case inputs and outputs, error messages, custom metrics, model or benchmark identifiers, and other event payloads. Store them under an appropriate retention policy, restrict access, and remove or redact fields that should not leave the evaluation environment. ::: +## Read the headline + +The headline of each project and of the run is resolved once, when the run ends, and written into `run-summary.json` as `{ headline, completion }`. The CLI, the viewer, and `vieval report` read it instead of deriving it again: + +| `headline.kind` | When | Shown as | +| --- | --- | --- | +| `score` | Every task that has scores shares one primary score name. | The pooled primary score with its label, for example `Pass 87.5%`. | +| `per-task` | The tasks have different primary scores. | No run-level score. Each task's primary score is listed with its task name. | +| `passed-cases` | No task has a primary score. | The share of passed cases, labeled "Passed cases". | + +`completion` is the completion rate, `(passed + failed) / (passed + failed + errored + timeout)`, and it is always shown next to the headline. The run summary line prints both, for example: + +```text +Pass 87.5% · completed 96.8% (4 errored) +``` + +Every score summary has `name`, `mean` (or `null` when no case counted), `n` (cases that counted), `total` (settled cases that were not skipped), `sum`, and `unscored` (counts per reason). Summaries of a project or a run pool the cases of their tasks; they are not a mean of task means. + ## Index, inspect, and analyze reports Every report command requires a report path. It can usually be a single run directory or a higher report root that contains multiple runs. @@ -75,7 +93,7 @@ vieval report analyze vieval report attempts --config vieval.config.ts ``` -`attempts` lists the attempts in a report root, newest first: per experiment and attempt, the workspaces, the passed, failed, and no-result case counts, and the coverage. "←" marks the attempt that `vieval run --resume` continues without an id; notes show which attempt an attempt resumed, interrupts, and runs killed before they wrote a summary. Pass a report root, or `--config` to list the reports next to it; filter with `--experiment` and `--workspace`, and choose `--format table|json`. +`attempts` lists the attempts in a report root, newest first: per experiment and attempt, the workspaces, the passed, failed, and no-result case counts, and the completion rate. "←" marks the attempt that `vieval run --resume` continues without an id; notes show which attempt an attempt resumed, interrupts, and runs killed before they wrote a summary. Pass a report root, or `--config` to list the reports next to it; filter with `--experiment` and `--workspace`, and choose `--format table|json`. ## Compare two existing report sets @@ -85,7 +103,7 @@ The report comparison command requires a left baseline and a right candidate: vieval report compare ``` -It reads normalized cases, aligns them by a case key, and reports matched deltas plus added and removed cases. Per-case and grouped deltas use matched records and are `right - left`; grouping uses the right-side record's selector value. A missing selected score on a matched case is treated as `0`. The overall delta instead subtracts the two full record-set means, ignoring records that lack the selected score, so additions and removals affect it and it is not the mean matched delta. `--score-kind ` defaults to `exact`, and output can be `table` or `json`. +It reads normalized cases, aligns them by a case key, and reports matched deltas plus added and removed cases. Per-case and grouped deltas use matched records and are `right - left`; grouping uses the right-side record's selector value. A missing selected score on a matched case is treated as `0`. The overall delta instead subtracts the two full record-set means, ignoring records that lack the selected score, so additions and removals affect it and it is not the mean matched delta. `--score ` selects the score and defaults to the primary score in `run-summary.json`. Output can be `table` or `json`. The default alignment key is the `benchmark.case.id` metric when present, then `vieval.case.id`, then `::`. Use `--case-key ` when the benchmark has another stable identifier. An explicit key must exist on every record. Duplicate resolved keys on either side are an error rather than an arbitrary match. @@ -117,7 +135,7 @@ vieval run --config evals/agent-memory/vieval.config.ts - The comparison `env` (and an entry's `env`) wins over each workspace config's `env`, and is applied before the workspace's plugins run. - `cache: { namespace }` makes all workspaces share `.vieval/cache///` next to the comparison config. - One live terminal window shows `Workspaces`, `Projects`, `Tasks`, `Cases`, `Concurrency`, and `Time` rows for all workspaces. A failing workspace does not stop the others. -- At the end, a leaderboard prints one row per workspace and project, sorted by hybrid score, then the failed workspaces with the last lines of their stderr. +- At the end, a leaderboard prints one row per workspace and project, ranked by the primary score, with the completion rate in its own column, then the failed workspaces with the last lines of their stderr. If the workspaces have different primary scores, the leaderboard says so and ranks by passed cases. - The exit code is `1` when any workspace failed or had failing cases. `--json` prints the comparison output. - Reports go to `.vieval/reports` next to the comparison config. The experiment defaults to the comparison id, and all workspaces share one attempt, so every run of the comparison sits under `////`. - With `--with-viewer`, one viewer serves the comparison's report root. @@ -168,7 +186,7 @@ vieval report compare \ .vieval/reports/baseline \ .vieval/reports/candidate \ --case-key benchmark.case.id \ - --score-kind exact \ + --score retrieval.recall \ --format table ``` diff --git a/docs/content/en/guide/learn/tasks-cases-and-inputs.md b/docs/content/en/guide/learn/tasks-cases-and-inputs.md index 72d3025..94929d1 100644 --- a/docs/content/en/guide/learn/tasks-cases-and-inputs.md +++ b/docs/content/en/guide/learn/tasks-cases-and-inputs.md @@ -79,7 +79,7 @@ An explicit `caseOf` name is under your control. Keep it stable when the scenari For `casesFromInputs`, generated names use the prefix plus a one-based position: ` #1`, ` #2`, and so on. Inserting or reordering rows changes those generated names and IDs. If reports must compare the same logical sample across changing dataset order, carry a unique identifier in the input and emit it as `benchmark.case.id` as shown above. That value must be unique across every case in each compared report, including cases from other tasks or projects. Duplicate comparison keys are rejected rather than matched ambiguously. -Report selectors can address direct case fields such as `caseName` and `caseId`, scores, or emitted metric names. Default case comparison looks for `benchmark.case.id`, then `vieval.case.id`, and finally the generated `caseId`. +Report selectors can address direct case fields such as `caseName` and `caseId`, scores by name (`scores.`), or emitted metric names. Default case comparison looks for `benchmark.case.id`, then `vieval.case.id`, and finally the generated `caseId`. ::: warning Keep registration inside the task `caseOf` and `casesFromInputs` require an active `describeTask` scope. Calling either function outside that scope throws during evaluation-module loading. diff --git a/docs/content/en/index.md b/docs/content/en/index.md index d3059a9..a38cdf4 100644 --- a/docs/content/en/index.md +++ b/docs/content/en/index.md @@ -20,9 +20,9 @@ home: ✓ |desktop-agent| (412 cases) cases 407 passed | 5 failed | 0 timeout $ pnpm vieval report cases .vieval/reports/candidate --where state=failed - ✗ chrome/open-settings exact 0 wrong tab + ✗ chrome/open-settings pass 0 wrong tab ✗ gimp/resize-image timeout 60m - ✗ vscode/rename-symbol exact 0 old name kept + ✗ vscode/rename-symbol pass 0 old name kept ✗ calc/sum-column rubric 0.42 partial ✗ chat/refund-policy rubric 0.31 hallucinated blocks: diff --git a/docs/content/zh-hans/api/index.md b/docs/content/zh-hans/api/index.md index f652ab4..7e37d30 100644 --- a/docs/content/zh-hans/api/index.md +++ b/docs/content/zh-hans/api/index.md @@ -6,11 +6,11 @@ Vieval 提供以下公开导入路径。编写普通评测时,通常只需从 | 导入路径 | 提供的功能 | | --- | --- | -| `vieval` | 用于编写评测文件和 CLI 配置的常用 API,包括 `describeTask`、`describeEval`、`caseOf`、`casesFromInputs`、`defineConfig`、`loadEnv`、`requiredEnvFrom`,以及已安装 Vieval 匹配器(matcher)的 `expect`。 | +| `vieval` | 用于编写评测文件和 CLI 配置的常用 API,包括 `describeTask`、`describeEval`、`caseOf`、`casesFromInputs`、`defineScore`、`defineMetric`、`defineConfig`、`loadEnv`、`requiredEnvFrom`,以及已安装 Vieval 匹配器(matcher)的 `expect`。 | | `vieval/expect` | 仅导出已安装 Vieval 匹配器的 `expect`。不需要任务 DSL 时,可以使用此入口。 | -| `vieval/core/assertions` | 创建和执行结构化断言,并将断言结果转换为供运行器使用的分数。常用导出包括 `evaluateAssertions`、`expectRubric` 和 `toRunScores`。 | +| `vieval/core/assertions` | 创建和执行结构化断言。常用导出包括 `evaluateAssertions`、`expectRubric` 和 `expectCustom`。每个断言结果都带有 `0..1` 之间的 `score`,由用例通过 `context.recordScore(definition, outcome.score)` 记录。 | -从 `vieval/core/assertions` 获得的断言结果不会自动写入任务的用例结果。调用方需要自行执行断言,并将结果传给 `toRunScores` 等后续处理函数。相关用法见[任务、用例与输入](/zh-hans/guide/learn/tasks-cases-and-inputs)和[断言、分数与指标](/zh-hans/guide/learn/assertions-scores-and-metrics)。 +从 `vieval/core/assertions` 获得的断言结果不会自动写入任务的用例结果。调用方需要自行执行断言,并把结果的 `score` 记到它所测量的分数上。相关用法见[任务、用例与输入](/zh-hans/guide/learn/tasks-cases-and-inputs)和[断言、分数与指标](/zh-hans/guide/learn/assertions-scores-and-metrics)。 ## 配置模型和执行器 @@ -31,8 +31,7 @@ Vieval 提供以下公开导入路径。编写普通评测时,通常只需从 | `vieval/cli` | 解析顶层 CLI 参数并分派命令,导出 `parseTopLevelCliArguments` 和 `runTopLevelCli`。 | | `vieval/core/runner` | 收集评测、生成调度任务、执行任务并汇总结果。常用导出包括 `collectEvalEntries`、`createRunnerSchedule`、`runScheduledTasks` 和 `aggregateRunResults`。 | | `vieval/core/scheduler` | 通过槽位池和中间件放行项目、任务和用例,导出 `createScheduler`;用 `Scheduler.child` 创建只属于某个项目、任务或用例组的池。 | -| `vieval/core/scoring` | 用与任务和查看器相同的规则为用例结果计分,导出 `scoreCaseOutcomes`、`strictExactOf` 和 `isMeasuredState`。它没有副作用,浏览器代码也可以导入。 | -| `vieval/core/processors/results` | 处理运行结果并执行内置的结果判定策略,导出 `processRunResults` 和 `ResultGateDecision`。 | +| `vieval/core/scoring` | 用与任务、报告和查看器相同的规则为用例结果计分。`scoreCases(outcomes, definitions)` 返回各分数的汇总、执行成功率、通过用例比例和主分数名,自定义的 `defineTask` 可以直接把它作为 `TaskRunOutput` 返回。`deriveScores` 计算一次用例尝试的派生分数(`combine`),`poolScoreSummaries` 合并多次任务运行中同名的汇总,`resolveHeadline` 解析项目或运行的标题数值,`isMeasuredState` 判断某个状态是否得出了结果。它没有副作用,浏览器代码也可以导入。 | 如果需要自行组织运行器、调度器、重试和缓存逻辑,请先阅读[可靠执行](/zh-hans/guide/learn/reliable-execution)。结果汇总、判定和报告文件的关系见[报告与比较](/zh-hans/guide/learn/reports-and-comparisons)。 diff --git a/docs/content/zh-hans/guide/core-concepts.md b/docs/content/zh-hans/guide/core-concepts.md index 8658005..1bb5c47 100644 --- a/docs/content/zh-hans/guide/core-concepts.md +++ b/docs/content/zh-hans/guide/core-concepts.md @@ -36,7 +36,7 @@ next: **工作区**是列出 `projects` 的配置,项目可以内联书写,也可以从 `packages/*` 等其他配置中收集。**比较**是列出 `workspaces` 的配置,它为每个工作区启动一个独立进程,并排运行。 -**项目**指定从哪个目录发现评测文件,并保存推理执行器、矩阵等项目级配置。通过 `defineEval` 声明的评测项可以包含一个**任务**,`describeTask` 等 DSL 则会直接注册任务。运行器会组合每个评测项、每个推理执行器、每行运行矩阵和每行评测矩阵,为每组组合创建一个待调度任务。任务包含一个或多个**用例**;用例执行后会产生通过或失败的断言结果,也可以记录归一化分数和自定义指标。 +**项目**指定从哪个目录发现评测文件,并保存推理执行器、矩阵等项目级配置。通过 `defineEval` 声明的评测项可以包含一个**任务**,`describeTask` 等 DSL 则会直接注册任务。运行器会组合每个评测项、每个推理执行器、每行运行矩阵和每行评测矩阵,为每组组合创建一个待调度任务。任务包含一个或多个**用例**;用例执行后会产生通过或失败的断言结果,也可以记录具名分数和自定义指标。每个任务有一个**主分数**,报告把它作为标题数值显示,并在旁边显示**执行成功率**,即得出结果的用例所占的比例。 Vieval 提供两种重复执行机制:尝试和重试。每次**尝试**都会执行任务中的全部用例;配置 `autoAttempt` 后,如果仍有符合条件的用例失败或超时,运行器会开始下一次尝试,并把各次结果都计入任务汇总。配置 `autoRetry` 后,**重试**只会在当前尝试中再次执行失败或超时的用例。 diff --git a/docs/content/zh-hans/guide/learn/assertions-scores-and-metrics.md b/docs/content/zh-hans/guide/learn/assertions-scores-and-metrics.md index 3ef627a..fd55157 100644 --- a/docs/content/zh-hans/guide/learn/assertions-scores-and-metrics.md +++ b/docs/content/zh-hans/guide/learn/assertions-scores-and-metrics.md @@ -10,15 +10,15 @@ next: # 断言、分数与指标 -评测用例需要明确记录通过或失败,并在必要时补充分数和指标。本节先介绍可由代码直接判断的断言,再说明如何记录取值在 `0..1` 之间的分数和报告指标。 +评测用例需要明确记录通过或失败,并在必要时补充分数和指标。本节先介绍可由代码直接判断的断言,再说明如何声明具名分数、组合分数,以及如何记录报告指标。 ## 从确定性断言开始 -Vieval 导出了与 Vitest 兼容的 `expect`。如果匹配失败,`expect` 会抛出 `AssertionError`,当前用例以 `failed` 结束:它得出了错误的结果,计入 `0` 分的 `exact` 分数。没有记录自定义分数的用例通过时,会得到 `1` 分。 +Vieval 导出了与 Vitest 兼容的 `expect`。如果匹配失败,`expect` 会抛出 `AssertionError`,当前用例以 `failed` 结束,表示它得出了错误的结果。 -其他错误,例如服务限流、连接中断或代码缺陷,会让用例以 `errored` 结束,表示没有得出结果。超时的用例以 `timeout` 结束,同样没有结果。这些用例不计入 `exact` 分数,而是降低覆盖率,即已结束的用例中得出结果的比例。运行汇总中的 `strictExactAverage` 保留了原来的口径:没有结果的用例按 `0` 分计算。阅读分数时请同时查看覆盖率:覆盖率低时,分数只反映了数据集的一部分。 +其他错误,例如服务限流、连接中断或代码缺陷,会让用例以 `errored` 结束,表示没有得出结果。超时的用例以 `timeout` 结束,同样没有结果。这些用例会降低**执行成功率**(completion rate),即得出结果的用例所占的比例。Vieval 在所有显示主分数的地方都会同时显示执行成功率,阅读时请把两者放在一起看:执行成功率低时,分数只反映了数据集的一部分。 -要表示结果错误,请使用 `expect` 或 `recordScore(0)`,不要直接抛出普通的 `Error`,否则会被视为“没有结果”。如果被测对象在困难输入上可能崩溃,而你希望把崩溃算作错误结果,可以在任务或用例上设置 `classifyError`: +要表示结果错误,请使用 `expect`,不要直接抛出普通的 `Error`,否则会被视为“没有结果”。如果被测对象在困难输入上可能崩溃,而你希望把崩溃算作错误结果,可以在任务或用例上设置 `classifyError`: ```ts import { isRetriableProviderError } from 'vieval/core/inference-executors' @@ -42,55 +42,196 @@ describeTask('normalized answer', ({ caseOf }) => { ``` ::: -精确值、数据结构、必需字段,以及其他可以由代码可靠判断的规则,都适合使用 `expect`。 +精确值、数据结构、必需字段,以及其他可以由代码可靠判断的规则,都适合使用 `expect`。没有记录任何分数的任务就没有分数,它的标题数值是通过用例的比例,并标注为“通过用例”。 ## 选择断言、分数或指标 | 术语 | 在 Vieval 中的含义 | 对任务用例的影响 | | --- | --- | --- | | 断言(Assertion) | 检查结果是否满足要求。`expect` 失败时会抛出错误;`vieval/core/assertions` 还提供返回结构化结果的断言函数。 | `expect` 抛出的错误会使当前用例失败。核心断言函数的返回值不会自动写入任务结果。 | -| 分数(Score) | 通过 `context.recordScore(value, kind)` 记录的 `0..1` 数值。分数类型可以是 `exact` 或 `judge`,默认为 `exact`。 | 用例通过时,分数会参与任务结果的汇总;相应事件也会写入报告中的用例记录。 | -| 指标(Metric) | 通过 `context.recordMetric(name, value)` 记录的具名数据。值可以是字符串、数值、布尔值、`null`,或这些值组成的数组。 | 指标会写入报告事件和用例记录,但不会改变用例状态,也不参与分数汇总。 | -| 评分标准(rubric) | 一类核心断言。`judge` 回调返回评分理由和分数,Vieval 再将分数与 `minScore` 比较;默认阈值为 `0.7`。 | 返回一个分数类型为 `judge` 的 `AssertionOutcome`。你需要自行决定如何将它写入当前用例。 | +| 分数(Score) | 用 `defineScore` 声明、用 `context.recordScore(score, value)` 记录的具名数值,取值在 `0..1` 之间,越高越好。每个任务有一个主分数。 | 按分数名在任务的所有用例上汇总。失败的用例在每个分数中都按 `0` 计算。 | +| 执行成功率(Completion rate) | 内置的数值,表示得出结果的用例比例:`(passed + failed) / (passed + failed + errored + timeout)`,不计跳过的用例。 | 总是显示在主分数旁边,评测无法关闭它。 | +| 指标(Metric) | 通过 `context.recordMetric(name, value)` 记录的具名数据,例如耗时、费用、token 数、类别。值可以是字符串、数值、布尔值、`null`,或这些值组成的数组。 | 指标会写入报告事件和用例记录,但不会改变用例状态,也不影响任何分数。 | +| 评分标准(rubric) | 一类核心断言。`judge` 回调返回评分理由和分数,Vieval 再将分数与 `minScore` 比较;默认阈值为 `0.7`。 | 返回一个 `AssertionOutcome`。由用例自己决定把它的 `score` 记到哪个分数上。 | -`caseOf` 和 `casesFromInputs` 收到的用例回调上下文提供 `recordScore` 与 `recordMetric` 方法。 +`caseOf` 和 `casesFromInputs` 收到的用例回调上下文提供 `recordScore`、`recordUnscored` 与 `recordMetric` 方法。 -## 在用例中记录分数与指标 +## 声明分数 -需要计入任务平均分的数据应使用 `recordScore`。只用于筛选、分组或排查问题的数据应使用 `recordMetric`。 +`defineScore(name, metadata)` 用来声明一个分数,用法和 `defineMetric` 一样。`name` 是写入报告的稳定标识。名称(label)和说明(description)可以是一个字符串,也可以按 BCP 47 语言标签各写一份;viewer 会按读者的语言选择,找不到时依次回退到 `en` 和第一项。在 viewer 中把鼠标悬停在分数上,就能看到它的说明。 ::: code-group ```ts [evals/retrieval.eval.ts] -import { describeTask, expect } from 'vieval' +import { defineScore, describeTask, expect } from 'vieval' + +const recall = defineScore('retrieval.recall', { + description: { + 'en': 'Share of the expected documents that the retriever returned.', + 'zh-Hans': '检索结果中包含的期望文档所占的比例。', + }, + label: { 'en': 'Recall', 'zh-Hans': '召回率' }, +}) describeTask('retrieval', ({ caseOf }) => { caseOf('finds the expected documents', ({ recordMetric, recordScore }) => { const expected = new Set(['doc-a', 'doc-b']) const retrieved = ['doc-a', 'doc-c'] const matches = retrieved.filter(id => expected.has(id)).length - const recall = matches / expected.size - recordScore(recall, 'exact') + recordScore(recall, matches / expected.size) recordMetric('benchmark.case.id', 'retrieval-basic') recordMetric('retrieved.count', retrieved.length) - expect(recall).toBeGreaterThan(0) + expect(matches).toBeGreaterThan(0) }) +}, { scores: [recall] }) +``` +::: + +- 分数必须是 `0..1` 之间的有限数值。在同一次用例尝试中,同名分数以最后一次 `recordScore` 为准。 +- `recordScore` 也可以直接传分数名,例如 `recordScore('retrieval.recall', 0.5)`,这时分数没有名称和说明。 +- 用例第一次记录某个定义时,Vieval 会把它写入报告。也可以在 `describeTask` 的 `scores` 选项中列出任务的分数,让它们在任务开始时就写入报告。列出的分数在第一个用例之前就是已知的;通过的用例如果没有记录它,就算作缺失。 + +## 选择主分数 + +页面、排行榜、对比和 CLI 汇总行会优先显示每个任务的一个分数,并在数值旁边显示它的名称。这个分数就是**主分数**(primary)。 + +- 用 `primary: true` 标记主分数。一个任务最多只有一个主分数,声明第二个时会抛出错误。 +- 只有一个分数的任务不需要标记,这个分数就是主分数。 +- 有多个分数但都没有标记的任务没有主分数,标题数值会回退为通过用例的比例。 +- 如果一次运行中各任务的主分数不同,运行级别就不显示标题分数,而是按任务名分别列出各自的主分数,运行的标题数值只保留执行成功率。 + +## 组合分数 + +派生分数由同一次用例尝试中的其他分数计算得出。当标题数值要回答的是几项测量组合起来的问题时,例如“听写正确并且读法自然”,就可以使用派生分数: + +::: code-group +```ts [evals/read-aloud.eval.ts] +import { defineScore, describeTask } from 'vieval' + +const correct = defineScore('tts.correct', { + label: { 'en': 'Written back correctly', 'zh-Hans': '听写正确' }, +}) +const natural = defineScore('tts.natural', { + description: { 'en': 'The judge median: natural 1, mixed 0.5, spelled 0.', 'zh-Hans': '评委中位数:natural 1,mixed 0.5,spelled 0。' }, + label: { 'en': 'Natural reading', 'zh-Hans': '读法自然' }, +}) +const pass = defineScore('tts.pass', { + // 不带阈值的输入相当于 `atLeast: 1`,所以 mixed(0.5)不算通过。 + combine: { all: [correct, natural] }, + label: { 'en': 'Pass', 'zh-Hans': '通过' }, + primary: true, }) + +describeTask('read aloud', ({ caseOf }) => { + caseOf('reads a fraction', async ({ recordScore }) => { + const { listenerScore, readingScore } = await readAloud('1/2') + recordScore(correct, listenerScore) + recordScore(natural, readingScore) + // `pass` 会在这次尝试结束时自动计算;直接记录它会抛出错误。 + }) +}, { scores: [correct, natural, pass] }) ``` ::: -分数必须是 `0..1` 之间的有限数值。在同一个用例中,如果再次记录相同类型的分数,新值会替换旧值。因此,每种分数类型最好只记录一次最终结果。 +`combine` 只支持三种形式,这样 viewer 才能显示公式,例如“全部满足:听写正确、读法自然”: + +| 形式 | 取值 | +| --- | --- | +| `{ all: [a, { score: b, atLeast: 0.5 }] }` | 每个输入都达到阈值时为 `1`,否则为 `0`。不带阈值的输入要达到 `1` 才算满足。`all` 的结果总是 0 或 1。 | +| `{ mean: [a, b] }` | 各输入的平均值。 | +| `{ weighted: [{ score: a, weight: 2 }, { score: b, weight: 1 }] }` | 按 `weight` 加权的平均值,权重会自动归一化。 | + +- 派生分数必须列在 `describeTask` 的 `scores` 选项中,因为没有用例会记录它。 +- 每次用例尝试结束时,只要派生分数的所有输入都已记录,运行时就会计算它。缺少任何一个输入时,这个用例的派生分数也是缺失的。 +- 用例不能直接记录派生分数,否则 `recordScore` 会抛出错误。 +- 如果需要其他计算规则,请在用例里自己算出数值,记录为普通分数,并在 `description` 中写明规则。 + +## 用例状态如何计入分数 + +每个用例在每个分数中如何计算,只由一条规则决定。任务结果、`run-summary.json`、`cases.jsonl` 和 viewer 都使用这条规则: + +| 用例状态 | 任务的每个分数 | +| --- | --- | +| `passed` | 用例记录的值;没有记录时为缺失。 | +| `failed` | `0`。断言失败,说明用例得出了错误的结果。用例记录过的值会作为详情保留,但不参与计算。 | +| `errored`、`timeout` | 默认不计入(`errors: 'exclude'`)。设置 `errors: 'zero'` 时按 `0` 计算。 | +| `skipped` | 不计入。 | + +`errors` 按分数分别设置。`exclude` 的出发点是:服务故障不是被测对象的结果,被排除的用例数量由执行成功率体现。`zero` 适合把每个错误都算作答错的基准测试,例如 Harbor 和 Terminal-Bench: + +```ts +const solved = defineScore('bench.solved', { errors: 'zero', primary: true }) +``` + +每个分数的汇总包含 `mean`(计入用例的平均值,没有用例计入时为 `null`)、`n`(计入的用例数)和 `total`(已结束且未跳过的用例数)。如果没有选中任何用例,例如 `--case` 过滤后为空,任务不会返回分数。 + +## 记录无法评分的情况 + +有时用例运行完了,评分方却给不出分数:评委的回答无法解析、评分程序出错,或者输出为空。这既不是错误的结果,也不是执行错误。请用 `recordUnscored` 记录: + +```ts +caseOf('reads a fraction', async ({ recordScore, recordUnscored }) => { + const verdict = await judgeReading(audio) + if (verdict == null) { + recordUnscored(natural, 'invalid_response') + return + } + recordScore(natural, verdict.score) +}) +``` + +- `reason` 是一个字符串。viewer 会为 `grader_failed`、`invalid_response`、`no_response` 和 `refusal` 显示专门的名称,其他字符串按原样显示。 +- 无论 `errors` 如何设置,无法评分的值都不参与计算。它不等于 `0`,因为它没有反映被测对象的任何情况。 +- 同一次尝试中,同名的无法评分记录和分数值会互相替换,以最后一次调用为准。 +- 派生分数的输入中只要有一个无法评分,派生分数也无法评分,原因为 `input_unscored`。 +- 用例状态不变。通过的用例即使有无法评分的值,仍然是通过;执行成功率也不变。 +- 每个分数的汇总会在 `unscored` 中按原因统计次数,例如 `{ grader_failed: 2 }`。这些用例计入 `total`,但不计入 `n`。 + +## 说明分数的来由:理由与评分方 + +记录分数时,可以说明为什么是这个值,以及由谁给出。viewer 会在用例页面的分数标记旁显示这些信息: + +```ts +recordScore(natural, median(verdicts.map(verdict => verdict.score)), { + grader: { id: 'judge', kind: 'model', model: judgeModel.model, reducer: 'median', votes: verdicts.length }, + metadata: { verdicts }, + rationale: verdicts.map(verdict => verdict.reason).join('\n'), +}) +``` + +- `rationale` 是评委的推理过程,或命中的规则。 +- `grader` 说明分数由谁给出。`kind` 可以是 `rule`(确定性代码)、`model`(LLM 或其他模型)、`human`(人工评审)或 `external`(其他评测框架的验证程序)。`id`、`model`、`version`、`votes` 和 `reducer` 都是可选的。 +- `metadata` 存放其他详情,以对象树的形式显示。 +- 常用的评分方可以在定义里声明一次:`defineScore(name, { grader: { kind: 'model', id: 'judge' } })`。单次记录可以覆盖它,例如评委模型本身是矩阵的一个维度时。 +- 派生分数没有自己的评分方,viewer 会显示其输入的评分方。 +- `grader.id` 与评委 `inference.*` 事件中的 `role` 一致,viewer 据此把分数和对应的评委调用关联起来。 + +评分方是分数含义的一部分。如果两次运行的评分方在 `kind`、`model` 或 `version` 上不同,它们测量的就不是同一件事,对比这两次运行时,该分数旁边会显示警告。 + +## 分数与指标的区别 + +分数回答的是“这个用例做得有多好”,取值在 `0..1` 之间,越高越好。其他数据都应该记为指标:取值范围、优劣方向、单位、计数和类别都属于指标。 + +耗时是指标,而不是分数。为它设置方向,报告就知道数值越低越好: + +```ts +import { defineMetric } from 'vieval' -任务汇总分数和报告中的用例记录并非同时写入。每次调用 `recordScore` 都会立即发出事件,所以用例失败前记录的分数仍可能出现在报告中。 +const latency = defineMetric('read_aloud.latency', { + better: 'lower', + label: { 'en': 'Read-aloud latency', 'zh-Hans': '朗读耗时' }, + unit: 'ms', +}) -如果用例最终失败,任务汇总会忽略这些自定义分数,并为该用例计入一个 `exact: 0`;如果用例出错或超时,任务汇总不会把它计入 `exact` 分数。对于失败的用例,只有报告中的用例记录还没有 `exact` 分数时,Vieval 才会补写 `exact: 0`。`errored` 和 `timeout` 的记录不会补写分数,因此 `vieval report` 计算平均值时会跳过它们。因此,排查结果时应同时检查用例的 `state` 和分数记录。 +recordMetric(latency, elapsedMs) +``` -指标用于筛选、分组和诊断报告。单独记录指标不会让用例通过或失败,也不会提高任务汇总分数。 +指标用于筛选、分组和诊断报告。单独记录指标不会让用例通过或失败,也不会改变任何分数。 ## 给指标起一个易读的名字 -viewer 会按原样显示 `osworld.model.usage.output_tokens` 这类指标名。用 `defineMetric` 定义一次指标,就可以给它加上名称(label)、说明(description)、单位(unit)和方向(better)。名称和说明可以是一个字符串,也可以按 BCP 47 语言标签各写一份;viewer 会按读者的语言选择,找不到时依次回退到 `en` 和第一项。 +viewer 会按原样显示 `osworld.model.usage.output_tokens` 这类指标名。用 `defineMetric` 定义一次指标,就可以给它加上名称(label)、说明(description)、单位(unit)和方向(better)。名称和说明的格式与分数相同。 ::: code-group ```ts [evals/agent.eval.ts] @@ -117,7 +258,7 @@ describeTask('agent', ({ caseOf }) => { ``` ::: -第一个带有元数据的值会为当前任务发出一个 `metric.defined` 事件。相同的元数据不会重复发出,元数据变化时会再发一次。每个指标名最好只保留一套元数据,因为 viewer 只显示最新的一套。在 viewer 里,鼠标悬停在指标上,可以看到它的名称、原始指标名、说明、单位、方向,以及它在这个任务所有用例中的取值分布。 +第一个带有元数据的值会为当前任务发出一个 `metric.defined` 事件。相同的元数据不会重复发出,元数据变化时会再发一次。每个指标名最好只保留一套元数据,因为 viewer 只显示最新的一套。在 viewer 里,鼠标悬停在指标上,可以看到它的名称、原始指标名、说明、单位、方向,以及它在这个任务所有用例中的取值分布。分数也会出现在同一个指标面板中,排在指标前面,并始终以越高越好的百分比显示。 `recordArtifact` 也接受同样格式的 `label` 和 `description`,例如 `recordArtifact('final.webp', { contentType: 'image/webp', path, label: { 'en': 'Final screen', 'zh-Hans': '最终画面' } })`。 @@ -146,16 +287,21 @@ const [outcome] = await evaluateAssertions([ 这个示例只执行本地代码,不会调用模型。`expectRubric` 本身既不选择模型,也不请求模型服务;是否调用模型完全由 `judge` 回调决定。这个回调也可以使用本地规则或人工评分结果。 -核心断言 API 与任务 DSL 是两套独立的公开接口。`evaluateAssertions` 只返回断言结果,不会自动调用用例上下文中的 `recordScore`、`recordMetric` 或 `expect`。`toRunScores(outcomes)` 可以把这些结果转换成 `RunScore[]`,但同样不会将分数写入当前用例。 +核心断言 API 与任务 DSL 是两套独立的公开接口。`evaluateAssertions` 只返回断言结果,不会调用用例上下文中的 `recordScore`、`recordMetric` 或 `expect`。请显式地把结果记到它所测量的分数上: -得到 `outcome` 后,你可以调用 `recordScore(outcome.score, outcome.scoreKind)` 记录分数,在 `outcome.pass` 为 `false` 时抛出错误,也可以将评分理由记为指标。选择哪种方式取决于你希望它如何影响用例状态和任务汇总。 +```ts +const concise = defineScore('answer.concise', { grader: { id: 'concise-answer', kind: 'rule' } }) + +recordScore(concise, outcome.score, { rationale: outcome.reason }) +expect(outcome.pass).toBe(true) +``` -如果你通过抛出 `AssertionError`(例如借助 `expect`)使当前用例失败,任务汇总会为该用例计入 `exact: 0`;抛出其他错误时,除非 `classifyError` 另有规定,用例会以 `errored` 结束。抛错前已经发出的分数事件仍可能保留在报告的用例记录中。 +是否同时让用例失败,需要你自己决定。抛出 `AssertionError`(例如借助 `expect`)会让用例以 `failed` 结束,任务的每个分数都会把它按 `0` 计算;抛出其他错误时,除非 `classifyError` 另有规定,用例会以 `errored` 结束。 ::: warning 模型评分会带来运行成本 如果 `judge` 回调调用模型,就需要配置对应的模型服务和凭据,并承担请求延迟和费用。并发运行用例时,可能同时发出多次评分请求,因此应根据服务方的速率限制和配额设置并发上限。还应检查服务方如何处理数据,并决定哪些用例输入、模型输出、评分提示词、评分理由、指标和事件可以写入报告文件。 ::: -常见错误包括把指标当成分数,或误以为评分标准断言的结果会自动写入当前用例。编写评测时,应分别处理用例状态、参与汇总的分数,以及用于排查问题的指标。 +常见错误包括把指标当成分数,或误以为评分标准断言的结果会自动写入当前用例。编写评测时,应分别处理用例状态、分数,以及用于排查问题的指标。 下一步在[模型与推理执行器](/zh-hans/guide/learn/models-and-inference-executors)中学习如何注册并选择模型。所有核心断言 API 都可以从 [`vieval/core/assertions`](/zh-hans/api/) 导入。 diff --git a/docs/content/zh-hans/guide/learn/matrices-and-datasets.md b/docs/content/zh-hans/guide/learn/matrices-and-datasets.md index 2f341b1..117b782 100644 --- a/docs/content/zh-hans/guide/learn/matrices-and-datasets.md +++ b/docs/content/zh-hans/guide/learn/matrices-and-datasets.md @@ -114,6 +114,7 @@ Vieval 按以下顺序合并矩阵配置: ```ts [evals/layered.eval.ts] import { defineEval, defineTask } from 'vieval/config' +import { scoreCases } from 'vieval/core/scoring' export default defineEval({ description: 'Shows matrix layering.', @@ -139,12 +140,12 @@ export default defineEval({ }, }, run(context) { - return { - scores: [{ - kind: 'exact', - score: context.task.matrix.eval.rubric === 'strict' ? 1 : 0, - }], - } + // 自定义任务需要返回分数汇总,`scoreCases` 可以根据用例结果生成它。 + return scoreCases([{ + scores: { 'layered.strict': context.task.matrix.eval.rubric === 'strict' ? 1 : 0 }, + state: 'passed', + unscored: {}, + }]) }, }), }) diff --git a/docs/content/zh-hans/guide/learn/reliable-execution.md b/docs/content/zh-hans/guide/learn/reliable-execution.md index d3c5957..9fc7cbe 100644 --- a/docs/content/zh-hans/guide/learn/reliable-execution.md +++ b/docs/content/zh-hans/guide/learn/reliable-execution.md @@ -150,7 +150,7 @@ const providerRetry = { 用例重试会重新执行整个用例。如果只想重试失败的那一次服务调用,可以用 `vieval/core/inference-executors` 的 `runWithRetry` 包住这次调用。它默认在服务的 `Retry-After` 和带随机抖动的指数退避之间取较长的一个作为等待时间;传入 `{ signal }` 后,运行被中断时会立即停止等待。 -`autoAttempt: 1` 表示首次尝试结束后,最多再运行一次完整用例集。只有某个用例在用完重试次数后仍然失败或超时,并且尚未达到自己的尝试上限,Vieval 才会开始下一次尝试。每次完成的尝试都会参与任务分数计算。例如,同一个用例第一次失败、第二次通过时,最终的 `exact` 平均分为 `0.5`,后一次通过不会覆盖前一次失败。 +`autoAttempt: 1` 表示首次尝试结束后,最多再运行一次完整用例集。只有某个用例在用完重试次数后仍然失败或超时,并且尚未达到自己的尝试上限,Vieval 才会开始下一次尝试。每次完成的尝试都会参与任务分数计算。例如,同一个用例第一次失败、第二次通过并记录 `1` 分时,它在该分数上的平均值为 `0.5`,后一次通过不会覆盖前一次失败。 ::: tip 根据要回答的问题选择重复方式 如果要观察多次执行的通过率,请使用 `autoAttempt`,因为每次尝试的结果都会参与任务分数计算。`autoRetry` 更适合应对网络抖动等临时故障:只要某次重试通过,这次尝试就按通过计算,不会把此前的失败计入任务分数。各次运行仍会写入生命周期事件,便于排查问题。 @@ -172,7 +172,7 @@ describeTask('read aloud', build, { 只有没有得出结果的运行(`errored`、`timeout`)会被重新排队;断言失败已经是一个结果。用例在等待期间会让出槽位;之后的每次运行都会重新获得 `autoRetry` 次重试,所以一个用例最多运行 `(1 + autoRetry) × (1 + requeue)` 次。剩下的用例不多时,队列末尾可能马上就轮到它,这时没有 `requeueDelay` 的重新排队就等于立刻重试。 -在最后一次运行结束之前,用例都还没有结束:它发出的是 `case.requeued` 而不是 `case.ended`,查看器把它显示为等待重跑(尚未完成),它也不计入分数和覆盖率。只有最终那次运行会计分。重试和重新排队次数都用完后仍没有结果的用例以 `errored` 结束,之后可以用 `--resume` 继续。 +在最后一次运行结束之前,用例都还没有结束:它发出的是 `case.requeued` 而不是 `case.ended`,查看器把它显示为等待重跑(尚未完成),它也不计入分数和执行成功率。只有最终那次运行会计分。重试和重新排队次数都用完后仍没有结果的用例以 `errored` 结束,之后可以用 `--resume` 继续。 ### 继续之前的尝试 @@ -241,13 +241,13 @@ export default defineConfig({ 任务分数和报告文件保留的信息不同: -- 失败或超时的用例会为任务增加一个值为 `0` 的 `exact` 分数;没有自定义分数的通过用例会增加 `1`。 -- 通过用例发出的 `exact` 和 `judge` 分数会参与任务计算;失败用例在运行中发出的自定义分数不会参与任务计算。 +- 失败的用例在任务的每个分数中都按 `0` 计算。出错或超时的用例在 `errors: 'exclude'`(默认)的分数中不计入,在 `errors: 'zero'` 的分数中按 `0` 计算;无论哪种情况,它都会降低执行成功率。 +- 通过的用例按它记录的值参与计算。通过但没有记录某个分数的用例,在该分数中算作缺失,而不是按 `1` 计算。 - 每次完成的自动尝试都会再向任务分数加入一组用例结果。 - 执行过程会产生生命周期、分数和指标事件。除非使用 `--no-report`,这些事件都会按顺序写入 `events.jsonl`。 -- `cases.jsonl` 最终为每个「任务 ID + 用例 ID」组合写一条记录,多次重试或自动尝试不会分别占一行。对于启用自动尝试的任务,该记录的 `attempts` 会列出每次尝试的结果。要查看准确的执行顺序,请读取 `events.jsonl`(`case.started` 和 `case.ended` 带有 `attemptIndex`);要查看包含所有尝试结果的任务分数,请读取 `run-summary.json`。 +- `cases.jsonl` 最终为每个「任务 ID + 用例 ID」组合写一条记录,多次重试或自动尝试不会分别占一行。记录中的 `scores` 按与任务结果相同的规则保存每个分数的计入值,因此失败用例显示为 `0`。对于启用自动尝试的任务,该记录的 `attempts` 会列出每次尝试的结果。要查看准确的执行顺序,请读取 `events.jsonl`(`case.started` 和 `case.ended` 带有 `attemptIndex`);要查看包含所有尝试结果的任务分数,请读取 `run-summary.json`。 -正如[断言、分数与指标](/zh-hans/guide/learn/assertions-scores-and-metrics)所述,用例在失败前发出的分数事件可能仍会出现在报告记录中,但任务分数会把该用例计为失败。排查问题时,请同时检查用例状态和分数。 +正如[断言、分数与指标](/zh-hans/guide/learn/assertions-scores-and-metrics)所述,用例在失败前记录的分数仍会保留在 `events.jsonl` 和用例记录的 `scoreDetails` 中,但不参与计算,用例记录的 `scores` 显示为 `0`。排查问题时,请同时检查用例状态和分数。 不要使用 `autoRetry` 统计多次执行的通过率:重试后恢复的用例在任务分数中只算一次通过。如果希望早期失败也参与最终分数,请使用 `autoAttempt`,并注意它会重新运行已经通过的用例。`--attempt` 不会增加自动尝试次数,它只为运行设置标签。 diff --git a/docs/content/zh-hans/guide/learn/reports-and-comparisons.md b/docs/content/zh-hans/guide/learn/reports-and-comparisons.md index c98ad42..d5b81d9 100644 --- a/docs/content/zh-hans/guide/learn/reports-and-comparisons.md +++ b/docs/content/zh-hans/guide/learn/reports-and-comparisons.md @@ -35,11 +35,11 @@ vieval run \ | 文件 | 内容 | | --- | --- | -| `run-summary.json` | 恰好一个项目的运行结果,包括 `schemaVersion: 1`、`runId`、`reportDirectory`、各类 ID、项目状态、用例计数与失败信息、矩阵摘要和汇总分数。`vieval report` 命令遇到其他版本时会报错。 | -| `events.jsonl` | 按发生顺序记录运行、任务、用例、分数、指标和自定义事件。 | -| `cases.jsonl` | 每个「任务 ID + 用例 ID」组合的最终记录,包括状态、耗时、根据事件推算的重试次数、分数、指标以及可选的输入和输出。 | -| `metrics-summary.json` | 从 `cases.jsonl` 计算出的分数数量、总和与平均值。 | -| `otlp/traces.json`、`otlp/logs.json`、`otlp/metrics.json` | 从用例记录转换得到的本地 OTLP 数据。 | +| `run-summary.json` | 恰好一个项目的运行结果,包括 `schemaVersion: 2`、`runId`、`reportDirectory`、各类 ID、项目状态、用例计数与失败信息、矩阵摘要、每次任务运行的分数汇总、任务声明的 `scoreDefinitions`,以及解析好的标题数值(见下文)。`vieval report` 命令遇到其他版本时会报错。 | +| `events.jsonl` | 按发生顺序记录运行、任务、用例、分数(`score.defined`、`score.recorded`)、指标和自定义事件。 | +| `cases.jsonl` | 每个「任务 ID + 用例 ID」组合的最终记录(`schemaVersion: 2`),包括状态、耗时、根据事件推算的重试次数、分数、指标以及可选的输入和输出。`scores` 按分数名保存计入的值,`unscored` 按分数名保存无法评分的原因,`scoreDetails` 保存每条记录附带的 `rationale`、`grader` 和 `metadata`。 | +| `metrics-summary.json` | 从 `cases.jsonl` 按分数名计算出的数量、总和与平均值。 | +| `otlp/traces.json`、`otlp/logs.json`、`otlp/metrics.json` | 从用例记录转换得到的本地 OTLP 数据。分数指标名为 `vieval.score.`。 | `vieval report index` 默认会创建 `index/runs.jsonl`,也可以通过 `--output` 指定其他位置;`vieval run` 本身不会写入这个索引文件。 @@ -49,12 +49,30 @@ vieval run \ `cases.jsonl` 中的 `retryCount` 只统计最后一次尝试内的重试。对于启用自动尝试的任务,它根据 `attemptIndex` 计算,后续尝试不会被算作重试。在 `attemptIndex` 出现之前写入的报告没有这个字段,其中后续尝试的重复开始事件仍可能让 `retryCount` 增加。 -事件行仍为 `schemaVersion: 2`,用例记录仍为 `schemaVersion: 1`:新增可选字段或新事件名不改变版本号,读取方会忽略不认识的字段;重命名、删除字段或改变字段类型时才会增加版本号。 +事件行为 `schemaVersion: 3`,用例记录为 `schemaVersion: 2`。事件的第 3 版用具名分数取代了固定的 `exact` 和 `judge` 两类分数。新增可选字段或新事件名不改变版本号,读取方会忽略不认识的字段;重命名、删除字段或改变字段类型时才会增加版本号。读取方会拒绝旧版本写入的报告(“This report was written by an older vieval. Run it again.”),viewer 会把这类运行标记为不支持;没有迁移命令,请重新运行。 ::: warning 报告文件可能包含敏感信息 报告可能包含用例输入和输出、错误信息、自定义指标、模型或评测基准标识符,以及其他事件数据。请设置合适的保留期限和访问权限,并删除或脱敏不应离开评测环境的字段。 ::: +## 读取标题数值 + +每个项目和整个运行的标题数值只在运行结束时解析一次,以 `{ headline, completion }` 的形式写入 `run-summary.json`。CLI、viewer 和 `vieval report` 都直接读取它,不会各自重新计算: + +| `headline.kind` | 条件 | 显示方式 | +| --- | --- | --- | +| `score` | 所有带分数的任务使用同一个主分数名。 | 合并后的主分数及其名称,例如 `通过 87.5%`。 | +| `per-task` | 各任务的主分数不同。 | 不显示运行级别的分数,按任务名分别列出各自的主分数。 | +| `passed-cases` | 没有任何任务有主分数。 | 通过用例的比例,标注为“通过用例”。 | + +`completion` 是执行成功率,即 `(passed + failed) / (passed + failed + errored + timeout)`,总是显示在标题数值旁边。运行结束时的汇总行会同时输出两者,例如: + +```text +Pass 87.5% · completed 96.8% (4 errored) +``` + +每个分数汇总都包含 `name`、`mean`(没有用例计入时为 `null`)、`n`(计入的用例数)、`total`(已结束且未跳过的用例数)、`sum` 和 `unscored`(按原因统计的次数)。项目和运行的汇总会把各任务的用例合并后再计算,而不是对各任务的平均值再求平均。 + ## 索引、检查与分析报告 每个报告命令都需要一个报告路径。这个路径通常既可以是单次运行目录,也可以是包含多次运行的上层目录。 @@ -97,7 +115,7 @@ vieval report attempts --config vieval.config.ts ::: -`attempts` 按时间从新到旧列出报告目录中的尝试:每个实验和尝试一行,显示涉及的工作区、通过、失败和没有结果的用例数,以及覆盖率。"←" 标记 `vieval run --resume` 不指定标识时会继续的那次尝试;备注列显示该尝试继续自哪次尝试、是否被中断,以及没来得及写入摘要就被终止的运行。可以传入报告目录,或用 `--config` 列出配置文件旁边的报告;用 `--experiment` 和 `--workspace` 筛选,用 `--format table|json` 选择输出格式。 +`attempts` 按时间从新到旧列出报告目录中的尝试:每个实验和尝试一行,显示涉及的工作区、通过、失败和没有结果的用例数,以及执行成功率。"←" 标记 `vieval run --resume` 不指定标识时会继续的那次尝试;备注列显示该尝试继续自哪次尝试、是否被中断,以及没来得及写入摘要就被终止的运行。可以传入报告目录,或用 `--config` 列出配置文件旁边的报告;用 `--experiment` 和 `--workspace` 筛选,用 `--format table|json` 选择输出格式。 ## 比较两组已有报告 @@ -115,7 +133,7 @@ vieval report compare <基线报告目录> <候选方案报告目录> 1. **先对齐用例。** 命令从基线和候选方案中读取 `cases.jsonl`,根据用例键找到同一个用例。候选方案独有的记录记为新增,基线独有的记录记为移除。 2. **再计算匹配项。** 单个用例和分组的差值都按「候选方案减基线」计算,并且只使用已经对齐的记录。使用 `--group-by` 时,分组值取自候选方案。 -3. **处理缺失分数。** 已对齐的用例如果在某份报告中缺少 `--score-kind` 指定的分数,对应分数按 `0` 计算。`--score-kind` 默认为 `exact`。 +3. **处理缺失分数。** 已对齐的用例如果在某份报告中缺少 `--score ` 指定的分数,对应分数按 `0` 计算。`--score` 默认取 `run-summary.json` 中的主分数。 4. **单独计算总体差值。** 命令分别对基线和候选方案中所有包含该分数的记录求平均值,再用候选平均值减去基线平均值。新增和移除的用例会影响总体差值;缺少所选分数的记录不会进入这一步。因此,总体差值不一定等于匹配用例差值的平均值。 输出格式支持 `table` 和 `json`。 @@ -158,7 +176,7 @@ vieval run --config evals/agent-memory/vieval.config.ts - 比较配置的 `env`(以及单个条目的 `env`)优先于各工作区配置的 `env`,并在工作区插件运行之前生效。 - `cache: { namespace }` 让所有工作区共用比较配置旁边的 `.vieval/cache/<比较 ID>//`。 - 终端中只有一个实时窗口,显示所有工作区的 `Workspaces`、`Projects`、`Tasks`、`Cases`、`Concurrency` 和 `Time` 行。某个工作区失败不会中止其他工作区。 -- 运行结束后会输出排行榜:每个工作区的每个项目占一行,按混合分数(hybrid)排序;随后列出失败的工作区及其 stderr 的最后几行。 +- 运行结束后会输出排行榜:每个工作区的每个项目占一行,按主分数排序,执行成功率单独占一列;随后列出失败的工作区及其 stderr 的最后几行。如果各工作区的主分数不同,排行榜会提示这一点,并改为按通过用例排序。 - 只要有工作区失败或存在失败的用例,退出码就是 `1`。`--json` 会输出比较结果。 - 报告写入比较配置旁边的 `.vieval/reports`。实验 ID 默认为比较 ID,所有工作区共用同一个尝试,因此比较中的每次运行都位于 `<工作区>/<项目>/<比较 ID>/<尝试>/` 下。 - 使用 `--with-viewer` 时,只启动一个查看器,展示比较的报告根目录。 @@ -237,7 +255,7 @@ vieval report compare \ .vieval/reports/baseline \ .vieval/reports/candidate \ --case-key benchmark.case.id \ - --score-kind exact \ + --score retrieval.recall \ --format table ``` diff --git a/docs/content/zh-hans/guide/learn/tasks-cases-and-inputs.md b/docs/content/zh-hans/guide/learn/tasks-cases-and-inputs.md index a31157f..f3e895c 100644 --- a/docs/content/zh-hans/guide/learn/tasks-cases-and-inputs.md +++ b/docs/content/zh-hans/guide/learn/tasks-cases-and-inputs.md @@ -85,7 +85,7 @@ caseOf(..., { input }) 或 casesFromInputs(...) 在参与比较的每一份报告中,`benchmark.case.id` 都不能重复,即使这些用例来自不同任务或项目。如果出现重复值,比较命令会报错,而不会自行猜测应该如何配对。 -报告选择器可以读取 `caseName`、`caseId` 等用例字段,也可以读取分数和已记录的指标。比较用例时,Vieval 默认依次尝试 `benchmark.case.id`、`vieval.case.id` 和自动生成的 `caseId`。 +报告选择器可以读取 `caseName`、`caseId` 等用例字段,也可以按名称读取分数(`scores.`)和已记录的指标。比较用例时,Vieval 默认依次尝试 `benchmark.case.id`、`vieval.case.id` 和自动生成的 `caseId`。 ::: warning 在任务或评测中注册用例 `caseOf` 和 `casesFromInputs` 必须在 `describeTask` 或 `describeEval` 的回调中调用。在其他位置调用时,Vieval 会在加载评测文件时抛出错误。 diff --git a/docs/content/zh-hans/index.md b/docs/content/zh-hans/index.md index bd605cd..68f5548 100644 --- a/docs/content/zh-hans/index.md +++ b/docs/content/zh-hans/index.md @@ -20,9 +20,9 @@ home: ✓ |desktop-agent| (412 cases) cases 407 passed | 5 failed | 0 timeout $ pnpm vieval report cases .vieval/reports/candidate --where state=failed - ✗ chrome/open-settings exact 0 wrong tab + ✗ chrome/open-settings pass 0 wrong tab ✗ gimp/resize-image timeout 60m - ✗ vscode/rename-symbol exact 0 old name kept + ✗ vscode/rename-symbol pass 0 old name kept ✗ calc/sum-column rubric 0.42 partial ✗ chat/refund-policy rubric 0.31 hallucinated blocks: diff --git a/examples/run-matrix-multi-models/evals/extract.eval.ts b/examples/run-matrix-multi-models/evals/extract.eval.ts index fe7fc3f..ab08020 100644 --- a/examples/run-matrix-multi-models/evals/extract.eval.ts +++ b/examples/run-matrix-multi-models/evals/extract.eval.ts @@ -1,7 +1,19 @@ -import { caseOf, describeTask } from 'vieval' +import { caseOf, defineScore, describeTask } from 'vieval' import { extract } from './agent' +/** + * The one score of this task, so it is also the task's primary score. + */ +const extractScore = defineScore('sleep_matrix.extract', { + description: { + 'en': 'Mock extraction quality: a random value in 0..1 returned by the agent.', + 'zh-Hans': '模拟的抽取质量:agent 返回的 0..1 之间的随机值。', + }, + grader: { id: 'mock', kind: 'rule' }, + label: { 'en': 'Extract', 'zh-Hans': '抽取' }, +}) + async function scoreExtract(input: number) { return input } @@ -9,6 +21,6 @@ async function scoreExtract(input: number) { describeTask('sleep-matrix', () => { caseOf('sleep mock', async (context) => { const res = await extract() - context.recordScore(await scoreExtract(res)) + context.recordScore(extractScore, await scoreExtract(res)) }) -}) +}, { scores: [extractScore] }) diff --git a/packages/ui/API.md b/packages/ui/API.md index 2ab40cd..13e9aa9 100644 --- a/packages/ui/API.md +++ b/packages/ui/API.md @@ -33,13 +33,13 @@ schemas. This document explains the routes and the rules behind the numbers. | `live-bus` | Run of this process (`vieval run --with-viewer`) | `onRunReady` → `bus.onAny`; every event from `seq` 1. | | `live-tail` | Run directory without an end event and without `run-summary.json` (another process is writing it) | Byte-offset tail of `events.jsonl`: only complete lines, a half-written line waits for the next read, multi-byte characters stay intact, a truncated or replaced file restarts from byte 0. `fs.watch` + 1 s polling while a stream is open; on demand otherwise. | | `offline` | Run directory with an end event or `run-summary.json` | Same reader, read to the end once; official numbers from `run-summary.json`. | -| `legacy` | `events.jsonl` with event schema version 1 | Rebuilt from `cases.jsonl` + `run-summary.json` (no rounds, retries, logs). | +| `unsupported` | `events.jsonl` with event schema version below 3 | Listed, not opened: every route but `GET /api/runs/:runKey` answers 422 "This report was written by an older vieval. Run it again." | All sources feed one reducer (`src/shared/reduce.ts`). Envelopes with a `seq` at or below the last applied one are dropped (duplicates); missing numbers are counted in `seqGaps`; invalid lines in `parseErrors`. `state`: `running`, `stale` (no end event and the last event is older than `--stale-after`, default 60 min), -`ended`, `interrupted` (`run.interrupted` or `interruptSignal` in the summary), `legacy`. +`ended`, `interrupted` (`run.interrupted` or `interruptSignal` in the summary), `unsupported`. ## Routes @@ -49,27 +49,45 @@ dropped (duplicates); missing numbers are counted in `seqGaps`; invalid lines in |---|---|---| | `GET /api/health` | `{ ok, apiVersion, mode, reportRoots, evidenceRoots, logDirectories }` | | | `GET /api/tree` | `TreeResponse` | `workspaces[].projects[].experiments[].attempts[].runs[]` (`RunListItem`). Project = report path segment. Current vieval writes one run per project; older reports may use `multi-project` for runs of several projects (see `projectIds`). | -| `GET /api/runs?workspace=&project=&experiment=&attempt=&state=&source=` | `RunListItem[]` | Newest first. `counts`, `exactAverage`/`judgeAverage`/`hybridAverage` (from `run-summary.json` once written, else from events). `reportRoot` is the absolute report root the run was found in (tells archived roots apart), `reportPath` the directory below it. `matrix`: run-matrix axes of the run's tasks with their distinct values in natural order (`{ model: ['gpt-5.5'], repeat: ['1', '2', '10'] }`), `{}` without a run matrix. | +| `GET /api/runs?workspace=&project=&experiment=&attempt=&state=&source=` | `RunListItem[]` | Newest first. `counts`, `headline` (a `HeadlineResult`, see "Scores": from `run-summary.json` once written, else from events; `null` before any case settled) and `headlineDefinitions` (the latest `score.defined` of each score in `headline`, for its label and description). `reportRoot` is the absolute report root the run was found in (tells archived roots apart), `reportPath` the directory below it. `matrix`: run-matrix axes of the run's tasks with their distinct values in natural order (`{ model: ['gpt-5.5'], repeat: ['1', '2', '10'] }`), `{}` without a run matrix. | | `GET /api/runs/:runKey` | `RunListItem` | | ### Run | Route | Response | Notes | |---|---|---| -| `GET /api/runs/:runKey/snapshot` | `RunSnapshot` | Identity, state, `seq`, projects, tasks → cases → rounds → tries (the execution tree), `progress`, `current`, `summary`, `eventCounts`. | +| `GET /api/runs/:runKey/snapshot` | `RunSnapshot` | Identity, state, `seq`, projects, tasks → cases → rounds → tries (the execution tree), `progress`, `current`, `summary` (`caseSummary` and `headline` of `run-summary.json`), `headline` (from events), `scoreDefinitions`, `eventCounts`. Each task has `scoring` (a `CaseScoring`) and `scoreGraders` (every distinct grader kind, model, and version its score records carried, by score name); each case and round has `scores` (`{ values, unscored }`). | | `GET /api/runs/:runKey/progress` | `ProgressResponse` | `progress` + `current` + `state` + `seq`, for polling. | | `GET /api/runs/:runKey/events?after=&limit=<1..5000>` | `{ events, nextAfter, seq }` | Raw envelopes after `after` (default 0, limit default 1000). | | `GET /api/runs/:runKey/stream?after=` | `text/event-stream` | See "Live updates". `Last-Event-ID` is honored. | | `GET /api/runs/:runKey/task?taskId=` | `TaskNode` | | -| `GET /api/runs/:runKey/case?taskId=&caseId=` | `CaseDetail` | Node + `input`, `output`, `metrics`, `metricDefinitions` (latest `metric.defined` of each metric), `artifacts` (with `label` / `description` when recorded), `evidence`, `inference` (inference and tool-call events), `taskDisplayName`. | +| `GET /api/runs/:runKey/case?taskId=&caseId=` | `CaseDetail` | Node + `input`, `output`, `metrics`, `metricDefinitions` (latest `metric.defined` of each metric), `scoreDefinitions` (latest `score.defined` of each score of the task), `scoreRecords` (every `score.recorded` of the case in event order, with `roundIndex`, `seq`, and the record's `rationale`, `grader`, `metadata`, and `derived`), `artifacts` (with `label` / `description` when recorded), `evidence`, `inference` (inference and tool-call events), `taskDisplayName`. | | `GET /api/runs/:runKey/timeline?taskId=&caseId=` | `TimelineResponse` | Every try with round, retry index, times, state; per task a summary per round (passed/failed/running, start/end). | | `GET /api/runs/:runKey/logs?taskId=&caseId=&stream=stdout\|stderr&round=&offset=&limit=<1..5000>` | `LogsResponse` | Console output of cases (`case.output`), split into lines. Output outside cases is not recorded by vieval. | -| `GET /api/runs/:runKey/metrics?taskId=&caseId=` | `MetricsResponse` | Numeric metrics as series with `min/max/avg/sum/count`; other values listed; `definitions`: the latest `metric.defined` (label, description, unit, better) of each listed metric; inference totals. | -| `GET /api/runs/:runKey/scores` | `ScoresResponse` | `overall`, `byTask`, `byRound`, `cases`, `summary` (from `run-summary.json`). | - -**Score rule** (same as `describeTask`): every settled round of every case contributes; a case that did not pass -scores exact 0; a passed case without `recordScore` scores exact 1; otherwise its recorded `exact` (default 1) and -`judge`. `hybrid = (exact + judge) / 2` when both exist. Task scores are averaged into `overall`. +| `GET /api/runs/:runKey/metrics?taskId=&caseId=` | `MetricsResponse` | Numeric metrics as series with `min/max/avg/sum/count`; other values listed; `definitions`: the latest `metric.defined` (label, description, unit, better) of each listed metric; inference totals. `scores`: each score beside the metrics, with one point per case (the counted value of its last round) and the `ScoreSummary` of the selection; `scoreDefinitions` for their labels. | +| `GET /api/runs/:runKey/scores` | `ScoresResponse` | `overall` (`headline` and each score pooled over the tasks), `byTask` and `byRound` (a `CaseScoring` each), `cases` (`scores` of each case), `definitions` (latest `score.defined` of each score), `summary` (the `HeadlineResult` of `run-summary.json`, once written). | + +**Scores**: the viewer computes scores with `scoreCases` of `vieval/core/scoring`, the rule of `describeTask`, so +live runs, `run-summary.json`, and `cases.jsonl` agree. Every settled round of every case contributes. Per score: + +- `passed`: the recorded (or derived) value; a passed case that did not record the score is missing, not 1. +- `failed`: 0. Recorded values stay in `scoreRecords` but are not counted. +- `errored`, `timeout`: left out, or 0 when the score's `score.defined` has `errors: 'zero'`. +- `skipped`: left out. An unscored value (`recordUnscored`) never counts; it is counted per reason in `unscored`. + +A `ScoreSummary` is `{ name, mean, n, total, sum, unscored }`: `mean = sum / n` (`null` when `n` is 0), `n` the cases +that counted, `total` the settled cases that were not skipped. A `CaseScoring` (one task, one round) is +`{ scores, primary?, completion, passed }`: `primary` is the score declared `primary`, or the only score of the task; +`completion` is the completion rate (`(passed + failed) / settled`, with `errored` / `timeout` counts in `unscored`); +`passed` is the share of passed cases. Summaries of several tasks pool their cases (`poolScoreSummaries`); they are +not a mean of task means. + +A `HeadlineResult` is `{ headline, completion }`, resolved with `resolveHeadline`: `headline.kind` is `score` (every +task with scores shares one primary name; its pooled summary), `per-task` (primary names differ; one summary per task +in `tasks`), or `passed-cases` (no primary score; the share of passed cases in `score`). Show `completion` next to the +headline wherever it appears. Labels and descriptions come from the `scoreDefinitions` / `headlineDefinitions` of the +response (`score.defined`: `label`, `description`, `primary`, `combine`, `errors`, `grader`); a name without a +definition is shown as written. **Progress and ETA**: `T` = `run.started.totalTasks`; a task's case total is its first `case.started.total` (unknown until the task starts; unknown totals are extrapolated from the mean of known ones: `basis: @@ -132,9 +150,9 @@ evidence link. | Route | Response | |---|---| -| `GET /api/runs/:runKey/otel/traces` | Span tree `run → project → task → (round) → case → (try)` rebuilt from events, with ids, times, status. | +| `GET /api/runs/:runKey/otel/traces` | Span tree `run → project → task → (round) → case → (try)` rebuilt from events, with ids, times, status. Task spans carry `vieval.score.` (the task mean) and `vieval.task.primary_score`; case spans carry `vieval.score.` and `vieval.score..unscored`. | | `GET /api/runs/:runKey/otel/logs` | Console lines (INFO/WARN), case failures and inference failures (ERROR), interruption (WARN). | -| `GET /api/runs/:runKey/otel/metrics` | Gauges: numeric metrics, `vieval.case.duration_ms`, `vieval.case.score.{exact,judge}`. | +| `GET /api/runs/:runKey/otel/metrics` | Gauges: numeric metrics, `vieval.case.duration_ms`, and one `vieval.score.` per score. | | `GET /api/runs/:runKey/otel/raw/:signal` | `otlp/.json` written by vieval when the run ended (`404` before). | Limits: no OTLP receiver, no user-defined spans, no cross-process trace correlation, no sampling, querying, or @@ -143,7 +161,9 @@ storage. Spans of the live OpenTelemetry runtime are not collected. ### Comparisons All return `ComparisonResponse`: `columns` (each with the runs it covers), `rows` (one per case name; `cells[column]` -= `{ state, exact?, judge?, roundCount }`; `changed` when state or exact score differs between columns), `summary`. += `{ state, scores, roundCount }`, `scores` being `{ values, unscored }`; `changed` when the state or the value of +`primary` differs between columns), `primary` (the primary score the rows compare, when the columns share one; without +it, `changed` uses the state only), `scoreDefinitions`, `summary`. Each column's runs carry `headline`. | Route | Columns | |---|---| @@ -151,26 +171,28 @@ All return `ComparisonResponse`: `columns` (each with the runs it covers), `rows | `GET /api/compare/attempts?workspace=&project=&experiment=` | One per run of the experiment (attempt label + run id), oldest first. | | `GET /api/compare/runs?runs=,[,…]` | The given runs, in order (≥ 2). | +Each column carries `scoreGraders`, the graders that produced each score in its runs. The viewer warns on the primary score when the graders of the columns differ in kind, model, or version, because the values then do not measure the same thing. + ### Matrix | Route | Response | Notes | |---|---|---| | `GET /api/matrix?workspace=&project=&experiment=` | `MatrixResponse` | Every run in scope (filters as in `/api/runs`; none = all runs; an unknown value is an empty matrix). | -A cell is one eval entry × run-matrix row × eval-matrix row (from the task id). Every run of a cell has a `coverage`: +A cell is one eval entry × run-matrix row × eval-matrix row (from the task id). Every run of a cell has a `coverage` (the case coverage of the cell, not the completion rate): `full` (every expected case settled and at least one has a result; errored cases count as settled, over the same cases as the headline run), `other` (every expected case settled, but over other cases than the headline run, told apart by `identity`: a smoke or limited run, or another case set under the same entry), `partial` (some expected cases never ran or never settled), or `none` (no case has a result). `latest` is the task of the newest run (by start time) that covered the cell fully or is still running it, else of the newest run: run, -experiment, attempt, task state, coverage, expected case total (`caseTotal`), case counts, the exact score of every case -(`caseScores`), exact and judge score. The headline run is the newest one that settled every case it planned over cases no other run covers a strict +experiment, attempt, task state, coverage, expected case total (`caseTotal`), case counts, the primary score of every +case (`caseScores`), and the task's `scoring` (a `CaseScoring`). The headline run is the newest one that settled every case it planned over cases no other run covers a strict superset of; `caseCount` is its number of cases. A partial re-run or a run over other cases therefore never replaces a full run as the cell's score. `superseded` is `true` when a newer run ran the same entry and run row with other eval values (the eval axes changed since). `history` lists every run with that cell, oldest first -(one point per run: attempt, coverage, case counts, exact, state, start time). Two tasks of one +(one point per run: attempt, coverage, case counts, `score` (mean of the cell's primary score), `completion`, state, start time). Two tasks of one run in the same cell (several inference executors) keep the last one in queue order. `axes` lists the run-matrix axes, then the eval-matrix axes, each with its values in natural order. Cells are ordered by entry, then run row and eval -row in natural order. Tasks whose id has no matrix are left out. The route builds every run's task nodes once. +row in natural order. Tasks whose id has no matrix are left out. `scoreDefinitions` holds the latest `score.defined` of each score in the cells. The route builds every run's task nodes once. ## Live updates (SSE) @@ -196,7 +218,7 @@ data: {"seq":251,"state":"ended"} ← terminal state reached; ``` Client pattern: `GET /snapshot`, then open the stream with `after=snapshot.seq` and apply each `run-event` with the -same reducer (`@vieval/ui/reduce`). Finished and legacy runs send their backlog, one `progress`, and `end`. +same reducer (`@vieval/ui/reduce`). Finished runs send their backlog, one `progress`, and `end`. ## Security diff --git a/packages/ui/README.md b/packages/ui/README.md index fc744f1..27e9651 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -178,7 +178,7 @@ See [API.md § Security](./API.md#security) for the status codes. | A new run does not appear in the sidebar | The sidebar re-reads report roots every 10 s. Check that the run writes below one of the roots in `GET /api/health`. | | Evidence says "Path is outside the allowed evidence roots." | The `*.evidence` metric points outside the `.vieval` directory. Add its directory with `--evidence-root`. | | *Command logs* is empty | The viewer looks for `.vieval/logs/*.jsonl` next to the report root. Logs without the case id, pod, or a time in the case's window are not matched. | -| No rounds, tries, or console logs for an old run | The run used event schema version 1. These runs only have `cases.jsonl` (shown as `legacy`). | +| An old run is listed but does not open | It was written by a vieval before named scores (event schema version below 3). Run it again. It is shown as `unsupported`. | | Component tests fail with "browserType.launch" | Run `pnpm exec playwright install chromium` once. | | `vieval run` without `--with-viewer` changed behavior | It should not: the viewer package is not imported then. Report it with the command and the `stdout` of both versions. | diff --git a/packages/ui/client/src/caseMetrics.test.ts b/packages/ui/client/src/caseMetrics.test.ts index f5e99e8..0476d33 100644 --- a/packages/ui/client/src/caseMetrics.test.ts +++ b/packages/ui/client/src/caseMetrics.test.ts @@ -1,8 +1,8 @@ -import type { CaseNode, MetricsResponse } from '../../src/shared/contract' +import type { CaseNode, MetricsResponse, ScoreDefinition } from '../../src/shared/contract' import { describe, expect, it } from 'vitest' -import { buildCaseTable, derivedMetricName, matchesFilters, metricLabel, separationOf } from './caseMetrics' +import { buildCaseTable, columnDefinitions, derivedMetricName, formatColumnValue, matchesFilters, metricDefinition, metricLabel, separationOf } from './caseMetrics' function caseNode(index: number, name: string, state: CaseNode['state']): CaseNode { const caseId = `${index}:${name}` @@ -19,14 +19,22 @@ function caseNode(index: number, name: string, state: CaseNode['state']): CaseNo metricCount: 0, name, rounds: [], - scores: {}, + scores: { unscored: {}, values: {} }, startedAt: '2026-10-13T00:00:00.000Z', state, taskId: 'task', } } -function metrics(series: Record): MetricsResponse { +/** + * A metrics response of the task `task`: `series` as recorded metrics, `scores` as each case's counted score value, + * with the scores' `score.defined`. + */ +function metrics( + series: Record, + scores: Record = {}, + scoreDefinitions: ScoreDefinition[] = [], +): MetricsResponse { const point = (name: string, [caseId, value, roundIndex]: [string, unknown, number?], seq: number) => ({ caseId, name, roundIndex, seq, taskId: 'task', time: '2026-10-13T00:00:00.000Z', value }) const entries = Object.entries(series) return { @@ -38,6 +46,12 @@ function metrics(series: Record point(name, item, seq)), summary: { avg: 0, count: points.length, max: 0, min: 0, sum: 0 }, })), + scoreDefinitions, + scores: Object.entries(scores).map(([name, points]) => ({ + name, + points: points.map(([caseId, value]) => ({ caseId, taskId: 'task', value })), + summary: { mean: null, n: points.length, name, sum: 0, total: points.length, unscored: {} }, + })), } } @@ -158,16 +172,18 @@ describe('buildCaseTable across tasks', () => { describe('buildCaseTable categories', () => { /** * @example - * expect(table.duplicates).toEqual([{ name: 'exact', of: 'state' }, { name: 'domain', of: 'group' }]) + * expect(table.duplicates).toEqual([{ name: 'domain', of: 'group' }, { name: 'pass', of: 'state' }]) */ it('hides categories that split the cases exactly like an earlier one', () => { - const scored = cases.map((node, index) => ({ ...node, scores: index === 3 ? {} : { exact: node.state === 'passed' ? 1 : 0 } })) - const table = buildCaseTable(scored, metrics({ + const table = buildCaseTable(cases, metrics({ domain: cases.map(node => [node.caseId, node.name.split('/')[0]]), + }, { + // The running case has no counted value yet. + pass: cases.slice(0, 3).map(node => [node.caseId, node.state === 'passed' ? 1 : 0]), })) expect(table.categories).toEqual(['state', 'group']) - expect(table.duplicates).toEqual([{ name: 'domain', of: 'group' }, { name: 'exact', of: 'state' }]) + expect(table.duplicates).toEqual([{ name: 'domain', of: 'group' }, { name: 'pass', of: 'state' }]) expect(table.groups.map(group => group.representative)).toEqual(['duration_s']) }) }) @@ -197,18 +213,21 @@ describe('separationOf', () => { * expect(separation.get('steps')).toEqual({ auc: 1, higherIn: 'failed' }) */ it('folds the AUC to 0.5..1, keeps the direction, and leaves scores out', () => { - const scored = cases.map((node, index) => ({ ...node, scores: { exact: index === 1 ? 0 : 1 } })) - const table = buildCaseTable(scored, metrics({ + const table = buildCaseTable(cases, metrics({ captures: cases.map((node, index) => [node.caseId, [5, 1, 6, 7][index]!]), flat: cases.map((node, index) => [node.caseId, index % 2]), steps: cases.map((node, index) => [node.caseId, [2, 50, 4, 9][index]!]), + }, { + // Three distinct values, so the score is a numeric axis like the metrics. + natural: cases.map((node, index) => [node.caseId, [1, 0, 0.5, 1][index]!]), })) const separation = separationOf(table) // The one failed case has the most steps, and the fewest captures. expect(separation.get('steps')).toEqual({ auc: 1, higherIn: 'failed' }) expect(separation.get('captures')).toEqual({ auc: 1, higherIn: 'passed' }) - expect(separation.has('exact')).toBe(false) + expect(table.groups.map(group => group.representative)).toContain('natural') + expect(separation.has('natural')).toBe(false) }) // ROOT CAUSE: @@ -216,17 +235,97 @@ describe('separationOf', () => { // In a scored eval every case passes (a score of 0 is a result), so there was no failed case to compare against and // the ranking was empty; the chart opened on metrics in recording order instead of the ones that lower the score. // - // We fixed this by comparing cases below the mean `exact` score with the rest when no case failed. + // We fixed this by comparing cases below the mean primary score with the rest when no case failed. /** * @example * expect(separation.get('steps')).toEqual({ auc: 1, higherIn: 'lower-scored' }) */ - it('ranks against the exact score when every case passed', () => { - const passed = cases.slice(0, 3).map((node, index) => ({ ...node, scores: { exact: [1, 0, 1][index]! }, state: 'passed' as const })) + it('ranks against the primary score when every case passed', () => { + const passed = cases.slice(0, 3).map(node => ({ ...node, state: 'passed' as const })) const table = buildCaseTable(passed, metrics({ steps: passed.map((node, index) => [node.caseId, [2, 50, 4][index]!]), - })) + }, { + correct: passed.map(node => [node.caseId, 1]), + pass: passed.map((node, index) => [node.caseId, [1, 0, 1][index]!]), + }, [{ name: 'correct' }, { name: 'pass', primary: true }])) + expect(table.primary).toBe('pass') expect(separationOf(table).get('steps')).toEqual({ auc: 1, higherIn: 'lower-scored' }) }) + + /** + * @example + * expect(separationOf(table).size).toBe(0) + */ + it('ranks nothing when every case passed and no score is primary', () => { + const passed = cases.slice(0, 3).map(node => ({ ...node, state: 'passed' as const })) + const table = buildCaseTable(passed, metrics({ + steps: passed.map((node, index) => [node.caseId, [2, 50, 4][index]!]), + }, { + correct: passed.map((node, index) => [node.caseId, [1, 0, 1][index]!]), + natural: passed.map((node, index) => [node.caseId, [0, 1, 1][index]!]), + })) + + expect(table.primary).toBeUndefined() + expect(separationOf(table).size).toBe(0) + }) +}) + +describe('buildCaseTable scores', () => { + /** + * @example + * expect(table.scores).toEqual(['pass', 'correct']) + */ + it('joins the counted score values, the primary score first and before the metrics', () => { + const table = buildCaseTable(cases, metrics({ + steps: cases.map((node, index) => [node.caseId, [2, 8, 30, 64][index]!]), + }, { + correct: cases.map((node, index) => [node.caseId, [1, 0.5, 0, 1][index]!]), + // The failed case counts 0 whatever it recorded; the running one is not counted yet. + pass: cases.slice(0, 3).map((node, index) => [node.caseId, [1, 0, 0.5][index]!]), + }, [{ name: 'correct' }, { name: 'pass', primary: true }])) + + expect(table.primary).toBe('pass') + expect(table.scores).toEqual(['pass', 'correct']) + expect(table.rows.map(row => row.numbers.pass)).toEqual([1, 0, 0.5, undefined]) + // Scores lead the axes; the metrics follow in recording order. + expect(table.groups.map(group => group.representative).slice(0, 2)).toEqual(['pass', 'correct']) + }) + + /** + * @example + * expect(table.rows.map(row => row.numbers.pass)).toEqual([1, 0, undefined, undefined]) + */ + it('reads the recorded values of the cases while the metrics are not loaded', () => { + const recorded = cases.map((node, index): CaseNode => ({ + ...node, + scores: { unscored: index === 2 ? { pass: 'grader_failed' } : {}, values: index < 2 ? { pass: [1, 0][index]! } : {} }, + })) + const table = buildCaseTable(recorded, undefined) + + expect(table.rows.map(row => row.numbers.pass)).toEqual([1, 0, undefined, undefined]) + // The only score of the cases is their primary one. + expect(table.primary).toBe('pass') + }) +}) + +describe('columnDefinitions', () => { + /** + * @example + * expect(metricLabel('pass', definitions)).toBe('Pass') + */ + it('reads scores as higher-is-better columns with their formula, shown as percentages', () => { + const scoreDefinitions: ScoreDefinition[] = [ + { label: 'Written back correctly', name: 'correct' }, + { combine: { inputs: [{ name: 'correct' }, { atLeast: 0.5, name: 'natural' }], kind: 'all' }, label: 'Pass', name: 'pass', primary: true }, + ] + const definitions = columnDefinitions([{ better: 'lower', name: 'latency', unit: 'ms' }], scoreDefinitions) + + expect(metricLabel('pass', definitions)).toBe('Pass') + expect(metricDefinition('pass', definitions)?.better).toBe('higher') + expect(metricDefinition('pass', definitions)?.formula).toBe('all of: Written back correctly, natural ≥ 50%') + expect(formatColumnValue(0.875, metricDefinition('pass', definitions))).toBe('87.5%') + expect(formatColumnValue(1234.5, metricDefinition('latency', definitions))).toBe('1.2K ms') + expect(formatColumnValue(1.23456, undefined)).toBe('1.23') + }) }) diff --git a/packages/ui/client/src/caseMetrics.ts b/packages/ui/client/src/caseMetrics.ts index b43a4a3..664f94b 100644 --- a/packages/ui/client/src/caseMetrics.ts +++ b/packages/ui/client/src/caseMetrics.ts @@ -1,11 +1,14 @@ import type { ScaleContinuousNumeric } from 'd3' -import type { CaseNode, CaseState, MetricDefinition, MetricsResponse } from '../../src/shared/contract' +import type { CaseNode, CaseState, MetricDefinition, MetricsResponse, ScoreDefinition } from '../../src/shared/contract' +import type { ColumnDefinition } from './scores' import { scaleLinear, scaleSymlog } from 'd3' import { parseTaskId } from '../../src/shared/reduce' +import { formatScore } from './format' import { localize } from './localize' +import { primaryScoreName, scoreColumn } from './scores' /** One case of a task, flattened for the multi-metric charts (parallel coordinates, scatterplot matrix). */ export interface CaseRow { @@ -37,7 +40,11 @@ export interface CaseTable { groups: MetricGroup[] /** Numeric metrics whose largest value is far above the median; charts draw them on a symlog scale. */ heavyTailed: Set + /** Score the cases are read by (see {@link primaryScoreName}); `undefined` without one. */ + primary?: string rows: CaseRow[] + /** Names of the scores among the numeric values, the primary score first; they are axes like any metric. */ + scores: string[] /** Cases per value of each category axis, most frequent first (`state` in its usual order, passed first). */ valueCounts: Record> } @@ -82,7 +89,7 @@ export interface MetricSeparation { * metric alone (`0.5`: no better than a coin, `1`: every case of one outcome lies on one side). */ auc: number - /** Which outcome has the larger values: a case state, or the lower / higher `exact` score. */ + /** Which outcome has the larger values: a case state, or the lower / higher primary score. */ higherIn: 'failed' | 'higher-scored' | 'lower-scored' | 'passed' } @@ -98,13 +105,13 @@ export interface MetricSeparation { * matched to cases by task id and case id * * Returns: - * - one row per case with the value of its last round for every metric; built-in axes `duration_s`, `exact`, - * `judge` (when recorded), `state`, `group` (the name prefix before `/`, when names share that shape), and the - * eval `entry` and the run and eval matrix axes of the case's task (they split the cases only when several tasks - * are in the table) + * - one row per case with the value of its last round for every metric and every score (the counted value of + * `metrics.scores`, else the recorded value of the case); built-in axes `duration_s`, `state`, `group` (the name + * prefix before `/`, when names share that shape), and the eval `entry` and the run and eval matrix axes of the + * case's task (they split the cases only when several tasks are in the table) * - metrics recorded as a list of numbers per case summarized as `#mean` and `#length` * - constant metrics split out, and correlated metrics (|r| ≥ 0.95 on `log1p` values) folded into the first of - * them in recording order, built-ins first + * them in recording order: scores first (the primary leads), then the built-ins, then the recorded metrics */ export function buildCaseTable(cases: readonly CaseNode[], metrics: MetricsResponse | undefined): CaseTable { // Keyed like `CaseNode.key` (task id, NUL, case id): one case id repeats in every task of a run (an OSWorld question @@ -132,6 +139,32 @@ export function buildCaseTable(cases: readonly CaseNode[], metrics: MetricsRespo } } } + // Scores of the case's last round as the summaries count them (a failed case scores 0); a case left out of a score + // (errored, unscored) has no point and so no value. + const scoreNames: string[] = [] + for (const series of metrics?.scores ?? []) { + scoreNames.push(series.name) + for (const point of series.points) { + const entry = entryOf(point.taskId, point.caseId) + const round = point.roundIndex ?? 0 + if ((entry.rounds[series.name] ?? -1) <= round) { + entry.rounds[series.name] = round + entry.numbers[series.name] = point.value + } + } + } + // Without the metrics response (still loading), the cases' own recorded values stand in for the counted ones. + if (metrics == null) { + for (const node of cases) { + for (const [name, value] of Object.entries(node.scores.values)) { + if (!scoreNames.includes(name)) + scoreNames.push(name) + entryOf(node.taskId, node.caseId).numbers[name] = value + } + } + } + const primary = primaryScoreName(metrics?.scoreDefinitions ?? [], scoreNames) + const scores = primary == null ? scoreNames : [primary, ...scoreNames.filter(name => name !== primary)] const derivedNames: string[] = [] for (const series of metrics?.nonNumeric ?? []) { for (const point of series.values) { @@ -166,10 +199,6 @@ export function buildCaseTable(cases: readonly CaseNode[], metrics: MetricsRespo const numbers: Record = { ...recorded?.numbers } if (node.durationMs != null) numbers.duration_s = node.durationMs / 1000 - if (node.scores.exact != null) - numbers.exact = node.scores.exact - if (node.scores.judge != null) - numbers.judge = node.scores.judge // Built-ins first (key order is the default axis order), and they win over a recorded metric of the same name. // The matrix axes of the case's task (`tts`, `asr`, …): one value per task, so they split only a run-wide table. // The eval entry too (`…/formula-speech/en`, by its last path segment): one entry per language or dataset split. @@ -181,9 +210,8 @@ export function buildCaseTable(cases: readonly CaseNode[], metrics: MetricsRespo return { caseId: node.caseId, categories, key: node.key, name: node.name, numbers, state: node.state, taskId: node.taskId } }) - const builtIns = ['duration_s', 'exact', 'judge'] const recordedNames = [...(metrics?.numeric ?? []).map(series => series.name), ...derivedNames] - const numericNames = [...builtIns, ...recordedNames].filter((name, index, all) => all.indexOf(name) === index && rows.some(row => row.numbers[name] != null)) + const numericNames = [...scores, 'duration_s', ...recordedNames].filter((name, index, all) => all.indexOf(name) === index && rows.some(row => row.numbers[name] != null)) const constants: CaseTable['constants'] = [] const groups: MetricGroup[] = [] @@ -246,12 +274,56 @@ export function buildCaseTable(cases: readonly CaseNode[], metrics: MetricsRespo : numericValues ? (value: string) => Number(value) : (value: string) => -counts.get(value)! valueCounts[name] = new Map([...counts].sort(([a], [b]) => order(a) - order(b))) } - return { categories, constants, duplicates, groups, heavyTailed, rows, valueCounts } + return { categories, constants, duplicates, groups, heavyTailed, primary, rows, scores: scores.filter(name => rows.some(row => row.numbers[name] != null)), valueCounts } +} + +/** + * Definitions of every column of a case table by name: the metrics' `metric.defined`, and the scores' `score.defined` + * read as columns ({@link scoreColumn}); a score wins over a metric of the same name. + * + * Use when: + * - a chart, menu, or table labels and describes the columns of a {@link CaseTable} (`metricLabel`, `metricDefinition`) + */ +export function columnDefinitions(metricDefinitions: readonly MetricDefinition[], scoreDefinitions: readonly ScoreDefinition[]): Map { + return new Map([ + ...metricDefinitions.map(definition => [definition.name, definition] as const), + ...scoreDefinitions.map(definition => [definition.name, scoreColumn(definition, scoreDefinitions)] as const), + ]) +} + +/** + * Formats one value of a column for people: a score as a percentage, a metric rounded with its unit. + * + * Before: + * - (0.875, score column), (48213, { unit: 'tokens' }), (1.23456, undefined) + * + * After: + * - "87.5%", "48.2K tokens", "1.23" + */ +export function formatColumnValue(value: number, definition: ColumnDefinition | undefined): string { + if (definition?.score != null) + return formatScore(value) + const text = Math.abs(value) >= 1000 ? new Intl.NumberFormat(undefined, { maximumFractionDigits: 1, notation: 'compact' }).format(value) : String(Math.round(value * 100) / 100) + return definition?.unit == null ? text : `${text} ${definition.unit}` } /** Separator between a list metric's name and its summary in a derived metric name; never part of a recorded name. */ const DERIVED_SEPARATOR = '#' +/** + * Stable offset in `-0.5..0.5` for one case, so a dot keeps its spot inside a band (or a line its place on a category + * axis) across redraws instead of jumping on every update. `salt` gives a case independent offsets per direction. + * + * Use when: + * - spreading many cases that share one value (a category, the free direction of a strip) so they stay countable + */ +export function caseOffset(key: string, salt = 0): number { + let hash = (salt + 1) * 2654435761 + for (let index = 0; index < key.length; index++) + hash = Math.imul(hash ^ key.charCodeAt(index), 2654435761) + return ((hash >>> 0) % 1000) / 1000 - 0.5 +} + /** * Pearson correlation of two metrics over the cases that recorded both, on `log1p` values so token counts that span * several orders of magnitude are compared by shape, not by their few largest cases. `0` when either is flat. @@ -329,7 +401,7 @@ export function matchesFilters(row: CaseRow, filters: CaseFilters): boolean { * Definition of a metric for its hover card; a derived metric borrows the description of its list metric (and its * unit for the mean, not for the length). */ -export function metricDefinition(name: string, definitions: ReadonlyMap): MetricDefinition | undefined { +export function metricDefinition(name: string, definitions: ReadonlyMap): ColumnDefinition | undefined { const derived = parseDerivedMetric(name) const base = definitions.get(derived?.base ?? name) if (derived == null || base == null) @@ -344,7 +416,7 @@ export function metricDefinition(name: string, definitions: ReadonlyMap): string { +export function metricLabel(name: string, definitions: ReadonlyMap): string { const derived = parseDerivedMetric(name) if (derived != null) return `${metricLabel(derived.base, definitions)} · ${derived.summary}` @@ -387,65 +459,6 @@ export function parseDerivedMetric(name: string): undefined | { base: string, su return at > 0 && (summary === 'mean' || summary === 'length') ? { base: name.slice(0, at), summary } : undefined } -/** - * Short label of a metric for a chart axis: the last dot segment, or more segments while two picked metrics would - * share a label. - * - * Before: - * - "osworld.model.usage.output_tokens_details.reasoning_tokens" - * - * After: - * - "reasoning_tokens" - */ -export function shortMetricName(name: string, all: readonly string[]): string { - const parts = name.split('.') - for (let take = 1; take < parts.length; take++) { - const label = parts.slice(-take).join('.') - if (all.every(other => other === name || (!other.endsWith(`.${label}`) && other !== label))) - return label - } - return name -} - -/** The numbers of a recorded list value, or `undefined` when it is not a non-empty list of finite numbers. */ -function numbersOf(value: unknown): number[] | undefined { - if (!Array.isArray(value) || value.length === 0) - return undefined - return value.every(item => typeof item === 'number' && Number.isFinite(item)) ? value as number[] : undefined -} - -/** Whether two categories split the cases the same way, value for value (`exact` 1/0 against `state` passed/failed). */ -function sameSplit(rows: readonly CaseRow[], left: string, right: string): boolean { - const forward = new Map() - const backward = new Map() - for (const row of rows) { - const a = row.categories[left] ?? '' - const b = row.categories[right] ?? '' - if ((forward.get(a) ?? b) !== b || (backward.get(b) ?? a) !== a) - return false - forward.set(a, b) - backward.set(b, a) - } - return true -} - -/** Scores are the outcome itself, so ranking them by how they separate the outcome would explain nothing. */ -const OUTCOME_METRICS = new Set(['exact', 'judge']) - -/** - * Stable offset in `-0.5..0.5` for one case, so a dot keeps its spot inside a band (or a line its place on a category - * axis) across redraws instead of jumping on every update. `salt` gives a case independent offsets per direction. - * - * Use when: - * - spreading many cases that share one value (a category, the free direction of a strip) so they stay countable - */ -export function caseOffset(key: string, salt = 0): number { - let hash = (salt + 1) * 2654435761 - for (let index = 0; index < key.length; index++) - hash = Math.imul(hash ^ key.charCodeAt(index), 2654435761) - return ((hash >>> 0) % 1000) / 1000 - 0.5 -} - /** * Ranks numeric metrics by how well they separate failed from passed cases. * @@ -456,19 +469,20 @@ export function caseOffset(key: string, salt = 0): number { * - finished cases (`passed`, `failed`, `errored`, `timeout`); running and skipped cases are left out * * Returns: - * - one entry per non-constant metric with values in both outcomes, except the score metrics; ties count half + * - one entry per non-constant metric with values in both outcomes, except the scores; ties count half * (Mann–Whitney U), so a metric that is mostly 0 in both outcomes stays near `0.5` * - when no finished case failed (a scored eval, where a score of 0 is a result, not a failure), the outcomes are the - * cases below the mean `exact` score against the rest, so the ranking still explains what lowers the score + * cases below the mean primary score against the rest, so the ranking still explains what lowers the score */ export function separationOf(table: CaseTable): Map { const result = new Map() const finished = table.rows.filter(row => row.state === 'passed' || row.state === 'failed' || row.state === 'errored' || row.state === 'timeout') - const outcome = outcomeOf(finished) + const outcome = outcomeOf(finished, table.primary) if (outcome == null) return result for (const name of table.groups.flatMap(group => [group.representative, ...group.members.map(member => member.name)])) { - if (OUTCOME_METRICS.has(name)) + // Scores are the outcome itself, so ranking them by how they separate the outcome would explain nothing. + if (table.scores.includes(name)) continue const low: number[] = [] const high: number[] = [] @@ -502,14 +516,43 @@ export function separationOf(table: CaseTable): Map { return result } +/** + * Short label of a metric for a chart axis: the last dot segment, or more segments while two picked metrics would + * share a label. + * + * Before: + * - "osworld.model.usage.output_tokens_details.reasoning_tokens" + * + * After: + * - "reasoning_tokens" + */ +export function shortMetricName(name: string, all: readonly string[]): string { + const parts = name.split('.') + for (let take = 1; take < parts.length; take++) { + const label = parts.slice(-take).join('.') + if (all.every(other => other === name || (!other.endsWith(`.${label}`) && other !== label))) + return label + } + return name +} + +/** The numbers of a recorded list value, or `undefined` when it is not a non-empty list of finite numbers. */ +function numbersOf(value: unknown): number[] | undefined { + if (!Array.isArray(value) || value.length === 0) + return undefined + return value.every(item => typeof item === 'number' && Number.isFinite(item)) ? value as number[] : undefined +} + /** * The two outcomes the separation compares: failed against passed cases, or, with no failed case, cases below the - * mean `exact` score against the rest. `undefined` when neither split has two sides. + * mean primary score against the rest. `undefined` when neither split has two sides. */ -function outcomeOf(finished: readonly CaseRow[]): undefined | { high: MetricSeparation['higherIn'], low: MetricSeparation['higherIn'], sideOf: (row: CaseRow) => 'high' | 'low' | undefined } { +function outcomeOf(finished: readonly CaseRow[], primary: string | undefined): undefined | { high: MetricSeparation['higherIn'], low: MetricSeparation['higherIn'], sideOf: (row: CaseRow) => 'high' | 'low' | undefined } { if (finished.some(row => row.state === 'failed') && finished.some(row => row.state === 'passed')) return { high: 'passed', low: 'failed', sideOf: row => (row.state === 'passed' ? 'high' : 'low') } - const scores = finished.map(row => row.numbers.exact).filter((value): value is number => value != null) + if (primary == null) + return undefined + const scores = finished.map(row => row.numbers[primary]).filter((value): value is number => value != null) if (new Set(scores).size < 2) return undefined const mean = scores.reduce((sum, value) => sum + value, 0) / scores.length @@ -517,8 +560,23 @@ function outcomeOf(finished: readonly CaseRow[]): undefined | { high: MetricSepa high: 'higher-scored', low: 'lower-scored', sideOf: (row) => { - const value = row.numbers.exact + const value = row.numbers[primary] return value == null ? undefined : value < mean ? 'low' : 'high' }, } } + +/** Whether two categories split the cases the same way, value for value (a 1/0 score against `state` passed/failed). */ +function sameSplit(rows: readonly CaseRow[], left: string, right: string): boolean { + const forward = new Map() + const backward = new Map() + for (const row of rows) { + const a = row.categories[left] ?? '' + const b = row.categories[right] ?? '' + if ((forward.get(a) ?? b) !== b || (backward.get(b) ?? a) !== a) + return false + forward.set(a, b) + backward.set(b, a) + } + return true +} diff --git a/packages/ui/client/src/components/AxisPicker.vue b/packages/ui/client/src/components/AxisPicker.vue index ecc66c5..4133127 100644 --- a/packages/ui/client/src/components/AxisPicker.vue +++ b/packages/ui/client/src/components/AxisPicker.vue @@ -1,6 +1,6 @@