Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions packages/loopover-miner/lib/calibration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,33 @@ function recordKey(project: string, targetId: string): string {
return `${project} ${targetId}`;
}

/** Build the same `(project, targetId)` → normalized-outcome map {@link buildCalibrationReport} uses internally.
* Malformed outcome records are skipped. Exported so metrics-cli (#8315) can reuse the join without a second
* implementation. */
export function buildOutcomeDecisionMap(outcomes: ObservedOutcomeRecord[]): Map<string, "merge" | "close" | "hold" | ""> {
const outcomeByKey = new Map<string, "merge" | "close" | "hold" | "">();
for (const outcome of Array.isArray(outcomes) ? outcomes : []) {
if (!isObservedOutcomeRecord(outcome)) continue;
outcomeByKey.set(recordKey(outcome.project, outcome.targetId), normalizeDecision(outcome.outcomeDecision));
}
return outcomeByKey;
}

/** Resolve one prediction's `correct` flag using the same join rules as {@link buildCalibrationReport}: only
* directional merge/close predictions with a realized merge/close outcome are scored; hold, pending, and
* unclassifiable rows return `undefined` (unset). Malformed predictions return `undefined`. */
export function resolvePredictionCorrectness(
prediction: PredictedVerdictRecord,
outcomeByKey: Map<string, "merge" | "close" | "hold" | "">,
): boolean | undefined {
if (!isPredictedVerdictRecord(prediction)) return undefined;
const observed = outcomeByKey.get(recordKey(prediction.project, prediction.targetId));
if (observed !== "merge" && observed !== "close") return undefined;
const predicted = normalizeDecision(prediction.predictedDecision);
if (predicted !== "merge" && predicted !== "close") return undefined;
return predicted === observed;
}

/**
* Join predicted-verdict records with realized-outcome records into a per-project calibration report. Pure and
* read-only. A prediction counts as "decided" only when a realized outcome for the SAME `(project, targetId)`
Expand All @@ -70,11 +97,7 @@ export function buildCalibrationReport(
predictions: PredictedVerdictRecord[],
outcomes: ObservedOutcomeRecord[],
): CalibrationReport {
const outcomeByKey = new Map<string, "merge" | "close" | "hold" | "">();
for (const outcome of Array.isArray(outcomes) ? outcomes : []) {
if (!isObservedOutcomeRecord(outcome)) continue;
outcomeByKey.set(recordKey(outcome.project, outcome.targetId), normalizeDecision(outcome.outcomeDecision));
}
const outcomeByKey = buildOutcomeDecisionMap(outcomes);

const byProject = new Map<string, CalibrationRow>();
for (const prediction of Array.isArray(predictions) ? predictions : []) {
Expand Down
52 changes: 41 additions & 11 deletions packages/loopover-miner/lib/metrics-cli.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,40 @@
import { renderMinerPredictionMetrics } from "@loopover/engine";
import type { MinerPredictionMetricRow } from "@loopover/engine";
import { buildOutcomeDecisionMap, resolvePredictionCorrectness } from "./calibration.js";
import type { ObservedOutcomeRecord } from "./calibration-types.js";
import { toOutcomeRecords, toPredictionRecords } from "./calibration-cli.js";
import { initEventLedger, resolveEventLedgerDbPath } from "./event-ledger.js";
import type { EventLedger } from "./event-ledger.js";
import { initPredictionLedger } from "./prediction-ledger.js";
import type { PredictionLedger } from "./prediction-ledger.js";
import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js";

// `metrics` (#4838): render the miner's prediction-calibration counters as Prometheus text-exposition to stdout,
// for a scrape wrapper or cron redirect. The counters are produced by the engine's already-built
// renderMinerPredictionMetrics (packages/loopover-engine/src/miner-prediction-metrics.ts) -- this command only
// reads the local prediction ledger and feeds it in, never touching the renderer itself. Strictly local + offline:
// no network, no writes.
// renderMinerPredictionMetrics (packages/loopover-engine/src/miner-prediction-metrics.ts) -- this command reads
// the local prediction ledger, joins each row with realized `pr_outcome` events from the event ledger
// (calibration-cli.js's toPredictionRecords/toOutcomeRecords + calibration.js's join), and feeds the resolved
// rows to the renderer. Strictly local + offline: no network, no writes.

const METRICS_USAGE = "Usage: loopover-miner metrics";

/**
* Project prediction-ledger rows onto the engine renderer's metric-row shape -- the predicted `conclusion` only.
* The realized-outcome pairing (`correct`) is intentionally left unset: the miner has no outcome-join yet, so the
* correct/incorrect counters stay zero and only `predictions_total{conclusion}` moves -- exactly how the renderer
* is designed to degrade before outcome-pairing exists (see its header comment).
* Project prediction-ledger rows onto the engine renderer's metric-row shape, pairing each predicted `conclusion`
* with a realized outcome when one exists for the same `(repoFullName, targetId)`. Reuses calibration-cli.js's
* record mappers and calibration.js's {@link resolvePredictionCorrectness} so the join matches
* `buildCalibrationReport` exactly.
*/
export function collectPredictionMetricRows(ledger: PredictionLedger): MinerPredictionMetricRow[] {
return ledger.readPredictions().map((entry) => ({ conclusion: entry.conclusion }));
export function collectPredictionMetricRows(
ledger: PredictionLedger,
outcomes: ObservedOutcomeRecord[] = [],
): MinerPredictionMetricRow[] {
const outcomeByKey = buildOutcomeDecisionMap(outcomes);
return toPredictionRecords(ledger.readPredictions()).map((prediction) => {
const correct = resolvePredictionCorrectness(prediction, outcomeByKey);
const row: MinerPredictionMetricRow = { conclusion: prediction.predictedDecision };
if (correct !== undefined) row.correct = correct;
return row;
});
}

// Open the local prediction ledger (or a test-injected one) for the duration of `run`, closing it only when we
Expand All @@ -37,19 +52,34 @@ function withPredictionLedger<T>(
}
}

