@lsctech/polaris 0.5.6 → 0.5.8

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 (37) hide show
  1. package/dist/autoresearch/index.js +30 -1
  2. package/dist/autoresearch/proposal.js +5 -0
  3. package/dist/autoresearch/score.js +174 -2
  4. package/dist/autoresearch/sol-evaluation-writer.js +135 -0
  5. package/dist/autoresearch/sol-evidence-loader.js +40 -3
  6. package/dist/autoresearch/sol-evidence-normalizer.js +337 -0
  7. package/dist/autoresearch/sol-recommendations.js +212 -0
  8. package/dist/autoresearch/sol-report-renderer.js +282 -0
  9. package/dist/autoresearch/sol-report.js +44 -0
  10. package/dist/autoresearch/sol-scorecard-calculator.js +865 -0
  11. package/dist/autoresearch/sol-scorer.js +45 -1
  12. package/dist/cli/autoresearch.js +77 -0
  13. package/dist/cluster-state/store.js +56 -0
  14. package/dist/config/defaults.js +1 -0
  15. package/dist/config/validator.js +157 -0
  16. package/dist/finalize/artifact-policy.js +2 -0
  17. package/dist/finalize/github.js +13 -9
  18. package/dist/finalize/index.js +198 -4
  19. package/dist/finalize/linear.js +8 -4
  20. package/dist/finalize/steps/08-create-pr.js +2 -2
  21. package/dist/finalize/steps/11-update-linear.js +2 -2
  22. package/dist/loop/parent.js +189 -33
  23. package/dist/loop/worker-packet.js +75 -1
  24. package/dist/qc/artifacts.js +27 -0
  25. package/dist/qc/fixtures/repair-packets.js +170 -0
  26. package/dist/qc/index.js +2 -0
  27. package/dist/qc/orchestration.js +5 -2
  28. package/dist/qc/policy.js +30 -3
  29. package/dist/qc/providers/coderabbit.js +152 -17
  30. package/dist/qc/registry.js +36 -3
  31. package/dist/qc/repair-loop.js +458 -0
  32. package/dist/qc/repair-packets.js +420 -0
  33. package/dist/qc/runner.js +328 -37
  34. package/dist/qc/schemas.js +79 -1
  35. package/dist/types/sol-metrics.js +108 -0
  36. package/dist/types/sol-scorecard.js +148 -0
  37. package/package.json +1 -1
