{"version":3,"file":"comparison.d.ts","sourceRoot":"","sources":["../src/comparison.ts"],"names":[],"mappings":"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","sourcesContent":["import type { EvoPaths } from \"./paths.ts\";\nimport { loadProposal, saveProposal } from \"./proposal.ts\";\nimport { readEvaluationArtifact, saveProposalRevisionSnapshot, writeComparisonArtifact } from \"./proposal-artifacts.ts\";\nimport {\n\tlistSessionDigests,\n\ttype SessionDigest,\n\ttype SessionDigestMetrics,\n\ttype SessionTaskClass,\n} from \"./recorder/digest.ts\";\nimport { canonicalJson, sha256 } from \"./storage.ts\";\nimport type { EvaluationArtifactRef, Proposal, TrialState } from \"./types.ts\";\n\nexport interface ComparisonRates {\n\ttoolErrorRate: number | null;\n\tverificationPassRate: number | null;\n\tassistantTurnsPerTask: number | null;\n\tfollowUpsPerTask: number | null;\n\ttokensPerTask: number | null;\n}\n\nexport interface ComparisonTotals extends SessionDigestMetrics {\n\tsessions: number;\n}\n\nexport interface ComparisonCohort {\n\tbundleDigest: string;\n\tsessions: Array<{ sessionId: string; sourceDigest: string; taskClass: SessionTaskClass }>;\n\ttotals: ComparisonTotals;\n\trates: ComparisonRates;\n}\n\nexport interface TrialComparison {\n\tschemaVersion: 1;\n\tproposalId: string;\n\trevision: number;\n\tdiffDigest: string;\n\tgeneratedAt: string;\n\ttrialStartedAt: string;\n\tchange: {\n\t\tchangedPaths: string[];\n\t\texpectedEffect: string;\n\t};\n\tmatching: {\n\t\ttaskClasses: SessionTaskClass[];\n\t\texcludedBeforeSessions: number;\n\t};\n\tsufficiency: {\n\t\tstatus: \"sufficient\" | \"insufficient\";\n\t\tminimumSessionsPerCohort: number;\n\t\treasons: string[];\n\t};\n\tbefore: ComparisonCohort;\n\tafter: ComparisonCohort;\n\tdelta: ComparisonRates;\n\tevidenceDigest: string;\n}\n\nexport interface StoredTrialComparison {\n\tcomparison: TrialComparison;\n\tproposal: Proposal;\n\treference: EvaluationArtifactRef;\n\treused: boolean;\n}\n\nfunction emptyMetrics(): SessionDigestMetrics {\n\treturn {\n\t\ttasks: 0,\n\t\tuserMessages: 0,\n\t\tassistantMessages: 0,\n\t\ttoolResults: 0,\n\t\tfollowUpUserMessages: 0,\n\t\ttoolCalls: 0,\n\t\ttoolErrors: 0,\n\t\ttoolDurationMs: 0,\n\t\tverificationRuns: 0,\n\t\tverificationPassed: 0,\n\t\tverificationFailed: 0,\n\t\tpreferenceSignals: 0,\n\t\tcompactions: 0,\n\t\tcompactionRetries: 0,\n\t\tcompactionDurationMs: 0,\n\t\tcompactionTokensBefore: 0,\n\t\tusage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 },\n\t};\n}\n\nfunction ratio(numerator: number, denominator: number): number | null {\n\treturn denominator > 0 ? numerator / denominator : null;\n}\n\nfunction aggregateCohort(bundleDigest: string, sessions: SessionDigest[]): ComparisonCohort {\n\tconst metrics = emptyMetrics();\n\tfor (const session of sessions) {\n\t\tfor (const key of [\n\t\t\t\"tasks\",\n\t\t\t\"userMessages\",\n\t\t\t\"assistantMessages\",\n\t\t\t\"toolResults\",\n\t\t\t\"followUpUserMessages\",\n\t\t\t\"toolCalls\",\n\t\t\t\"toolErrors\",\n\t\t\t\"toolDurationMs\",\n\t\t\t\"verificationRuns\",\n\t\t\t\"verificationPassed\",\n\t\t\t\"verificationFailed\",\n\t\t\t\"preferenceSignals\",\n\t\t\t\"compactions\",\n\t\t\t\"compactionRetries\",\n\t\t\t\"compactionDurationMs\",\n\t\t\t\"compactionTokensBefore\",\n\t\t] as const) {\n\t\t\tmetrics[key] += session.metrics[key];\n\t\t}\n\t\tfor (const key of [\"input\", \"output\", \"cacheRead\", \"cacheWrite\", \"totalTokens\"] as const) {\n\t\t\tmetrics.usage[key] += session.metrics.usage[key];\n\t\t}\n\t}\n\treturn {\n\t\tbundleDigest,\n\t\tsessions: sessions.map(({ sessionId, sourceDigest, taskClass }) => ({ sessionId, sourceDigest, taskClass })),\n\t\ttotals: { sessions: sessions.length, ...metrics },\n\t\trates: {\n\t\t\ttoolErrorRate: ratio(metrics.toolErrors, metrics.toolCalls),\n\t\t\tverificationPassRate: ratio(metrics.verificationPassed, metrics.verificationRuns),\n\t\t\tassistantTurnsPerTask: ratio(metrics.assistantMessages, metrics.tasks),\n\t\t\tfollowUpsPerTask: ratio(metrics.followUpUserMessages, metrics.tasks),\n\t\t\ttokensPerTask: ratio(metrics.usage.totalTokens, metrics.tasks),\n\t\t},\n\t};\n}\n\nfunction subtract(after: number | null, before: number | null): number | null {\n\treturn after === null || before === null ? null : after - before;\n}\n\nexport function trialEvidenceDigest(corpusDigest: string, comparisonDigest: string): string {\n\treturn sha256(canonicalJson({ trialEvidenceSchemaVersion: 1, corpusDigest, comparisonDigest }));\n}\n\nfunction comparisonSufficiency(before: ComparisonCohort, after: ComparisonCohort) {\n\tconst minimumSessionsPerCohort = 3;\n\tconst reasons: string[] = [];\n\tif (before.totals.sessions < minimumSessionsPerCohort) {\n\t\treasons.push(`baseline has ${before.totals.sessions}/${minimumSessionsPerCohort} required sessions`);\n\t}\n\tif (after.totals.sessions < minimumSessionsPerCohort) {\n\t\treasons.push(`candidate has ${after.totals.sessions}/${minimumSessionsPerCohort} required sessions`);\n\t}\n\treturn {\n\t\tstatus: reasons.length === 0 ? (\"sufficient\" as const) : (\"insufficient\" as const),\n\t\tminimumSessionsPerCohort,\n\t\treasons,\n\t};\n}\n\nfunction formatRate(value: number | null): string {\n\treturn value === null ? \"n/a\" : value.toFixed(3);\n}\n\nexport function renderTrialComparisonMarkdown(comparison: TrialComparison): string {\n\tconst rows: Array<[string, number | null, number | null, number | null]> = [\n\t\t[\n\t\t\t\"Tool error rate\",\n\t\t\tcomparison.before.rates.toolErrorRate,\n\t\t\tcomparison.after.rates.toolErrorRate,\n\t\t\tcomparison.delta.toolErrorRate,\n\t\t],\n\t\t[\n\t\t\t\"Verification pass rate\",\n\t\t\tcomparison.before.rates.verificationPassRate,\n\t\t\tcomparison.after.rates.verificationPassRate,\n\t\t\tcomparison.delta.verificationPassRate,\n\t\t],\n\t\t[\n\t\t\t\"Assistant turns / task\",\n\t\t\tcomparison.before.rates.assistantTurnsPerTask,\n\t\t\tcomparison.after.rates.assistantTurnsPerTask,\n\t\t\tcomparison.delta.assistantTurnsPerTask,\n\t\t],\n\t\t[\n\t\t\t\"Follow-ups / task\",\n\t\t\tcomparison.before.rates.followUpsPerTask,\n\t\t\tcomparison.after.rates.followUpsPerTask,\n\t\t\tcomparison.delta.followUpsPerTask,\n\t\t],\n\t\t[\n\t\t\t\"Tokens / task\",\n\t\t\tcomparison.before.rates.tokensPerTask,\n\t\t\tcomparison.after.rates.tokensPerTask,\n\t\t\tcomparison.delta.tokensPerTask,\n\t\t],\n\t];\n\treturn [\n\t\t\"# Automatic trial comparison\",\n\t\t\"\",\n\t\t`- Proposal: ${comparison.proposalId} revision ${comparison.revision}`,\n\t\t`- Evidence: ${comparison.evidenceDigest}`,\n\t\t`- Trial started: ${comparison.trialStartedAt}`,\n\t\t`- Generated: ${comparison.generatedAt}`,\n\t\t`- Evidence status: **${comparison.sufficiency.status}**`,\n\t\t...comparison.sufficiency.reasons.map((reason) => `  - ${reason}`),\n\t\t\"\",\n\t\t\"## Change\",\n\t\t\"\",\n\t\t`Expected effect: ${comparison.change.expectedEffect}`,\n\t\t\"\",\n\t\t...comparison.change.changedPaths.map((path) => `- \\`${path}\\``),\n\t\t\"\",\n\t\t\"## Automatic cohort matching\",\n\t\t\"\",\n\t\t`Candidate task classes: ${comparison.matching.taskClasses.join(\", \") || \"none yet\"}`,\n\t\t`Excluded non-matching baseline sessions: ${comparison.matching.excludedBeforeSessions}`,\n\t\t`Baseline sessions: ${comparison.before.totals.sessions}; candidate sessions: ${comparison.after.totals.sessions}`,\n\t\t\"\",\n\t\t\"## Deterministic metrics\",\n\t\t\"\",\n\t\t\"| Metric | Before | After | Delta (after - before) |\",\n\t\t\"| --- | ---: | ---: | ---: |\",\n\t\t...rows.map(\n\t\t\t([label, before, after, delta]) =>\n\t\t\t\t`| ${label} | ${formatRate(before)} | ${formatRate(after)} | ${formatRate(delta)} |`,\n\t\t),\n\t\t\"\",\n\t].join(\"\\n\");\n}\n\nexport async function buildTrialComparison(\n\tpaths: EvoPaths,\n\tproposal: Proposal,\n\ttrial: TrialState,\n): Promise<TrialComparison> {\n\tconst digests = (await listSessionDigests(paths)).filter((digest) => digest.assessment.comparisonEligible);\n\tconst eligibleBeforeSessions = digests.filter(\n\t\t(digest) => digest.bundleDigest === trial.parent && (digest.endedAt ?? \"\") < trial.startedAt,\n\t);\n\tconst afterSessions = digests.filter(\n\t\t(digest) => digest.bundleDigest === trial.digest && (digest.startedAt ?? \"\") >= trial.startedAt,\n\t);\n\tconst taskClasses = [...new Set(afterSessions.map((digest) => digest.taskClass))].sort();\n\tconst beforeSessions =\n\t\ttaskClasses.length === 0\n\t\t\t? eligibleBeforeSessions\n\t\t\t: eligibleBeforeSessions.filter((digest) => taskClasses.includes(digest.taskClass));\n\tconst before = aggregateCohort(trial.parent, beforeSessions);\n\tconst after = aggregateCohort(trial.digest, afterSessions);\n\tconst generatedAt = [...beforeSessions, ...afterSessions].reduce(\n\t\t(cutoff, digest) => (digest.endedAt && digest.endedAt > cutoff ? digest.endedAt : cutoff),\n\t\ttrial.startedAt,\n\t);\n\tconst evidenceDigest = sha256(\n\t\tcanonicalJson({\n\t\t\tcomparisonSchemaVersion: 1,\n\t\t\tproposalId: proposal.id,\n\t\t\trevision: proposal.revision,\n\t\t\tdiffDigest: proposal.diffDigest,\n\t\t\tbefore: before.sessions,\n\t\t\tafter: after.sessions,\n\t\t}),\n\t);\n\treturn {\n\t\tschemaVersion: 1,\n\t\tproposalId: proposal.id,\n\t\trevision: proposal.revision,\n\t\tdiffDigest: proposal.diffDigest,\n\t\tgeneratedAt,\n\t\ttrialStartedAt: trial.startedAt,\n\t\tchange: {\n\t\t\tchangedPaths: proposal.changedPaths,\n\t\t\texpectedEffect: proposal.expectedEffect,\n\t\t},\n\t\tmatching: {\n\t\t\ttaskClasses,\n\t\t\texcludedBeforeSessions: eligibleBeforeSessions.length - beforeSessions.length,\n\t\t},\n\t\tsufficiency: comparisonSufficiency(before, after),\n\t\tbefore,\n\t\tafter,\n\t\tdelta: {\n\t\t\ttoolErrorRate: subtract(after.rates.toolErrorRate, before.rates.toolErrorRate),\n\t\t\tverificationPassRate: subtract(after.rates.verificationPassRate, before.rates.verificationPassRate),\n\t\t\tassistantTurnsPerTask: subtract(after.rates.assistantTurnsPerTask, before.rates.assistantTurnsPerTask),\n\t\t\tfollowUpsPerTask: subtract(after.rates.followUpsPerTask, before.rates.followUpsPerTask),\n\t\t\ttokensPerTask: subtract(after.rates.tokensPerTask, before.rates.tokensPerTask),\n\t\t},\n\t\tevidenceDigest,\n\t};\n}\n\nexport async function storeTrialComparison(\n\tpaths: EvoPaths,\n\tproposal: Proposal,\n\ttrial: TrialState,\n\tnow: () => Date = () => new Date(),\n): Promise<StoredTrialComparison> {\n\tconst comparison = await buildTrialComparison(paths, proposal, trial);\n\tconst existing = proposal.artifacts.comparison;\n\tif (existing?.evidence?.digest === comparison.evidenceDigest) {\n\t\tconst content = await readEvaluationArtifact({\n\t\t\tpaths,\n\t\t\tproposalId: proposal.id,\n\t\t\trevision: proposal.revision,\n\t\t\tdiffDigest: proposal.diffDigest,\n\t\t\tkind: \"comparison\",\n\t\t\treference: existing,\n\t\t});\n\t\treturn { comparison: JSON.parse(content) as TrialComparison, proposal, reference: existing, reused: true };\n\t}\n\tconst content = `${canonicalJson(comparison)}\\n`;\n\tconst reference = await writeComparisonArtifact({\n\t\tpaths,\n\t\tproposalId: proposal.id,\n\t\trevision: proposal.revision,\n\t\tdiffDigest: proposal.diffDigest,\n\t\tcontent,\n\t\tmarkdownContent: renderTrialComparisonMarkdown(comparison),\n\t\tevidenceDigest: comparison.evidenceDigest,\n\t\tevidenceCutoff: comparison.generatedAt,\n\t\tnow,\n\t});\n\tconst current = await loadProposal(paths, proposal.id);\n\tcurrent.artifacts.comparison = reference;\n\tawait saveProposal(paths, current);\n\tawait saveProposalRevisionSnapshot(paths, current);\n\treturn { comparison, proposal: current, reference, reused: false };\n}\n\n// ============================================================================\n// Trial contract: the measurable metric registry and its deterministic gates.\n// A plan may only promise metrics this module actually measures, and keep /\n// rollback decisions compare measured deltas against the frozen minimum effect.\n// ============================================================================\n\nexport const TRIAL_METRIC_DIRECTIONS = {\n\ttoolErrorRate: \"lower\",\n\tverificationPassRate: \"higher\",\n\tassistantTurnsPerTask: \"lower\",\n\tfollowUpsPerTask: \"lower\",\n\ttokensPerTask: \"lower\",\n} as const satisfies Record<keyof ComparisonRates, \"lower\" | \"higher\">;\n\nexport type TrialMetricName = keyof typeof TRIAL_METRIC_DIRECTIONS;\n\nexport function isTrialMetricName(value: string): value is TrialMetricName {\n\treturn value in TRIAL_METRIC_DIRECTIONS;\n}\n\nexport function parseTrialDurationDays(value: string): number | undefined {\n\tconst match = /^([1-9]\\d*)d$/.exec(value.trim());\n\treturn match ? Number(match[1]) : undefined;\n}\n\ninterface TrialContract {\n\tprimaryMetric?: string;\n\tminimumEffect: Record<string, number>;\n}\n\n/** Signed improvement of a delta under the metric's direction (positive = better). */\nfunction improvement(metric: TrialMetricName, delta: number): number {\n\treturn TRIAL_METRIC_DIRECTIONS[metric] === \"lower\" ? -delta : delta;\n}\n\nexport interface TrialContractGate {\n\tallowed: boolean;\n\treasons: string[];\n}\n\nexport interface ContractMetricRegression {\n\tmetric: TrialMetricName;\n\tprimary: boolean;\n\treason: string;\n}\n\n/**\n * Every frozen-contract metric regression: the primary metric plus each other\n * pre-registered metric in minimumEffect acts as a guardrail. A regression at\n * least as large as a metric's frozen minimum effect fires, so a proposal that\n * improves its primary metric while tanking a guardrail still gets caught.\n */\nexport function contractMetricRegressions(\n\tcontract: TrialContract | undefined,\n\tcomparison: TrialComparison,\n): ContractMetricRegression[] {\n\tif (!contract) return [];\n\tconst regressions: ContractMetricRegression[] = [];\n\tfor (const [metric, threshold] of Object.entries(contract.minimumEffect)) {\n\t\tif (!isTrialMetricName(metric) || !threshold || threshold <= 0) continue;\n\t\tconst delta = comparison.delta[metric];\n\t\tif (delta === null) continue;\n\t\tif (improvement(metric, delta) <= -threshold) {\n\t\t\tconst primary = metric === contract.primaryMetric;\n\t\t\tregressions.push({\n\t\t\t\tmetric,\n\t\t\t\tprimary,\n\t\t\t\treason: `${primary ? \"primary\" : \"guardrail\"} metric ${metric} regressed by ${(-improvement(metric, delta)).toFixed(4)} (frozen minimum effect ${threshold})`,\n\t\t\t});\n\t\t}\n\t}\n\treturn regressions;\n}\n\n/**\n * Deterministic keep-gate: sufficiency must hold, the primary metric must have\n * improved by at least the frozen minimum effect, and no pre-registered\n * guardrail metric may have regressed beyond its own frozen effect size. Model\n * recommendations can veto a keep but can never substitute for this gate.\n */\nexport function evaluateTrialContractGate(\n\tcontract: TrialContract | undefined,\n\tcomparison: TrialComparison,\n): TrialContractGate {\n\tconst reasons: string[] = [];\n\tif (comparison.sufficiency.status !== \"sufficient\") {\n\t\treasons.push(`comparison evidence is insufficient: ${comparison.sufficiency.reasons.join(\"; \") || \"unknown\"}`);\n\t}\n\tconst primary = contract?.primaryMetric;\n\tif (primary && isTrialMetricName(primary)) {\n\t\tconst delta = comparison.delta[primary];\n\t\tconst threshold = contract.minimumEffect[primary] ?? 0;\n\t\tif (delta === null) {\n\t\t\treasons.push(`primary metric ${primary} has no measurement in either cohort`);\n\t\t} else if (improvement(primary, delta) < threshold) {\n\t\t\treasons.push(\n\t\t\t\t`primary metric ${primary} improved by ${improvement(primary, delta).toFixed(4)}, below the frozen minimum effect ${threshold}`,\n\t\t\t);\n\t\t}\n\t}\n\tfor (const regression of contractMetricRegressions(contract, comparison)) {\n\t\tif (!regression.primary) reasons.push(regression.reason);\n\t}\n\treturn { allowed: reasons.length === 0, reasons };\n}\n\n/**\n * Machine rollback trigger: the primary metric regressed by at least the frozen\n * minimum effect. Returns the reason, or undefined when no trigger fires.\n */\nexport function primaryMetricRegression(\n\tcontract: TrialContract | undefined,\n\tcomparison: TrialComparison,\n): string | undefined {\n\tconst primary = contract?.primaryMetric;\n\tif (!primary || !isTrialMetricName(primary)) return undefined;\n\tconst threshold = contract?.minimumEffect[primary];\n\tif (!threshold || threshold <= 0) return undefined;\n\tconst delta = comparison.delta[primary];\n\tif (delta === null) return undefined;\n\tif (improvement(primary, delta) <= -threshold) {\n\t\treturn `primary metric ${primary} regressed by ${(-improvement(primary, delta)).toFixed(4)} (frozen minimum effect ${threshold})`;\n\t}\n\treturn undefined;\n}\n"]}