diff --git a/README.md b/README.md index 5de78f1..c2f8110 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,10 @@ also requires all checks to pass. [Verification and limits](docs/verification.md **Inspect coverage before trusting a perfect score.** The website now lists all three task packs and links each single-case dependency directly to its recorded checks and seeds. Offline audit reports provide the same disclosures without scripts or remote assets. These are new views of the original records, not new model runs. +## Trace Workbench — 0.11.0 + +Import saved AgentCore Evaluate responses, versioned golden cases and Skills Anywhere delivery receipts. Inspect zero scores, skipped judges, missing results and missed skills separately. Compare matching datasets/rubrics and verify preserved input bytes offline. [Try the authored controls](https://noteflowai.github.io/evalarc/trace-workbench/) · [Actual local MCP delivery](https://noteflowai.github.io/evalarc/trace-mcp/) · [Input contract](docs/trace-workbench.md). No live AWS evaluation is claimed. + ## New in 0.9.0: research you can inspect [Explore all 27 real GPU skill trials](https://noteflowai.github.io/evalarc/skill-impact/) and [the research pilots](docs/research-pilots.md). Robot Reel's [captured-scene editor](https://noteflowai.github.io/robot-reel/scene-lab/) and [official LIBERO-Plus replay](https://noteflowai.github.io/robot-reel/libero-plus/) connect real source records with portable skill delivery and independent grading. Every failed attempt stays visible; no skill efficacy, full-benchmark or real-hardware result is implied. @@ -247,7 +251,7 @@ publication status. EvalArc's intended place is an **audit layer alongside existing environment and training frameworks**. Harbor already supports multi-step tasks and separate verifier environments; neither is claimed as an invention here. -Harbor and Prime Intellect adapters are roadmap items, not current integrations. +Native Harbor task export, oracle/NOP execution and ATIF 1.8 records are available as [bounded research integrations](docs/research-pilots.md). A general production adapter and Prime Intellect integration remain future work. ## Project name diff --git a/README.zh-CN.md b/README.zh-CN.md index 65fa182..4a9f591 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -98,6 +98,10 @@ Docker 评测:参考策略 3/3 轮完全通过,重复写入策略虽然平 **在信任满分前,先检查覆盖薄弱点。** 网页现展示全部三个任务包,可从仅靠一个用例检出的缺陷直接定位到原始检查与种子记录。离线审计报告也提供相同的证据展开入口,不依赖脚本或远程资源;新增界面沿用原始数据,不冒充新模型运行。 +## 0.11.0:运行记录评估工作台 + +导入已保存的 AgentCore Evaluate 结果、版本化黄金案例和 Skills Anywhere 加载回执,分别查看有效零分、评估跳过、缺少结果及漏调用技能。支持相同测试集与评分规则下的对比,以及原始输入的离线复核。[交互示例](https://noteflowai.github.io/evalarc/trace-workbench/) · [真实本地 MCP 加载](https://noteflowai.github.io/evalarc/trace-mcp/) · [数据契约](docs/trace-workbench.md)。示例明确区分合成评分与真实加载记录,未运行云端评估。 + ## 0.9.0:有原始证据的研究场景 [查看 27 次真实 GPU 技能评测](https://noteflowai.github.io/evalarc/skill-impact/),并阅读[完整方法与限制](docs/research-pilots.md)。新增[实景 Blender 编辑](https://noteflowai.github.io/robot-reel/scene-lab/)与[官方 LIBERO-Plus 子集回放](https://noteflowai.github.io/robot-reel/libero-plus/),把原始记录、技能交付与独立验收连接起来。失败尝试全部保留;不宣称技能提分、完整基准成绩或真机效果。 diff --git a/docs/architecture.md b/docs/architecture.md index b68c913..22c66a3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,8 +1,10 @@ # Architecture and domain expansion EvalArc's direction is open environments and auditable evaluations for -software agents. This document distinguishes the working v0.6 implementation -from interfaces proposed for subsequent releases. +software agents. This document distinguishes current implementation from proposed interfaces. +The foundational coding/support architecture below is supplemented by the +robot evidence task, Harbor/ATIF research adapters and the offline +[Trace Workbench](trace-workbench.md) in v0.11.0. ## Current implementation @@ -20,8 +22,11 @@ use `evalarc.audit.v2` for declared controls. Metadata includes domain, task version, command, fingerprints, runtime limits, outcomes, and validity. Checks and evidence remain domain-specific. -The current CLI does not invoke a model or expose a browser. The tool policy is -an external program; Python and JavaScript examples are scripted controls. +The core task CLI executes external candidate programs. Separate research +scripts include recorded local-model pilots and native Harbor execution. +`trace-import` reviews supplied AgentCore Evaluate responses offline; it does not +invoke a cloud model. A general model-provider or browser-task adapter is not +implemented. See [research scope](research-pilots.md). `repeat` executes fresh attempts from a frozen candidate and summarizes case/check variability. `suite` coordinates repetitions across declared jobs: diff --git a/docs/roadmap.md b/docs/roadmap.md index 884b159..fcc1a31 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -19,11 +19,13 @@ question needs substantially more evidence. | v0.4, implemented | Fixed-candidate repeated evaluation, per-check variability, case deadlines, progress events, process diagnostics | Fresh state per attempt, explicit invalid denominators, shared restart budgets, cancellation and cleanup tests | | v0.5, implemented | Declarative suites, per-job acceptance gates, HTML/JSON and JUnit export | All-candidate preflight, workload preview, protected dimensions, distinct invalid outcomes, no cross-domain score average | | v0.6, implemented | Python/JavaScript templates and independent controls for both tasks | The same 15 declared faults, Node Docker execution, lossless numeric handling, process-crash recovery, atomic workspace initialization | +| v0.11, implemented | Offline AgentCore trace review and skill receipt linkage | Preserved bytes, versioned golden cases/rubrics, separate missing/skipped/zero results and synthetic vs recorded provenance | +| Next | Judge calibration and broader trace collection adapters | Live upstream runs, frozen judge repetitions and independent calibration cases | | Next | Broader language coverage for coding | A non-Python Durable KV submission passes the same full contract | | Next | Independent defect packs and task authors | Reviewer-authored faults and a third independently authored task | | Next | Real model-provider adapter | End-to-end run with measured usage, task evidence, repeated attempts | | Later | Browser environment adapter | Reproducible initial state, backend outcome checks, isolated sessions | -| Next | Harbor integration using supported verifier boundaries | Actual upstream task execution, pinned compatibility version | +| Research pilot, implemented | Native Harbor task export, oracle/NOP execution and ATIF 1.8 | Pinned Harbor 0.23.0; upstream reward separate from independent checks | | Next | Procedural task variants with separated public and private acceptance | Measured exposure controls; distribution-level split | | Research pilot | Three deep task families | Human baselines, repeated real agent attempts, failure taxonomy | | Research study | Grader-audit ablations and downstream training | Unseen-family transfer, matched budgets, uncertainty intervals | diff --git a/docs/trace-workbench.md b/docs/trace-workbench.md new file mode 100644 index 0000000..c6af7ea --- /dev/null +++ b/docs/trace-workbench.md @@ -0,0 +1,135 @@ +# Trace Workbench + +`trace-import` turns a bounded export into a local, filterable report. It checks +case/session/trace/span linkage, applies explicitly supplied evaluator scales, +matches Skills Anywhere delivery receipts to reviewed bundle hashes, and keeps +missing results distinct from assessed zero scores. + +This is **review of imported judgments**, separate from EvalArc's independent +executable task graders. The importer does not call AWS, run a model, authenticate +the producer or prove that the supplied traces cover every action. + +## Start with the authored controls + +```bash +evalarc trace-import examples/trace-workbench/current.json \ + --baseline examples/trace-workbench/baseline.json \ + --output runs/trace-review-001 +evalarc trace-verify runs/trace-review-001 +``` + +Open `runs/trace-review-001/index.html` directly, including offline. All rendering +and filtering are local. The folder includes exact original input bytes, +optional baseline bytes and a recomputable `review.json`. + +The five **synthetic** controls demonstrate an accepted case, assessed zero, +skipped evaluation, missing result and missed required skill. Their placeholder +hashes and authored scores are not cloud/model measurements. + +## Prepare a real export + +Use the example JSON as the wrapper. Fill in: + +| Field | Contract | +| --- | --- | +| `schema_version` | `evalarc.trace-input.v1` | +| `run_id` | Your run identity; one attempt per input | +| `provenance` | `kind: recorded` and a description of collection method and limitations | +| `dataset` | Stable ID, version and ordered cases: `id`, `goal`, `expected_skills` | +| `configuration` | Model identity/parameters, prompt and tool-schema SHA-256, `skills` mapping names to reviewed bundle SHA-256 (or `null` when unknown) | +| `evaluators` | IDs, frozen revision descriptions, target `level`, and explicitly declared rating/gate rules | +| `cases` | Exactly one recording per golden case, in the same order | + +A case contains `case_id`, `session_id`, `trace_ids`, `spans`, +`skill_observation_complete`, `skill_calls` and `evaluation_response`. + +Copy flat OpenTelemetry spans with `traceId` and `spanId` into `spans`. +Attributes may be a plain object or OTLP key/value array. When `session.id` is +present it must match the case. Every declared trace must have a span. This first +adapter accepts a flat span array, not arbitrary OTLP envelopes or CloudWatch +query output wrappers; extract the native span objects first. + +Copy the native AgentCore **Evaluate API** response into `evaluation_response`. +Preserve `evaluationResults` and original fields. Result targets use +`context.spanContext.sessionId`, with `traceId` and `spanId` according to level. +The Workshop CLI's `run.results[].sessionScores` output is a different envelope +and is not accepted by this adapter. + +- `session`: one result for this case's session; no trace/span target. +- `trace`: one result for each declared trace; no span target. +- `skill`: one result for each explicitly annotated skill call's trace/span. + This level intentionally covers skill calls, not arbitrary tool-call evaluators. +- Declare at least one session evaluator so every golden case has an expected + result even when no skill was called. + +Numeric ratings declare `min`, `max`, `pass_at_least`; categorical ratings +declare `labels` and `pass_labels`. Never infer a scale or freeze a built-in +evaluator version from its name alone. Record the configuration and collection +date actually used; the revision field is caller-supplied metadata. + +Unknown IDs, duplicate evaluator/target results, wrong sessions and out-of-range +ratings are rejected. Numeric zero is assessed. Error and skipped results are +unassessed even if they carry a raw number; that number remains in `input.json`. +`Skipped` in `errorCode`, or in a label outside a declared categorical scale, is +recognized; this is not a promise that every service uses that spelling. +Other errors retain their code. Empty responses produce missing expected results. + +## Associate an actual skill load + +Skills Anywhere 0.12+ returns `structuredContent.receipt` from successful +`open_skill` calls. Your collector should preserve the response and annotate its +actual tool span: + +```json +{ + "name": "evidence-review", + "trace_id": "YOUR_TRACE_ID", + "span_id": "YOUR_TOOL_SPAN_ID", + "receipt": {"schema": "skills-anywhere-load-1", "...": "copy the complete receipt"} +} +``` + +The snippet is schematic; use the complete returned object. The receipt's +`bundle_sha256` is available when `include_bundle: true` or +`expected_bundle_sha256` is requested. The wrapper's `configuration.skills` must +contain the bundle hashes reviewed before the run. + +This annotation is explicit: the importer does not guess skill names from +arbitrary tool arguments. Set `skill_observation_complete: true` only if your +collector observed all skill loads for the case. Absent required calls are +`not_called` only with declared complete coverage; otherwise they remain +unknown. A matching receipt identifies delivered content, not followed +instructions or completed work. Multiple loads need distinct receipt IDs. + +## Compare and verify + +Comparison requires identical complete golden-set contents and evaluator +definitions, including revision and rating rules. It shows configuration changes +and case transitions without pooling evaluator scores or claiming causal gains. +Synthetic and recorded inputs cannot be compared with each other. + +`trace-verify` reconstructs the review from preserved bytes and compares all +fields. It detects changed input or summary bytes, but does not authenticate a +producer or verify HTML. It does not execute any imported content. + +By default successful import exits 0 even when gates reject cases. Add +`--require-accepted` for CI: 0 = every case accepted, 1 = complete results with +rejected gates, 2 = incomplete results or invalid input. The complete report is +still written for well-formed input with a rejected/incomplete gate. + +## Scope and sources + +Inputs are bounded to 4 MiB, 200 cases, 512 spans per case, and 32 evaluators. +Duplicate JSON keys, nonfinite numbers and invalid Unicode are rejected. Existing +output folders are preserved. There is no browser file upload, automatic +collection, cloud deployment, judge calibration or statistical significance +calculation in this first release. Review private trace content before sharing. + +- [AgentCore Evaluate API](https://docs.aws.amazon.com/bedrock-agentcore/latest/APIReference/API_Evaluate.html) +- [Evaluation result fields](https://docs.aws.amazon.com/bedrock-agentcore/latest/APIReference/API_EvaluationResultContent.html) +- [Skill evaluators](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/skill-evaluators.html) +- [Eval-First Workshop](https://catalog.us-east-1.prod.workshops.aws/workshops/bdb5c2fd-86cc-4a86-b55f-fbc2a81c001a/zh-CN/010-introduction) + +The AWS adapter has been tested against authored API-shaped controls. The +separate MCP recording exercises actual local instruction delivery with missing +evaluator results; no successful live AgentCore evaluation is claimed. diff --git a/examples/trace-workbench/baseline.json b/examples/trace-workbench/baseline.json new file mode 100644 index 0000000..b3bbaad --- /dev/null +++ b/examples/trace-workbench/baseline.json @@ -0,0 +1,354 @@ +{ + "schema_version": "evalarc.trace-input.v1", + "run_id": "synthetic-baseline", + "provenance": { + "kind": "synthetic", + "description": "Five authored controls for import and review behavior. Placeholder hashes, simulated spans and judgments; no AWS or model run." + }, + "dataset": { + "id": "trace-review-controls", + "version": "1", + "cases": [ + { + "id": "accepted-control", + "goal": "Inspect the accepted-control review condition.", + "expected_skills": [ + "evidence-review" + ] + }, + { + "id": "assessed-zero", + "goal": "Inspect the assessed-zero review condition.", + "expected_skills": [ + "evidence-review" + ] + }, + { + "id": "skipped-judge", + "goal": "Inspect the skipped-judge review condition.", + "expected_skills": [] + }, + { + "id": "missing-result", + "goal": "Inspect the missing-result review condition.", + "expected_skills": [] + }, + { + "id": "missed-skill", + "goal": "Inspect the missed-skill review condition.", + "expected_skills": [ + "evidence-review" + ] + } + ] + }, + "configuration": { + "model": "synthetic-control", + "model_parameters": {}, + "prompt_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "tools_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "skills": { + "evidence-review": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + }, + "evaluators": [ + { + "id": "goal-control", + "revision": "authored-1", + "level": "session", + "rating": { + "kind": "numeric", + "min": 0, + "max": 1, + "pass_at_least": 1 + } + }, + { + "id": "Builtin.SkillInstructionFollowing", + "revision": "synthetic-scale-1", + "level": "skill", + "rating": { + "kind": "numeric", + "min": 0, + "max": 1, + "pass_at_least": 1 + } + } + ], + "cases": [ + { + "case_id": "accepted-control", + "session_id": "session-0", + "trace_ids": [ + "00000000000000000000000000000001" + ], + "skill_observation_complete": true, + "spans": [ + { + "traceId": "00000000000000000000000000000001", + "spanId": "0000000000000001", + "name": "authored-control", + "attributes": { + "session.id": "session-0" + } + } + ], + "skill_calls": [ + { + "name": "evidence-review", + "trace_id": "00000000000000000000000000000001", + "span_id": "0000000000000001", + "receipt": { + "schema": "skills-anywhere-load-1", + "load_id": "synthetic-0", + "loaded_at": "2026-09-15T00:00:00Z", + "provider": "dsh-skills-anywhere", + "provider_version": "0.12.0", + "name": "evidence-review", + "skill_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "content_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "bundle_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "declared_tools": null, + "permissions_enforced": false + } + } + ], + "evaluation_response": { + "evaluationResults": [ + { + "evaluatorId": "goal-control", + "evaluatorName": "goal-control", + "evaluatorArn": "synthetic-control", + "context": { + "spanContext": { + "sessionId": "session-0" + } + }, + "value": 1, + "explanation": "Authored passing control." + }, + { + "evaluatorId": "Builtin.SkillInstructionFollowing", + "evaluatorName": "Builtin.SkillInstructionFollowing", + "evaluatorArn": "synthetic-control", + "context": { + "spanContext": { + "sessionId": "session-0", + "traceId": "00000000000000000000000000000001", + "spanId": "0000000000000001" + } + }, + "value": 1, + "explanation": "Authored passing control." + } + ] + } + }, + { + "case_id": "assessed-zero", + "session_id": "session-1", + "trace_ids": [ + "00000000000000000000000000000002" + ], + "skill_observation_complete": true, + "spans": [ + { + "traceId": "00000000000000000000000000000002", + "spanId": "0000000000000002", + "name": "authored-control", + "attributes": { + "session.id": "session-1" + } + } + ], + "skill_calls": [ + { + "name": "evidence-review", + "trace_id": "00000000000000000000000000000002", + "span_id": "0000000000000002", + "receipt": { + "schema": "skills-anywhere-load-1", + "load_id": "synthetic-1", + "loaded_at": "2026-09-15T00:00:00Z", + "provider": "dsh-skills-anywhere", + "provider_version": "0.12.0", + "name": "evidence-review", + "skill_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "content_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "bundle_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "declared_tools": null, + "permissions_enforced": false + } + } + ], + "evaluation_response": { + "evaluationResults": [ + { + "evaluatorId": "goal-control", + "evaluatorName": "goal-control", + "evaluatorArn": "synthetic-control", + "context": { + "spanContext": { + "sessionId": "session-1" + } + }, + "value": 1, + "explanation": "Authored passing control." + }, + { + "evaluatorId": "Builtin.SkillInstructionFollowing", + "evaluatorName": "Builtin.SkillInstructionFollowing", + "evaluatorArn": "synthetic-control", + "context": { + "spanContext": { + "sessionId": "session-1", + "traceId": "00000000000000000000000000000002", + "spanId": "0000000000000002" + } + }, + "value": 1, + "explanation": "Authored passing control." + } + ] + } + }, + { + "case_id": "skipped-judge", + "session_id": "session-2", + "trace_ids": [ + "00000000000000000000000000000003" + ], + "skill_observation_complete": true, + "spans": [ + { + "traceId": "00000000000000000000000000000003", + "spanId": "0000000000000003", + "name": "authored-control", + "attributes": { + "session.id": "session-2" + } + } + ], + "skill_calls": [], + "evaluation_response": { + "evaluationResults": [ + { + "evaluatorId": "goal-control", + "evaluatorName": "goal-control", + "evaluatorArn": "synthetic-control", + "context": { + "spanContext": { + "sessionId": "session-2" + } + }, + "value": 1, + "explanation": "Authored passing control." + } + ] + } + }, + { + "case_id": "missing-result", + "session_id": "session-3", + "trace_ids": [ + "00000000000000000000000000000004" + ], + "skill_observation_complete": true, + "spans": [ + { + "traceId": "00000000000000000000000000000004", + "spanId": "0000000000000004", + "name": "authored-control", + "attributes": { + "session.id": "session-3" + } + } + ], + "skill_calls": [], + "evaluation_response": { + "evaluationResults": [ + { + "evaluatorId": "goal-control", + "evaluatorName": "goal-control", + "evaluatorArn": "synthetic-control", + "context": { + "spanContext": { + "sessionId": "session-3" + } + }, + "value": 1, + "explanation": "Authored passing control." + } + ] + } + }, + { + "case_id": "missed-skill", + "session_id": "session-4", + "trace_ids": [ + "00000000000000000000000000000005" + ], + "skill_observation_complete": true, + "spans": [ + { + "traceId": "00000000000000000000000000000005", + "spanId": "0000000000000005", + "name": "authored-control", + "attributes": { + "session.id": "session-4" + } + } + ], + "skill_calls": [ + { + "name": "evidence-review", + "trace_id": "00000000000000000000000000000005", + "span_id": "0000000000000005", + "receipt": { + "schema": "skills-anywhere-load-1", + "load_id": "synthetic-4", + "loaded_at": "2026-09-15T00:00:00Z", + "provider": "dsh-skills-anywhere", + "provider_version": "0.12.0", + "name": "evidence-review", + "skill_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "content_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "bundle_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "declared_tools": null, + "permissions_enforced": false + } + } + ], + "evaluation_response": { + "evaluationResults": [ + { + "evaluatorId": "goal-control", + "evaluatorName": "goal-control", + "evaluatorArn": "synthetic-control", + "context": { + "spanContext": { + "sessionId": "session-4" + } + }, + "value": 1, + "explanation": "Authored passing control." + }, + { + "evaluatorId": "Builtin.SkillInstructionFollowing", + "evaluatorName": "Builtin.SkillInstructionFollowing", + "evaluatorArn": "synthetic-control", + "context": { + "spanContext": { + "sessionId": "session-4", + "traceId": "00000000000000000000000000000005", + "spanId": "0000000000000005" + } + }, + "value": 1, + "explanation": "Authored passing control." + } + ] + } + } + ] +} diff --git a/examples/trace-workbench/current.json b/examples/trace-workbench/current.json new file mode 100644 index 0000000..7bffe78 --- /dev/null +++ b/examples/trace-workbench/current.json @@ -0,0 +1,309 @@ +{ + "schema_version": "evalarc.trace-input.v1", + "run_id": "synthetic-current", + "provenance": { + "kind": "synthetic", + "description": "Five authored controls for import and review behavior. Placeholder hashes, simulated spans and judgments; no AWS or model run." + }, + "dataset": { + "id": "trace-review-controls", + "version": "1", + "cases": [ + { + "id": "accepted-control", + "goal": "Inspect the accepted-control review condition.", + "expected_skills": [ + "evidence-review" + ] + }, + { + "id": "assessed-zero", + "goal": "Inspect the assessed-zero review condition.", + "expected_skills": [ + "evidence-review" + ] + }, + { + "id": "skipped-judge", + "goal": "Inspect the skipped-judge review condition.", + "expected_skills": [] + }, + { + "id": "missing-result", + "goal": "Inspect the missing-result review condition.", + "expected_skills": [] + }, + { + "id": "missed-skill", + "goal": "Inspect the missed-skill review condition.", + "expected_skills": [ + "evidence-review" + ] + } + ] + }, + "configuration": { + "model": "synthetic-control", + "model_parameters": {}, + "prompt_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "tools_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "skills": { + "evidence-review": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + }, + "evaluators": [ + { + "id": "goal-control", + "revision": "authored-1", + "level": "session", + "rating": { + "kind": "numeric", + "min": 0, + "max": 1, + "pass_at_least": 1 + } + }, + { + "id": "Builtin.SkillInstructionFollowing", + "revision": "synthetic-scale-1", + "level": "skill", + "rating": { + "kind": "numeric", + "min": 0, + "max": 1, + "pass_at_least": 1 + } + } + ], + "cases": [ + { + "case_id": "accepted-control", + "session_id": "session-0", + "trace_ids": [ + "00000000000000000000000000000001" + ], + "skill_observation_complete": true, + "spans": [ + { + "traceId": "00000000000000000000000000000001", + "spanId": "0000000000000001", + "name": "authored-control", + "attributes": { + "session.id": "session-0" + } + } + ], + "skill_calls": [ + { + "name": "evidence-review", + "trace_id": "00000000000000000000000000000001", + "span_id": "0000000000000001", + "receipt": { + "schema": "skills-anywhere-load-1", + "load_id": "synthetic-0", + "loaded_at": "2026-09-15T00:00:00Z", + "provider": "dsh-skills-anywhere", + "provider_version": "0.12.0", + "name": "evidence-review", + "skill_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "content_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "bundle_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "declared_tools": null, + "permissions_enforced": false + } + } + ], + "evaluation_response": { + "evaluationResults": [ + { + "evaluatorId": "goal-control", + "evaluatorName": "goal-control", + "evaluatorArn": "synthetic-control", + "context": { + "spanContext": { + "sessionId": "session-0" + } + }, + "value": 1, + "explanation": "Authored passing control." + }, + { + "evaluatorId": "Builtin.SkillInstructionFollowing", + "evaluatorName": "Builtin.SkillInstructionFollowing", + "evaluatorArn": "synthetic-control", + "context": { + "spanContext": { + "sessionId": "session-0", + "traceId": "00000000000000000000000000000001", + "spanId": "0000000000000001" + } + }, + "value": 1, + "explanation": "Authored passing control." + } + ] + } + }, + { + "case_id": "assessed-zero", + "session_id": "session-1", + "trace_ids": [ + "00000000000000000000000000000002" + ], + "skill_observation_complete": true, + "spans": [ + { + "traceId": "00000000000000000000000000000002", + "spanId": "0000000000000002", + "name": "authored-control", + "attributes": { + "session.id": "session-1" + } + } + ], + "skill_calls": [ + { + "name": "evidence-review", + "trace_id": "00000000000000000000000000000002", + "span_id": "0000000000000002", + "receipt": { + "schema": "skills-anywhere-load-1", + "load_id": "synthetic-1", + "loaded_at": "2026-09-15T00:00:00Z", + "provider": "dsh-skills-anywhere", + "provider_version": "0.12.0", + "name": "evidence-review", + "skill_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "content_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "bundle_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "declared_tools": null, + "permissions_enforced": false + } + } + ], + "evaluation_response": { + "evaluationResults": [ + { + "evaluatorId": "goal-control", + "evaluatorName": "goal-control", + "evaluatorArn": "synthetic-control", + "context": { + "spanContext": { + "sessionId": "session-1" + } + }, + "value": 0, + "explanation": "Valid zero: the authored output lacks evidence." + }, + { + "evaluatorId": "Builtin.SkillInstructionFollowing", + "evaluatorName": "Builtin.SkillInstructionFollowing", + "evaluatorArn": "synthetic-control", + "context": { + "spanContext": { + "sessionId": "session-1", + "traceId": "00000000000000000000000000000002", + "spanId": "0000000000000002" + } + }, + "value": 1, + "explanation": "Authored passing control." + } + ] + } + }, + { + "case_id": "skipped-judge", + "session_id": "session-2", + "trace_ids": [ + "00000000000000000000000000000003" + ], + "skill_observation_complete": true, + "spans": [ + { + "traceId": "00000000000000000000000000000003", + "spanId": "0000000000000003", + "name": "authored-control", + "attributes": { + "session.id": "session-2" + } + } + ], + "skill_calls": [], + "evaluation_response": { + "evaluationResults": [ + { + "evaluatorId": "goal-control", + "evaluatorName": "goal-control", + "evaluatorArn": "synthetic-control", + "context": { + "spanContext": { + "sessionId": "session-2" + } + }, + "explanation": "Authored passing control.", + "errorCode": "Skipped", + "errorMessage": "Authored example of unavailable context." + } + ] + } + }, + { + "case_id": "missing-result", + "session_id": "session-3", + "trace_ids": [ + "00000000000000000000000000000004" + ], + "skill_observation_complete": true, + "spans": [ + { + "traceId": "00000000000000000000000000000004", + "spanId": "0000000000000004", + "name": "authored-control", + "attributes": { + "session.id": "session-3" + } + } + ], + "skill_calls": [], + "evaluation_response": { + "evaluationResults": [] + } + }, + { + "case_id": "missed-skill", + "session_id": "session-4", + "trace_ids": [ + "00000000000000000000000000000005" + ], + "skill_observation_complete": true, + "spans": [ + { + "traceId": "00000000000000000000000000000005", + "spanId": "0000000000000005", + "name": "authored-control", + "attributes": { + "session.id": "session-4" + } + } + ], + "skill_calls": [], + "evaluation_response": { + "evaluationResults": [ + { + "evaluatorId": "goal-control", + "evaluatorName": "goal-control", + "evaluatorArn": "synthetic-control", + "context": { + "spanContext": { + "sessionId": "session-4" + } + }, + "value": 1, + "explanation": "Authored passing control." + } + ] + } + } + ] +} diff --git a/examples/trace-workbench/mcp-recorded.json b/examples/trace-workbench/mcp-recorded.json new file mode 100644 index 0000000..13ad44d --- /dev/null +++ b/examples/trace-workbench/mcp-recorded.json @@ -0,0 +1,103 @@ +{ + "schema_version": "evalarc.trace-input.v1", + "run_id": "mcp-delivery-bbc3c08e-7dc7-434a-9c65-8d745bcc8547", + "provenance": { + "kind": "recorded", + "description": "Actual local stdio MCP instruction delivery, collected by scripts/record-load.mjs using an authored skill. No model or AWS evaluation; evaluator results intentionally absent." + }, + "dataset": { + "id": "mcp-delivery-control", + "version": "1", + "cases": [ + { + "id": "load-evidence-review", + "goal": "Load the authored evidence-review skill through MCP.", + "expected_skills": [ + "evidence-review" + ] + } + ] + }, + "configuration": { + "model": "none: local MCP client", + "model_parameters": {}, + "prompt_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tools_sha256": "2c52bc1da3c4285bb25979f13097557ea48d74d978902a278c9d869ab5ff7aa9", + "skills": { + "evidence-review": "f93102cb0592a462afb43cc6cf78b09096208aa1772bcaa341e75a20c1deffd0" + } + }, + "evaluators": [ + { + "id": "goal-review", + "revision": "not-run", + "level": "session", + "rating": { + "kind": "numeric", + "min": 0, + "max": 1, + "pass_at_least": 1 + } + }, + { + "id": "Builtin.SkillInstructionFollowing", + "revision": "not-run", + "level": "skill", + "rating": { + "kind": "numeric", + "min": 0, + "max": 1, + "pass_at_least": 1 + } + } + ], + "cases": [ + { + "case_id": "load-evidence-review", + "session_id": "bbc3c08e-7dc7-434a-9c65-8d745bcc8547", + "trace_ids": [ + "7419e7e182a245919fc3c49b1c02b7a7" + ], + "skill_observation_complete": true, + "spans": [ + { + "traceId": "7419e7e182a245919fc3c49b1c02b7a7", + "spanId": "481d88f854ae4e7c", + "name": "open_skill", + "startTimeUnixNano": "1789490688827000000", + "endTimeUnixNano": "1789490688898000000", + "attributes": { + "session.id": "bbc3c08e-7dc7-434a-9c65-8d745bcc8547", + "gen_ai.tool.name": "open_skill", + "mcp.protocol.version": "2026-07-28" + } + } + ], + "skill_calls": [ + { + "name": "evidence-review", + "trace_id": "7419e7e182a245919fc3c49b1c02b7a7", + "span_id": "481d88f854ae4e7c", + "receipt": { + "schema": "skills-anywhere-load-1", + "load_id": "dff92916-a748-4b3b-aca9-293d0043c6c1", + "loaded_at": "2026-09-15T16:44:48.892Z", + "provider": "dsh-skills-anywhere", + "provider_version": "0.12.0", + "name": "evidence-review", + "skill_sha256": "8e08d5d07d0b7f9683145ef435653e4cfe1d8a312d1255ed79a6d0241e5c7542", + "content_sha256": "473876db0cd9579138f1cd7905f6037855bea94c4c3740eb2c7f698aef2ddc0b", + "bundle_sha256": "f93102cb0592a462afb43cc6cf78b09096208aa1772bcaa341e75a20c1deffd0", + "declared_tools": [ + "Read" + ], + "permissions_enforced": false + } + } + ], + "evaluation_response": { + "evaluationResults": [] + } + } + ] +} diff --git a/package-lock.json b/package-lock.json index d1a9f06..2d5dadc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "evalarc-evidence-site", - "version": "0.10.2", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "evalarc-evidence-site", - "version": "0.10.2", + "version": "0.11.0", "devDependencies": { "playwright": "1.63.0" } diff --git a/package.json b/package.json index 3151bc7..b697ebe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "evalarc-evidence-site", - "version": "0.10.2", + "version": "0.11.0", "private": true, "description": "Browser checks for the static EvalArc evidence explorer", "scripts": { diff --git a/pyproject.toml b/pyproject.toml index 6d13990..a384d7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "evalarc" -version = "0.10.2" +version = "0.11.0" description = "Auditable task environments and evaluations for coding and tool-using agents." readme = "README.md" requires-python = ">=3.11" diff --git a/scripts/build_site.py b/scripts/build_site.py index 357912d..0f992a0 100644 --- a/scripts/build_site.py +++ b/scripts/build_site.py @@ -331,6 +331,8 @@ def build(destination: Path) -> dict: path.read_text() .replace("__EVALARC_VERSION__", version) .replace("__AUDIT_COVERAGE__", coverage_cards(audits)) + .replace("__TASK_PACK_COUNT__", str(len(audits))) + .replace("__FAULT_COUNT__", str(sum(a["total"] for a in audits.values()))) ) else: shutil.copyfile(path, destination / path.name) @@ -392,6 +394,15 @@ def build(destination: Path) -> dict: verify_records(ROOT / "examples/research") shutil.copytree(ROOT / "examples/skill-impact", destination / "skill-impact") shutil.copytree(ROOT / "examples/research", destination / "research") + from evalarc.trace_review import import_trace + + trace_examples = ROOT / "examples/trace-workbench" + import_trace( + trace_examples / "current.json", + destination / "trace-workbench", + trace_examples / "baseline.json", + ) + import_trace(trace_examples / "mcp-recorded.json", destination / "trace-mcp") shutil.copyfile(ROOT / "LICENSE", destination / "LICENSE") shutil.copyfile(ROOT / "huggingface" / "README.md", destination / "README.md") (destination / ".nojekyll").touch() diff --git a/scripts/build_trace_examples.py b/scripts/build_trace_examples.py new file mode 100644 index 0000000..86007de --- /dev/null +++ b/scripts/build_trace_examples.py @@ -0,0 +1,134 @@ +"""Generate explicitly synthetic controls, never cloud or model performance evidence.""" + +import copy +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def example() -> dict: + names = ["accepted-control", "assessed-zero", "skipped-judge", "missing-result", "missed-skill"] + data = { + "schema_version": "evalarc.trace-input.v1", + "run_id": "synthetic-baseline", + "provenance": { + "kind": "synthetic", + "description": ( + "Five authored controls for import and review behavior. " + "Placeholder hashes, simulated spans and judgments; no AWS or model run." + ), + }, + "dataset": { + "id": "trace-review-controls", + "version": "1", + "cases": [ + { + "id": name, + "goal": f"Inspect the {name} review condition.", + "expected_skills": ["evidence-review"] if i in (0, 1, 4) else [], + } + for i, name in enumerate(names) + ], + }, + "configuration": { + "model": "synthetic-control", + "model_parameters": {}, + "prompt_sha256": "a" * 64, + "tools_sha256": "b" * 64, + "skills": {"evidence-review": "c" * 64}, + }, + "evaluators": [ + { + "id": "goal-control", + "revision": "authored-1", + "level": "session", + "rating": {"kind": "numeric", "min": 0, "max": 1, "pass_at_least": 1}, + }, + { + "id": "Builtin.SkillInstructionFollowing", + "revision": "synthetic-scale-1", + "level": "skill", + "rating": {"kind": "numeric", "min": 0, "max": 1, "pass_at_least": 1}, + }, + ], + "cases": [], + } + for index, definition in enumerate(data["dataset"]["cases"]): + sid, tid, spid = f"session-{index}", f"{index + 1:032x}", f"{index + 1:016x}" + has_skill = bool(definition["expected_skills"]) + case = { + "case_id": definition["id"], + "session_id": sid, + "trace_ids": [tid], + "skill_observation_complete": True, + "spans": [ + { + "traceId": tid, + "spanId": spid, + "name": "authored-control", + "attributes": {"session.id": sid}, + } + ], + "skill_calls": [], + "evaluation_response": {"evaluationResults": []}, + } + for evaluator in data["evaluators"][: 2 if has_skill else 1]: + ctx = {"sessionId": sid} + if evaluator["level"] == "skill": + ctx.update(traceId=tid, spanId=spid) + case["evaluation_response"]["evaluationResults"].append( + { + "evaluatorId": evaluator["id"], + "evaluatorName": evaluator["id"], + "evaluatorArn": "synthetic-control", + "context": {"spanContext": ctx}, + "value": 1, + "explanation": "Authored passing control.", + } + ) + if has_skill: + case["skill_calls"].append( + { + "name": "evidence-review", + "trace_id": tid, + "span_id": spid, + "receipt": { + "schema": "skills-anywhere-load-1", + "load_id": f"synthetic-{index}", + "loaded_at": "2026-09-15T00:00:00Z", + "provider": "dsh-skills-anywhere", + "provider_version": "0.12.0", + "name": "evidence-review", + "skill_sha256": "d" * 64, + "content_sha256": "e" * 64, + "bundle_sha256": "c" * 64, + "declared_tools": None, + "permissions_enforced": False, + }, + } + ) + data["cases"].append(case) + return data + + +def current_example() -> dict: + data = copy.deepcopy(example()) + data["run_id"] = "synthetic-current" + data["configuration"]["prompt_sha256"] = "f" * 64 + result = data["cases"][1]["evaluation_response"]["evaluationResults"][0] + result.update(value=0, explanation="Valid zero: the authored output lacks evidence.") + result = data["cases"][2]["evaluation_response"]["evaluationResults"][0] + result.pop("value") + result.update(errorCode="Skipped", errorMessage="Authored example of unavailable context.") + data["cases"][3]["evaluation_response"]["evaluationResults"] = [] + data["cases"][4]["skill_calls"] = [] + data["cases"][4]["evaluation_response"]["evaluationResults"].pop() + return data + + +if __name__ == "__main__": + folder = ROOT / "examples/trace-workbench" + folder.mkdir(exist_ok=True) + for name, data in (("baseline", example()), ("current", current_example())): + (folder / f"{name}.json").write_text(json.dumps(data, indent=2) + "\n") diff --git a/scripts/check_installed.py b/scripts/check_installed.py index a9dcd99..e64dd68 100644 --- a/scripts/check_installed.py +++ b/scripts/check_installed.py @@ -59,6 +59,34 @@ def hashes() -> dict: if not result["verified"] or hashes() != before: raise ValueError("verification failed or changed evidence") results[name] = result + trace = folder / "trace-review" + process = subprocess.run( + [ + entry, + "trace-import", + str(source / "examples/trace-workbench/current.json"), + "--output", + str(trace), + ], + cwd=folder, + env=environment, + capture_output=True, + text=True, + timeout=30, + ) + if process.returncode != 0: + raise ValueError(f"installed trace import failed: {process.stderr}") + process = subprocess.run( + [entry, "trace-verify", str(trace)], + cwd=folder, + env=environment, + capture_output=True, + text=True, + timeout=30, + ) + if process.returncode != 0 or not json.loads(process.stdout)["verified"]: + raise ValueError("installed trace verifier failed") + results["trace"] = json.loads(process.stdout) for language in ("python", "javascript"): target = folder / f"robot-{language}" subprocess.run( diff --git a/scripts/check_site.cjs b/scripts/check_site.cjs index b835d17..a472777 100644 --- a/scripts/check_site.cjs +++ b/scripts/check_site.cjs @@ -355,6 +355,50 @@ async function main() { duplicateHashRejected:true, legacyLinkDisclosed:true, missingCryptoRecovery:true}); } finally { await identityPage.close(); } } + if (process.env.SITE_HUB !== "1") { + for (const width of [1440, 390, 320]) { + const page = await browser.newPage({viewport:{width,height:1000}}); + const errors = []; + page.on("pageerror", error => errors.push(error.message)); + try { + await page.goto(new URL("trace-workbench/",base).href); + await page.locator("#filter-status").filter({hasText:"5 cases shown"}).waitFor(); + assert.equal(await page.locator("article[data-gate=accepted]").count(),1); + assert.equal(await page.locator("article[data-gate=rejected]").count(),2); + assert.equal(await page.locator("article[data-gate=incomplete]").count(),2); + assert.equal(await page.locator("#case-1 tbody tr").first().locator("td").nth(1).innerText(),"0"); + assert.match(await page.locator("#case-2").innerText(),/SKIPPED/); + assert.match(await page.locator("#case-3").innerText(),/MISSING/); + assert.match(await page.locator("#case-4").innerText(),/NOT CALLED/); + await page.getByRole("button",{name:"Incomplete",exact:true}).click(); + assert.equal(await page.locator("article:visible").count(),2); + await page.locator("#search").fill("unavailable context"); + assert.equal(await page.locator("article:visible").count(),1); + await page.locator("#search").fill("nothing matches this phrase"); + assert.equal(await page.locator("#filter-status").innerText(),"0 cases shown"); + await page.locator("#search").fill(""); + await page.getByRole("button",{name:"All",exact:true}).click(); + assert.equal(await page.locator("article:visible").count(),5); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), + `Trace review must fit viewport ${width}`); + const pending = page.waitForEvent("download"); + await page.getByRole("link",{name:"Download original input",exact:true}).click(); + const download = await pending; + assert.equal(await download.failure(),null); + const original = fs.readFileSync(path.join(root,"trace-workbench/input.json")); + assert.deepEqual(fs.readFileSync(await download.path()),original); + await page.goto(new URL("trace-mcp/",base).href); + await page.locator("#filter-status").filter({hasText:"1 cases shown"}).waitFor(); + assert.match(await page.locator("body").innerText(),/Actual local stdio MCP/); + assert.match(await page.locator("article").innerText(),/MATCHED/); + assert.equal(await page.locator("article[data-gate=incomplete]").count(),1); + assert.equal(await page.locator("article .badge").filter({hasText:"MISSING"}).count(),2); + assert.deepEqual(errors,[]); + results.push({traceWorkbench:true,width,zeroSkippedMissingDistinct:true, + filtersAndEmptyState:true,originalBytesDownload:true,actualMcpUnassessed:true}); + } finally { await page.close(); } + } + } console.log(JSON.stringify({url:base, checks:results}, null, 2)); } finally { await browser.close(); diff --git a/site/index.html b/site/index.html index 0055a69..16cf984 100644 --- a/site/index.html +++ b/site/index.html @@ -38,9 +38,10 @@

Look past
the score.

Generate Python or JavaScript candidates and audit independent controls against the same task contracts. Try both runtimes ↗. The recorded showcases below retain their original versions and fingerprints.

NEW / RECORDED MODEL EVIDENCE

A skill loaded. Did the task pass?

27 real Qwen3-8B trials compare no skill, direct loading and MCP delivery on attributed robot recordings. Inspect every tool receipt, candidate, independent score and ATIF trajectory. Three engineering profiles, including negative results.

L40S recordings; one public development task. No skill accuracy gain or general model ranking is claimed.

+

NEW / BRING YOUR AGENT RECORDS

Zero, skipped, or missing?

A zero can be a valid judgment. A skipped evaluator needs context. A required skill may never have loaded. Inspect each against a versioned golden case, with recording identity and explicit acceptance rules.

The five controls are synthetic. The separate MCP record contains a real local load and no evaluator scores. Review your own saved AgentCore Evaluate export offline with evalarc trace-import.

-
2task packs
-
15declared faults caught
+
__TASK_PACK_COUNT__task packs
+
__FAULT_COUNT__declared faults caught
JSONdownloadable evidence
CPUreproduce without a model API
diff --git a/src/evalarc/__init__.py b/src/evalarc/__init__.py index cc921c4..6332104 100644 --- a/src/evalarc/__init__.py +++ b/src/evalarc/__init__.py @@ -1,3 +1,3 @@ """Auditable evaluations for AI agents.""" -__version__ = "0.10.2" +__version__ = "0.11.0" diff --git a/src/evalarc/cli.py b/src/evalarc/cli.py index 607c332..2894834 100644 --- a/src/evalarc/cli.py +++ b/src/evalarc/cli.py @@ -23,6 +23,7 @@ from evalarc.suite import load_suite, run_suite from evalarc.tasks import TASKS from evalarc.templates import LANGUAGES, initialize +from evalarc.trace_review import import_trace, verify_trace from evalarc.trajectory import summarize from evalarc.verify import SCOPE, verify @@ -55,6 +56,19 @@ def parser() -> argparse.ArgumentParser: atif.add_argument("input", type=Path) atif.add_argument("--export-trial", action="store_true") atif.add_argument("--output", type=Path, default=Path("runs/trajectory.atif.json")) + trace = commands.add_parser( + "trace-import", help="review a bounded AgentCore export and skill receipts offline" + ) + trace.add_argument("input", type=Path) + trace.add_argument("--baseline", type=Path, help="compare the same golden set and rubrics") + trace.add_argument("--output", type=Path, required=True) + trace.add_argument( + "--require-accepted", action="store_true", help="apply declared review gates" + ) + trace_verify = commands.add_parser( + "trace-verify", help="recompute a trace review from its preserved input bytes" + ) + trace_verify.add_argument("directory", type=Path) for name, help_text in ( ("evaluate", "grade a candidate directory"), ("audit", "evaluate a task's reference and behavioral negative controls"), @@ -185,6 +199,29 @@ def main(argv: list[str] | None = None) -> int: print(f"Verification failed: {result['error']}", file=sys.stderr) return code try: + if args.command == "trace-verify": + print(json.dumps(verify_trace(args.directory), indent=2)) + return 0 + if args.command == "trace-import": + result = import_trace(args.input, args.output, args.baseline) + print( + json.dumps( + { + "summary": result["summary"], + "source_sha256": result["source_sha256"], + "report": str(args.output / "index.html"), + "scope": result["scope"], + }, + indent=2, + ) + ) + if args.require_accepted: + return ( + 2 + if result["summary"]["incomplete"] + else (1 if result["summary"]["rejected"] else 0) + ) + return 0 if args.command == "atif": document, source_hash, _ = read_document(args.input) if args.export_trial: diff --git a/src/evalarc/trace_report.py b/src/evalarc/trace_report.py new file mode 100644 index 0000000..2787f2f --- /dev/null +++ b/src/evalarc/trace_report.py @@ -0,0 +1,215 @@ +"""Self-contained, accessible trace review; all imported text is escaped.""" + +from __future__ import annotations + +import html +import json +from pathlib import Path + +STYLE = """ +:root{color-scheme:dark;font:16px/1.6 system-ui,sans-serif;background:#101618;color:#edf1e9} +*{box-sizing:border-box}body{margin:0}main,header,footer{max-width:1160px;margin:auto;padding:24px} +a{color:#c4efaa;text-underline-offset:4px}h1{font-size:clamp(2.4rem,6vw,4.4rem);line-height:1.08; +letter-spacing:-.04em;margin:32px 0 20px}h1 em{color:#c4efaa;font-style:normal} +h2{line-height:1.25}h3{margin:10px 0}p{max-width:80ch}.muted{color:#b5c0b7} +.eyebrow{font-size:.8rem;letter-spacing:.12em;text-transform:uppercase} +header{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap} +.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin:28px 0} +.stats div,article,details.panel{border:1px solid #46534b;border-radius:12px; +padding:20px;background:#18201e} +.stats strong{display:block;font-size:2rem} +.badge{display:inline-block;border:1px solid currentColor; +padding:3px 9px;border-radius:8px;font-size:.8rem;letter-spacing:.04em} +.accepted,.matched{color:#c4efaa}.rejected,.changed,.not_called{color:#ffbb9a} +.incomplete,.unknown,.missing,.skipped,.error,.unassessed{color:#f0d48b} +.tools{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:22px 0} +button,input{font:inherit;background:#17201d;color:inherit;border:1px solid #708275; +border-radius:8px;padding:10px 14px;min-height:44px} +input{width:100%;max-width:450px}button{cursor:pointer} +button[aria-pressed=true]{background:#c4efaa;color:#17201d} +:focus-visible{outline:3px solid #e4c783;outline-offset:4px} +article{margin:18px 0;scroll-margin-top:12px}article[hidden]{display:none} +.case-top{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap} +.table-scroll{max-width:100%;overflow-x:auto}table{width:100%;border-collapse:collapse} +caption{text-align:left;font-weight:600;margin:18px 0 8px} +td,th{text-align:left;vertical-align:top;padding:12px 8px;border-bottom:1px solid #46534b} +th{color:#b5c0b7}td:first-child{min-width:180px}pre,code{font-size:.88rem} +.comparison td{min-width:130px}.comparison td:last-child{min-width:220px} +pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#101618;padding:14px;border-radius:8px} +summary{cursor:pointer;padding:12px 0;min-height:44px}small{display:block;color:#b5c0b7} +.links{display:flex;flex-wrap:wrap;gap:18px;margin:20px 0} +.scope{border-left:3px solid #c4efaa;padding-left:18px} +.skip{position:absolute;left:8px;top:-80px}.skip:focus{top:8px;background:#101618;padding:12px} +@media(max-width:480px){main,header,footer{padding:16px}article{padding:14px}.stats{gap:6px} +.stats div{padding:10px}.stats span{font-size:.82rem}.stats strong{font-size:1.6rem} +.scores thead{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)} +.scores,.scores tbody{display:block}.scores tr{display:grid;grid-template-columns:1fr 1fr; +border-bottom:1px solid #708275;margin-bottom:16px} +.scores td{display:block;min-width:0;border:0;overflow-wrap:anywhere} +.scores td:first-child,.scores td:last-child{grid-column:1 / -1} +.scores td[data-label]::before{content:attr(data-label);display:block;color:#b5c0b7; +font-size:.8rem;margin-bottom:4px}.scores caption{display:block}} +""" + +SCRIPT = """ +const buttons = [...document.querySelectorAll('[data-filter]')]; +const search = document.querySelector('#search'); +let filter = 'all'; +function update() { + let count = 0; + const query = search.value.toLowerCase(); + document.querySelectorAll('article[data-gate]').forEach(card => { + card.hidden = !(filter === 'all' || card.dataset.gate === filter) + || !card.textContent.toLowerCase().includes(query); + if (!card.hidden) count++; + }); + document.querySelector('#filter-status').textContent = count + ' cases shown'; +} +buttons.forEach(button => button.addEventListener('click', () => { + filter = button.dataset.filter; + buttons.forEach(other => other.setAttribute('aria-pressed', String(other === button))); + update(); +})); +search.addEventListener('input', update); +update(); +""" + + +def escape(value: object) -> str: + return html.escape(str(value), quote=True) + + +def pretty(value: object) -> str: + return escape(json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False)) + + +def badge(state: str) -> str: + return f'{escape(state.replace("_", " ").upper())}' + + +def render(record: dict, target: Path) -> None: + cards = [] + for index, case in enumerate(record["cases"]): + coverage = "complete" if case["skill_observation_complete"] else "incomplete" + rows = [] + for row in case["results"]: + verdict = ( + "accepted" + if row["accepted"] is True + else "rejected" + if row["accepted"] is False + else row["status"] + ) + value = row["value"] if row["value"] is not None else row["label"] + display = escape(value) if row["status"] == "assessed" else "—" + rows.append( + f"{escape(row['evaluator'])}{escape(row['revision'])}" + f"{escape(row['span_id'] or row['trace_id'] or 'Session')}" + f'{display}' + f'{badge(verdict)}' + f'{escape(row["explanation"])}' + f"{escape(row['error'] or '')}" + ) + skills = ( + "".join( + f"
  • {escape(s['name'])} {badge(s['status'])}
  • " + for s in case["expected_skills"] + ) + or "
  • No skill required by this case.
  • " + ) + calls = ( + "".join( + f"
    {escape(call['name'])} — bundle {escape(call['identity'])}" + f"
    {pretty(call)}
    " + for call in case["skill_calls"] + ) + or "

    No annotated skill calls.

    " + ) + cards.append( + f'
    ' + f'

    {escape(case["case_id"])}

    {badge(case["gate"])}
    ' + f"

    {escape(case['goal'])}

    " + f'

    {case["span_count"]} exported spans · ' + f"Skill observation declared {coverage}" + f"

    Expected skills

    " + f'
    ' + f'' + f'' + f'' + f"{''.join(rows)}
    Imported evaluator results
    Evaluator / targetValueConfigured gateExplanation
    " + f"
    Skill deliveries and recording identity{calls}" + f"
    {pretty({'session_id': case['session_id'], 'trace_ids': case['trace_ids']})}"
    +            f"
    " + ) + comparison = "" + if "comparison" in record: + compared = record["comparison"] + transitions = "".join( + f"{escape(row['case_id'])}{badge(row['baseline'])}" + f"{badge(row['current'])}" + f"{'Previously accepted; now needs review' if row['regressed'] else '—'}" + for row in compared["cases"] + ) + comparison = ( + '

    What changed between runs?

    ' + '
    ' + '' + "" + '' + f'{transitions}
    Same golden set and evaluator rules
    CaseBaselineCurrentChange
    ' + "
    Changed model, prompt, tool and skill configuration" + f"
    {pretty(compared['configuration_changes'])}
    " + f'

    {escape(compared["interpretation"])}

    ' + 'Download baseline input
    ' + ) + stats = "".join( + f"
    {record['summary'][state]}{state.title()} cases
    " + for state in ("accepted", "rejected", "incomplete") + ) + filters = "".join( + f'" + for state in ("all", "accepted", "rejected", "incomplete") + ) + identity = { + k: record[k] + for k in ("run_id", "source_sha256", "dataset_sha256", "configuration", "evaluators") + } + target.write_text( + '' + '' + '' + "EvalArc · Trace Workbench" + f'' + "
    EvalArc / Trace Workbench" + 'Source & documentation
    ' + '

    Golden cases · skill deliveries · evaluator results

    ' + "

    Follow the run.
    Keep the evidence.

    " + f"

    {badge(record['provenance']['kind'])} " + f"{escape(record['provenance']['description'])}

    " + "

    Inspect each case against its declared acceptance rules. Missing results, skipped " + "evaluations and assessed zero scores remain distinct.

    " + f'

    {escape(record["scope"])}

    {stats}
    ' + '' + '
    Review your own AgentCore export locally' + "

    Wrap the saved spans and Evaluate responses with your versioned golden cases and " + "rubrics, following the input contract. The CLI runs offline and creates this report.

    " + "
    evalarc trace-import input.json --output runs/review-001\n"
    +        "evalarc trace-import current.json --baseline baseline.json --output runs/compare-001\n"
    +        "evalarc trace-verify runs/review-001
    " + '' + "Input contract and preparation guide
    " + f'{comparison}

    Follow each golden case

    ' + '' + '

    ' + f'
    {filters}
    ' + '

    ' + f'{"".join(cards)}
    ' + "Frozen configuration, dataset and source identity" + f"
    {pretty(identity)}
    " + "
    " + f"", + encoding="utf-8", + ) diff --git a/src/evalarc/trace_review.py b/src/evalarc/trace_review.py new file mode 100644 index 0000000..76fbcda --- /dev/null +++ b/src/evalarc/trace_review.py @@ -0,0 +1,482 @@ +"""Offline review of explicitly bounded AgentCore Evaluate exports and skill receipts.""" + +from __future__ import annotations + +import hashlib +import json +import re +from pathlib import Path + +from evalarc.artifacts import new_run +from evalarc.records import numeric, read_bytes + +INPUT_SCHEMA = "evalarc.trace-input.v1" +SCHEMA = "evalarc.trace-review.v1" +MAX_BYTES = 4 * 1024 * 1024 +SCOPE = ( + "Imported evaluator judgments and caller-declared trace coverage. " + "Identity and consistency checks do not authenticate the producer, rerun a judge, " + "enforce permissions or independently verify task completion." +) + + +def digest(content: bytes) -> str: + return hashlib.sha256(content).hexdigest() + + +def canonical(value: object) -> bytes: + return json.dumps( + value, sort_keys=True, ensure_ascii=True, separators=(",", ":"), allow_nan=False + ).encode() + + +def obj(value: object, label: str) -> dict: + if not isinstance(value, dict): + raise ValueError(f"{label}: expected an object") + return value + + +def text(value: object, label: str, maximum: int = 4096) -> str: + if not isinstance(value, str) or not value.strip() or len(value) > maximum: + raise ValueError(f"{label}: expected nonempty text (maximum {maximum} characters)") + return value + + +def sha(value: object) -> str: + if not isinstance(value, str) or not re.fullmatch("[0-9a-f]{64}", value): + raise ValueError("expected a lowercase SHA-256") + return value + + +def array(value: object, label: str, maximum: int = 200) -> list: + if not isinstance(value, list) or len(value) > maximum: + raise ValueError(f"{label}: expected an array of at most {maximum} entries") + return value + + +def number(value: object) -> bool: + return numeric(value) + + +def unique(values: list, label: str) -> None: + if len(values) != len(set(values)): + raise ValueError(f"{label}: duplicate identity") + + +def decode(content: bytes, limit: int = MAX_BYTES) -> dict: + if len(content) > limit: + raise ValueError(f"trace JSON exceeds {limit} bytes") + + def pairs(items: list) -> dict: + result = {} + for key, value in items: + if key in result: + raise ValueError(f"duplicate JSON key: {key}") + result[key] = value + return result + + try: + value = json.loads(content, object_pairs_hook=pairs) + canonical(value) # Reject NaN/infinity, including those in preserved opaque payloads. + json.dumps(value, ensure_ascii=False).encode("utf-8") + except (UnicodeError, RecursionError, OverflowError) as error: + raise ValueError("invalid UTF-8 JSON or excessive nesting") from error + return obj(value, "trace input") + + +def attributes(raw: object) -> dict: + if isinstance(raw, dict): + return raw + result = {} + for row in array(raw, "span attributes", 512): + row = obj(row, "attribute") + key = text(row.get("key"), "attribute key") + if key in result: + raise ValueError("duplicate span attribute") + value = obj(row.get("value"), "typed attribute") + result[key] = value.get("stringValue") + return result + + +def evaluator_specs(raw: object) -> list[dict]: + specs = array(raw, "evaluators", 32) + if not specs: + raise ValueError("declare at least one evaluator") + for spec in specs: + obj(spec, "evaluator") + text(spec.get("id"), "evaluator id") + text(spec.get("revision"), "evaluator revision") + if spec.get("level") not in ("session", "trace", "skill"): + raise ValueError("evaluator level must be session, trace or skill") + rating = obj(spec.get("rating"), "rating") + if rating.get("kind") == "numeric": + values = [rating.get(k) for k in ("min", "pass_at_least", "max")] + if not all(number(v) for v in values) or not values[0] <= values[1] <= values[2]: + raise ValueError("numeric scale requires min <= pass_at_least <= max") + if values[0] == values[2]: + raise ValueError("numeric scale needs a nonzero range") + elif rating.get("kind") == "categorical": + labels = array(rating.get("labels"), "rating labels", 32) + passes = array(rating.get("pass_labels"), "passing labels", 32) + for label in labels + passes: + text(label, "rating label", 128) + unique(labels, "rating labels") + unique(passes, "passing labels") + if not labels or not passes or not set(passes) <= set(labels): + raise ValueError("passing labels must be a nonempty subset of the scale") + else: + raise ValueError("rating kind must be numeric or categorical") + unique([spec["id"] for spec in specs], "evaluators") + if not any(spec["level"] == "session" for spec in specs): + raise ValueError("declare a session evaluator for every golden case") + return specs + + +def score(raw: dict, spec: dict) -> dict: + value, label = raw.get("value"), raw.get("label") + error = raw.get("errorCode") + for field in ("label", "explanation", "errorCode", "errorMessage"): + if field in raw and not isinstance(raw[field], str): + raise ValueError(f"result {field} must be text") + if value is not None and not number(value): + raise ValueError("result value must be a finite number, never a boolean") + if error: + status = "skipped" if error.casefold() == "skipped" else "error" + elif label and label.casefold() == "skipped" and label not in spec["rating"].get("labels", []): + status = "skipped" + elif value is None and label is None: + status = "unassessed" + else: + status = "assessed" + accepted = None + if status == "assessed": + rating = spec["rating"] + if rating["kind"] == "numeric": + if value is None: + status = "unassessed" + elif not rating["min"] <= value <= rating["max"]: + raise ValueError("result is outside the declared numeric scale") + else: + accepted = value >= rating["pass_at_least"] + else: + if value is not None: + raise ValueError("categorical result cannot contain a numeric value") + if label not in rating["labels"]: + raise ValueError("result label is outside the declared categorical scale") + accepted = label in rating["pass_labels"] + return { + "status": status, + "value": value if status == "assessed" else None, + "label": label, + "accepted": accepted, + "explanation": raw.get("explanation", ""), + "error": raw.get("errorMessage", error), + } + + +def receipt(raw: object, name: str) -> dict | None: + if raw is None: + return None + row = obj(raw, "skill receipt") + if row.get("schema") != "skills-anywhere-load-1" or row.get("name") != name: + raise ValueError("skill receipt schema or name mismatch") + if row.get("provider") != "dsh-skills-anywhere" or row.get("permissions_enforced") is not False: + raise ValueError("unsupported receipt provider or permission claim") + for key in ("load_id", "loaded_at", "provider_version"): + text(row.get(key), f"receipt {key}") + for key in ("skill_sha256", "content_sha256"): + sha(row.get(key)) + if row.get("bundle_sha256") is not None: + sha(row["bundle_sha256"]) + if row.get("declared_tools") is not None: + for tool in array(row["declared_tools"], "declared tools", 128): + text(tool, "declared tool") + return row + + +def review(content: bytes) -> dict: + data = decode(content) + if data.get("schema_version") != INPUT_SCHEMA: + raise ValueError(f"expected {INPUT_SCHEMA}") + provenance = obj(data.get("provenance"), "provenance") + if provenance.get("kind") not in ("synthetic", "recorded"): + raise ValueError("provenance kind must be synthetic or recorded") + text(provenance.get("description"), "provenance description") + text(data.get("run_id"), "run id") + dataset = obj(data.get("dataset"), "dataset") + text(dataset.get("id"), "dataset id") + text(dataset.get("version"), "dataset version") + golden = array(dataset.get("cases"), "golden cases") + if not golden: + raise ValueError("golden set cannot be empty") + for case in golden: + obj(case, "golden case") + text(case.get("id"), "case id") + text(case.get("goal"), "case goal") + names = array(case.get("expected_skills"), "expected skills", 32) + for name in names: + text(name, "expected skill", 128) + unique(names, "expected skills") + unique([case["id"] for case in golden], "golden cases") + config = obj(data.get("configuration"), "configuration") + text(config.get("model"), "model identity") + obj(config.get("model_parameters"), "model parameters") + sha(config.get("prompt_sha256")) + sha(config.get("tools_sha256")) + pins = obj(config.get("skills"), "reviewed skill bundles") + for name, pin in pins.items(): + text(name, "skill name", 128) + if pin is not None: + sha(pin) + specs = evaluator_specs(data.get("evaluators")) + runs = array(data.get("cases"), "case runs") + if [row.get("case_id") for row in runs if isinstance(row, dict)] != [ + row["id"] for row in golden + ]: + raise ValueError("case runs must exactly match golden case order and inventory") + sessions, traces, loads = set(), set(), set() + output = [] + for definition, case in zip(golden, runs, strict=True): + sid = text(case.get("session_id"), "session id") + if sid in sessions: + raise ValueError("a session cannot belong to two golden cases") + sessions.add(sid) + tids = array(case.get("trace_ids"), "trace ids", 64) + for tid in tids: + text(tid, "trace id", 128) + if tid in traces: + raise ValueError("trace IDs must be globally unique within a run") + traces.add(tid) + if not tids: + raise ValueError("every case must declare at least one trace") + spans = {} + for span in array(case.get("spans"), "spans", 512): + obj(span, "span") + tid = text(span.get("traceId"), "span traceId", 128) + spid = text(span.get("spanId"), "span spanId", 128) + if tid not in tids or (tid, spid) in spans: + raise ValueError("span outside the case or duplicate span identity") + attrs = attributes(span.get("attributes", {})) + if "session.id" in attrs and attrs["session.id"] != sid: + raise ValueError("span session differs from the declared case") + spans[tid, spid] = span + if {key[0] for key in spans} != set(tids): + raise ValueError("each declared trace must have at least one exported span") + complete = case.get("skill_observation_complete") + if type(complete) is not bool: + raise ValueError("declare skill_observation_complete as a boolean") + calls = [] + for call in array(case.get("skill_calls"), "skill calls", 128): + obj(call, "skill call") + key = (call.get("trace_id"), call.get("span_id")) + if key not in spans: + raise ValueError("skill call has no matching exported span") + name = text(call.get("name"), "skill name", 128) + loaded = receipt(call.get("receipt"), name) + if loaded: + if loaded["load_id"] in loads: + raise ValueError("a skill load receipt cannot be reused") + loads.add(loaded["load_id"]) + expected = pins.get(name) + observed = loaded["bundle_sha256"] if loaded else None + identity = ( + "unknown" + if expected is None or observed is None + else ("matched" if expected == observed else "changed") + ) + calls.append({**call, "receipt": loaded, "identity": identity}) + unique([(c["trace_id"], c["span_id"]) for c in calls], "skill call spans") + call_keys = {(c["trace_id"], c["span_id"]) for c in calls} + response = obj(case.get("evaluation_response"), "evaluation response") + results = array(response.get("evaluationResults"), "evaluationResults", 1024) + found = {} + by_id = {spec["id"]: spec for spec in specs} + for raw in results: + obj(raw, "evaluation result") + spec = by_id.get(raw.get("evaluatorId")) + if spec is None: + raise ValueError("result uses an undeclared evaluator") + ctx = obj(obj(raw.get("context"), "context").get("spanContext"), "span context") + rsid, tid, spid = ctx.get("sessionId"), ctx.get("traceId"), ctx.get("spanId") + if rsid != sid: + raise ValueError("evaluation result session differs from its case") + level = spec["level"] + if level == "session" and (tid is not None or spid is not None): + raise ValueError("session evaluator must target the session only") + if level == "trace" and (tid not in tids or spid is not None): + raise ValueError("trace evaluator must target a declared trace only") + if level == "skill" and (tid, spid) not in call_keys: + raise ValueError("skill evaluator must target an annotated skill span") + key = (spec["id"], tid, spid) + if key in found: + raise ValueError( + "duplicate evaluator target; keep judge repetitions in separate runs" + ) + found[key] = score(raw, spec) + rows = [] + for spec in specs: + targets = ( + [(None, None)] + if spec["level"] == "session" + else [(tid, None) for tid in tids] + if spec["level"] == "trace" + else [(c["trace_id"], c["span_id"]) for c in calls] + ) + if not targets: + rows.append( + { + "evaluator": spec["id"], + "revision": spec["revision"], + "trace_id": None, + "span_id": None, + "status": "not_applicable" if complete else "missing", + "value": None, + "label": None, + "accepted": None, + "explanation": "No annotated skill calls; coverage is caller-declared.", + "error": None, + } + ) + for tid, spid in targets: + result = found.get( + (spec["id"], tid, spid), + { + "status": "missing", + "value": None, + "label": None, + "accepted": None, + "explanation": "No result for this declared target.", + "error": None, + }, + ) + rows.append( + { + "evaluator": spec["id"], + "revision": spec["revision"], + "trace_id": tid, + "span_id": spid, + **result, + } + ) + skills = [] + for name in definition["expected_skills"]: + matching = [call for call in calls if call["name"] == name] + state = ( + "not_called" + if not matching and complete + else "unknown" + if not matching + else "changed" + if any(c["identity"] == "changed" for c in matching) + else "unknown" + if any(c["identity"] == "unknown" for c in matching) + else "matched" + ) + skills.append({"name": name, "status": state}) + incomplete = any( + row["status"] not in ("assessed", "not_applicable") for row in rows + ) or any(row["status"] == "unknown" for row in skills) + if not complete and (skills or any(spec["level"] == "skill" for spec in specs)): + incomplete = True + rejected = ( + any(row["accepted"] is False for row in rows) + or any(row["status"] in ("not_called", "changed") for row in skills) + or any(call["identity"] == "changed" for call in calls) + ) + output.append( + { + "case_id": definition["id"], + "goal": definition["goal"], + "session_id": sid, + "trace_ids": tids, + "span_count": len(spans), + "skill_observation_complete": complete, + "skill_calls": calls, + "expected_skills": skills, + "results": rows, + "gate": "incomplete" if incomplete else "rejected" if rejected else "accepted", + "has_rejection": rejected, + } + ) + counts = { + state: sum(c["gate"] == state for c in output) + for state in ("accepted", "rejected", "incomplete") + } + return { + "schema_version": SCHEMA, + "source_sha256": digest(content), + "source_bytes": len(content), + "run_id": data["run_id"], + "provenance": provenance, + "dataset": dataset, + "dataset_sha256": digest(canonical(dataset)), + "configuration": config, + "evaluators": specs, + "cases": output, + "summary": counts, + "scope": SCOPE, + } + + +def compare_reviews(baseline: dict, current: dict) -> dict: + for field in ("dataset", "evaluators"): + if canonical(baseline[field]) != canonical(current[field]): + raise ValueError(f"comparison requires the same {field}, including versions and rules") + if baseline["provenance"]["kind"] != current["provenance"]["kind"]: + raise ValueError("cannot compare synthetic and recorded runs") + if baseline["source_sha256"] == current["source_sha256"]: + raise ValueError("choose two different run records") + differences = {} + for key in sorted(set(baseline["configuration"]) | set(current["configuration"])): + before, after = baseline["configuration"].get(key), current["configuration"].get(key) + if canonical(before) != canonical(after): + differences[key] = {"baseline": before, "current": after} + return { + "baseline_source_sha256": baseline["source_sha256"], + "configuration_changes": differences, + "cases": [ + { + "case_id": old["case_id"], + "baseline": old["gate"], + "current": new["gate"], + "regressed": old["gate"] == "accepted" and new["gate"] != "accepted", + } + for old, new in zip(baseline["cases"], current["cases"], strict=True) + ], + "interpretation": "Paired descriptive comparison; no causal or statistical gain claim.", + } + + +def import_trace(source: Path, output: Path, baseline: Path | None = None) -> dict: + content = read_bytes(source, limit=MAX_BYTES) + record = review(content) + before = read_bytes(baseline, limit=MAX_BYTES) if baseline else None + if before is not None: + record["comparison"] = compare_reviews(review(before), record) + from evalarc.trace_report import render + + with new_run(output) as folder: + (folder / "input.json").write_bytes(content) + if before is not None: + (folder / "baseline-input.json").write_bytes(before) + (folder / "review.json").write_bytes(canonical(record) + b"\n") + render(record, folder / "index.html") + return record + + +def verify_trace(folder: Path) -> dict: + if any(p.is_symlink() for p in (folder, *folder.parents)): + raise ValueError("trace evidence path must not contain symlinks") + recorded = decode(read_bytes(folder / "review.json", limit=MAX_BYTES * 2), MAX_BYTES * 2) + current = review(read_bytes(folder / "input.json", limit=MAX_BYTES)) + if "comparison" in recorded: + before = review(read_bytes(folder / "baseline-input.json", limit=MAX_BYTES)) + current["comparison"] = compare_reviews(before, current) + if canonical(recorded) != canonical(current): + raise ValueError("trace review differs from its original input evidence") + return { + "verified": True, + "source_sha256": current["source_sha256"], + "summary": current["summary"], + "scope": SCOPE + " HTML is not verified.", + } diff --git a/tests/test_trace_review.py b/tests/test_trace_review.py new file mode 100644 index 0000000..e45ee9d --- /dev/null +++ b/tests/test_trace_review.py @@ -0,0 +1,173 @@ +import copy +import json +from pathlib import Path + +import pytest + +from evalarc.cli import main +from evalarc.trace_review import ( + canonical, + compare_reviews, + import_trace, + review, + verify_trace, +) + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture +def data(): + return json.loads((ROOT / "examples/trace-workbench/current.json").read_bytes()) + + +def test_zero_skipped_missing_and_missed_skill_are_separate(data): + result = review(canonical(data)) + assert result["summary"] == {"accepted": 1, "rejected": 2, "incomplete": 2} + zero, skipped, missing, missed = result["cases"][1:] + assert zero["results"][0]["status"] == "assessed" + assert zero["results"][0]["value"] == 0 + assert zero["results"][0]["accepted"] is False + assert skipped["results"][0]["status"] == "skipped" + assert skipped["results"][0]["value"] is None + assert missing["results"][0]["status"] == "missing" + assert missed["expected_skills"] == [{"name": "evidence-review", "status": "not_called"}] + assert missed["results"][1]["status"] == "not_applicable" + + +@pytest.mark.parametrize("kind", ["bundle_changed", "missing_receipt", "missing_coverage"]) +def test_skill_identity_and_coverage_affect_gate(data, kind): + case = data["cases"][0] + if kind == "bundle_changed": + case["skill_calls"][0]["receipt"]["bundle_sha256"] = "0" * 64 + elif kind == "missing_receipt": + case["skill_calls"][0].pop("receipt") + else: + case["skill_observation_complete"] = False + result = review(canonical(data))["cases"][0] + assert result["gate"] == ("rejected" if kind == "bundle_changed" else "incomplete") + + +@pytest.mark.parametrize( + "mutation", + [ + lambda d: d["cases"][0]["evaluation_response"]["evaluationResults"].append( + copy.deepcopy(d["cases"][0]["evaluation_response"]["evaluationResults"][0]) + ), + lambda d: d["cases"][0]["spans"][0]["attributes"].update({"session.id": "another"}), + lambda d: d["cases"][0]["evaluation_response"]["evaluationResults"][0]["context"][ + "spanContext" + ].update(sessionId="another"), + lambda d: d["cases"][0]["evaluation_response"]["evaluationResults"][0].update(value=True), + lambda d: d["cases"][0]["evaluation_response"]["evaluationResults"][0].update(value=2), + lambda d: d["cases"][0]["skill_calls"][0].update(span_id="not-exported"), + lambda d: d["cases"].pop(), + lambda d: d["cases"][1].update(session_id=d["cases"][0]["session_id"]), + lambda d: d["cases"][0]["skill_calls"][0]["receipt"].update(name="another"), + lambda d: d["evaluators"][0]["rating"].update(pass_at_least=-1), + lambda d: d["cases"][0].update(evaluation_response=[]), + ], +) +def test_invalid_linkage_or_rating_is_rejected(data, mutation): + mutation(data) + with pytest.raises(ValueError): + review(canonical(data)) + + +def test_typed_otel_attributes_and_categorical_results(data): + data["cases"][0]["spans"][0]["attributes"] = [ + {"key": "session.id", "value": {"stringValue": "session-0"}} + ] + data["evaluators"][0]["rating"] = { + "kind": "categorical", + "labels": ["Pass", "Fail"], + "pass_labels": ["Pass"], + } + for case in data["cases"]: + for result in case["evaluation_response"]["evaluationResults"]: + if result["evaluatorId"] == "goal-control" and "value" in result: + result["label"] = "Pass" if result.pop("value") else "Fail" + result = review(canonical(data)) + assert result["cases"][0]["gate"] == "accepted" + assert result["cases"][1]["results"][0]["value"] is None + assert result["cases"][1]["results"][0]["accepted"] is False + assert result["cases"][1]["gate"] == "rejected" + + +def test_comparison_requires_same_dataset_and_rubrics(data): + current = review(canonical(data)) + baseline = review((ROOT / "examples/trace-workbench/baseline.json").read_bytes()) + comparison = compare_reviews(baseline, current) + assert len([c for c in comparison["cases"] if c["regressed"]]) == 4 + assert list(comparison["configuration_changes"]) == ["prompt_sha256"] + for field in ("dataset", "evaluators"): + changed = copy.deepcopy(current) + if field == "dataset": + changed[field]["version"] = "2" + else: + changed[field][0]["rating"]["pass_at_least"] = 0.5 + with pytest.raises(ValueError, match=field): + compare_reviews(baseline, changed) + + +def test_preserved_bytes_offline_verify_tamper_and_output_protection(tmp_path, data): + source = tmp_path / "source.json" + raw = json.dumps(data, indent=3).encode() + b"\n\n" + source.write_bytes(raw) + output = tmp_path / "review" + import_trace(source, output, ROOT / "examples/trace-workbench/baseline.json") + assert (output / "input.json").read_bytes() == raw + assert verify_trace(output)["verified"] + with pytest.raises(ValueError, match="already exists"): + import_trace(source, output) + document = json.loads((output / "review.json").read_bytes()) + document["summary"]["accepted"] += 1 + (output / "review.json").write_bytes(canonical(document)) + with pytest.raises(ValueError, match="differs"): + verify_trace(output) + + +def test_untrusted_text_stays_text(tmp_path, data): + data["dataset"]["cases"][0]["goal"] = '' + source = tmp_path / "input.json" + source.write_bytes(canonical(data)) + import_trace(source, tmp_path / "out") + page = (tmp_path / "out/index.html").read_text() + assert "