@@ -0,0 +1,337 @@
1
+ "use strict";
2
+ /**
3
+ * SOL evidence normalizer.
4
+ *
5
+ * Materializes raw metric events (SolMetricEvent[]) and source references
6
+ * (SolSourceRef[]) from a loaded SolEvidence record. This is the bridge
7
+ * between the evidence loader and scorecard generation.
8
+ *
9
+ * Design rules:
10
+ * - Never throws on missing data — absent optional fields reduce confidence.
11
+ * - Source refs track every artifact that contributed evidence (present or not).
12
+ * - Provider startup failures are distinguished from worker execution failures:
13
+ * startup failure = provider-exhausted or selected_provider=null in router evidence
14
+ * worker execution failure = worker ran but status="failed"|"error"
15
+ * - Router fallback + candidate/rejection context are preserved when present.
16
+ * - Missing router or QC evidence sets availability flags; no metric events
17
+ * are emitted for those categories.
18
+ * - Confidence: "high" when all expected source refs are available,
19
+ * "medium" when some are absent, "low" when most are absent.
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.normalizeSolEvidence = normalizeSolEvidence;
23
+ function makeSourceRef(kind, path, available, unavailableReason) {
24
+ return available
25
+ ? { kind, path, available: true }
26
+ : { kind, path, available: false, unavailable_reason: unavailableReason };
27
+ }
28
+ function computeConfidence(refs) {
29
+ if (refs.length === 0)
30
+ return "none";
31
+ const available = refs.filter((r) => r.available).length;
32
+ const ratio = available / refs.length;
33
+ if (ratio >= 0.8)
34
+ return "high";
35
+ if (ratio >= 0.4)
36
+ return "medium";
37
+ if (ratio > 0)
38
+ return "low";
39
+ return "none";
40
+ }
41
+ // ──────────────────────────────────────────────
42
+ // Source ref builders
43
+ // ──────────────────────────────────────────────
44
+ function buildSourceRefs(evidence, paths) {
45
+ const refs = [];
46
+ // Run state
47
+ if (paths.runStatePath) {
48
+ refs.push(makeSourceRef("run-state", paths.runStatePath, evidence.run.status !== null));
49
+ }
50
+ else {
51
+ refs.push(makeSourceRef("run-state", ".taskchain_artifacts/polaris-run/current-state.json", false, "path not resolved"));
52
+ }
53
+ // Telemetry
54
+ if (paths.telemetryPath) {
55
+ const hasTelemetry = evidence.tokens.total_worker_heartbeats > 0 || evidence.foreman.escalation_events > 0;
56
+ refs.push(makeSourceRef("telemetry", paths.telemetryPath, hasTelemetry));
57
+ }
58
+ else {
59
+ refs.push(makeSourceRef("telemetry", ".taskchain_artifacts/polaris-run/runs/<run-id>/telemetry.jsonl", false, "path not resolved"));
60
+ }
61
+ // Cluster state — available when the path was resolved (regardless of QC status)
62
+ if (paths.clusterStatePath) {
63
+ refs.push(makeSourceRef("cluster-state", paths.clusterStatePath, true));
64
+ }
65
+ else {
66
+ refs.push(makeSourceRef("cluster-state", `.polaris/clusters/${evidence.cluster_id ?? "unknown"}/cluster-state.json`, false, evidence.cluster_id ? "not found" : "cluster id not resolved"));
67
+ }
68
+ // Result packets (one per child with a known path)
69
+ for (const child of evidence.children) {
70
+ // Match filenames that are exactly "{child_id}.json" or start with "{child_id}-"
71
+ // to handle commit-suffixed names (e.g. "POL-001-abc.json") while avoiding
72
+ // false positives when IDs share a prefix (e.g. "1" vs "10").
73
+ const packetPath = paths.resultPacketPaths.find((p) => {
74
+ const filename = p.split('/').pop() ?? '';
75
+ return filename === `${child.child_id}.json` || filename.startsWith(`${child.child_id}-`);
76
+ });
77
+ const path = packetPath ?? `.polaris/clusters/${evidence.cluster_id ?? "unknown"}/results/${child.child_id}.json`;
78
+ refs.push(makeSourceRef("result-packet", path, packetPath !== undefined));
79
+ }
80
+ // QC artifacts
81
+ if (evidence.qc.availability === "available") {
82
+ refs.push(makeSourceRef("qc-finding", paths.qcDir ?? `.polaris/clusters/${evidence.cluster_id ?? "unknown"}/qc`, true));
83
+ }
84
+ else if (evidence.qc.availability === "future") {
85
+ refs.push(makeSourceRef("qc-finding", `.polaris/clusters/${evidence.cluster_id ?? "unknown"}/qc`, false, "QC not yet run for this cluster"));
86
+ }
87
+ // "unavailable" (qc-disabled): no ref emitted
88
+ // Run report
89
+ if (paths.runReportPath) {
90
+ refs.push(makeSourceRef("run-report", paths.runReportPath, true));
91
+ }
92
+ else {
93
+ refs.push(makeSourceRef("run-report", `smartdocs/reports/sol/${evidence.run_id}-evaluation-report.md`, false, "path not resolved"));
94
+ }
95
+ return refs;
96
+ }
97
+ // ──────────────────────────────────────────────
98
+ // Metric event materializers
99
+ // ──────────────────────────────────────────────
100
+ function materializeProviderStartupFailures(evidence) {
101
+ if (evidence.router.availability !== "available")
102
+ return [];
103
+ return evidence.router.decisions
104
+ .filter((d) => d.exhausted)
105
+ .map((d) => ({
106
+ category: "provider-startup-failure",
107
+ run_id: evidence.run_id,
108
+ child_id: d.child_id || undefined,
109
+ provider: d.selected_provider ?? d.providers_tried[d.providers_tried.length - 1] ?? "unknown",
110
+ failure_reason: d.exhausted_reason,
111
+ providers_tried: d.providers_tried,
112
+ all_providers_exhausted: d.exhausted,
113
+ }));
114
+ }
115
+ function materializeRouterFallbacks(evidence) {
116
+ if (evidence.router.availability !== "available")
117
+ return [];
118
+ return evidence.router.decisions
119
+ .filter((d) => d.fallback_used && !d.exhausted)
120
+ .map((d) => {
121
+ const originalProvider = d.providers_tried[0] ?? null;
122
+ const fallbackProvider = d.selected_provider;
123
+ // Child succeeded if it has a "done" completion (check worker evidence)
124
+ const childResult = evidence.children.find((c) => c.child_id === d.child_id);
125
+ const fallbackSucceeded = childResult?.status === "done";
126
+ return {
127
+ category: "router-fallback",
128
+ run_id: evidence.run_id,
129
+ child_id: d.child_id || undefined,
130
+ original_provider: originalProvider,
131
+ fallback_provider: fallbackProvider,
132
+ providers_tried: d.providers_tried,
133
+ fallback_succeeded: fallbackSucceeded ?? false,
134
+ rejection_reasons: d.rejection_reasons,
135
+ };
136
+ });
137
+ }
138
+ function materializeWorkerExecutionFailures(evidence) {
139
+ // Worker execution failures: worker ran (not a startup failure) but status failed/error
140
+ const exhaustedChildIds = new Set(evidence.router.availability === "available"
141
+ ? evidence.router.decisions.filter((d) => d.exhausted).map((d) => d.child_id)
142
+ : []);
143
+ return evidence.children
144
+ .filter((c) => {
145
+ // Must be a failed/error status (not "done" or "blocked")
146
+ if (c.status !== "failed" && c.status !== "error")
147
+ return false;
148
+ // Exclude children where the provider never started (startup failure)
149
+ if (exhaustedChildIds.has(c.child_id))
150
+ return false;
151
+ return true;
152
+ })
153
+ .map((c) => {
154
+ const validationRecord = evidence.validation.find((v) => v.child_id === c.child_id);
155
+ return {
156
+ category: "worker-execution-failure",
157
+ run_id: evidence.run_id,
158
+ child_id: c.child_id,
159
+ worker_status: c.status,
160
+ validation: validationRecord?.outcome ?? "unknown",
161
+ provider: c.provider,
162
+ error_message: validationRecord?.error_message ?? null,
163
+ out_of_scope_escalation: evidence.intervention.out_of_scope_count > 0,
164
+ escalation_count: c.escalation_count,
165
+ };
166
+ });
167
+ }
168
+ function materializeValidationFailures(evidence) {
169
+ return evidence.validation
170
+ .filter((v) => v.outcome === "failed")
171
+ .map((v) => {
172
+ const childResult = evidence.children.find((c) => c.child_id === v.child_id);
173
+ return {
174
+ category: "validation-failure",
175
+ run_id: evidence.run_id,
176
+ child_id: v.child_id,
177
+ worker_status: childResult?.status ?? "unknown",
178
+ failed_commands: [], // Commands not individually tracked in SolValidationEvidence
179
+ passed_commands: v.passed_commands,
180
+ error_message: v.error_message,
181
+ };
182
+ });
183
+ }
184
+ function materializeQcFindings(evidence) {
185
+ if (evidence.qc.availability !== "available")
186
+ return [];
187
+ const events = [];
188
+ // Blocking open findings by severity
189
+ const severities = ["critical", "high", "medium", "low", "info"];
190
+ for (const sev of severities) {
191
+ const count = evidence.qc.open_by_severity[sev];
192
+ for (let i = 0; i < count; i++) {
193
+ const isHighSeverity = sev === "critical" || sev === "high";
194
+ events.push({
195
+ category: "qc-finding",
196
+ run_id: evidence.run_id,
197
+ qc_provider: Object.keys(evidence.qc.provider_breakdown)[0] ?? "unknown",
198
+ severity: sev,
199
+ blocking: isHighSeverity && evidence.qc.blocks_delivery,
200
+ autofixed: false,
201
+ repaired: false,
202
+ waived: false,
203
+ unvalidated: false,
204
+ summary: `Open ${sev} QC finding`,
205
+ attribution_confidence: isHighSeverity ? "high" : "medium",
206
+ });
207
+ }
208
+ }
209
+ // Emit a single unvalidated aggregate event when unvalidated_findings > 0
210
+ if (evidence.qc.unvalidated_findings > 0) {
211
+ const noisyProvider = evidence.qc.noisy_providers[0] ?? "unknown";
212
+ events.push({
213
+ category: "qc-finding",
214
+ run_id: evidence.run_id,
215
+ qc_provider: noisyProvider,
216
+ severity: "info",
217
+ blocking: false,
218
+ autofixed: false,
219
+ repaired: false,
220
+ waived: false,
221
+ unvalidated: true,
222
+ summary: `${evidence.qc.unvalidated_findings} unvalidated finding(s) from provider ${noisyProvider}`,
223
+ attribution_confidence: "none",
224
+ });
225
+ }
226
+ return events;
227
+ }
228
+ function materializeInterventions(evidence) {
229
+ const events = [];
230
+ if (evidence.intervention.user_intervened) {
231
+ // Find the children that had user interventions for child_id attribution
232
+ const intervened = evidence.children.filter((c) => c.user_intervened === true);
233
+ if (intervened.length > 0) {
234
+ for (const child of intervened) {
235
+ events.push({
236
+ category: "user-intervention",
237
+ run_id: evidence.run_id,
238
+ child_id: child.child_id,
239
+ actor: "user",
240
+ intervention_type: "commit",
241
+ resolved: true,
242
+ });
243
+ }
244
+ }
245
+ else {
246
+ events.push({
247
+ category: "user-intervention",
248
+ run_id: evidence.run_id,
249
+ actor: "user",
250
+ intervention_type: "unspecified",
251
+ resolved: true,
252
+ });
253
+ }
254
+ }
255
+ if (evidence.intervention.foreman_intervened) {
256
+ const intervened = evidence.children.filter((c) => c.foreman_intervened === true);
257
+ if (intervened.length > 0) {
258
+ for (const child of intervened) {
259
+ events.push({
260
+ category: "foreman-intervention",
261
+ run_id: evidence.run_id,
262
+ child_id: child.child_id,
263
+ actor: "foreman",
264
+ intervention_type: "commit",
265
+ resolved: true,
266
+ });
267
+ }
268
+ }
269
+ else {
270
+ events.push({
271
+ category: "foreman-intervention",
272
+ run_id: evidence.run_id,
273
+ actor: "foreman",
274
+ intervention_type: "unspecified",
275
+ resolved: true,
276
+ });
277
+ }
278
+ }
279
+ if (evidence.intervention.state_repair_required) {
280
+ events.push({
281
+ category: "foreman-intervention",
282
+ run_id: evidence.run_id,
283
+ actor: "foreman",
284
+ intervention_type: "state-repair",
285
+ resolved: false,
286
+ });
287
+ }
288
+ if (evidence.intervention.out_of_scope_count > 0) {
289
+ events.push({
290
+ category: "user-intervention",
291
+ run_id: evidence.run_id,
292
+ actor: "user",
293
+ intervention_type: "out-of-scope",
294
+ resolved: evidence.intervention.blocked_event_count > 0,
295
+ });
296
+ }
297
+ return events;
298
+ }
299
+ // ──────────────────────────────────────────────
300
+ // Main entry point
301
+ // ──────────────────────────────────────────────
302
+ /**
303
+ * Normalize a loaded SolEvidence record into raw metric events and source refs.
304
+ *
305
+ * Does not throw on missing data — absent optional fields mark source refs
306
+ * as unavailable and reduce confidence. Missing router or QC inputs emit no
307
+ * events for those categories.
308
+ *
309
+ * @param evidence — already-loaded SolEvidence (from aggregateSolEvidence)
310
+ * @param paths — artifact path context for source ref generation
311
+ * @returns SolEvidenceNormalizationResult
312
+ */
313
+ function normalizeSolEvidence(evidence, paths = {
314
+ runStatePath: null,
315
+ telemetryPath: null,
316
+ clusterStatePath: null,
317
+ resultPacketPaths: [],
318
+ qcDir: null,
319
+ runReportPath: null,
320
+ }) {
321
+ const source_refs = buildSourceRefs(evidence, paths);
322
+ const events = [
323
+ ...materializeProviderStartupFailures(evidence),
324
+ ...materializeRouterFallbacks(evidence),
325
+ ...materializeWorkerExecutionFailures(evidence),
326
+ ...materializeValidationFailures(evidence),
327
+ ...materializeQcFindings(evidence),
328
+ ...materializeInterventions(evidence),
329
+ ];
330
+ return {
331
+ run_id: evidence.run_id,
332
+ normalized_at: new Date().toISOString(),
333
+ events,
334
+ source_refs,
335
+ evidence_confidence: computeConfidence(source_refs),
336
+ };
337
+ }
@@ -19,6 +19,10 @@ exports.generateRecommendations = generateRecommendations;
19
19
  exports.recommendationToProposal = recommendationToProposal;
