@dailephd/my-dev-kit-lab 0.9.2 → 0.9.3

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +6 -1
  2. package/README.md +7 -5
  3. package/dist/src/agents/adapters/codexAdapter.js +4 -1
  4. package/dist/src/cli/help.js +38 -2
  5. package/dist/src/cli/runLabCli.js +9 -1
  6. package/dist/src/commands/buildGalleryCommand.js +16 -3
  7. package/dist/src/commands/runComparePromptsCommand.js +109 -0
  8. package/dist/src/commands/runExperimentRunCommand.js +47 -2
  9. package/dist/src/core/processTree.js +41 -17
  10. package/dist/src/core/runMeasuredCommand.js +14 -8
  11. package/dist/src/experiments/plugins/agentSuccessRate/campaignCheckpointStore.js +23 -1
  12. package/dist/src/experiments/plugins/agentSuccessRate/campaignExecution.js +7 -2
  13. package/dist/src/experiments/plugins/agentSuccessRate/campaignIdentity.js +29 -4
  14. package/dist/src/experiments/plugins/agentSuccessRate/campaignResumableExecution.js +1 -1
  15. package/dist/src/experiments/plugins/agentSuccessRate/config.js +47 -2
  16. package/dist/src/experiments/plugins/agentSuccessRate/plugin.js +4 -2
  17. package/dist/src/experiments/plugins/agentSuccessRate/promptContract.js +121 -0
  18. package/dist/src/experiments/plugins/agentSuccessRate/realAgentExecution.js +32 -2
  19. package/dist/src/experiments/plugins/agentSuccessRate/realAgentPrompt.js +105 -11
  20. package/dist/src/experiments/plugins/agentSuccessRate/strictDiffResponse.js +312 -0
  21. package/dist/src/gallery/galleryPathSafety.js +111 -0
  22. package/dist/src/gallery/readGallerySources.js +542 -0
  23. package/dist/src/gallery/writeGalleryManifest.js +57 -3
  24. package/dist/src/report/experiments/buildPluginExperimentReport.js +4 -16
  25. package/dist/src/report/experiments/htmlSafety.js +32 -0
  26. package/dist/src/report/experiments/metricEvidence.js +389 -0
  27. package/dist/src/report/experiments/metricGlossary.js +227 -0
  28. package/dist/src/report/experiments/metricGlossaryCatalog.js +199 -0
  29. package/dist/src/report/experiments/promptTemplateComparisonEvidence.js +377 -0
  30. package/dist/src/report/experiments/promptTemplateComparisonModel.js +436 -0
  31. package/dist/src/report/experiments/renderPluginExperimentReportHtml.js +158 -64
  32. package/dist/src/report/experiments/renderPluginExperimentReportText.js +22 -677
  33. package/dist/src/report/experiments/renderPromptTemplateComparison.js +192 -0
  34. package/dist/src/report/experiments/renderSpecializedTextSections.js +633 -0
  35. package/dist/src/report/experiments/reportSectionRegistry.js +79 -0
  36. package/dist/src/report/experiments/specializedReportSections.js +142 -0
  37. package/dist/src/report/experiments/writePluginExperimentReports.js +4 -2
  38. package/dist/src/securityValidation/attackScenarios/scenarios/secretLeakageScenario.js +146 -133
  39. package/dist/src/securityValidation/attackScenarios/scenarios/subprocessInjectionScenario.js +54 -36
  40. package/dist/src/securityValidation/commandRunner.js +13 -6
  41. package/docs/METRICS.md +11 -1
  42. package/package.json +1 -1
