@agentskit/doc-bridge 1.7.44 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +471 -0
- package/CONTRIBUTING.md +29 -4
- package/README.md +87 -40
- package/SECURITY.md +7 -0
- package/action.yml +1 -1
- package/bin/ak-docs.js +2 -2
- package/bin/ak-verify.js +13 -7
- package/dist/cli/program.d.ts +3 -1
- package/dist/cli/program.js +15888 -6061
- package/dist/cli/program.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +91 -9
- package/dist/config/index.js.map +1 -1
- package/dist/index-Beor6Yhi.d.ts +792 -0
- package/dist/index.d.ts +9979 -3257
- package/dist/index.js +15954 -5774
- package/dist/index.js.map +1 -1
- package/docs/MARKETPLACE.md +1 -1
- package/docs/PRD-documentation-efficiency-study.md +406 -0
- package/docs/PRD-knowledge-retrieval-and-enrichment.md +466 -0
- package/docs/RELEASE.md +22 -8
- package/docs/adr/0002-documentation-audit-boundary.md +22 -0
- package/docs/adr/0003-study-protocol-and-historical-evidence.md +40 -0
- package/docs/adr/0004-controlled-study-runner.md +25 -0
- package/docs/adr/0005-documentation-quality-and-criticality.md +20 -0
- package/docs/adr/0006-registry-semantic-grounding.md +20 -0
- package/docs/adr/0007-longitudinal-study-metrics.md +21 -0
- package/docs/adr/0008-study-verification-boundary.md +21 -0
- package/docs/adr/0009-study-provider-cli-adapter.md +25 -0
- package/docs/agent-corpus/INDEX.md +14 -3
- package/docs/agent-corpus/OVERVIEW.md +25 -0
- package/docs/agent-corpus/chat.md +7 -3
- package/docs/agent-corpus/cli.md +18 -2
- package/docs/agent-corpus/conformance.md +14 -2
- package/docs/agent-corpus/doc-bridge.md +48 -1
- package/docs/agent-corpus/doctor.md +10 -2
- package/docs/agent-corpus/gates.md +6 -2
- package/docs/agent-corpus/mcp.md +15 -2
- package/docs/agent-corpus/memory.md +6 -2
- package/docs/agent-corpus/query.md +35 -2
- package/docs/bench/README.md +122 -0
- package/docs/bench/retrieval-baseline-v1.json +28 -0
- package/docs/bench/retrieval-suite-v1.json +1033 -0
- package/docs/chat-and-rag.md +3 -2
- package/docs/for-agents.md +9 -1
- package/docs/getting-started.md +4 -11
- package/docs/guides/gate-ci.md +11 -1
- package/docs/guides/install-and-run.md +9 -65
- package/docs/index.md +22 -1
- package/docs/knowledge-engine-runbook.md +51 -4
- package/docs/landing/assets/context-payload-reduction.svg +21 -0
- package/docs/landing/assets/controlled-ab-comparison.svg +30 -0
- package/docs/landing/index.html +119 -5
- package/docs/loop-workflow.md +117 -0
- package/docs/mcp.md +6 -1
- package/docs/parity/public-claims-v1.json +145 -0
- package/docs/playbook/doc-bridge-pattern.md +1 -1
- package/docs/query.md +90 -2
- package/docs/recipes/index-pipeline.md +1 -1
- package/docs/schemas/agent-handoff-v1.md +15 -0
- package/docs/schemas/doc-bridge-index-v1.md +65 -0
- package/docs/spec/benchmark-v1.md +39 -1
- package/docs/spec/cli.md +30 -10
- package/docs/spec/config-v1.md +192 -8
- package/docs/spec/documentation-audit-v1.md +61 -0
- package/docs/spec/enrichment-overlay-v1.md +241 -0
- package/docs/spec/graph-signals-v1.md +92 -0
- package/docs/spec/incremental-scan-v1.md +102 -0
- package/docs/spec/markdown-analyzer-v1.md +73 -0
- package/docs/spec/mcp-knowledge-tools-v1.md +147 -0
- package/docs/spec/measured-enrichment-v1.md +229 -0
- package/docs/spec/public-parity-v1.md +119 -0
- package/docs/spec/registry-agents.md +6 -0
- package/docs/spec/render-v1.md +122 -0
- package/docs/spec/retrieval-index-v1.md +164 -0
- package/docs/spec/study-metrics-v1.md +58 -0
- package/docs/spec/study-protocol-v1.md +46 -0
- package/docs/spec/study-provider-cli-v1.md +116 -0
- package/docs/spec/study-runner-v1.md +35 -0
- package/docs/spec/study-task-suite-v1.md +41 -0
- package/docs/spec/study-verification-v1.md +40 -0
- package/docs/study/README.md +84 -0
- package/docs/study/ab-adjudicated-cost-analysis-v1.md +29 -0
- package/docs/study/ab-adjudicated-cost-plan-v1.json +33 -0
- package/docs/study/ab-adjudicated-cost-plan-v2-v1.json +33 -0
- package/docs/study/ab-adjudicated-cost-result-v1.json +80 -0
- package/docs/study/ab-baseline-analysis-v1.md +21 -0
- package/docs/study/ab-baseline-plan-v1.json +33 -0
- package/docs/study/ab-baseline-recovery-plan-v1.json +33 -0
- package/docs/study/ab-baseline-result-v1.json +79 -0
- package/docs/study/documentation-audit-round-2026-08-31.json +183 -0
- package/docs/study/historical-evidence-v1.json +252 -0
- package/docs/study/observation-ledger-v1.json +30632 -0
- package/docs/study/phase3-task-coverage-v1.json +34 -0
- package/docs/study/phase4-public-pilot-ledger-v1.json +1344 -0
- package/docs/study/phase4-public-pilot-result-v1.json +52 -0
- package/docs/study/phase4-public-pilot-run-plan-v1.json +26 -0
- package/docs/study/phase4-public-pilot-task-suite-v1.json +71 -0
- package/docs/study/pilot-round-2026-08-31.json +46 -0
- package/docs/study/protocol-v1.json +90 -0
- package/docs/study/publication-gate-v1.md +45 -0
- package/docs/study/quality-scorecard-cycle-plan.md +545 -0
- package/docs/study/quality-scorecard-v1.json +38 -0
- package/docs/study/round-1-adjudicated-smoke-v1.json +30642 -0
- package/docs/study/round-1-instrumentation-plan-v1.md +39 -0
- package/docs/study/round-2-expanded-adjudication-v1.json +91 -0
- package/docs/study/round-2-expanded-validation-v1.md +58 -0
- package/docs/study/round-3-evidence-contract-v1.json +75 -0
- package/docs/study/round-3-evidence-contract-v1.md +57 -0
- package/docs/study/round-4-confirmation-v1.json +75 -0
- package/docs/study/round-4-confirmation-v1.md +55 -0
- package/docs/study/run-plan-v1.json +33 -0
- package/docs/study/semantic-adjudication-cycle-8.md +20 -0
- package/docs/study/task-suite-v1.json +96 -0
- package/docs/study/token-efficiency-plan-v1.md +337 -0
- package/docs/study/token-efficiency-protocol-v2.json +62 -0
- package/docs/study/verification-binding-v1.json +27 -0
- package/docs/validation-cycle-plan.md +33 -0
- package/docs/verification-harness.md +15 -6
- package/ecosystem-claims.json +2 -2
- package/ecosystem-upstream.json +2 -2
- package/ecosystem.json +4 -4
- package/mcpb/manifest.json +9 -1
- package/package.json +89 -72
- package/scripts/check-ecosystem-upstream.mjs +36 -7
- package/scripts/report-visual-check.mjs +20 -3
- package/skills/doc-bridge-handoff/fixtures/synthetic-repo/docs/for-agents/packages/payments.md +7 -0
- package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
- package/src/agents/registry-adapter.ts +192 -24
- package/src/audit/documentation.ts +513 -0
- package/src/bench/baseline.ts +198 -0
- package/src/bench/overlay-delta.ts +139 -0
- package/src/bench/retrieval.ts +319 -0
- package/src/budget/compile.ts +91 -0
- package/src/budget/sections.ts +70 -0
- package/src/cli/demo.ts +2 -2
- package/src/cli/program.ts +699 -79
- package/src/cli/usage.ts +71 -0
- package/src/config/defaults.ts +1 -0
- package/src/config/index.ts +4 -0
- package/src/config/load-config.ts +7 -1
- package/src/config/schema.ts +121 -4
- package/src/conformance/documentation-standard-v1.ts +22 -14
- package/src/discovery/areas.ts +182 -0
- package/src/discovery/documentation.ts +255 -23
- package/src/discovery/identity.ts +24 -0
- package/src/discovery/incremental.ts +314 -0
- package/src/discovery/inputs.ts +110 -0
- package/src/discovery/markdown.ts +481 -0
- package/src/discovery/repository.ts +557 -125
- package/src/doctor/run-doctor.ts +246 -27
- package/src/enrich/approvals.ts +190 -0
- package/src/enrich/cache.ts +93 -0
- package/src/enrich/context-pack.ts +272 -0
- package/src/enrich/overlay.ts +255 -0
- package/src/enrich/review.ts +106 -0
- package/src/enrich/stage.ts +374 -0
- package/src/enrich/stats.ts +100 -0
- package/src/enrich/validate.ts +410 -0
- package/src/federation/llms.ts +74 -24
- package/src/findings/report.ts +103 -0
- package/src/fixes/proposals.ts +4 -3
- package/src/graph/build.ts +356 -0
- package/src/graph/memory.ts +208 -0
- package/src/index-builder/build-handoffs.ts +22 -11
- package/src/index-builder/build-index.ts +132 -3
- package/src/index-builder/human-adapters/fumadocs.ts +1 -1
- package/src/index-builder/llms-txt.ts +48 -8
- package/src/index-builder/project-corpus.ts +111 -0
- package/src/index-builder/watch-index.ts +1 -1
- package/src/index.ts +630 -2
- package/src/lib/bounded-text.ts +15 -10
- package/src/lib/fuzzy-match.ts +235 -0
- package/src/mcp/knowledge.ts +554 -0
- package/src/mcp/server.ts +113 -18
- package/src/metrics/benchmark.ts +21 -0
- package/src/parity/check.ts +309 -0
- package/src/parity/claims.ts +259 -0
- package/src/parity/resolve.ts +160 -0
- package/src/query/handoff.ts +326 -0
- package/src/query/load-index.ts +53 -1
- package/src/query/query.ts +92 -59
- package/src/query/search.ts +289 -92
- package/src/query/text.ts +155 -0
- package/src/reconciliation/reconcile.ts +148 -15
- package/src/render/data.ts +356 -0
- package/src/render/engine.ts +398 -0
- package/src/render/generated.ts +77 -0
- package/src/render/render.ts +209 -0
- package/src/render/template-source.ts +52 -0
- package/src/render/templates.ts +289 -0
- package/src/report/html.ts +23 -17
- package/src/retrieval/bm25.ts +161 -0
- package/src/retrieval/project.ts +495 -0
- package/src/retrieval/rank.ts +383 -0
- package/src/retrieval/weights.ts +39 -0
- package/src/retriever/doc-bridge-retriever.ts +100 -15
- package/src/rules/engine.ts +45 -12
- package/src/safety/repository.ts +1 -1
- package/src/schemas/agent-handoff.ts +56 -0
- package/src/schemas/budget.ts +37 -0
- package/src/schemas/doc-bridge-index.ts +53 -2
- package/src/schemas/enrichment.ts +369 -0
- package/src/schemas/json-schemas.ts +39 -2
- package/src/schemas/knowledge.ts +19 -3
- package/src/schemas/retrieval-index.ts +152 -0
- package/src/shims/graphology.d.ts +91 -0
- package/src/study/adjudication.ts +196 -0
- package/src/study/execution.ts +350 -0
- package/src/study/expectations.ts +219 -0
- package/src/study/metrics.ts +467 -0
- package/src/study/protocol.ts +271 -0
- package/src/study/provider-cli.ts +115 -0
- package/src/study/provider-telemetry.ts +47 -0
- package/src/study/quality-scorecard.ts +164 -0
- package/src/study/runner.ts +461 -0
- package/src/study/task-suite.ts +321 -0
- package/src/study/verification.ts +134 -0
- package/src/validate.ts +8 -5
- package/src/version.ts +1 -1
- package/src/workflow/engine.ts +36 -11
- package/dist/index-C2PCQSrB.d.ts +0 -2251
- package/scripts/verification-harness.mjs +0 -483
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
|
|
5
|
+
import { contentHashForArtifactV1, sha256NormalizedV1 } from '../index-builder/content-hash.js'
|
|
6
|
+
import {
|
|
7
|
+
createControlledStudyLedger,
|
|
8
|
+
createControlledStudyObservation,
|
|
9
|
+
parseControlledStudyLedger,
|
|
10
|
+
persistControlledStudyLedger,
|
|
11
|
+
runControlledCommand,
|
|
12
|
+
upsertControlledStudyObservation,
|
|
13
|
+
type ControlledStudyObservationLedgerV1,
|
|
14
|
+
type ControlledStudyObservationV1,
|
|
15
|
+
type ControlledStudyRunPlanV1,
|
|
16
|
+
type TaskExecutionV1,
|
|
17
|
+
} from './runner.js'
|
|
18
|
+
import { providerForStudyExecution, parseStudyProviderCliConfig, validateStudyProviderCommand, type StudyProviderCliConfigV1 } from './provider-cli.js'
|
|
19
|
+
import { evaluateStudyTask, parseStudyTaskSuite, selectTaskExecutions, type StudyTaskSuiteV1, type StudyTaskV1 } from './task-suite.js'
|
|
20
|
+
|
|
21
|
+
const PROVIDER_RESPONSE_CONTRACT = 'Return one JSON object matching the output schema. Required keys: taskOutcome, evidenceQuality, safetyOutcome, evidenceIds, clarificationRequests, reworkCount, and measurements. Each measurement is {name:string,value:number>=0}. Run every available acceptance check and report observed acceptanceChecksPassed, acceptanceChecksTotal, and acceptanceChecksExecuted; include firstEvidenceLatencyMs only when observed. Use canonical names when observed: tokensToFirstEvidence (tokens consumed before correct grounded evidence was in hand), registryAgentInputTokens, registryAgentOutputTokens, registryAgentCostUsd, registryAgentRuns (the enrichment agent of the assisted arm, reported apart from your own cost), searchHitRate, acceptanceChecksPassed, acceptanceChecksTotal, acceptanceChecksExecuted, entrypointEvidenceCount, ownershipEvidenceCount, architectureRelationCount, documentationClaimEvidenceCount, sourceComparisonEvidenceCount, verificationEvidenceCount, errorRate, documentationFindingCount, documentationExampleRate, documentationFreshnessRate, documentationCorrectnessRate, documentationCompletenessRate, documentationClarityRate, documentationMaintainabilityRate, timeToFirstEvidenceMs, analysisCostUsd, and agentCostUsd. Omit unknown values; never invent. Output no markdown, prose, logs, token counts, or extra keys; stdout must contain only the JSON object.'
|
|
22
|
+
|
|
23
|
+
export const STUDY_REPOSITORY_CONFIG_SCHEMA_VERSION = 1 as const
|
|
24
|
+
export const STUDY_REPOSITORY_CONFIG_CONTENT_HASH_ALGO = 'sha256-normalized-v1' as const
|
|
25
|
+
|
|
26
|
+
const hash = z.string().regex(/^[a-f0-9]{64}$/)
|
|
27
|
+
const identifier = z.string().regex(/^[a-z][a-z0-9-]{0,127}$/)
|
|
28
|
+
const reference = z.string().regex(/^[a-z0-9][a-z0-9._:-]{0,255}$/)
|
|
29
|
+
|
|
30
|
+
const RepositoryConfigPayloadSchema = z.object({
|
|
31
|
+
type: z.literal('controlled-study-repository-config'),
|
|
32
|
+
schemaVersion: z.literal(STUDY_REPOSITORY_CONFIG_SCHEMA_VERSION),
|
|
33
|
+
configVersion: reference,
|
|
34
|
+
repositories: z.array(z.object({
|
|
35
|
+
id: identifier,
|
|
36
|
+
root: z.string().min(1).max(4_096),
|
|
37
|
+
}).strict()).min(1).max(64),
|
|
38
|
+
}).strict()
|
|
39
|
+
|
|
40
|
+
export const StudyRepositoryConfigV1Schema = RepositoryConfigPayloadSchema.extend({
|
|
41
|
+
contentHash: hash,
|
|
42
|
+
contentHashAlgo: z.literal(STUDY_REPOSITORY_CONFIG_CONTENT_HASH_ALGO),
|
|
43
|
+
}).strict()
|
|
44
|
+
|
|
45
|
+
export type StudyRepositoryConfigV1 = z.infer<typeof StudyRepositoryConfigV1Schema>
|
|
46
|
+
|
|
47
|
+
export type ControlledStudyRunOptions = {
|
|
48
|
+
readonly plan: ControlledStudyRunPlanV1
|
|
49
|
+
readonly suite: StudyTaskSuiteV1
|
|
50
|
+
readonly providers: StudyProviderCliConfigV1
|
|
51
|
+
readonly repositories: StudyRepositoryConfigV1
|
|
52
|
+
readonly ledgerPath: string
|
|
53
|
+
readonly round?: string
|
|
54
|
+
readonly dryRun?: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether the assisted arm can run at all, and why not when it cannot.
|
|
59
|
+
*
|
|
60
|
+
* The `registry-assisted` scenario has been reserved since the first suite and has never
|
|
61
|
+
* executed. It must be possible to run a study without it — a missing Registry is a fact about
|
|
62
|
+
* the environment, not a reason to lose the other two arms — so the arm reports itself
|
|
63
|
+
* unavailable and its executions are recorded as unavailable observations.
|
|
64
|
+
*/
|
|
65
|
+
export type AssistedArmStatus = {
|
|
66
|
+
readonly status: 'ready' | 'unavailable'
|
|
67
|
+
readonly reason?: string
|
|
68
|
+
/**
|
|
69
|
+
* Fields the arm ran without: `promptVersion`, `agentBudget`. It still runs — losing the third
|
|
70
|
+
* arm over a missing declaration would be worse than running it — but a run that cannot name
|
|
71
|
+
* the prompt it used, or cost the enrichment agent apart from the model, says so here.
|
|
72
|
+
*/
|
|
73
|
+
readonly undeclared?: readonly string[]
|
|
74
|
+
/** Executions recorded as unavailable because the arm could not run. */
|
|
75
|
+
readonly recorded: number
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type ControlledStudyRunSummary = {
|
|
79
|
+
readonly status: 'dry-run' | 'completed'
|
|
80
|
+
readonly runId: string
|
|
81
|
+
readonly planned: number
|
|
82
|
+
readonly executed: number
|
|
83
|
+
readonly skipped: number
|
|
84
|
+
readonly ledgerPath?: string
|
|
85
|
+
readonly ledgerHash?: string
|
|
86
|
+
readonly providerConfigHash: string
|
|
87
|
+
readonly repositoryConfigHash: string
|
|
88
|
+
readonly assistedArm: AssistedArmStatus
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const createStudyRepositoryConfig = (input: unknown): StudyRepositoryConfigV1 => {
|
|
92
|
+
const payload = RepositoryConfigPayloadSchema.parse(input)
|
|
93
|
+
const hashable = { ...payload, contentHashAlgo: STUDY_REPOSITORY_CONFIG_CONTENT_HASH_ALGO }
|
|
94
|
+
return StudyRepositoryConfigV1Schema.parse({ ...hashable, contentHash: sha256NormalizedV1(hashable) })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const parseStudyRepositoryConfig = (input: unknown): StudyRepositoryConfigV1 => {
|
|
98
|
+
const config = StudyRepositoryConfigV1Schema.parse(input)
|
|
99
|
+
if (contentHashForArtifactV1(config) !== config.contentHash) throw new Error('Invalid study repository config content hash.')
|
|
100
|
+
if (new Set(config.repositories.map((repository) => repository.id)).size !== config.repositories.length) throw new Error('Study repository config contains duplicate repository ids.')
|
|
101
|
+
return config
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const emptyLedger = (): ControlledStudyObservationLedgerV1 => createControlledStudyLedger({
|
|
105
|
+
type: 'controlled-study-observation-ledger',
|
|
106
|
+
schemaVersion: 1,
|
|
107
|
+
ledgerVersion: 'v1',
|
|
108
|
+
observations: [],
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const loadLedger = (path: string): ControlledStudyObservationLedgerV1 => {
|
|
112
|
+
if (!existsSync(path)) return emptyLedger()
|
|
113
|
+
return parseControlledStudyLedger(JSON.parse(readFileSync(path, 'utf8')) as unknown)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const executionKey = (execution: { readonly taskId: string; readonly repositoryId: string; readonly scenarioId: string; readonly modelId: string; readonly replicate: number; readonly variantId: string }): string => sha256NormalizedV1({
|
|
117
|
+
taskId: execution.taskId,
|
|
118
|
+
repositoryId: execution.repositoryId,
|
|
119
|
+
scenarioId: execution.scenarioId,
|
|
120
|
+
modelId: execution.modelId,
|
|
121
|
+
replicate: execution.replicate,
|
|
122
|
+
variantId: execution.variantId,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
export const ASSISTED_SCENARIO = 'registry-assisted' as const
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* What the assisted arm needs before it is allowed to run: a provider for every model, and a
|
|
129
|
+
* scenario that declares the agent, its version, its prompt version and its own budget.
|
|
130
|
+
*/
|
|
131
|
+
export const assistedArmReadiness = (
|
|
132
|
+
plan: ControlledStudyRunPlanV1,
|
|
133
|
+
providers: StudyProviderCliConfigV1,
|
|
134
|
+
suite: StudyTaskSuiteV1,
|
|
135
|
+
): Omit<AssistedArmStatus, 'recorded'> => {
|
|
136
|
+
const scenario = plan.scenarios.find((entry) => entry.id === ASSISTED_SCENARIO)
|
|
137
|
+
if (!scenario) return { status: 'unavailable', reason: 'The run plan declares no registry-assisted scenario.' }
|
|
138
|
+
if (!scenario.agentId || !scenario.agentVersion) {
|
|
139
|
+
return { status: 'unavailable', reason: 'The registry-assisted scenario names no agent identity and version, so nothing it produced could be attributed.' }
|
|
140
|
+
}
|
|
141
|
+
const withoutProvider = suite.modelIds.filter((modelId) => !providers.providers.some((provider) => provider.modelId === modelId && provider.scenarioIds.includes(ASSISTED_SCENARIO)))
|
|
142
|
+
if (withoutProvider.length) {
|
|
143
|
+
return { status: 'unavailable', reason: `No provider CLI is configured for the registry-assisted scenario and model(s) ${withoutProvider.join(', ')}.` }
|
|
144
|
+
}
|
|
145
|
+
const undeclared = [...(scenario.promptVersion ? [] : ['promptVersion']), ...(scenario.agentBudget ? [] : ['agentBudget'])]
|
|
146
|
+
return {
|
|
147
|
+
status: 'ready',
|
|
148
|
+
...(undeclared.length
|
|
149
|
+
? {
|
|
150
|
+
undeclared,
|
|
151
|
+
reason: `The registry-assisted scenario declares no ${undeclared.join(' or ')}; the arm runs, but it cannot be ${undeclared.includes('promptVersion') ? 'reproduced' : 'costed'} from this ledger alone.`,
|
|
152
|
+
}
|
|
153
|
+
: {}),
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The observation an unavailable arm leaves behind.
|
|
159
|
+
*
|
|
160
|
+
* Recorded rather than skipped: a scenario absent from the ledger is indistinguishable from one
|
|
161
|
+
* that was never planned, and the study's whole purpose is to compare the arms it planned.
|
|
162
|
+
*/
|
|
163
|
+
const unavailableObservation = (
|
|
164
|
+
plan: ControlledStudyRunPlanV1,
|
|
165
|
+
execution: TaskExecutionV1,
|
|
166
|
+
reason: string,
|
|
167
|
+
round: string | undefined,
|
|
168
|
+
): ControlledStudyObservationV1 => createControlledStudyObservation({
|
|
169
|
+
type: 'controlled-study-observation',
|
|
170
|
+
schemaVersion: 1,
|
|
171
|
+
observationVersion: 'v1',
|
|
172
|
+
observedAt: new Date().toISOString(),
|
|
173
|
+
runId: plan.runId,
|
|
174
|
+
planHash: plan.contentHash,
|
|
175
|
+
task: execution,
|
|
176
|
+
model: plan.models.find((model) => model.id === execution.modelId),
|
|
177
|
+
scenario: plan.scenarios.find((scenario) => scenario.id === execution.scenarioId),
|
|
178
|
+
execution: { status: 'unavailable', exitCode: null, signal: null, durationMs: 0, responseBytes: 0, stderrBytes: 0, errorCode: 'registry-unavailable' },
|
|
179
|
+
contextBytes: 0,
|
|
180
|
+
evidenceIds: [],
|
|
181
|
+
...(round === undefined ? {} : { round }),
|
|
182
|
+
adjudication: { status: 'automated', actor: 'deterministic-rubric-v1', method: 'deterministic-rubric-v1', outcome: 'blocked', reason },
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
export const adjudicateControlledStudyObservation = (task: StudyTaskV1, observation: ControlledStudyObservationV1): ControlledStudyObservationV1 => {
|
|
186
|
+
const passed = observation.measurements?.acceptanceChecksPassed
|
|
187
|
+
const total = observation.measurements?.acceptanceChecksTotal
|
|
188
|
+
const executed = observation.measurements?.acceptanceChecksExecuted
|
|
189
|
+
const acceptanceTotal = task.acceptanceChecks.length
|
|
190
|
+
const blocked = observation.execution.status !== 'completed'
|
|
191
|
+
|| passed === undefined
|
|
192
|
+
|| total !== acceptanceTotal
|
|
193
|
+
|| executed !== acceptanceTotal
|
|
194
|
+
|| passed > executed
|
|
195
|
+
const evidenceIds = new Set(observation.evidenceIds)
|
|
196
|
+
const requiredEvidencePresent = task.evidenceRequirements.filter((requirement) => evidenceIds.has(requirement.id)).length
|
|
197
|
+
const evaluation = evaluateStudyTask(task, {
|
|
198
|
+
acceptanceChecksPassed: passed ?? 0,
|
|
199
|
+
evidenceItemsPresent: requiredEvidencePresent,
|
|
200
|
+
blocked,
|
|
201
|
+
})
|
|
202
|
+
const { contentHash: _contentHash, contentHashAlgo: _contentHashAlgo, ...payload } = observation
|
|
203
|
+
return createControlledStudyObservation({
|
|
204
|
+
...payload,
|
|
205
|
+
adjudication: {
|
|
206
|
+
status: 'automated',
|
|
207
|
+
actor: 'deterministic-rubric-v1',
|
|
208
|
+
method: 'deterministic-rubric-v1',
|
|
209
|
+
outcome: evaluation.status,
|
|
210
|
+
reason: 'Independent bounded evaluation of execution status, acceptance metrics, and exact required evidence coverage.',
|
|
211
|
+
},
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const assertRunInputs = (options: ControlledStudyRunOptions): Map<string, { readonly id: string; readonly root: string }> => {
|
|
216
|
+
if (options.plan.taskSuiteHash !== options.suite.contentHash) throw new Error('Run plan taskSuiteHash does not match the supplied task suite.')
|
|
217
|
+
if (options.plan.models.map((model) => model.id).sort().join(',') !== [...options.suite.modelIds].sort().join(',')) throw new Error('Run plan model ids do not match the task suite.')
|
|
218
|
+
if (options.plan.scenarios.map((scenario) => scenario.id).sort().join(',') !== [...options.suite.scenarioIds].sort().join(',')) throw new Error('Run plan scenario ids do not match the task suite.')
|
|
219
|
+
if (options.plan.taskIds.slice().sort().join(',') !== options.suite.tasks.map((task) => task.id).sort().join(',')) throw new Error('Run plan task ids do not match the task suite.')
|
|
220
|
+
const repositories = new Map(options.repositories.repositories.map((repository) => [repository.id, repository]))
|
|
221
|
+
if (repositories.size !== options.suite.population.length || options.suite.population.some((repositoryId) => !repositories.has(repositoryId))) {
|
|
222
|
+
throw new Error('Study repository config must contain exactly one root for every task-suite population id.')
|
|
223
|
+
}
|
|
224
|
+
for (const repository of options.repositories.repositories) {
|
|
225
|
+
const root = resolve(repository.root)
|
|
226
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`Study repository ${repository.id} is not available at the configured root.`)
|
|
227
|
+
}
|
|
228
|
+
const executions = selectTaskExecutions(options.suite, options.plan.sampling.sampleSize, options.plan.sampling)
|
|
229
|
+
const assisted = assistedArmReadiness(options.plan, options.providers, options.suite)
|
|
230
|
+
for (const execution of executions) {
|
|
231
|
+
const repository = repositories.get(execution.repositoryId)
|
|
232
|
+
if (!repository) throw new Error(`No repository root is configured for ${execution.repositoryId}.`)
|
|
233
|
+
// An unavailable assisted arm is recorded, not validated: the other two arms still run.
|
|
234
|
+
if (execution.scenarioId === ASSISTED_SCENARIO && assisted.status === 'unavailable') continue
|
|
235
|
+
const provider = providerForStudyExecution(options.providers, execution.modelId, execution.scenarioId as 'repository-only' | 'deterministic-doc-bridge' | 'registry-assisted')
|
|
236
|
+
validateStudyProviderCommand(provider, repository.root)
|
|
237
|
+
const task = options.suite.tasks.find((item) => item.id === execution.taskId)
|
|
238
|
+
if (!task) throw new Error(`Task ${execution.taskId} is not present in the task suite.`)
|
|
239
|
+
const input = JSON.stringify({
|
|
240
|
+
protocol: 'doc-bridge.study-provider.v1',
|
|
241
|
+
response: PROVIDER_RESPONSE_CONTRACT,
|
|
242
|
+
task: { ...execution, difficulty: task.difficulty },
|
|
243
|
+
objective: task.objective,
|
|
244
|
+
initialContext: task.initialContext,
|
|
245
|
+
expectedOutcome: task.expectedOutcome,
|
|
246
|
+
evidenceRequirements: task.evidenceRequirements,
|
|
247
|
+
acceptanceChecks: task.acceptanceChecks,
|
|
248
|
+
allowedTools: task.allowedTools,
|
|
249
|
+
forbiddenActions: task.forbiddenActions,
|
|
250
|
+
})
|
|
251
|
+
if (Buffer.byteLength(input, 'utf8') > provider.maxInputBytes) throw new Error(`Provider CLI input limit ${provider.maxInputBytes} bytes exceeded for ${execution.taskId}.`)
|
|
252
|
+
}
|
|
253
|
+
return repositories
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export const runControlledStudy = async (options: ControlledStudyRunOptions): Promise<ControlledStudyRunSummary> => {
|
|
257
|
+
const plan = options.plan
|
|
258
|
+
const suite = parseStudyTaskSuite(options.suite)
|
|
259
|
+
const providers = parseStudyProviderCliConfig(options.providers)
|
|
260
|
+
const repositories = parseStudyRepositoryConfig(options.repositories)
|
|
261
|
+
const executions = selectTaskExecutions(suite, plan.sampling.sampleSize, plan.sampling)
|
|
262
|
+
const repositoryMap = assertRunInputs({ ...options, plan, suite, providers, repositories })
|
|
263
|
+
const assisted = assistedArmReadiness(plan, providers, suite)
|
|
264
|
+
if (options.dryRun) return {
|
|
265
|
+
status: 'dry-run',
|
|
266
|
+
runId: plan.runId,
|
|
267
|
+
planned: executions.length,
|
|
268
|
+
executed: 0,
|
|
269
|
+
skipped: 0,
|
|
270
|
+
providerConfigHash: providers.contentHash,
|
|
271
|
+
repositoryConfigHash: repositories.contentHash,
|
|
272
|
+
assistedArm: { ...assisted, recorded: 0 },
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let ledger = loadLedger(resolve(options.ledgerPath))
|
|
276
|
+
if (ledger.observations.some((observation) => observation.runId === plan.runId && observation.planHash !== plan.contentHash)) throw new Error(`Ledger already contains run ${plan.runId} with a different plan hash.`)
|
|
277
|
+
let executed = 0
|
|
278
|
+
let skipped = 0
|
|
279
|
+
let unavailableRecorded = 0
|
|
280
|
+
for (const execution of executions) {
|
|
281
|
+
const existing = ledger.observations.find((observation) => observation.runId === plan.runId && executionKey(observation.task) === executionKey(execution))
|
|
282
|
+
if (existing) { skipped += 1; continue }
|
|
283
|
+
if (execution.scenarioId === ASSISTED_SCENARIO && assisted.status === 'unavailable') {
|
|
284
|
+
const typedUnavailable = { ...execution, difficulty: suite.tasks.find((item) => item.id === execution.taskId)?.difficulty, scenarioId: ASSISTED_SCENARIO } as TaskExecutionV1
|
|
285
|
+
ledger = upsertControlledStudyObservation(ledger, unavailableObservation(plan, typedUnavailable, assisted.reason ?? 'The registry-assisted arm is unavailable.', options.round))
|
|
286
|
+
persistControlledStudyLedger(options.ledgerPath, ledger)
|
|
287
|
+
unavailableRecorded += 1
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
const repository = repositoryMap.get(execution.repositoryId)
|
|
291
|
+
if (!repository) throw new Error(`No repository root is configured for ${execution.repositoryId}.`)
|
|
292
|
+
const provider = providerForStudyExecution(providers, execution.modelId, execution.scenarioId as 'repository-only' | 'deterministic-doc-bridge' | 'registry-assisted')
|
|
293
|
+
const task = suite.tasks.find((item) => item.id === execution.taskId)
|
|
294
|
+
if (!task) throw new Error(`Task ${execution.taskId} is not present in the task suite.`)
|
|
295
|
+
const input = JSON.stringify({
|
|
296
|
+
protocol: 'doc-bridge.study-provider.v1',
|
|
297
|
+
response: PROVIDER_RESPONSE_CONTRACT,
|
|
298
|
+
task: { ...execution, difficulty: task.difficulty },
|
|
299
|
+
objective: task.objective,
|
|
300
|
+
initialContext: task.initialContext,
|
|
301
|
+
expectedOutcome: task.expectedOutcome,
|
|
302
|
+
evidenceRequirements: task.evidenceRequirements,
|
|
303
|
+
acceptanceChecks: task.acceptanceChecks,
|
|
304
|
+
allowedTools: task.allowedTools,
|
|
305
|
+
forbiddenActions: task.forbiddenActions,
|
|
306
|
+
})
|
|
307
|
+
const typedExecution = { ...execution, difficulty: task.difficulty, scenarioId: execution.scenarioId as 'repository-only' | 'deterministic-doc-bridge' | 'registry-assisted' }
|
|
308
|
+
const observation = await runControlledCommand({
|
|
309
|
+
plan,
|
|
310
|
+
execution: typedExecution,
|
|
311
|
+
command: provider.command,
|
|
312
|
+
args: provider.args,
|
|
313
|
+
cwd: repository.root,
|
|
314
|
+
input,
|
|
315
|
+
envAllowlist: provider.envAllowlist,
|
|
316
|
+
maxOutputBytes: Math.min(provider.maxOutputBytes, plan.budget.maxOutputBytes),
|
|
317
|
+
contextBytes: Buffer.byteLength(input, 'utf8'),
|
|
318
|
+
...(provider.pricing === undefined ? {} : { providerPricing: provider.pricing }),
|
|
319
|
+
...(options.round === undefined ? {} : { round: options.round }),
|
|
320
|
+
})
|
|
321
|
+
ledger = upsertControlledStudyObservation(ledger, adjudicateControlledStudyObservation(task, observation))
|
|
322
|
+
persistControlledStudyLedger(options.ledgerPath, ledger)
|
|
323
|
+
executed += 1
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
status: 'completed',
|
|
327
|
+
runId: plan.runId,
|
|
328
|
+
planned: executions.length,
|
|
329
|
+
executed,
|
|
330
|
+
skipped,
|
|
331
|
+
ledgerPath: resolve(options.ledgerPath),
|
|
332
|
+
ledgerHash: ledger.contentHash,
|
|
333
|
+
providerConfigHash: providers.contentHash,
|
|
334
|
+
repositoryConfigHash: repositories.contentHash,
|
|
335
|
+
assistedArm: { ...assisted, recorded: unavailableRecorded },
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export const formatControlledStudyRunText = (summary: ControlledStudyRunSummary): readonly string[] => [
|
|
340
|
+
`Study run: ${summary.runId}`,
|
|
341
|
+
`Status: ${summary.status}`,
|
|
342
|
+
`Planned: ${summary.planned}`,
|
|
343
|
+
`Executed: ${summary.executed}`,
|
|
344
|
+
`Skipped: ${summary.skipped}`,
|
|
345
|
+
...(summary.ledgerPath === undefined ? [] : [`Ledger: ${summary.ledgerPath}`, `Ledger hash: ${summary.ledgerHash}`]),
|
|
346
|
+
`Provider config hash: ${summary.providerConfigHash}`,
|
|
347
|
+
`Repository config hash: ${summary.repositoryConfigHash}`,
|
|
348
|
+
`Assisted arm: ${summary.assistedArm.status}${summary.assistedArm.recorded ? ` (${summary.assistedArm.recorded} execution(s) recorded as unavailable)` : ''}${summary.assistedArm.undeclared?.length ? ` (undeclared: ${summary.assistedArm.undeclared.join(', ')})` : ''}`,
|
|
349
|
+
...(summary.assistedArm.reason ? [` ${summary.assistedArm.reason}`] : []),
|
|
350
|
+
]
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
import { contentHashForArtifactV1, sha256NormalizedV1 } from '../index-builder/content-hash.js'
|
|
4
|
+
import { EVAL_FORMAT_VERSION, runRetrievalBench, type RetrievalBenchResultV1, type RetrievalSuite } from '../bench/retrieval.js'
|
|
5
|
+
import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
|
|
6
|
+
import { hasRetrievalExpectations, taskRetrievalQueries, type StudyTaskSuiteV1, type StudyTaskV1 } from './task-suite.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The mechanical half of a study task, and why it lives in two files.
|
|
10
|
+
*
|
|
11
|
+
* The last controlled round recorded zero semantic successes in both arms, which means it could
|
|
12
|
+
* not tell a working retrieval layer from a broken one. The cause was structural: a task whose
|
|
13
|
+
* only success criterion is a model's opinion produces no signal. The fix is to state what
|
|
14
|
+
* retrieval is expected to return and check it — with `ak-docs bench retrieval`, deterministically.
|
|
15
|
+
*
|
|
16
|
+
* The expectations are split across two artifacts on purpose. The task suite is
|
|
17
|
+
* publication-bound, so it may only carry opaque references (`primary-entrypoint`), never a path.
|
|
18
|
+
* This file is the local resolution — references to concrete entity ids and document paths for a
|
|
19
|
+
* repository on the operator's disk — and it must never be published. Putting it under
|
|
20
|
+
* `docs/study/` would fail the privacy gate, which is the intended outcome rather than a bug.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export const STUDY_EXPECTATIONS_SCHEMA_VERSION = 1 as const
|
|
24
|
+
export const STUDY_EXPECTATIONS_CONTENT_HASH_ALGO = 'sha256-normalized-v1' as const
|
|
25
|
+
|
|
26
|
+
const hash = z.string().regex(/^[a-f0-9]{64}$/)
|
|
27
|
+
const identifier = z.string().regex(/^[a-z][a-z0-9-]{0,127}$/)
|
|
28
|
+
const reference = z.string().regex(/^[a-z0-9][a-z0-9._:-]{0,255}$/)
|
|
29
|
+
const target = z.string().min(1).max(512)
|
|
30
|
+
|
|
31
|
+
const ExpectationsPayloadSchema = z.object({
|
|
32
|
+
type: z.literal('controlled-study-expectations'),
|
|
33
|
+
schemaVersion: z.literal(STUDY_EXPECTATIONS_SCHEMA_VERSION),
|
|
34
|
+
configVersion: reference,
|
|
35
|
+
/** Self-declared: this artifact resolves references to paths and is never publication-bound. */
|
|
36
|
+
scope: z.literal('local'),
|
|
37
|
+
/** The suite these references belong to. A suite that moved on invalidates the resolution. */
|
|
38
|
+
taskSuiteHash: hash,
|
|
39
|
+
repositories: z.array(z.object({
|
|
40
|
+
id: identifier,
|
|
41
|
+
/** Opaque reference → the entity ids or document paths it stands for in this repository. */
|
|
42
|
+
targets: z.record(reference, z.array(target).min(1).max(32)),
|
|
43
|
+
}).strict()).min(1).max(16),
|
|
44
|
+
}).strict()
|
|
45
|
+
|
|
46
|
+
export const StudyExpectationsV1Schema = ExpectationsPayloadSchema.extend({
|
|
47
|
+
contentHash: hash,
|
|
48
|
+
contentHashAlgo: z.literal(STUDY_EXPECTATIONS_CONTENT_HASH_ALGO),
|
|
49
|
+
}).strict()
|
|
50
|
+
|
|
51
|
+
export type StudyExpectationsV1 = z.infer<typeof StudyExpectationsV1Schema>
|
|
52
|
+
|
|
53
|
+
export const createStudyExpectations = (input: unknown): StudyExpectationsV1 => {
|
|
54
|
+
const payload = ExpectationsPayloadSchema.parse(input)
|
|
55
|
+
const hashable = { ...payload, contentHashAlgo: STUDY_EXPECTATIONS_CONTENT_HASH_ALGO }
|
|
56
|
+
return StudyExpectationsV1Schema.parse({ ...hashable, contentHash: sha256NormalizedV1(hashable) })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const parseStudyExpectations = (input: unknown): StudyExpectationsV1 => {
|
|
60
|
+
const expectations = StudyExpectationsV1Schema.parse(input)
|
|
61
|
+
if (contentHashForArtifactV1(expectations) !== expectations.contentHash) throw new Error('Invalid study expectations content hash.')
|
|
62
|
+
if (new Set(expectations.repositories.map((entry) => entry.id)).size !== expectations.repositories.length) {
|
|
63
|
+
throw new Error('Study expectations contain duplicate repository ids.')
|
|
64
|
+
}
|
|
65
|
+
return expectations
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A reference a task expects that the local file does not resolve. Reported, never ignored. */
|
|
69
|
+
export type UnresolvedExpectation = {
|
|
70
|
+
readonly taskId: string
|
|
71
|
+
readonly repositoryId: string
|
|
72
|
+
readonly reference: string
|
|
73
|
+
readonly kind: 'entity' | 'document'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type StudyRetrievalSuite = {
|
|
77
|
+
readonly suite: RetrievalSuite
|
|
78
|
+
readonly unresolved: readonly UnresolvedExpectation[]
|
|
79
|
+
/** Task ids that declare no expectations, so nothing about them is checked here. */
|
|
80
|
+
readonly withoutExpectations: readonly string[]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type StudyRetrievalSuiteOptions = {
|
|
84
|
+
readonly taskSuite: StudyTaskSuiteV1
|
|
85
|
+
readonly expectations: StudyExpectationsV1
|
|
86
|
+
/** Check one repository instead of every one the expectations resolve. */
|
|
87
|
+
readonly repositoryId?: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const caseId = (task: StudyTaskV1, index: number, total: number): string => (total === 1 ? task.id : `${task.id}-q${index + 1}`)
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Turn the study's expectations into a retrieval suite.
|
|
94
|
+
*
|
|
95
|
+
* One case per task query, whose expected targets are the resolved references. The result is an
|
|
96
|
+
* ordinary Open Eval Format suite, so the same command, the same ranking and the same metrics
|
|
97
|
+
* that gate this repository's retrieval also answer the study's mechanical questions — rather
|
|
98
|
+
* than a second, study-shaped checker nobody else exercises.
|
|
99
|
+
*/
|
|
100
|
+
export const studyRetrievalSuite = (options: StudyRetrievalSuiteOptions): StudyRetrievalSuite => {
|
|
101
|
+
const { taskSuite, expectations } = options
|
|
102
|
+
if (expectations.taskSuiteHash !== taskSuite.contentHash) {
|
|
103
|
+
throw new Error('Study expectations were written for a different task suite; re-resolve the references against this one.')
|
|
104
|
+
}
|
|
105
|
+
const byRepository = new Map(expectations.repositories.map((entry) => [entry.id, entry.targets]))
|
|
106
|
+
const unresolved: UnresolvedExpectation[] = []
|
|
107
|
+
const withoutExpectations: string[] = []
|
|
108
|
+
const cases: RetrievalSuite['cases'] = []
|
|
109
|
+
|
|
110
|
+
for (const task of taskSuite.tasks) {
|
|
111
|
+
if (options.repositoryId !== undefined && task.repositoryId !== options.repositoryId) continue
|
|
112
|
+
if (!hasRetrievalExpectations(task)) {
|
|
113
|
+
withoutExpectations.push(task.id)
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
const targets = byRepository.get(task.repositoryId)
|
|
117
|
+
const resolve = (references: readonly string[], kind: UnresolvedExpectation['kind']): string[] =>
|
|
118
|
+
references.flatMap((item) => {
|
|
119
|
+
const resolved = targets?.[item]
|
|
120
|
+
if (!resolved?.length) {
|
|
121
|
+
unresolved.push({ taskId: task.id, repositoryId: task.repositoryId, reference: item, kind })
|
|
122
|
+
return []
|
|
123
|
+
}
|
|
124
|
+
return resolved
|
|
125
|
+
})
|
|
126
|
+
const expectedTargets = [
|
|
127
|
+
...new Set([...resolve(task.expectedEntities ?? [], 'entity'), ...resolve(task.expectedDocuments ?? [], 'document')]),
|
|
128
|
+
].sort()
|
|
129
|
+
if (!expectedTargets.length) continue
|
|
130
|
+
const queries = taskRetrievalQueries(task)
|
|
131
|
+
for (const [index, input] of queries.entries()) {
|
|
132
|
+
cases.push({
|
|
133
|
+
id: caseId(task, index, queries.length),
|
|
134
|
+
input,
|
|
135
|
+
metadata: { expectedTargets, kind: 'question', studyTaskId: task.id, studyRepositoryId: task.repositoryId, studyCategory: task.category },
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
suite: {
|
|
142
|
+
evalFormatVersion: EVAL_FORMAT_VERSION,
|
|
143
|
+
name: `study-${taskSuite.suiteVersion}`,
|
|
144
|
+
description: 'Mechanical retrieval expectations resolved from the controlled study task suite.',
|
|
145
|
+
cases,
|
|
146
|
+
},
|
|
147
|
+
unresolved: [...unresolved].sort((a, b) => a.taskId.localeCompare(b.taskId) || a.reference.localeCompare(b.reference)),
|
|
148
|
+
withoutExpectations,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export type StudyExpectationOutcome = {
|
|
153
|
+
readonly taskId: string
|
|
154
|
+
readonly repositoryId: string
|
|
155
|
+
readonly caseId: string
|
|
156
|
+
readonly hit: boolean
|
|
157
|
+
readonly rank: number | null
|
|
158
|
+
readonly expectedTargets: readonly string[]
|
|
159
|
+
readonly rankedTargets: readonly string[]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export type StudyExpectationCheck = {
|
|
163
|
+
/** True when every case hit and every reference resolved: the mechanical verdict, with no model. */
|
|
164
|
+
readonly ok: boolean
|
|
165
|
+
readonly checkedTasks: number
|
|
166
|
+
readonly withoutExpectations: readonly string[]
|
|
167
|
+
readonly unresolved: readonly UnresolvedExpectation[]
|
|
168
|
+
readonly outcomes: readonly StudyExpectationOutcome[]
|
|
169
|
+
readonly result?: RetrievalBenchResultV1
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export type CheckStudyExpectationsOptions = StudyRetrievalSuiteOptions & {
|
|
173
|
+
readonly index: DocBridgeIndexV1
|
|
174
|
+
readonly limit?: number
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Run the study's mechanical expectations through the retrieval benchmark.
|
|
179
|
+
*
|
|
180
|
+
* A task with no expectations is reported as unchecked rather than counted as a pass, and an
|
|
181
|
+
* unresolved reference fails the check: an expectation nobody resolved is an expectation nobody
|
|
182
|
+
* tested, and calling that a success is exactly the failure this replaces.
|
|
183
|
+
*/
|
|
184
|
+
export const checkStudyExpectations = (options: CheckStudyExpectationsOptions): StudyExpectationCheck => {
|
|
185
|
+
const { suite, unresolved, withoutExpectations } = studyRetrievalSuite(options)
|
|
186
|
+
if (!suite.cases.length) {
|
|
187
|
+
return { ok: false, checkedTasks: 0, withoutExpectations, unresolved, outcomes: [] }
|
|
188
|
+
}
|
|
189
|
+
const result = runRetrievalBench({ index: options.index, suite, ...(options.limit === undefined ? {} : { limit: options.limit }) })
|
|
190
|
+
const byCase = new Map(result.cases.map((entry) => [entry.id, entry]))
|
|
191
|
+
const outcomes = suite.cases.map((entry): StudyExpectationOutcome => {
|
|
192
|
+
const outcome = byCase.get(entry.id)
|
|
193
|
+
return {
|
|
194
|
+
taskId: String(entry.metadata.studyTaskId ?? entry.id),
|
|
195
|
+
repositoryId: String(entry.metadata.studyRepositoryId ?? ''),
|
|
196
|
+
caseId: entry.id,
|
|
197
|
+
hit: outcome?.hitAt3 ?? false,
|
|
198
|
+
rank: outcome?.rank ?? null,
|
|
199
|
+
expectedTargets: outcome?.expectedTargets ?? entry.metadata.expectedTargets,
|
|
200
|
+
rankedTargets: outcome?.rankedTargets ?? [],
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
return {
|
|
204
|
+
ok: unresolved.length === 0 && outcomes.every((outcome) => outcome.hit),
|
|
205
|
+
checkedTasks: new Set(outcomes.map((outcome) => outcome.taskId)).size,
|
|
206
|
+
withoutExpectations,
|
|
207
|
+
unresolved,
|
|
208
|
+
outcomes,
|
|
209
|
+
result,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export const formatStudyExpectationsText = (check: StudyExpectationCheck): readonly string[] => [
|
|
214
|
+
`Study expectations: ${check.ok ? 'pass' : 'fail'}`,
|
|
215
|
+
`Tasks checked: ${check.checkedTasks}${check.withoutExpectations.length ? ` (${check.withoutExpectations.length} without expectations: ${check.withoutExpectations.slice(0, 8).join(', ')}${check.withoutExpectations.length > 8 ? ', …' : ''})` : ''}`,
|
|
216
|
+
...(check.result ? [`hit@3: ${(check.result.metrics.hitAt3 * 100).toFixed(1)}% over ${check.result.metrics.caseCount} case(s)`] : ['No case could be built: nothing was checked.']),
|
|
217
|
+
...check.unresolved.map((entry) => ` unresolved ${entry.kind} reference "${entry.reference}" for ${entry.taskId} (${entry.repositoryId})`),
|
|
218
|
+
...check.outcomes.filter((outcome) => !outcome.hit).map((outcome) => ` miss ${outcome.caseId}: expected ${outcome.expectedTargets.slice(0, 4).join(', ')}`),
|
|
219
|
+
]
|