20
20
  exports.recommendationsToProposals = recommendationsToProposals;
21
21
  exports.formatRecommendationsCli = formatRecommendationsCli;
22
+ exports.generateQcRecommendations = generateQcRecommendations;
23
+ exports.formatQcRecommendations = formatQcRecommendations;
24
+ exports.scorecardToRecommendationSummary = scorecardToRecommendationSummary;
25
+ exports.scorecardsToRecommendationSummaries = scorecardsToRecommendationSummaries;
22
26
  const sol_report_js_1 = require("./sol-report.js");
23
27
  // ──────────────────────────────────────────────
24
28
  // Constants and helpers
@@ -298,3 +302,211 @@ function formatRecommendationsCli(report) {
298
302
  }
299
303
  return lines.join("\n");
300
304
  }
305
+ function makeQcRecommendation(id, category, actionType, affected, proposedAction, rationale, confidence, evidenceRunId) {
306
+ return {
307
+ id,
308
+ category,
309
+ action_type: actionType,
310
+ affected,
311
+ proposed_action: proposedAction,
312
+ confidence: clamp01(confidence),
313
+ evidence: {
314
+ group_key: `run_id=${evidenceRunId}`,
315
+ grouped_by: ["run_id"],
316
+ count: 1,
317
+ mean_composite: null,
318
+ min_composite: null,
319
+ max_composite: null,
320
+ mean_foreman_composite: null,
321
+ mean_worker_composite: null,
322
+ run_ids: [evidenceRunId],
323
+ },
324
+ rationale,
325
+ };
326
+ }
327
+ /**
328
+ * Generate QC repair-loop follow-up recommendations from a single run's
329
+ * SOL evidence. These are advisory signals for noisy providers, repeated
330
+ * repair failures, unresolved high-severity findings, and max-round exhaustion.
331
+ */
332
+ function generateQcRecommendations(evidence) {
333
+ const recommendations = [];
334
+ const qc = evidence.qc;
335
+ const runId = evidence.run_id;
336
+ if (qc.availability !== "available") {
337
+ return {
338
+ generated_at: new Date().toISOString(),
339
+ evidence_run_id: runId,
340
+ recommendations,
341
+ };
342
+ }
343
+ for (const provider of qc.noisy_providers) {
344
+ const counts = qc.provider_breakdown[provider] ?? { total: 0, blocking: 0, unvalidated: 0 };
345
+ const confidence = counts.total > 0 ? counts.unvalidated / counts.total : 0;
346
+ recommendations.push(makeQcRecommendation(`qc-noisy-provider:${provider}`, "provider_policy", "analyze", { provider }, `Review QC provider '${provider}' for noisy/unvalidated findings; consider raising attribution confidence thresholds or disabling the provider for high-risk routes.`, `${counts.unvalidated}/${counts.total} findings from '${provider}' are unvalidated noise.`, confidence, runId));
347
+ }
348
+ if (qc.has_repair_failures) {
349
+ const failed = qc.repair_loop?.packets_failed ?? 0;
350
+ recommendations.push(makeQcRecommendation(`qc-repair-failure:${runId}`, "qc_follow_up", "implement", {}, "Repeated repair worker failures detected — inspect repair packet scope, validation commands, and Medic referral path.", `${failed} repair packet(s) failed; consider tightening acceptance criteria or escalating to Medic.`, 0.9, runId));
351
+ }
352
+ if (qc.unresolved_high_severity > 0) {
353
+ recommendations.push(makeQcRecommendation(`qc-unresolved-high-severity:${runId}`, "qc_follow_up", "analyze", {}, `${qc.unresolved_high_severity} unresolved critical/high QC findings remain — triage before delivery.`, `${qc.unresolved_high_severity} critical/high findings are still open after repair loop.`, Math.min(1, qc.unresolved_high_severity / 5), runId));
354
+ }
355
+ if (qc.max_round_exhausted) {
356
+ recommendations.push(makeQcRecommendation(`qc-max-rounds:${runId}`, "qc_follow_up", "analyze", {}, "QC repair loop exhausted max rounds — review provider noise, repair packet scope, and escalation policy.", `Repair loop reached round ${qc.repair_loop?.rounds_completed ?? 0}/${qc.repair_loop?.max_rounds ?? 0} without passing.`, 0.85, runId));
357
+ }
358
+ return {
359
+ generated_at: new Date().toISOString(),
360
+ evidence_run_id: runId,
361
+ recommendations,
362
+ };
363
+ }
364
+ function formatQcRecommendations(report) {
365
+ const lines = [];
366
+ lines.push("SOL QC Follow-Up Recommendations");
367
+ lines.push(`Generated: ${report.generated_at}`);
368
+ lines.push(`Run: ${report.evidence_run_id}`);
369
+ lines.push("");
370
+ if (report.recommendations.length === 0) {
371
+ lines.push("No QC follow-up signals detected.");
372
+ return lines.join("\n") + "\n";
373
+ }
374
+ for (const r of report.recommendations) {
375
+ lines.push(`[${r.action_type}] ${r.id}`);
376
+ lines.push(` Category: ${r.category}`);
377
+ lines.push(` Affected: ${fmtAffected(r.affected)}`);
378
+ lines.push(` Confidence: ${(r.confidence * 100).toFixed(1)}%`);
379
+ lines.push(` Action: ${r.proposed_action}`);
380
+ lines.push(` Rationale: ${r.rationale}`);
381
+ lines.push("");
382
+ }
383
+ return lines.join("\n");
384
+ }
385
+ // ── Internal helpers ──
386
+ function extractQptEvidence(subscores) {
387
+ const s = subscores.find((ss) => ss.dimension === "quality_per_token");
388
+ return { score: s?.score ?? null, detail: s?.detail ?? null };
389
+ }
390
+ function extractQcEvidence(subscores, rawMetrics) {
391
+ const qcSub = subscores.find((ss) => ss.dimension === "qc" || ss.dimension === "qc_repair_loop" || ss.dimension === "qc_result");
392
+ return {
393
+ qc_findings: rawMetrics.qc_total_findings,
394
+ blocking_findings: rawMetrics.qc_blocking_findings,
395
+ qc_repair_status: rawMetrics.qc_repair_loop_status,
396
+ qc_score: qcSub?.score ?? null,
397
+ };
398
+ }
399
+ function extractValidationEvidence(subscores, rawMetrics) {
400
+ const valSub = subscores.find((ss) => ss.dimension === "validation" ||
401
+ ss.dimension === "evidence_validation" ||
402
+ ss.dimension === "validation_result");
403
+ return {
404
+ validation_outcome: rawMetrics.validation_outcome,
405
+ passed_commands: [...(rawMetrics.passed_commands ?? [])],
406
+ validation_score: valSub?.score ?? null,
407
+ };
408
+ }
409
+ function deriveVerdict(scorecard, qcEvidence) {
410
+ const { subscores, aggregate_score, recommendation_inputs } = scorecard;
411
+ // Blocking QC findings always contradict.
412
+ if ((qcEvidence.blocking_findings ?? 0) > 0)
413
+ return "contradicted";
414
+ // confirmed_signal subscore is the strongest direct verdict signal.
415
+ const confirmed = subscores.find((s) => s.dimension === "confirmed_signal");
416
+ if (confirmed !== undefined && confirmed.score !== null) {
417
+ if (confirmed.score >= 0.9)
418
+ return "supported";
419
+ if (confirmed.score <= 0.1)
420
+ return "contradicted";
421
+ }
422
+ // Aggregate score determines verdict; intervention/router issues limit to inconclusive.
423
+ if (aggregate_score !== null) {
424
+ if (aggregate_score < 0.5)
425
+ return "contradicted";
426
+ if (aggregate_score >= 0.75 &&
427
+ !recommendation_inputs.intervention_detected &&
428
+ !recommendation_inputs.router_issue_detected) {
429
+ return "supported";
430
+ }
431
+ }
432
+ if (recommendation_inputs.intervention_detected || recommendation_inputs.router_issue_detected) {
433
+ return "inconclusive";
434
+ }
435
+ if (aggregate_score === null)
436
+ return "inconclusive";
437
+ if (aggregate_score >= 0.75)
438
+ return "supported";
439
+ if (aggregate_score < 0.6)
440
+ return "contradicted";
441
+ return "inconclusive";
442
+ }
443
+ function confidenceFromScorecard(scorecard) {
444
+ const { subscores, aggregate_score, recommendation_inputs } = scorecard;
445
+ const scoredCount = subscores.filter((s) => s.score !== null).length;
446
+ if (scoredCount === 0)
447
+ return 0.1; // minimal confidence without evidence
448
+ // Base: proportion of subscores with data.
449
+ const coverage = scoredCount / Math.max(subscores.length, 1);
450
+ // Small penalty when below threshold (more uncertainty in the recommendation).
451
+ const scorePenalty = aggregate_score !== null && recommendation_inputs.below_threshold ? 0.1 : 0;
452
+ return clamp01(coverage - scorePenalty);
453
+ }
454
+ function affectedFromScorecard(scorecard) {
455
+ const keys = scorecard.grouping_keys ?? {};
456
+ const affected = {};
457
+ if (keys.route)
458
+ affected.route = keys.route;
459
+ if (keys.task_type)
460
+ affected.task_type = keys.task_type;
461
+ if (keys.role)
462
+ affected.role = keys.role;
463
+ if (keys.provider)
464
+ affected.provider = keys.provider;
465
+ if (keys.model)
466
+ affected.model = keys.model;
467
+ // Subject-level key as fallback for provider/model scorecards.
468
+ if (!affected.provider && scorecard.subject === "provider")
469
+ affected.provider = scorecard.subject_key;
470
+ if (!affected.model && scorecard.subject === "model")
471
+ affected.model = scorecard.subject_key;
472
+ return affected;
473
+ }
474
+ /**
475
+ * Derive a `ScorecardRecommendationSummary` from a `SolScorecard`.
476
+ *
477
+ * This is a pure function: it does not read files, call APIs, or mutate
478
+ * the scorecard or any runtime state. Its output is advisory by default.
479
+ */
480
+ function scorecardToRecommendationSummary(scorecard) {
481
+ const { subscores, raw_metrics, source_refs, recommendation_inputs } = scorecard;
482
+ const qc = extractQcEvidence(subscores, raw_metrics);
483
+ const validation = extractValidationEvidence(subscores, raw_metrics);
484
+ const quality_per_token = extractQptEvidence(subscores);
485
+ const verdict = deriveVerdict(scorecard, qc);
486
+ const confidence = confidenceFromScorecard(scorecard);
487
+ const affected = affectedFromScorecard(scorecard);
488
+ const clonedSourceRefs = source_refs.map((ref) => ({ ...ref }));
489
+ return {
490
+ scorecard_id: scorecard.scorecard_id,
491
+ subject: scorecard.subject,
492
+ subject_key: scorecard.subject_key,
493
+ affected,
494
+ verdict,
495
+ aggregate_score: scorecard.aggregate_score,
496
+ confidence,
497
+ low_scoring_dimensions: [...recommendation_inputs.low_scoring_dimensions],
498
+ quality_per_token,
499
+ qc,
500
+ validation,
501
+ source_refs: clonedSourceRefs,
502
+ intervention_detected: recommendation_inputs.intervention_detected,
503
+ router_issue_detected: recommendation_inputs.router_issue_detected,
504
+ };
505
+ }
506
+ /**
507
+ * Derive recommendation summaries from a set of scorecards.
508
+ * Returns all summaries; callers filter by verdict as needed.
509
+ */
510
+ function scorecardsToRecommendationSummaries(scorecards) {
511
+ return scorecards.map(scorecardToRecommendationSummary);
512
+ }