@@ -0,0 +1,436 @@
1
+ // Read-only prompt-template comparison over already persisted agent-success-rate evidence. This module is pure:
2
+ // it receives validated side evidence and produces a deterministic report model. It never invokes a provider,
3
+ // never recalculates a task-success verdict, and never reads the filesystem.
4
+ import { CAMPAIGN_COMPONENTS, CAMPAIGN_IDENTITY_VERSION_V2 } from "../../experiments/plugins/agentSuccessRate/campaignIdentity.js";
5
+ export const PROMPT_TEMPLATE_COMPARISON_SCHEMA = "prompt-template-comparison/1.0.0";
6
+ export const PROMPT_TEMPLATE_COMPARISON_JSON_FILE = "prompt-template-comparison.json";
7
+ export const PROMPT_TEMPLATE_COMPARISON_TEXT_FILE = "prompt-template-comparison.txt";
8
+ export const PROMPT_TEMPLATE_COMPARISON_HTML_FILE = "prompt-template-comparison.html";
9
+ const TREATMENTS = ["raw-full-file", "context-pack"];
10
+ const MAX_LISTED_EXCLUSIONS = 200;
11
+ function attemptsOf(treatment) {
12
+ if (treatment.attempts && treatment.attempts.length > 0) {
13
+ return treatment.attempts.map((attempt) => ({ attempt, evidence: attempt.evidence, attemptNumber: attempt.attemptNumber, taskSuccess: attempt.taskSuccess }));
14
+ }
15
+ return [{ attempt: null, evidence: treatment, attemptNumber: 1, taskSuccess: null }];
16
+ }
17
+ /**
18
+ * Classifies one attempt's response under the invalid-output definition. An attempt is eligible only when
19
+ * persisted evidence proves a completed provider response, and invalid only when that evidence also proves a
20
+ * contract violation. A provider status alone is never sufficient. Anything the evidence cannot establish is
21
+ * excluded with an explicit reason: it is neither valid nor invalid and never enters the denominator.
22
+ */
23
+ export function classifyResponseValidity(unit) {
24
+ const evidence = unit.evidence;
25
+ const status = unit.attempt?.providerStatus ?? evidence.realAgent?.providerStatus ?? "not-invoked";
26
+ const category = unit.attempt?.failureCategory ?? null;
27
+ if (status === "not-invoked")
28
+ return { eligible: false, invalid: false, reason: "provider-not-invoked" };
29
+ if (status === "timeout")
30
+ return { eligible: false, invalid: false, reason: "provider-timeout" };
31
+ if (status === "agent-unavailable")
32
+ return { eligible: false, invalid: false, reason: "provider-unavailable" };
33
+ if (status === "agent-limit-reached")
34
+ return { eligible: false, invalid: false, reason: "provider-limit-reached" };
35
+ if (status === "failed" || status === "skipped")
36
+ return { eligible: false, invalid: false, reason: "provider-failure" };
37
+ // The provider run classifier's invalid-output status does not carry the application response contract result:
38
+ // the response is not evaluated against the prompt contract in that case, so it proves no specific violation.
39
+ if (status === "invalid-output")
40
+ return { eligible: false, invalid: false, reason: "unverified-provider-invalid-output-status" };
41
+ if (category === "infrastructure-failure" || category === "evidence-incomplete" || category === "integrity-unproven" || category === "context-unavailable" || category === "baseline-invalid" || category === "cleanup-failed") {
42
+ return { eligible: false, invalid: false, reason: `unverified-${category}` };
43
+ }
44
+ if (status !== "completed")
45
+ return { eligible: false, invalid: false, reason: "unverified-provider-status" };
46
+ const real = evidence.realAgent;
47
+ if (!real || real.providerInvoked !== true)
48
+ return { eligible: false, invalid: false, reason: "missing-response-evidence" };
49
+ const contractError = evidence.errors.find((error) => error.code.startsWith("OUTPUT_CONTRACT_"));
50
+ if (!real.finalAnswerAvailable) {
51
+ // A completed provider process with no usable final answer: invalid only when the persisted evidence names it.
52
+ if (evidence.errors.some((error) => error.code === "PROVIDER_EMPTY_ANSWER"))
53
+ return { eligible: true, invalid: true, reason: "provider-empty-answer" };
54
+ return { eligible: false, invalid: false, reason: "missing-response-evidence" };
55
+ }
56
+ if (contractError) {
57
+ if (!evidence.patch.attempted)
58
+ return { eligible: false, invalid: false, reason: "missing-response-evidence" };
59
+ return { eligible: true, invalid: true, reason: contractError.code };
60
+ }
61
+ if (!evidence.patch.attempted || evidence.patch.outcome === null)
62
+ return { eligible: false, invalid: false, reason: "unverified-patch-pipeline-outcome" };
63
+ if (evidence.patch.outcome === "parse-failure")
64
+ return { eligible: true, invalid: true, reason: `malformed-diff${evidence.patch.code ? `:${evidence.patch.code}` : ""}` };
65
+ // Policy rejection, git check/apply failure and failed task verification are well-formed responses.
66
+ return { eligible: true, invalid: false, reason: "valid" };
67
+ }
68
+ function unitKey(caseId, treatmentId) {
69
+ return `${caseId}/${treatmentId}`;
70
+ }
71
+ function sorted(values, key) {
72
+ return [...values].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
73
+ }
74
+ function summarize(side) {
75
+ const identity = side.identity;
76
+ const contract = side.promptContract;
77
+ return {
78
+ runId: side.execution?.runId ?? null,
79
+ completedAt: side.execution?.completedAt ?? null,
80
+ providerId: identity?.config.providerId ?? side.execution?.realAgent?.providerId ?? null,
81
+ identityDigest: identity?.digest ?? null,
82
+ identitySchema: identity?.schemaVersion ?? null,
83
+ promptBasis: side.promptBasis,
84
+ templateId: contract?.templateId ?? null,
85
+ promptContractDigest: identity && identity.schemaVersion === CAMPAIGN_IDENTITY_VERSION_V2 ? identity.promptContractDigest : null,
86
+ promptContract: contract ? structuredClone(contract) : null,
87
+ repairAttempts: identity?.config.repairAttempts ?? null,
88
+ caseCount: identity?.selected.length ?? null
89
+ };
90
+ }
91
+ function emptyRate(reason) {
92
+ return { numerator: 0, denominator: 0, ratePercent: null, availability: "unavailable", reason, invalidByReason: {} };
93
+ }
94
+ function rate(invalid, eligible, byReason) {
95
+ if (eligible === 0)
96
+ return { numerator: invalid, denominator: 0, ratePercent: null, availability: "unavailable", reason: "no eligible completed responses in the matched cohort", invalidByReason: byReason };
97
+ return { numerator: invalid, denominator: eligible, ratePercent: (invalid / eligible) * 100, availability: "available", reason: null, invalidByReason: byReason };
98
+ }
99
+ function sortedRecord(record) {
100
+ return Object.fromEntries(Object.entries(record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
101
+ }
102
+ function stratumOf(name, units) {
103
+ const baseReasons = {};
104
+ const candReasons = {};
105
+ let baseInvalid = 0;
106
+ let candInvalid = 0;
107
+ for (const unit of units) {
108
+ if (unit.base.invalid) {
109
+ baseInvalid += 1;
110
+ baseReasons[unit.base.reason] = (baseReasons[unit.base.reason] ?? 0) + 1;
111
+ }
112
+ if (unit.cand.invalid) {
113
+ candInvalid += 1;
114
+ candReasons[unit.cand.reason] = (candReasons[unit.cand.reason] ?? 0) + 1;
115
+ }
116
+ }
117
+ const baseline = rate(baseInvalid, units.length, sortedRecord(baseReasons));
118
+ const candidate = rate(candInvalid, units.length, sortedRecord(candReasons));
119
+ const available = baseline.availability === "available" && candidate.availability === "available";
120
+ return {
121
+ stratum: name,
122
+ eligibleUnitCount: units.length,
123
+ baseline,
124
+ candidate,
125
+ percentagePointDifference: available ? baseline.ratePercent - candidate.ratePercent : null,
126
+ availability: available ? "available" : "unavailable",
127
+ reason: available ? null : "no eligible matched units: the rate is unavailable, not zero percent"
128
+ };
129
+ }
130
+ function taskBasis(pairs) {
131
+ const result = { baselineSuccessCount: 0, candidateSuccessCount: 0, denominator: pairs.length, bothSucceeded: 0, onlyBaselineSucceeded: 0, onlyCandidateSucceeded: 0, neitherSucceeded: 0 };
132
+ for (const [base, cand] of pairs) {
133
+ if (base)
134
+ result.baselineSuccessCount += 1;
135
+ if (cand)
136
+ result.candidateSuccessCount += 1;
137
+ if (base && cand)
138
+ result.bothSucceeded += 1;
139
+ else if (base)
140
+ result.onlyBaselineSucceeded += 1;
141
+ else if (cand)
142
+ result.onlyCandidateSucceeded += 1;
143
+ else
144
+ result.neitherSucceeded += 1;
145
+ }
146
+ return result;
147
+ }
148
+ const SAME_TEMPLATE_LIMITATION = "Baseline and candidate use the same prompt template; no template effect is tested by this comparison.";
149
+ function compareIdentities(base, cand, contractBase, contractCand) {
150
+ const criteria = [];
151
+ const add = (id, ok, detail) => {
152
+ criteria.push({ id, status: ok ? "match" : "mismatch", detail });
153
+ };
154
+ add("provider", base.config.providerId === cand.config.providerId, `baseline ${base.config.providerId}, candidate ${cand.config.providerId}`);
155
+ add("repair-allowance", base.config.repairAttempts === cand.config.repairAttempts, `baseline ${base.config.repairAttempts}, candidate ${cand.config.repairAttempts}`);
156
+ add("timeout", base.config.timeoutMs === cand.config.timeoutMs, `baseline ${base.config.timeoutMs} ms, candidate ${cand.config.timeoutMs} ms`);
157
+ add("treatment-order", JSON.stringify(base.treatmentOrder) === JSON.stringify(cand.treatmentOrder), "fixed raw-full-file / context-pack order");
158
+ for (const field of ["lengthMode", "explanationMode", "toolUseMode", "maxCommandCount", "providerId"]) {
159
+ add(`prompt-${field}`, contractBase[field] === contractCand[field], `baseline ${String(contractBase[field])}, candidate ${String(contractCand[field])}`);
160
+ }
161
+ criteria.push({
162
+ id: "prompt-template",
163
+ status: "not-required",
164
+ detail: `independent variable: baseline ${contractBase.templateId}, candidate ${contractCand.templateId}; renderer digests may differ`
165
+ });
166
+ const candById = new Map(cand.selected.map((row) => [row.caseId, row]));
167
+ const baseIds = new Set(base.selected.map((row) => row.caseId));
168
+ const matched = [];
169
+ const unmatched = [];
170
+ for (const row of base.selected) {
171
+ const other = candById.get(row.caseId);
172
+ if (!other)
173
+ unmatched.push({ caseId: row.caseId, reason: "case-missing-in-candidate" });
174
+ else if (other.projectId !== row.projectId)
175
+ unmatched.push({ caseId: row.caseId, reason: "benchmark-project-mismatch" });
176
+ else if (other.taskDigest !== row.taskDigest)
177
+ unmatched.push({ caseId: row.caseId, reason: "task-digest-mismatch" });
178
+ else
179
+ matched.push(row.caseId);
180
+ }
181
+ for (const row of cand.selected)
182
+ if (!baseIds.has(row.caseId))
183
+ unmatched.push({ caseId: row.caseId, reason: "case-missing-in-baseline" });
184
+ unmatched.sort((a, b) => (a.caseId < b.caseId ? -1 : a.caseId > b.caseId ? 1 : a.reason < b.reason ? -1 : 1));
185
+ const sameSelection = unmatched.length === 0;
186
+ add("matched-cases", matched.length > 0, `${matched.length} case(s) share project and task digest`);
187
+ for (const name of CAMPAIGN_COMPONENTS) {
188
+ const a = base.components[name].digest;
189
+ const b = cand.components[name].digest;
190
+ const wholeCampaign = name === "corpus" || name === "tasks" || name === "projects" || name === "checks" || name === "behavior";
191
+ if (wholeCampaign && !sameSelection) {
192
+ criteria.push({ id: `component-${name}`, status: "not-required", detail: "selections differ; per-case task digests are compared instead" });
193
+ }
194
+ else if (a === null || b === null) {
195
+ // A provider build observed on neither side is permitted; one-sided or other missing evidence cannot certify a match.
196
+ if (a === null && b === null && name === "providerBuild")
197
+ criteria.push({ id: `component-${name}`, status: "not-required", detail: "provider build not observed on either side" });
198
+ else
199
+ criteria.push({ id: `component-${name}`, status: "unverifiable", detail: "component digest missing on at least one side" });
200
+ }
201
+ else
202
+ add(`component-${name}`, a === b, a === b ? "digests equal" : "digests differ");
203
+ }
204
+ return { criteria, matchedCases: matched, unmatchedCases: unmatched, sameSelection };
205
+ }
206
+ /** Builds the deterministic comparison model. Never throws for unverifiable evidence: that is INCOMPARABLE. */
207
+ export function buildPromptTemplateComparison(baseline, candidate) {
208
+ const reasons = [];
209
+ const warnings = [...baseline.warnings.map((w) => `baseline: ${w}`), ...candidate.warnings.map((w) => `candidate: ${w}`)];
210
+ const limitations = [
211
+ "Rates are descriptive observations over a finite matched cohort; no significance test, composite score or winner is produced.",
212
+ "Task correctness is reported separately and is read from persisted verdicts; it is not recalculated.",
213
+ "Deterministic fixtures can establish calculation correctness but not real-provider behavior."
214
+ ];
215
+ for (const side of [baseline, candidate]) {
216
+ for (const reason of side.unverifiable)
217
+ reasons.push(`${side.role}: ${reason}`);
218
+ if (side.identity === null && side.unverifiable.length === 0)
219
+ reasons.push(`${side.role}: campaign identity could not be verified`);
220
+ if (side.promptContract === null || side.promptBasis === "unverified")
221
+ reasons.push(`${side.role}: prompt template identity could not be established`);
222
+ if (side.execution === null && !side.unverifiable.some((reason) => reason.includes("execution")))
223
+ reasons.push(`${side.role}: execution evidence is unavailable`);
224
+ }
225
+ let criteria = [];
226
+ let matchedCases = [];
227
+ let unmatchedCases = [];
228
+ if (reasons.length === 0) {
229
+ const base = baseline.identity;
230
+ const cand = candidate.identity;
231
+ const outcome = compareIdentities(base, cand, baseline.promptContract, candidate.promptContract);
232
+ criteria = outcome.criteria;
233
+ matchedCases = outcome.matchedCases;
234
+ unmatchedCases = outcome.unmatchedCases;
235
+ if (base.config.providerId === "deterministic-fixture")
236
+ reasons.push("deterministic-fixture evidence has no prompt template to compare");
237
+ for (const criterion of criteria) {
238
+ if (criterion.status === "mismatch")
239
+ reasons.push(`${criterion.id}: ${criterion.detail}`);
240
+ if (criterion.status === "unverifiable")
241
+ reasons.push(`${criterion.id}: ${criterion.detail}`);
242
+ }
243
+ if (baseline.promptContract.templateId === candidate.promptContract.templateId)
244
+ limitations.push(SAME_TEMPLATE_LIMITATION);
245
+ }
246
+ const status = reasons.length === 0 ? "COMPARABLE" : "INCOMPARABLE";
247
+ const baseSummary = summarize(baseline);
248
+ const candSummary = summarize(candidate);
249
+ const header = {
250
+ schemaVersion: PROMPT_TEMPLATE_COMPARISON_SCHEMA,
251
+ reportKind: "prompt-template-comparison",
252
+ baseline: baseSummary,
253
+ candidate: candSummary,
254
+ criteria,
255
+ provenance: { evidenceSource: "persisted-agent-success-rate-artifacts", providerInvoked: false, generatedFrom: ["agent-success-rate-execution.json", "campaign identity checkpoint", "prompt-contract.json", "agent-success-rate-analysis.json"] },
256
+ behavioralImprovement: {
257
+ status: "NOT_VERIFIED",
258
+ reason: "Observed real-provider improvement requires separately authorized paired provider evidence under compatible conditions; none was evaluated."
259
+ },
260
+ warnings,
261
+ limitations
262
+ };
263
+ if (status === "INCOMPARABLE") {
264
+ return {
265
+ ...header,
266
+ comparison: { status, incomparabilityReasons: reasons.slice(0, 50), winner: null, basis: "no winner is declared for incomparable evidence" },
267
+ cohort: { provider: baseSummary.providerId, matchedUnitCount: 0, eligibleMatchedUnitCount: 0, excludedUnitCount: 0, exclusions: [], exclusionReasonCounts: {}, unknownOrUnavailableUnitCount: 0, unmatchedCases },
268
+ invalidOutput: {
269
+ metric: "invalid-eligible-first-attempt-responses / eligible-completed-first-attempt-responses",
270
+ basis: "matched-first-attempt",
271
+ strata: [],
272
+ overall: null,
273
+ repairAttemptSummary: []
274
+ },
275
+ taskSuccess: { separateFromInvalidOutput: true, strata: [], overall: null }
276
+ };
277
+ }
278
+ // ---- COMPARABLE: match units, classify first attempts, aggregate by treatment stratum ----
279
+ const baseExec = baseline.execution;
280
+ const candExec = candidate.execution;
281
+ const baseCases = new Map(baseExec.cases.map((c) => [c.caseId, c]));
282
+ const candCases = new Map(candExec.cases.map((c) => [c.caseId, c]));
283
+ const exclusions = [];
284
+ const exclusionCounts = {};
285
+ const exclude = (unit, reason) => {
286
+ exclusions.push({ unit, reason });
287
+ exclusionCounts[reason] = (exclusionCounts[reason] ?? 0) + 1;
288
+ };
289
+ const matchedUnits = [];
290
+ let unknownUnits = 0;
291
+ let matchedUnitCount = 0;
292
+ for (const caseId of sorted(matchedCases, (value) => value)) {
293
+ for (const treatment of TREATMENTS) {
294
+ const key = unitKey(caseId, treatment);
295
+ const b = baseCases.get(caseId)?.treatments.find((t) => t.treatmentId === treatment);
296
+ const c = candCases.get(caseId)?.treatments.find((t) => t.treatmentId === treatment);
297
+ if (!b || !c) {
298
+ exclude(key, "execution-evidence-missing");
299
+ unknownUnits += 1;
300
+ continue;
301
+ }
302
+ matchedUnitCount += 1;
303
+ const baseUnit = attemptsOf(b);
304
+ const candUnit = attemptsOf(c);
305
+ const baseUnverified = baseline.unverifiedUnits.get(key);
306
+ const candUnverified = candidate.unverifiedUnits.get(key);
307
+ if (baseUnverified || candUnverified) {
308
+ exclude(key, `${baseUnverified ? "baseline" : "candidate"}:${baseUnverified ?? candUnverified}`);
309
+ unknownUnits += 1;
310
+ continue;
311
+ }
312
+ const baseClass = classifyResponseValidity(baseUnit[0]);
313
+ const candClass = classifyResponseValidity(candUnit[0]);
314
+ if (!baseClass.eligible || !candClass.eligible) {
315
+ exclude(key, !baseClass.eligible ? `baseline:${baseClass.reason}` : `candidate:${candClass.reason}`);
316
+ if (baseClass.reason.startsWith("unverified") || candClass.reason.startsWith("unverified") || baseClass.reason === "missing-response-evidence" || candClass.reason === "missing-response-evidence")
317
+ unknownUnits += 1;
318
+ continue;
319
+ }
320
+ matchedUnits.push({ treatment, key, base: baseClass, cand: candClass, baseUnit, candUnit });
321
+ }
322
+ }
323
+ const strata = TREATMENTS.map((treatment) => stratumOf(treatment, matchedUnits.filter((unit) => unit.treatment === treatment)));
324
+ const overall = stratumOf("all-treatments", matchedUnits);
325
+ if (strata.some((stratum) => stratum.eligibleUnitCount === 0) && overall.eligibleUnitCount > 0) {
326
+ warnings.push("Overall figures omit a treatment stratum with no eligible units; read the per-treatment strata.");
327
+ }
328
+ // Repair attempts keep their attempt number and policy; they are never attributed to the initial response.
329
+ const repairAllowance = baseline.identity.config.repairAttempts;
330
+ const repairSummary = [];
331
+ for (const attemptNumber of [2, 3]) {
332
+ let baseEligible = 0;
333
+ let baseInvalid = 0;
334
+ let candEligible = 0;
335
+ let candInvalid = 0;
336
+ for (const unit of matchedUnits) {
337
+ const b = unit.baseUnit.find((u) => u.attemptNumber === attemptNumber);
338
+ const c = unit.candUnit.find((u) => u.attemptNumber === attemptNumber);
339
+ if (b) {
340
+ const cls = classifyResponseValidity(b);
341
+ if (cls.eligible) {
342
+ baseEligible += 1;
343
+ if (cls.invalid)
344
+ baseInvalid += 1;
345
+ }
346
+ }
347
+ if (c) {
348
+ const cls = classifyResponseValidity(c);
349
+ if (cls.eligible) {
350
+ candEligible += 1;
351
+ if (cls.invalid)
352
+ candInvalid += 1;
353
+ }
354
+ }
355
+ }
356
+ if (baseEligible + candEligible > 0) {
357
+ repairSummary.push({
358
+ attemptNumber,
359
+ baselineEligible: baseEligible,
360
+ baselineInvalid: baseInvalid,
361
+ candidateEligible: candEligible,
362
+ candidateInvalid: candInvalid,
363
+ repairAllowance,
364
+ note: "repair-attempt responses; unmatched across sides and not part of the primary first-attempt comparison"
365
+ });
366
+ }
367
+ }
368
+ // Task success: read persisted verdicts for matched units; determinate on both sides only.
369
+ const verdicts = (units) => ({
370
+ initial: units[0]?.attempt ? units[0].taskSuccess : null,
371
+ final: units.length > 0 && units[units.length - 1]?.attempt ? units[units.length - 1].taskSuccess : null
372
+ });
373
+ const taskStrata = [];
374
+ const allInitial = [];
375
+ const allFinal = [];
376
+ let allUnavailable = 0;
377
+ let allMatched = 0;
378
+ for (const treatment of TREATMENTS) {
379
+ const initial = [];
380
+ const final = [];
381
+ let unavailable = 0;
382
+ let matchedCount = 0;
383
+ for (const caseId of sorted(matchedCases, (value) => value)) {
384
+ const b = baseCases.get(caseId)?.treatments.find((t) => t.treatmentId === treatment);
385
+ const c = candCases.get(caseId)?.treatments.find((t) => t.treatmentId === treatment);
386
+ if (!b || !c)
387
+ continue;
388
+ matchedCount += 1;
389
+ const bv = verdicts(attemptsOf(b));
390
+ const cv = verdicts(attemptsOf(c));
391
+ if (bv.final === null || cv.final === null || bv.initial === null || cv.initial === null) {
392
+ unavailable += 1;
393
+ continue;
394
+ }
395
+ initial.push([bv.initial, cv.initial]);
396
+ final.push([bv.final, cv.final]);
397
+ }
398
+ taskStrata.push({ stratum: treatment, matchedUnitCount: matchedCount, unavailableUnitCount: unavailable, initial: taskBasis(initial), final: taskBasis(final) });
399
+ allInitial.push(...initial);
400
+ allFinal.push(...final);
401
+ allUnavailable += unavailable;
402
+ allMatched += matchedCount;
403
+ }
404
+ const taskOverall = allMatched > 0 ? { stratum: "all-treatments", matchedUnitCount: allMatched, unavailableUnitCount: allUnavailable, initial: taskBasis(allInitial), final: taskBasis(allFinal) } : null;
405
+ const listed = sorted(exclusions, (value) => `${value.unit}\u0000${value.reason}`);
406
+ if (listed.length > MAX_LISTED_EXCLUSIONS)
407
+ warnings.push(`Exclusion list truncated to ${MAX_LISTED_EXCLUSIONS} of ${listed.length} entries; counts are complete.`);
408
+ return {
409
+ ...header,
410
+ warnings,
411
+ comparison: {
412
+ status,
413
+ incomparabilityReasons: [],
414
+ winner: null,
415
+ basis: `matched ${matchedCases.length} case(s) over ${TREATMENTS.length} treatments; prompt template is the independent variable`
416
+ },
417
+ cohort: {
418
+ provider: baseSummary.providerId,
419
+ matchedUnitCount,
420
+ eligibleMatchedUnitCount: matchedUnits.length,
421
+ excludedUnitCount: exclusions.length,
422
+ exclusions: listed.slice(0, MAX_LISTED_EXCLUSIONS),
423
+ exclusionReasonCounts: sortedRecord(exclusionCounts),
424
+ unknownOrUnavailableUnitCount: unknownUnits,
425
+ unmatchedCases
426
+ },
427
+ invalidOutput: {
428
+ metric: "invalid-eligible-first-attempt-responses / eligible-completed-first-attempt-responses",
429
+ basis: "matched-first-attempt",
430
+ strata,
431
+ overall,
432
+ repairAttemptSummary: repairSummary
433
+ },
434
+ taskSuccess: { separateFromInvalidOutput: true, strata: taskStrata, overall: taskOverall }
435
+ };
436
+ }