export function runMetrics(args: string[], options: { initPredictionLedger?: () => PredictionLedger } = {}): number {
export function runMetrics(
args: string[],
options: {
initPredictionLedger?: () => PredictionLedger;
initEventLedger?: () => EventLedger;
env?: Record<string, string | undefined>;
} = {},
): number {
if (args.length > 0) {
return reportCliFailure(argsWantJson(args), METRICS_USAGE);
}

const env = options.env ?? process.env;
let eventLedger: EventLedger | undefined;
const ownsEventLedger = options.initEventLedger === undefined;

try {
return withPredictionLedger(options, (ledger) => {
eventLedger = (options.initEventLedger ?? (() => initEventLedger(resolveEventLedgerDbPath(env))))();
const outcomes = toOutcomeRecords(eventLedger.readEvents());
// renderMinerPredictionMetrics returns a newline-terminated document; console.log re-adds the terminator, so
// trim it to emit exactly one trailing newline.
console.log(renderMinerPredictionMetrics(collectPredictionMetricRows(ledger)).trimEnd());
console.log(renderMinerPredictionMetrics(collectPredictionMetricRows(ledger, outcomes)).trimEnd());
return 0;
});
} catch (error) {
return reportCliFailure(argsWantJson(args), describeCliError(error));
} finally {
if (ownsEventLedger) eventLedger?.close();
}
}
31 changes: 27 additions & 4 deletions test/unit/miner-calibration.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { describe, expect, it } from "vitest";
import {
buildCalibrationReport,
isCalibrationReport,
} from "../../packages/loopover-miner/lib/calibration.js";
import type {
ObservedOutcomeRecord,
PredictedVerdictRecord,
} from "../../packages/loopover-miner/lib/calibration.js";

// Same .ts-via-variable import as miner-metrics-cli.test.ts (#8315) — CI grades patch on the .ts paths.
const CALIBRATION_MODULE = "../../packages/loopover-miner/lib/calibration.ts";
const {
buildCalibrationReport,
buildOutcomeDecisionMap,
isCalibrationReport,
resolvePredictionCorrectness,
} = (await import(CALIBRATION_MODULE)) as typeof import("../../packages/loopover-miner/lib/calibration.js");

const TS = "2026-07-12T00:00:00.000Z";
const pred = (project: string, targetId: string, predictedDecision: string): PredictedVerdictRecord => ({
project,
Expand Down Expand Up @@ -113,3 +118,21 @@ describe("buildCalibrationReport (#4849)", () => {
expect(report.hasSignal).toBe(false); // outcome belongs to a different project
});
});

describe("resolvePredictionCorrectness (#8315)", () => {
it("scores directional predictions and leaves hold/pending/unrecognized unset", () => {
const outcomeByKey = buildOutcomeDecisionMap([
out("a/b", "1", "merged"),
out("a/b", "2", "closed"),
out("a/b", "3", "closed"),
out("a/b", "4", "unknown"),
]);
expect(resolvePredictionCorrectness(pred("a/b", "1", "merge"), outcomeByKey)).toBe(true);
expect(resolvePredictionCorrectness(pred("a/b", "2", "merge"), outcomeByKey)).toBe(false);
expect(resolvePredictionCorrectness(pred("a/b", "3", "close"), outcomeByKey)).toBe(true);
expect(resolvePredictionCorrectness(pred("a/b", "5", "merge"), outcomeByKey)).toBeUndefined();
expect(resolvePredictionCorrectness(pred("a/b", "3", "hold"), outcomeByKey)).toBeUndefined();
expect(resolvePredictionCorrectness(pred("a/b", "4", "merge"), outcomeByKey)).toBeUndefined();
expect(resolvePredictionCorrectness({ project: "a/b" } as never, outcomeByKey)).toBeUndefined();
});
});
Loading