Skip to content

Commit dfced6b

Browse files
fix(miner): resolve metrics-cli correct/incorrect via the calibration outcome-join
1 parent f1b5cc1 commit dfced6b

3 files changed

Lines changed: 206 additions & 31 deletions

File tree

packages/loopover-miner/lib/calibration.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export { isCalibrationReport, isCalibrationRow, isObservedOutcomeRecord, isPredi
2727
* unrecognized. `value` is always the already-validated non-empty string field of a record (the type guards run
2828
* first), so no non-string handling is needed here. Accepts both the predicted (`merge`/`close`/`hold`) and the
2929
* realized (`merged`/`closed`) forms. */
30-
function normalizeDecision(value: string): "merge" | "close" | "hold" | "" {
30+
export function normalizeDecision(value: string): "merge" | "close" | "hold" | "" {
3131
const decision = value.trim().toLowerCase();
3232
if (decision === "merge" || decision === "merged") return "merge";
3333
if (decision === "close" || decision === "closed") return "close";

packages/loopover-miner/lib/metrics-cli.ts

Lines changed: 88 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,125 @@
11
import { renderMinerPredictionMetrics } from "@loopover/engine";
22
import type { MinerPredictionMetricRow } from "@loopover/engine";
3-
import { initPredictionLedger } from "./prediction-ledger.js";
3+
import { toOutcomeRecords, toPredictionRecords } from "./calibration-cli.js";
4+
import { normalizeDecision } from "./calibration.js";
5+
import { initEventLedger, resolveEventLedgerDbPath } from "./event-ledger.js";
6+
import type { EventLedger, LedgerEntry } from "./event-ledger.js";
7+
import { initPredictionLedger, resolvePredictionLedgerDbPath } from "./prediction-ledger.js";
48
import type { PredictionLedger } from "./prediction-ledger.js";
59
import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js";
610

711
// `metrics` (#4838): render the miner's prediction-calibration counters as Prometheus text-exposition to stdout,
812
// for a scrape wrapper or cron redirect. The counters are produced by the engine's already-built
913
// renderMinerPredictionMetrics (packages/loopover-engine/src/miner-prediction-metrics.ts) -- this command only
10-
// reads the local prediction ledger and feeds it in, never touching the renderer itself. Strictly local + offline:
11-
// no network, no writes.
14+
// reads the local prediction ledger + realized `pr_outcome` event stream and feeds them in, never touching the
15+
// renderer itself. Strictly local + offline: no network, no writes.
1216

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

19+
// Key an outcome/prediction by its (project, targetId) exactly as calibration.ts's own join does, so metrics
20+
// resolves each prediction against the identical (project, targetId) pairing buildCalibrationReport uses.
21+
function recordKey(project: string, targetId: string): string {
22+
return `${project} ${targetId}`;
23+
}
24+
25+
// Resolve one prediction's realized-outcome pairing against its observed decision (`undefined` when none was
26+
// observed for its (project, targetId)), mirroring buildCalibrationReport's own classification: a prediction is
27+
// scored only when it itself normalizes to `merge`/`close` AND its observed outcome also normalizes to
28+
// `merge`/`close` -- `true` when they match, `false` when they differ. A `hold`/unrecognized prediction, a
29+
// still-pending row (no outcome), or an unclassifiable outcome all stay unresolved (`correct` left unset); an
30+
// undecided row is never fabricated as `false`.
31+
function resolveCorrect(predictedDecision: string, observedDecision: string | undefined): boolean | undefined {
32+
const predicted = normalizeDecision(predictedDecision);
33+
if (predicted !== "merge" && predicted !== "close") return undefined;
34+
if (observedDecision === undefined) return undefined;
35+
const observed = normalizeDecision(observedDecision);
36+
if (observed !== "merge" && observed !== "close") return undefined;
37+
return predicted === observed;
38+
}
39+
1540
/**
16-
* Project prediction-ledger rows onto the engine renderer's metric-row shape -- the predicted `conclusion` only.
17-
* The realized-outcome pairing (`correct`) is intentionally left unset: the miner has no outcome-join yet, so the
18-
* correct/incorrect counters stay zero and only `predictions_total{conclusion}` moves -- exactly how the renderer
19-
* is designed to degrade before outcome-pairing exists (see its header comment).
41+
* Project prediction-ledger rows onto the engine renderer's metric-row shape: the predicted `conclusion`, plus the
42+
* realized-outcome pairing (`correct`) resolved through the SAME join `calibration-cli.ts` already uses (#8315).
43+
* Each prediction's outcome is looked up by its `(project, targetId)` among the latest `pr_outcome` events
44+
* (`toOutcomeRecords`) and compared against the prediction (`toPredictionRecords`), both normalized through
45+
* calibration.ts's `normalizeDecision` exactly as `buildCalibrationReport` does. A row is left unresolved
46+
* (`correct` omitted) when it is still pending (no realized outcome), when its own conclusion is `hold` (which has
47+
* no `merged`/`closed` counterpart), or when the realized outcome is unclassifiable -- never fabricating `false`
48+
* for an undecided row. Resolved rows carry `correct: true` when the normalized prediction matches the realized
49+
* decision, `false` when they differ.
2050
*/
21-
export function collectPredictionMetricRows(ledger: PredictionLedger): MinerPredictionMetricRow[] {
22-
return ledger.readPredictions().map((entry) => ({ conclusion: entry.conclusion }));
51+
export function collectPredictionMetricRows(
52+
ledger: PredictionLedger,
53+
events: LedgerEntry[] = [],
54+
): MinerPredictionMetricRow[] {
55+
const outcomeByKey = new Map<string, string>();
56+
for (const outcome of toOutcomeRecords(events)) {
57+
outcomeByKey.set(recordKey(outcome.project, outcome.targetId), outcome.outcomeDecision);
58+
}
59+
return toPredictionRecords(ledger.readPredictions()).map((prediction) => {
60+
const correct = resolveCorrect(prediction.predictedDecision, outcomeByKey.get(recordKey(prediction.project, prediction.targetId)));
61+
return correct === undefined
62+
? { conclusion: prediction.predictedDecision }
63+
: { conclusion: prediction.predictedDecision, correct };
64+
});
2365
}
2466

2567
// Open the local prediction ledger (or a test-injected one) for the duration of `run`, closing it only when we
2668
// opened it -- an injected ledger is owned by the caller. Mirrors event-ledger-cli.js's withEventLedger.
2769
function withPredictionLedger<T>(
2870
options: { initPredictionLedger?: () => PredictionLedger },
71+
env: Record<string, string | undefined>,
2972
run: (ledger: PredictionLedger) => T,
3073
): T {
3174
const ownsLedger = options.initPredictionLedger === undefined;
32-
const ledger = (options.initPredictionLedger ?? initPredictionLedger)();
75+
const ledger = (options.initPredictionLedger ?? (() => initPredictionLedger(resolvePredictionLedgerDbPath(env))))();
76+
try {
77+
return run(ledger);
78+
} finally {
79+
if (ownsLedger) ledger.close();
80+
}
81+
}
82+
83+
// Open the local event ledger (or a test-injected one) for the duration of `run`, closing it only when we opened
84+
// it. Mirrors withPredictionLedger above and calibration-cli.ts's bare `calibration` command: open via
85+
// initEventLedger(resolveEventLedgerDbPath(env)), read once, close in a finally -- keeping `metrics` strictly
86+
// read-only and offline with no behavior change to its zero-argument usage contract.
87+
function withEventLedger<T>(
88+
options: { initEventLedger?: () => EventLedger },
89+
env: Record<string, string | undefined>,
90+
run: (ledger: EventLedger) => T,
91+
): T {
92+
const ownsLedger = options.initEventLedger === undefined;
93+
const ledger = (options.initEventLedger ?? (() => initEventLedger(resolveEventLedgerDbPath(env))))();
3394
try {
3495
return run(ledger);
3596
} finally {
3697
if (ownsLedger) ledger.close();
3798
}
3899
}
39100

40-
export function runMetrics(args: string[], options: { initPredictionLedger?: () => PredictionLedger } = {}): number {
101+
export function runMetrics(
102+
args: string[],
103+
options: {
104+
initPredictionLedger?: () => PredictionLedger;
105+
initEventLedger?: () => EventLedger;
106+
env?: Record<string, string | undefined>;
107+
} = {},
108+
): number {
41109
if (args.length > 0) {
42110
return reportCliFailure(argsWantJson(args), METRICS_USAGE);
43111
}
44112

113+
const env = options.env ?? process.env;
45114
try {
46-
return withPredictionLedger(options, (ledger) => {
47-
// renderMinerPredictionMetrics returns a newline-terminated document; console.log re-adds the terminator, so
48-
// trim it to emit exactly one trailing newline.
49-
console.log(renderMinerPredictionMetrics(collectPredictionMetricRows(ledger)).trimEnd());
50-
return 0;
51-
});
115+
return withPredictionLedger(options, env, (ledger) =>
116+
withEventLedger(options, env, (eventLedger) => {
117+
// renderMinerPredictionMetrics returns a newline-terminated document; console.log re-adds the terminator,
118+
// so trim it to emit exactly one trailing newline.
119+
console.log(renderMinerPredictionMetrics(collectPredictionMetricRows(ledger, eventLedger.readEvents())).trimEnd());
120+
return 0;
121+
}),
122+
);
52123
} catch (error) {
53124
return reportCliFailure(argsWantJson(args), describeCliError(error));
54125
}

test/unit/miner-metrics-cli.test.ts

Lines changed: 117 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@ import { mkdtempSync, rmSync } from "node:fs";
22
import { tmpdir } from "node:os";
33
import { join } from "node:path";
44
import { afterEach, describe, expect, it, vi } from "vitest";
5-
import { initPredictionLedger } from "../../packages/loopover-miner/lib/prediction-ledger.js";
5+
import {
6+
initEventLedger,
7+
resolveEventLedgerDbPath,
8+
} from "../../packages/loopover-miner/lib/event-ledger.js";
9+
import type { EventLedger, LedgerEntry } from "../../packages/loopover-miner/lib/event-ledger.js";
10+
import {
11+
initPredictionLedger,
12+
resolvePredictionLedgerDbPath,
13+
} from "../../packages/loopover-miner/lib/prediction-ledger.js";
614
import {
715
collectPredictionMetricRows,
816
runMetrics,
@@ -20,24 +28,64 @@ function tempLedger(): PredictionLedger {
2028
return ledger;
2129
}
2230

23-
function tempDbPath() {
31+
function tempEventLedger(): EventLedger {
32+
const root = mkdtempSync(join(tmpdir(), "loopover-miner-metrics-cli-evt-"));
33+
roots.push(root);
34+
const ledger = initEventLedger(join(root, "event-ledger.sqlite3"));
35+
ledgers.push(ledger);
36+
return ledger;
37+
}
38+
39+
function tempDir() {
2440
const root = mkdtempSync(join(tmpdir(), "loopover-miner-metrics-cli-"));
2541
roots.push(root);
26-
return join(root, "prediction-ledger.sqlite3");
42+
return root;
43+
}
44+
45+
/** Env pointing both local stores at a fresh temp config dir (mirrors miner-calibration-cli.test.ts). */
46+
function tempConfigEnv(): Record<string, string | undefined> {
47+
return { LOOPOVER_MINER_CONFIG_DIR: tempDir() };
2748
}
2849

2950
function appendPrediction(ledger: PredictionLedger, targetId: number, conclusion: string) {
3051
ledger.appendPrediction({ repoFullName: "acme/widgets", targetId, conclusion, pack: "gittensor", engineVersion: "0.2.0" });
3152
}
3253

54+
function seedPredictionEnv(env: Record<string, string | undefined>, targetId: number, conclusion: string) {
55+
const store = initPredictionLedger(resolvePredictionLedgerDbPath(env));
56+
store.appendPrediction({ repoFullName: "acme/widgets", targetId, conclusion, pack: "gittensor", engineVersion: "0.2.0" });
57+
store.close();
58+
}
59+
60+
function seedOutcomeEnv(env: Record<string, string | undefined>, prNumber: number, decision: string) {
61+
const ledger = initEventLedger(resolveEventLedgerDbPath(env));
62+
ledger.appendEvent({ type: "pr_outcome", repoFullName: "acme/widgets", payload: { prNumber, decision } });
63+
ledger.close();
64+
}
65+
66+
// Build a raw `pr_outcome` ledger row (or a deliberately malformed one) for the in-process join, exactly as
67+
// toOutcomeRecords would read it off the event ledger.
68+
let seq = 0;
69+
function outcomeEvent(prNumber: unknown, decision: unknown, repoFullName: string | null = "acme/widgets"): LedgerEntry {
70+
seq += 1;
71+
return {
72+
id: seq,
73+
seq,
74+
type: "pr_outcome",
75+
repoFullName,
76+
payload: { prNumber, decision } as Record<string, unknown>,
77+
createdAt: new Date(seq * 1000).toISOString(),
78+
};
79+
}
80+
3381
afterEach(() => {
3482
for (const ledger of ledgers.splice(0)) ledger.close();
3583
vi.restoreAllMocks();
3684
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
3785
});
3886

3987
describe("loopover-miner metrics CLI (#4838)", () => {
40-
it("collectPredictionMetricRows projects ledger rows onto the renderer's conclusion-only shape", () => {
88+
it("collectPredictionMetricRows leaves `correct` unset when no outcome events are supplied", () => {
4189
const ledger = tempLedger();
4290
appendPrediction(ledger, 1, "merge");
4391
appendPrediction(ledger, 2, "close");
@@ -51,34 +99,41 @@ describe("loopover-miner metrics CLI (#4838)", () => {
5199
appendPrediction(ledger, 3, "merge");
52100

53101
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
54-
expect(runMetrics([], { initPredictionLedger: () => ledger })).toBe(0);
102+
// Inject an empty event ledger: no realized outcomes, so the correct/incorrect counters stay zero.
103+
expect(runMetrics([], { initPredictionLedger: () => ledger, initEventLedger: () => tempEventLedger() })).toBe(0);
55104

56105
const text = String(log.mock.calls[0]?.[0]);
57106
expect(text).toContain("# TYPE loopover_miner_predictions_total counter");
58107
// Series are emitted in sorted conclusion order, so "close" precedes "merge".
59108
expect(text).toContain('loopover_miner_predictions_total{conclusion="close"} 1');
60109
expect(text).toContain('loopover_miner_predictions_total{conclusion="merge"} 2');
61-
// No outcome-join exists yet, so both the correct and incorrect counters stay zero.
110+
// No realized outcomes joined, so both the correct and incorrect counters stay zero.
62111
expect(text).toContain("loopover_miner_prediction_correct_total 0");
63112
expect(text).toContain("loopover_miner_prediction_incorrect_total 0");
64113
// The output is a single, once-terminated document (no doubled trailing blank line).
65114
expect(text.endsWith("\n")).toBe(false);
66115
});
67116

68-
it("runMetrics opens and closes its own default ledger when none is injected", () => {
69-
const dbPath = tempDbPath();
70-
const seed = initPredictionLedger(dbPath);
117+
it("runMetrics opens and closes its own default ledgers when none are injected", () => {
118+
const dir = tempDir();
119+
const predictionDbPath = join(dir, "prediction-ledger.sqlite3");
120+
const eventDbPath = join(dir, "event-ledger.sqlite3");
121+
const seed = initPredictionLedger(predictionDbPath);
71122
appendPrediction(seed, 1, "hold");
72123
seed.close();
73124

74-
const prev = process.env.LOOPOVER_MINER_PREDICTION_LEDGER_DB;
75-
process.env.LOOPOVER_MINER_PREDICTION_LEDGER_DB = dbPath;
125+
const prevPrediction = process.env.LOOPOVER_MINER_PREDICTION_LEDGER_DB;
126+
const prevEvent = process.env.LOOPOVER_MINER_EVENT_LEDGER_DB;
127+
process.env.LOOPOVER_MINER_PREDICTION_LEDGER_DB = predictionDbPath;
128+
process.env.LOOPOVER_MINER_EVENT_LEDGER_DB = eventDbPath;
76129
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
77130
try {
78131
expect(runMetrics([])).toBe(0);
79132
} finally {
80-
if (prev === undefined) delete process.env.LOOPOVER_MINER_PREDICTION_LEDGER_DB;
81-
else process.env.LOOPOVER_MINER_PREDICTION_LEDGER_DB = prev;
133+
if (prevPrediction === undefined) delete process.env.LOOPOVER_MINER_PREDICTION_LEDGER_DB;
134+
else process.env.LOOPOVER_MINER_PREDICTION_LEDGER_DB = prevPrediction;
135+
if (prevEvent === undefined) delete process.env.LOOPOVER_MINER_EVENT_LEDGER_DB;
136+
else process.env.LOOPOVER_MINER_EVENT_LEDGER_DB = prevEvent;
82137
}
83138
expect(String(log.mock.calls[0]?.[0])).toContain('loopover_miner_predictions_total{conclusion="hold"} 1');
84139
});
@@ -122,4 +177,53 @@ describe("loopover-miner metrics CLI (#4838)", () => {
122177
).toBe(2);
123178
expect(error).toHaveBeenCalledWith("prediction-ledger-unavailable");
124179
});
180+
181+
describe("outcome-join (#8315)", () => {
182+
it("resolves each prediction's `correct` against the realized pr_outcome via the calibration join", () => {
183+
const ledger = tempLedger();
184+
appendPrediction(ledger, 1, "merge"); // realized merged -> correct
185+
appendPrediction(ledger, 2, "merge"); // realized closed -> incorrect
186+
appendPrediction(ledger, 3, "close"); // realized closed -> correct
187+
appendPrediction(ledger, 4, "close"); // realized merged -> incorrect
188+
appendPrediction(ledger, 5, "hold"); // hold has no realized counterpart -> unresolved
189+
appendPrediction(ledger, 6, "merge"); // no outcome yet (pending) -> unresolved
190+
appendPrediction(ledger, 7, "merge"); // outcome present but unclassifiable -> unresolved
191+
192+
const events: LedgerEntry[] = [
193+
outcomeEvent(1, "merged"),
194+
outcomeEvent(2, "closed"),
195+
outcomeEvent(3, "closed"),
196+
outcomeEvent(4, "merged"),
197+
outcomeEvent(5, "merged"),
198+
// no event for prediction 6 (still pending)
199+
outcomeEvent(7, "reopened"), // a well-formed pr_outcome whose decision is neither merged nor closed
200+
outcomeEvent("not-a-number", "merged"), // malformed: non-integer prNumber -> skipped by toOutcomeRecords
201+
];
202+
203+
expect(collectPredictionMetricRows(ledger, events)).toEqual([
204+
{ conclusion: "merge", correct: true },
205+
{ conclusion: "merge", correct: false },
206+
{ conclusion: "close", correct: true },
207+
{ conclusion: "close", correct: false },
208+
{ conclusion: "hold" },
209+
{ conclusion: "merge" },
210+
{ conclusion: "merge" },
211+
]);
212+
});
213+
214+
it("runMetrics opens the event ledger by env path and moves the correct/incorrect counters", () => {
215+
const env = tempConfigEnv();
216+
seedPredictionEnv(env, 1, "merge");
217+
seedPredictionEnv(env, 2, "close");
218+
seedOutcomeEnv(env, 1, "merged"); // merge predicted, merged realized -> correct
219+
seedOutcomeEnv(env, 2, "merged"); // close predicted, merged realized -> incorrect
220+
221+
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
222+
expect(runMetrics([], { env })).toBe(0);
223+
224+
const text = String(log.mock.calls[0]?.[0]);
225+
expect(text).toContain("loopover_miner_prediction_correct_total 1");
226+
expect(text).toContain("loopover_miner_prediction_incorrect_total 1");
227+
});
228+
});
125229
});

0 commit comments

Comments
 (0)