forgedock 1.31.0 → 1.32.1
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.
- package/bin/engine/phases.mjs +28 -7
- package/bin/engine-cli.mjs +79 -22
- package/bin/hooks/reread-nudge.mjs +65 -0
- package/bin/lean/batch.mjs +9 -3
- package/bin/lean/batch.test.mjs +75 -2
- package/bin/lean/context.mjs +12 -1
- package/bin/lean/context.test.mjs +18 -1
- package/bin/lean/effects.mjs +41 -4
- package/bin/lean/execution.mjs +4 -2
- package/bin/lean/lean.mjs +83 -18
- package/bin/lean/lean.test.mjs +265 -4
- package/bin/lean/lightlane.mjs +219 -0
- package/bin/lean/lightlane.test.mjs +237 -0
- package/bin/lean/orchestrate.mjs +75 -15
- package/bin/lean/precheck.test.mjs +5 -0
- package/bin/lean/prefilter.mjs +69 -8
- package/bin/lean/prefilter.test.mjs +109 -2
- package/bin/lean/prompts.mjs +3 -0
- package/bin/lean/retry.mjs +15 -1
- package/bin/lean/sched.mjs +16 -1
- package/bin/lean/sched.test.mjs +48 -0
- package/bin/lean/schedd.mjs +50 -14
- package/bin/lean/schedd.test.mjs +153 -20
- package/bin/stats/efficiency.mjs +22 -6
- package/bin/stats/efficiency.test.mjs +35 -2
- package/bin/stats/index.mjs +25 -3
- package/bin/stats/local-source.mjs +59 -5
- package/bin/stats/query.mjs +133 -3
- package/bin/stats/stats.test.mjs +181 -5
- package/docs/CONFIG.md +3 -3
- package/docs/STATS.md +13 -0
- package/docs/eval/corpus/lightlane-v1.json +55 -0
- package/docs/eval/lightlane-bench.md +91 -0
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +286 -0
- package/scripts/refresh-bot-token.test.sh +56 -0
package/bin/stats/index.mjs
CHANGED
|
@@ -11,10 +11,10 @@ import { dirname, join } from "node:path";
|
|
|
11
11
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
12
|
import { aggregate, computeAlerts, DEFAULT_ALERTS, DEFAULT_MIN_N } from "./aggregate.mjs";
|
|
13
13
|
import { defaultGh, fetchGithubRecords, isValidRepo, toModel } from "./github-source.mjs";
|
|
14
|
-
import { readLanes, readLocalRuns, runsDir } from "./local-source.mjs";
|
|
14
|
+
import { readLanes, readLocalLaneRuns, readLocalRuns, runsDir } from "./local-source.mjs";
|
|
15
15
|
import { parseForgeYaml } from "../forge-utils.mjs";
|
|
16
16
|
import { renderCompact, renderJson, renderMarkdown } from "./render.mjs";
|
|
17
|
-
import { edgeQuery, parseEdge, verdictMix, contextPackAb, renderContextAb, builderTierReport, renderTiers } from "./query.mjs";
|
|
17
|
+
import { edgeQuery, parseEdge, verdictMix, contextPackAb, renderContextAb, builderTierReport, renderTiers, exploreAb, renderExploreAb, laneProfileReport, renderLaneProfiles } from "./query.mjs";
|
|
18
18
|
import { batchEfficiency, windowEfficiency, formatEfficiencyLines, parseWindows } from "./efficiency.mjs";
|
|
19
19
|
import { batchRoot } from "../lean/batch.mjs";
|
|
20
20
|
|
|
@@ -52,6 +52,10 @@ Context-pack A/B (forge#4275; local run logs only, no GitHub crawl):
|
|
|
52
52
|
--context-ab first-pass yield, builder turns, cache tokens and cost per lane type (rerun, child,
|
|
53
53
|
first) and context_pack mode (on, off, shadow); --json for JSON
|
|
54
54
|
|
|
55
|
+
Explorer A/B (forge#4418; local run logs only, every run of each log, no GitHub crawl):
|
|
56
|
+
--explore-ab builder turns, builder cache-read, merge and conformance rates per size (S, M, L) and
|
|
57
|
+
lean.explore mode (on, off), plus the same issues run in both modes; --json for JSON
|
|
58
|
+
|
|
55
59
|
Efficiency (forge#4402; local batch dirs and run logs, gh only for merged issues' bodies, labels and probes):
|
|
56
60
|
--efficiency $/merge, turns/merge, context/turn per size and model, retries/merge by cause, no-action
|
|
57
61
|
lanes, issue quality vs cost and the acceptance pass rate 7 days after promotion
|
|
@@ -61,10 +65,15 @@ Efficiency (forge#4402; local batch dirs and run logs, gh only for merged issues
|
|
|
61
65
|
Builder model tiers (forge#4394; local run logs only, no GitHub crawl):
|
|
62
66
|
--tiers merge, conformance and post-merge-rework rates, first-pass yield and cost per builder tier
|
|
63
67
|
(mid, mid->top escalated, top) and routing class; --json for JSON
|
|
68
|
+
|
|
69
|
+
Lane profile baseline (forge#4436; local run logs only, every run of each issue, no GitHub crawl):
|
|
70
|
+
--lane-profile first-turn baseline_tokens per lane profile (full, memory-off, lane) and phase, and the
|
|
71
|
+
conformance rate per profile; --json for JSON
|
|
72
|
+
--issues <list> pin the report to these issues (comma list), for a before/after on the same issue set
|
|
64
73
|
`;
|
|
65
74
|
|
|
66
75
|
export function parseArgs(argv) {
|
|
67
|
-
const o = { since: null, repo: null, edge: null, milestone: null, contextAb: false, tiers: false, efficiency: false, batch: null, windows: null, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
|
|
76
|
+
const o = { since: null, repo: null, edge: null, milestone: null, contextAb: false, exploreAb: false, tiers: false, laneProfile: false, issues: null, efficiency: false, batch: null, windows: null, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
|
|
68
77
|
const val = (i, name) => {
|
|
69
78
|
const v = argv[i + 1];
|
|
70
79
|
if (v == null || v.startsWith("--")) throw new Error(`${name} needs a value`);
|
|
@@ -81,7 +90,10 @@ export function parseArgs(argv) {
|
|
|
81
90
|
else if (name === "--edge") { const v = take(); if (!parseEdge(v)) throw new Error("--edge must be FROM:TO"); o.edge = v; }
|
|
82
91
|
else if (name === "--milestone") o.milestone = take();
|
|
83
92
|
else if (name === "--context-ab") o.contextAb = true;
|
|
93
|
+
else if (name === "--explore-ab") o.exploreAb = true;
|
|
84
94
|
else if (name === "--tiers") o.tiers = true;
|
|
95
|
+
else if (name === "--lane-profile") o.laneProfile = true;
|
|
96
|
+
else if (name === "--issues") { const v = take(); if (!/^[1-9]\d*(,[1-9]\d*)*$/.test(v)) throw new Error("--issues must be a comma list of issue numbers"); o.issues = v.split(",").map(Number); }
|
|
85
97
|
else if (name === "--efficiency") o.efficiency = true;
|
|
86
98
|
else if (name === "--batch") { const v = take(); if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v)) throw new Error(`invalid --batch id: ${v}`); o.batch = v; }
|
|
87
99
|
else if (name === "--window") o.windows = parseWindows(take());
|
|
@@ -214,10 +226,18 @@ export async function buildReport(opts, deps = {}) {
|
|
|
214
226
|
const runs = (deps.localRuns ?? readLocalRuns(runsDir(env))).filter((r) => r.repo == null || r.repo === repo);
|
|
215
227
|
return { repo, sources: { local: { runs: runs.length } }, query: { context_ab: contextPackAb(runs) } };
|
|
216
228
|
}
|
|
229
|
+
if (opts.exploreAb) { // forge#4418: run logs only, every RUN_START slice
|
|
230
|
+
const runs = (deps.localRuns ?? readLocalLaneRuns(runsDir(env))).filter((r) => r.repo == null || r.repo === repo);
|
|
231
|
+
return { repo, sources: { local: { runs: runs.length } }, query: { explore_ab: exploreAb(runs) } };
|
|
232
|
+
}
|
|
217
233
|
if (opts.tiers) { // forge#4394: run logs only
|
|
218
234
|
const runs = (deps.localRuns ?? readLocalRuns(runsDir(env))).filter((r) => r.repo == null || r.repo === repo);
|
|
219
235
|
return { repo, sources: { local: { runs: runs.length } }, query: { tiers: builderTierReport(runs) } };
|
|
220
236
|
}
|
|
237
|
+
if (opts.laneProfile) { // forge#4436: run logs only, every RUN_START slice
|
|
238
|
+
const runs = (deps.localRuns ?? readLocalRuns(runsDir(env), { allSlices: true })).filter((r) => r.repo == null || r.repo === repo);
|
|
239
|
+
return { repo, sources: { local: { runs: runs.length } }, query: { lane_profile: laneProfileReport(runs, { issues: opts.issues }) } };
|
|
240
|
+
}
|
|
221
241
|
const bound = sinceToTimestamp(opts.since, releaseTimes);
|
|
222
242
|
if (bound.note) (deps.warn ?? ((m) => process.stderr.write(`forgedock stats: ${m}\n`)))(bound.note);
|
|
223
243
|
const gh = await fetchGithubRecords({ repo, since: bound.since, refresh: opts.refresh, useCache: opts.cache, gh: deps.gh, env,
|
|
@@ -254,6 +274,7 @@ export async function runStats(argv, deps = {}) {
|
|
|
254
274
|
}
|
|
255
275
|
try {
|
|
256
276
|
const report = await buildReport(opts, { onProgress: makeProgressPrinter(), ...deps });
|
|
277
|
+
if (report.query?.lane_profile && !opts.json) { process.stdout.write(`${renderLaneProfiles(report.query.lane_profile)}\n`); return 0; }
|
|
257
278
|
if (report.query?.tiers && !opts.json) { process.stdout.write(`${renderTiers(report.query.tiers)}\n`); return 0; }
|
|
258
279
|
if (report.query?.efficiency && !opts.json) {
|
|
259
280
|
const e = report.query.efficiency;
|
|
@@ -263,6 +284,7 @@ export async function runStats(argv, deps = {}) {
|
|
|
263
284
|
return 0;
|
|
264
285
|
}
|
|
265
286
|
if (report.query?.context_ab && !opts.json) { process.stdout.write(`${renderContextAb(report.query.context_ab)}\n`); return 0; }
|
|
287
|
+
if (report.query?.explore_ab && !opts.json) { process.stdout.write(`${renderExploreAb(report.query.explore_ab)}\n`); return 0; }
|
|
266
288
|
process.stdout.write(`${opts.json || report.query ? renderJson(report) : opts.compact ? renderCompact(report) : renderMarkdown(report)}\n`);
|
|
267
289
|
return 0;
|
|
268
290
|
} catch (err) {
|
|
@@ -58,7 +58,7 @@ export function summariseLog(events, fallbackIssue = null) {
|
|
|
58
58
|
const usage = ev.filter((e) => e.event === "PHASE_ATTEMPT_USAGE");
|
|
59
59
|
const builderCalls = usage.filter((e) => e.phase === "implement");
|
|
60
60
|
const reviewCalls = usage.filter((e) => e.phase === "review").length;
|
|
61
|
-
const contextPack = { mode: term?.context_pack ?? cp?.mode ?? "off", laneType: term?.lane_type ?? cp?.lane_type ?? null };
|
|
61
|
+
const contextPack = { mode: term?.context_pack ?? cp?.mode ?? "off", laneType: term?.lane_type ?? cp?.lane_type ?? null, ab: (term?.context_ab ?? cp?.context_ab) === true };
|
|
62
62
|
const startTs = start.ts ?? null;
|
|
63
63
|
const endTs = finished ? ev[t].ts ?? null : null;
|
|
64
64
|
const a = Date.parse(startTs ?? ""); const b = Date.parse(endTs ?? "");
|
|
@@ -76,7 +76,15 @@ export function summariseLog(events, fallbackIssue = null) {
|
|
|
76
76
|
pitfallsMatched: Number.isInteger(term?.pitfalls_matched) ? term.pitfalls_matched
|
|
77
77
|
: Array.isArray(cp?.pitfalls) ? cp.pitfalls.filter((p) => p?.match === "files").length : null,
|
|
78
78
|
builderTurns: builderCalls.reduce((x, e) => x + n(e.turns), 0),
|
|
79
|
+
// #4418: the explorer A/B tag (null before #4391 wrote it) and the main-session (plan + build) cache-read
|
|
80
|
+
explore: term?.explore === "on" || term?.explore === "off" ? term.explore : null,
|
|
81
|
+
builderCacheRead: builderCalls.reduce((x, e) => x + n(e.usage?.cache_read_input_tokens), 0),
|
|
79
82
|
size: term?.turns?.size ?? null, // #4409: S|M|L from the lane's turn record, buckets the admission estimate history
|
|
83
|
+
// #4444: cache-read alone (per-merge A/B metric) and the implement attempt (builder calls other than the plan call)
|
|
84
|
+
cacheReadTokens: usage.reduce((x, e) => x + n(e.usage?.cache_read_input_tokens), 0),
|
|
85
|
+
implementAttempt: implementAttemptOf(builderCalls),
|
|
86
|
+
// #4444: the implement attempt of the earliest finished first/child run in this log, the same-issue baseline of a rerun
|
|
87
|
+
firstRunAttempt: firstRunAttemptOf(events.slice(0, s)),
|
|
80
88
|
cacheTokens: usage.reduce((x, e) => x + n(e.usage?.cache_read_input_tokens) + n(e.usage?.cache_creation_input_tokens), 0),
|
|
81
89
|
costUsd: usage.reduce((x, e) => x + n(costOf(e)), 0),
|
|
82
90
|
// #4394: the builder tier (RUN_TERMINAL builder_*, else the BUILDER_ROUTE / BUILDER_ESCALATED events); null before routing
|
|
@@ -88,9 +96,19 @@ export function summariseLog(events, fallbackIssue = null) {
|
|
|
88
96
|
reworkedMerges: earlierMergedRoutes(events.slice(0, s)),
|
|
89
97
|
// first pass: merged off one plan + one implement call, at most one review, no CI fix round
|
|
90
98
|
firstPass: finished && term?.reason === "merged" && builderCalls.length <= 2 && reviewCalls <= 1 && !ev.some((e) => e.event === "CI_FIX_ROUND"),
|
|
99
|
+
// #4436: the lane profile (#4395) of this slice's sessions ("mixed" when they differ, null before the field) and the
|
|
100
|
+
// fresh sessions' first-turn baseline_tokens per phase (resumed sessions carry none)
|
|
101
|
+
laneProfile: profileOf(usage),
|
|
102
|
+
baselines: usage.reduce((acc, e) => { if (Number.isFinite(e.baseline_tokens)) (acc[e.phase ?? "other"] ??= []).push(e.baseline_tokens); return acc; }, {}),
|
|
91
103
|
};
|
|
92
104
|
}
|
|
93
105
|
|
|
106
|
+
/** #4436: the one lane_profile of the usage records, "mixed" when they differ, null when none carries it. */
|
|
107
|
+
function profileOf(usage) {
|
|
108
|
+
const ps = new Set(usage.map((e) => e.lane_profile).filter((p) => typeof p === "string" && p));
|
|
109
|
+
return ps.size === 0 ? null : ps.size === 1 ? [...ps][0] : "mixed";
|
|
110
|
+
}
|
|
111
|
+
|
|
94
112
|
/** #4394: {tier, class, escalated, model} of a lane, or null when it computed no builder route. */
|
|
95
113
|
function routeOf(term, ev) {
|
|
96
114
|
if (typeof term?.builder_tier === "string") {
|
|
@@ -101,6 +119,31 @@ function routeOf(term, ev) {
|
|
|
101
119
|
return { tier: r.tier ?? null, class: r.class ?? null, escalated: ev.some((e) => e.event === "BUILDER_ESCALATED"), model: r.model ?? null };
|
|
102
120
|
}
|
|
103
121
|
|
|
122
|
+
/** #4444: {turns, output_tokens} of the builder calls other than the plan call; null when there is none. */
|
|
123
|
+
function implementAttemptOf(builderCalls) {
|
|
124
|
+
const traced = builderCalls.some((e) => typeof e.trigger === "string");
|
|
125
|
+
const calls = traced ? builderCalls.filter((e) => e.trigger !== "plan") : builderCalls.slice(1);
|
|
126
|
+
if (!calls.length) return null;
|
|
127
|
+
return { turns: calls.reduce((x, e) => x + n(e.turns), 0), output_tokens: calls.reduce((x, e) => x + n(e.usage?.output_tokens), 0) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** #4444: implementAttemptOf for the earliest finished RUN_START slice of `events` whose lane type was first or child. */
|
|
131
|
+
function firstRunAttemptOf(events) {
|
|
132
|
+
let slice = null;
|
|
133
|
+
const slices = [];
|
|
134
|
+
for (const e of events) {
|
|
135
|
+
if (e.event === "RUN_START") { slice = []; slices.push(slice); }
|
|
136
|
+
if (slice) slice.push(e);
|
|
137
|
+
}
|
|
138
|
+
for (const sl of slices) {
|
|
139
|
+
const term = [...sl].reverse().find((e) => e.event === "RUN_TERMINAL");
|
|
140
|
+
if (!["first", "child"].includes(term?.lane_type)) continue;
|
|
141
|
+
const att = implementAttemptOf(sl.filter((e) => e.event === "PHASE_ATTEMPT_USAGE" && e.phase === "implement"));
|
|
142
|
+
if (att) return att;
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
104
147
|
/** #4394: the routes ({tier, class, escalated, model}) of the RUN_START slices in `events` that ended merged with a route. */
|
|
105
148
|
function earlierMergedRoutes(events) {
|
|
106
149
|
const out = [];
|
|
@@ -119,21 +162,32 @@ function earlierMergedRoutes(events) {
|
|
|
119
162
|
return out;
|
|
120
163
|
}
|
|
121
164
|
|
|
122
|
-
/**
|
|
123
|
-
|
|
165
|
+
/**
|
|
166
|
+
* All run summaries in `dir` (a missing dir or an unreadable log yields nothing, never an error). One per log (its last
|
|
167
|
+
* RUN_START slice); with `allSlices` (#4436) one per RUN_START slice, so a before and an after run of one issue both count.
|
|
168
|
+
*/
|
|
169
|
+
export function readLocalRuns(dir = runsDir(), { allSlices = false } = {}) {
|
|
124
170
|
if (!existsSync(dir)) return [];
|
|
125
171
|
const runs = [];
|
|
126
172
|
for (const f of readdirSync(dir).sort()) {
|
|
127
173
|
if (!f.endsWith(".jsonl")) continue;
|
|
128
174
|
try {
|
|
129
175
|
const m = /(?:^|__)(\d+)\.jsonl$/.exec(f); // #4432: `<issue>.jsonl` or `<owner>__<repo>__<issue>.jsonl`
|
|
130
|
-
const
|
|
131
|
-
|
|
176
|
+
const events = parseLog(readFileSync(join(dir, f), "utf8"));
|
|
177
|
+
const starts = allSlices ? events.flatMap((e, i) => (e.event === "RUN_START" ? [i] : [])) : [events.length];
|
|
178
|
+
const mtimeTs = new Date(statSync(join(dir, f)).mtimeMs).toISOString();
|
|
179
|
+
starts.forEach((_, k) => {
|
|
180
|
+
const sum = summariseLog(allSlices ? events.slice(0, starts[k + 1] ?? events.length) : events, m ? Number(m[1]) : null);
|
|
181
|
+
if (sum) runs.push({ ...sum, mtimeTs });
|
|
182
|
+
});
|
|
132
183
|
} catch { /* unreadable log */ }
|
|
133
184
|
}
|
|
134
185
|
return runs;
|
|
135
186
|
}
|
|
136
187
|
|
|
188
|
+
/** #4418: every RUN_START slice (readLocalRuns allSlices), so an on run and an off run of the same issue both count. */
|
|
189
|
+
export const readLocalLaneRuns = (dir = runsDir()) => readLocalRuns(dir, { allSlices: true });
|
|
190
|
+
|
|
137
191
|
/**
|
|
138
192
|
* forge#4354: one lane per RUN_START..next-RUN_START slice (every slice, not only the last), with the fail-soft events the
|
|
139
193
|
* alert pass rates: DEPS_PREPARED rows, TOOLCHAIN_WARNING texts, deferred-to-ci precheck commands and the terminal code.
|
package/bin/stats/query.mjs
CHANGED
|
@@ -85,17 +85,55 @@ export function contextPackAb(runs) {
|
|
|
85
85
|
for (const mode of ["on", "off", "shadow"]) {
|
|
86
86
|
const rs = runs.filter((r) => r?.finished && r.contextPack?.laneType === lt && r.contextPack?.mode === mode);
|
|
87
87
|
out[lt][mode] = { n: rs.length, first_pass_yield: mean(rs.map((r) => (r.firstPass ? 1 : 0))), builder_turns: mean(rs.map((r) => r.builderTurns ?? 0)),
|
|
88
|
-
cache_tokens: mean(rs.map((r) => r.cacheTokens ?? 0)), cost_usd: mean(rs.map((r) => r.costUsd ?? 0)) };
|
|
88
|
+
cache_tokens: mean(rs.map((r) => r.cacheTokens ?? 0)), cost_usd: mean(rs.map((r) => r.costUsd ?? 0)), ...perMerge(rs, lt) };
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
|
+
out.recovery = recoveryVsFirst(runs);
|
|
91
92
|
return out;
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
const round4 = (v) => Math.round(v * 1e4) / 1e4;
|
|
96
|
+
/**
|
|
97
|
+
* #4444: per-merge A/B metrics of one cell: builder turns and cache-read over the cell's runs divided by its merges (null
|
|
98
|
+
* without a merge), and rework rate: runs that were remediated, followed an earlier merge, or are a rerun lane, over n.
|
|
99
|
+
*/
|
|
100
|
+
function perMerge(rs, lt) {
|
|
101
|
+
const merged = rs.filter((r) => r.reason === "merged").length;
|
|
102
|
+
const sum = (f) => rs.reduce((x, r) => x + (Number.isFinite(f(r)) ? f(r) : 0), 0);
|
|
103
|
+
const reworked = rs.filter((r) => r.remediated || (r.reworkedMerges?.length ?? 0) > 0 || lt === "rerun").length;
|
|
104
|
+
return { merged, turns_per_merge: merged ? round4(sum((r) => r.builderTurns) / merged) : null,
|
|
105
|
+
cache_read_per_merge: merged ? round4(sum((r) => r.cacheReadTokens) / merged) : null, rework_rate: rs.length ? round4(reworked / rs.length) : null };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* #4444 (R8): a rerun lane's implement attempt against the first run on the same issue. The baseline is the earlier
|
|
110
|
+
* first/child slice of the rerun's own log (firstRunAttempt), else the earliest finished first/child run of the same
|
|
111
|
+
* repo+issue in `runs`. Ratios are rerun / first per pair (a zero baseline is skipped); null without a pair.
|
|
112
|
+
*/
|
|
113
|
+
export function recoveryVsFirst(runs) {
|
|
114
|
+
const key = (r) => `${String(r?.repo ?? "").toLowerCase()}#${r?.issue}`;
|
|
115
|
+
const firsts = new Map();
|
|
116
|
+
for (const r of [...runs].filter((x) => x?.finished && ["first", "child"].includes(x.contextPack?.laneType) && x.implementAttempt)
|
|
117
|
+
.sort((a, b) => String(a.startTs ?? "").localeCompare(String(b.startTs ?? "")))) if (!firsts.has(key(r))) firsts.set(key(r), r.implementAttempt);
|
|
118
|
+
const turnR = [], outR = [];
|
|
119
|
+
for (const r of runs) {
|
|
120
|
+
if (!r?.finished || r.contextPack?.laneType !== "rerun" || !r.implementAttempt) continue;
|
|
121
|
+
const base = r.firstRunAttempt ?? firsts.get(key(r));
|
|
122
|
+
if (!base) continue;
|
|
123
|
+
if (base.turns > 0) turnR.push(r.implementAttempt.turns / base.turns);
|
|
124
|
+
if (base.output_tokens > 0) outR.push(r.implementAttempt.output_tokens / base.output_tokens);
|
|
125
|
+
}
|
|
126
|
+
const mean = (xs) => (xs.length ? round4(xs.reduce((a, b) => a + b, 0) / xs.length) : null);
|
|
127
|
+
return { pairs: Math.max(turnR.length, outR.length), mean_turn_ratio: mean(turnR), mean_output_ratio: mean(outR) };
|
|
128
|
+
}
|
|
129
|
+
|
|
94
130
|
/** Markdown table for contextPackAb. */
|
|
95
131
|
export function renderContextAb(ab) {
|
|
96
132
|
const f = (v) => (v == null ? "n/a" : String(v));
|
|
97
|
-
const rows = CONTEXT_LANE_TYPES.flatMap((lt) => ["on", "off", "shadow"].map((m) => { const g = ab[lt][m]; return `| ${lt} | ${m} | ${g.n} | ${f(g.first_pass_yield)} | ${f(g.builder_turns)} | ${f(g.cache_tokens)} | ${f(g.cost_usd)} |`; }));
|
|
98
|
-
|
|
133
|
+
const rows = CONTEXT_LANE_TYPES.flatMap((lt) => ["on", "off", "shadow"].map((m) => { const g = ab[lt][m]; return `| ${lt} | ${m} | ${g.n} | ${f(g.first_pass_yield)} | ${f(g.builder_turns)} | ${f(g.cache_tokens)} | ${f(g.cost_usd)} | ${f(g.merged)} | ${f(g.turns_per_merge)} | ${f(g.cache_read_per_merge)} | ${f(g.rework_rate)} |`; }));
|
|
134
|
+
const rc = ab.recovery; // #4444: R8, rerun implement attempt vs the first run on the same issue
|
|
135
|
+
const rec = rc ? `\n\nRecovery vs first run (same issue): ${rc.pairs} pair(s), implement turns ratio ${f(rc.mean_turn_ratio)}, output tokens ratio ${f(rc.mean_output_ratio)}` : "";
|
|
136
|
+
return `| Lane type | Mode | n | First-pass yield | Builder turns | Cache tokens | Cost USD | Merged | Turns/merge | Cache-read/merge | Rework rate |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n${rows.join("\n")}${rec}`;
|
|
99
137
|
}
|
|
100
138
|
|
|
101
139
|
/**
|
|
@@ -145,3 +183,95 @@ export function renderTiers(rep) {
|
|
|
145
183
|
const rows = (rep?.rows ?? []).map((g) => `| ${g.tier} | ${g.class} | ${g.n} | ${f(g.merge_rate)} | ${f(g.conformance_rate)} (${g.reviewed}) | ${f(g.post_merge_rework_rate)} (${g.merges}) | ${f(g.first_pass_yield)} | ${f(g.cost_usd)} |`);
|
|
146
184
|
return `| Tier | Class | n | Merge rate | Conformance rate (reviewed) | Post-merge rework (merges) | First-pass yield | Cost USD |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n${rows.length ? rows.join("\n") : "| none | | 0 | | | | | |"}\n\n${rep?.unrouted ?? 0} finished run(s) without a builder route (before #4394) left out.`;
|
|
147
185
|
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* forge#4418: the explorer A/B (#4391) over per-slice run summaries (local-source.mjs readLocalLaneRuns), per size bucket
|
|
189
|
+
* (RUN_TERMINAL turns.size, set before the build) and RUN_TERMINAL `explore` mode. builder_turns / builder_cache_read:
|
|
190
|
+
* means over the plan + build calls. merge_rate: merged / n. conformance_rate: as builderTierReport. `paired` keeps only
|
|
191
|
+
* issues with a finished run in both modes. Untagged runs (before #4391) are counted, never folded into `off`.
|
|
192
|
+
*/
|
|
193
|
+
export const EXPLORE_SIZES = Object.freeze(["S", "M", "L", "unsized"]);
|
|
194
|
+
const EXPLORE_MODES = ["on", "off"];
|
|
195
|
+
export function exploreAb(runs) {
|
|
196
|
+
const mean = (xs) => (xs.length ? Math.round((xs.reduce((a, b) => a + b, 0) / xs.length) * 1e4) / 1e4 : null);
|
|
197
|
+
const rate = (k, n) => (n ? Math.round((k / n) * 1e4) / 1e4 : null);
|
|
198
|
+
const finished = (runs ?? []).filter((r) => r?.finished);
|
|
199
|
+
const tagged = finished.filter((r) => EXPLORE_MODES.includes(r.explore));
|
|
200
|
+
const stats = (rs) => {
|
|
201
|
+
const reviewed = rs.filter((r) => r.reachedReview);
|
|
202
|
+
const conforming = reviewed.filter((r) => !r.conformanceRound && (r.shadowWould == null || r.shadowWould === "conforms"));
|
|
203
|
+
return { n: rs.length, builder_turns: mean(rs.map((r) => r.builderTurns ?? 0)), builder_cache_read: mean(rs.map((r) => r.builderCacheRead ?? 0)),
|
|
204
|
+
merge_rate: rate(rs.filter((r) => r.reason === "merged").length, rs.length), conformance_rate: rate(conforming.length, reviewed.length), reviewed: reviewed.length };
|
|
205
|
+
};
|
|
206
|
+
const bySize = {};
|
|
207
|
+
for (const size of EXPLORE_SIZES) {
|
|
208
|
+
bySize[size] = {};
|
|
209
|
+
for (const mode of EXPLORE_MODES) bySize[size][mode] = stats(tagged.filter((r) => (EXPLORE_SIZES.includes(r.size) ? r.size : "unsized") === size && r.explore === mode));
|
|
210
|
+
}
|
|
211
|
+
const key = (r) => `${r.repo ?? ""}#${r.issue}`;
|
|
212
|
+
const both = new Set(tagged.filter((r) => r.explore === "on").map(key).filter((k) => tagged.some((r) => r.explore === "off" && key(r) === k)));
|
|
213
|
+
const pairedRuns = tagged.filter((r) => both.has(key(r)));
|
|
214
|
+
const paired = { issues: both.size };
|
|
215
|
+
for (const mode of EXPLORE_MODES) paired[mode] = stats(pairedRuns.filter((r) => r.explore === mode));
|
|
216
|
+
return { by_size: bySize, paired, untagged: finished.length - tagged.length };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Markdown tables for exploreAb. */
|
|
220
|
+
export function renderExploreAb(ab) {
|
|
221
|
+
const f = (v) => (v == null ? "n/a" : String(v));
|
|
222
|
+
const row = (label, mode, g) => `| ${label} | ${mode} | ${g.n} | ${f(g.builder_turns)} | ${f(g.builder_cache_read)} | ${f(g.merge_rate)} | ${f(g.conformance_rate)} (${g.reviewed}) |`;
|
|
223
|
+
const head = "| Size | Explore | n | Builder turns | Builder cache-read | Merge rate | Conformance rate (reviewed) |\n| --- | --- | --- | --- | --- | --- | --- |";
|
|
224
|
+
const sizes = EXPLORE_SIZES.flatMap((s) => EXPLORE_MODES.map((m) => row(s, m, ab.by_size[s][m])));
|
|
225
|
+
const paired = EXPLORE_MODES.map((m) => row("paired", m, ab.paired[m]));
|
|
226
|
+
return `${head}\n${sizes.join("\n")}\n\nSame issues in both modes: ${ab.paired.issues}\n\n${head}\n${paired.join("\n")}\n\n${ab.untagged} finished run(s) without an explore tag (before #4391) left out.`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* forge#4436: the #4395 lane baseline before/after, over local run summaries (every RUN_START slice). Finished runs,
|
|
231
|
+
* optionally pinned to an issue set, grouped by lane profile (null → unknown). Per profile and phase: fresh-session
|
|
232
|
+
* first-turn baseline_tokens (sessions, median, mean). Per profile: conformance_rate (the builderTierReport rule) and
|
|
233
|
+
* shadow_blocks (reviewed runs with a would-block verdict). issues_in_all: issues with a finished run under every profile.
|
|
234
|
+
*/
|
|
235
|
+
export const LANE_PROFILES = Object.freeze(["full", "memory-off", "lane", "mixed", "unknown"]);
|
|
236
|
+
export function laneProfileReport(runs, { issues = null } = {}) {
|
|
237
|
+
const pin = Array.isArray(issues) && issues.length ? new Set(issues) : null;
|
|
238
|
+
const rate = (k, n) => (n ? Math.round((k / n) * 1e4) / 1e4 : null);
|
|
239
|
+
const groups = new Map();
|
|
240
|
+
for (const r of runs ?? []) {
|
|
241
|
+
if (!r?.finished || (pin && !pin.has(r.issue))) continue;
|
|
242
|
+
const name = typeof r.laneProfile === "string" && r.laneProfile ? r.laneProfile : "unknown";
|
|
243
|
+
const g = groups.get(name) ?? { profile: name, n: 0, issues: new Set(), reviewed: 0, conforming: 0, shadow_blocks: 0, phases: {} };
|
|
244
|
+
groups.set(name, g);
|
|
245
|
+
g.n++;
|
|
246
|
+
if (r.issue != null) g.issues.add(r.issue);
|
|
247
|
+
if (r.reachedReview) {
|
|
248
|
+
g.reviewed++;
|
|
249
|
+
if (!r.conformanceRound && (r.shadowWould == null || r.shadowWould === "conforms")) g.conforming++;
|
|
250
|
+
if (typeof r.shadowWould === "string" && r.shadowWould !== "conforms") g.shadow_blocks++;
|
|
251
|
+
}
|
|
252
|
+
for (const [phase, xs] of Object.entries(r.baselines ?? {})) (g.phases[phase] ??= []).push(...xs.filter(Number.isFinite));
|
|
253
|
+
}
|
|
254
|
+
const stat = (xs) => {
|
|
255
|
+
if (!xs.length) return { median: null, mean: null };
|
|
256
|
+
const s = [...xs].sort((a, b) => a - b), m = s.length >> 1;
|
|
257
|
+
return { median: Math.round(s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2), mean: Math.round(s.reduce((a, b) => a + b, 0) / s.length) };
|
|
258
|
+
};
|
|
259
|
+
const rank = (p) => { const i = LANE_PROFILES.indexOf(p); return i < 0 ? LANE_PROFILES.length : i; };
|
|
260
|
+
const profiles = [...groups.values()].sort((a, b) => rank(a.profile) - rank(b.profile) || a.profile.localeCompare(b.profile))
|
|
261
|
+
.map((g) => ({ profile: g.profile, n: g.n, issues: [...g.issues].sort((a, b) => a - b), reviewed: g.reviewed, conformance_rate: rate(g.conforming, g.reviewed),
|
|
262
|
+
shadow_blocks: g.shadow_blocks, phases: Object.keys(g.phases).sort().map((phase) => ({ phase, sessions: g.phases[phase].length, ...stat(g.phases[phase]) })) }));
|
|
263
|
+
const issuesInAll = profiles.length ? profiles[0].issues.filter((i) => profiles.every((p) => p.issues.includes(i))) : [];
|
|
264
|
+
return { profiles, issues_in_all: issuesInAll };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Markdown tables for laneProfileReport. */
|
|
268
|
+
export function renderLaneProfiles(rep) {
|
|
269
|
+
const f = (v) => (v == null ? "n/a" : String(v));
|
|
270
|
+
const ps = rep?.profiles ?? [];
|
|
271
|
+
const base = ps.flatMap((p) => (p.phases.length ? p.phases : [{ phase: "n/a", sessions: 0, median: null, mean: null }])
|
|
272
|
+
.map((x) => `| ${p.profile} | ${x.phase} | ${x.sessions} | ${f(x.median)} | ${f(x.mean)} |`));
|
|
273
|
+
const conf = ps.map((p) => `| ${p.profile} | ${p.n} | ${f(p.conformance_rate)} (${p.reviewed}) | ${p.shadow_blocks} |`);
|
|
274
|
+
return `| Profile | Phase | Sessions | Median baseline | Mean baseline |\n| --- | --- | --- | --- | --- |\n${base.length ? base.join("\n") : "| none | | 0 | | |"}\n\n`
|
|
275
|
+
+ `| Profile | Runs | Conformance rate (reviewed) | Shadow would-block |\n| --- | --- | --- | --- |\n${conf.length ? conf.join("\n") : "| none | 0 | | |"}\n\n`
|
|
276
|
+
+ `Issues with a finished run under every profile: ${rep?.issues_in_all?.length ? rep.issues_in_all.map((i) => `#${i}`).join(", ") : "none"}`;
|
|
277
|
+
}
|
package/bin/stats/stats.test.mjs
CHANGED
|
@@ -12,9 +12,9 @@ import { aggregate, compareVersions, computeAlerts, DEFAULT_ALERTS, percentile }
|
|
|
12
12
|
import { cacheDir } from "./cache.mjs";
|
|
13
13
|
import { fetchGithubRecords, parseTelemetryComments, runsFromRecords, toModel } from "./github-source.mjs";
|
|
14
14
|
import { buildReport, makeProgressPrinter, parseArgs, parseSince, readAlertConfig, runStats, sinceToTimestamp } from "./index.mjs";
|
|
15
|
-
import { lanesOfLog, readLanes, readLocalRuns, summariseLog } from "./local-source.mjs";
|
|
15
|
+
import { lanesOfLog, readLanes, readLocalLaneRuns, readLocalRuns, summariseLog } from "./local-source.mjs";
|
|
16
16
|
import { renderAlerts, renderCompact, renderJson, renderMarkdown } from "./render.mjs";
|
|
17
|
-
import { edgeQuery, parseEdge, verdictMix, contextPackAb, renderContextAb, builderTierReport, renderTiers } from "./query.mjs";
|
|
17
|
+
import { edgeQuery, parseEdge, verdictMix, contextPackAb, renderContextAb, builderTierReport, renderTiers, exploreAb, renderExploreAb, laneProfileReport, renderLaneProfiles } from "./query.mjs";
|
|
18
18
|
|
|
19
19
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
20
|
const fx = (f) => join(here, "fixtures", f);
|
|
@@ -538,7 +538,7 @@ describe("context-pack A/B over run logs (forge#4275)", () => {
|
|
|
538
538
|
|
|
539
539
|
it("summariseLog carries mode, lane type, builder turns, cache tokens, cost and first-pass", () => {
|
|
540
540
|
const s = summariseLog(log("on", "child"));
|
|
541
|
-
assert.deepEqual(s.contextPack, { mode: "on", laneType: "child" });
|
|
541
|
+
assert.deepEqual(s.contextPack, { mode: "on", laneType: "child", ab: false });
|
|
542
542
|
assert.deepEqual([s.builderTurns, s.cacheTokens, s.costUsd, s.firstPass], [10, 2130, 1.1, true]);
|
|
543
543
|
assert.equal(summariseLog(log("on", "child", { builds: 4 })).firstPass, false);
|
|
544
544
|
assert.equal(summariseLog(log("on", "child", { reason: "needs-human" })).firstPass, false);
|
|
@@ -553,8 +553,8 @@ describe("context-pack A/B over run logs (forge#4275)", () => {
|
|
|
553
553
|
const runs = [log("on", "first"), log("on", "first", { builds: 4, reviews: 2 }), log("off", "first"), log("shadow", "rerun"),
|
|
554
554
|
[{ event: "RUN_START", issue: 2 }, { event: "RUN_TERMINAL", reason: "merged" }]].map((l) => summariseLog(l));
|
|
555
555
|
const ab = contextPackAb(runs);
|
|
556
|
-
assert.deepEqual(Object.keys(ab), ["rerun", "child", "first"]);
|
|
557
|
-
assert.deepEqual(ab.first.on, { n: 2, first_pass_yield: 0.5, builder_turns: 15, cache_tokens: 3195, cost_usd: 1.65 });
|
|
556
|
+
assert.deepEqual(Object.keys(ab), ["rerun", "child", "first", "recovery"]);
|
|
557
|
+
assert.deepEqual(ab.first.on, { n: 2, first_pass_yield: 0.5, builder_turns: 15, cache_tokens: 3195, cost_usd: 1.65, merged: 2, turns_per_merge: 15, cache_read_per_merge: 3150, rework_rate: 0 });
|
|
558
558
|
assert.equal(ab.first.off.n, 1); assert.equal(ab.first.off.first_pass_yield, 1);
|
|
559
559
|
assert.equal(ab.rerun.shadow.n, 1); assert.equal(ab.child.on.n, 0); assert.equal(ab.child.on.cost_usd, null);
|
|
560
560
|
assert.match(renderContextAb(ab), /\| first \| on \| 2 \| 0\.5 \| 15 \| 3195 \| 1\.65 \|/);
|
|
@@ -567,6 +567,100 @@ describe("context-pack A/B over run logs (forge#4275)", () => {
|
|
|
567
567
|
});
|
|
568
568
|
});
|
|
569
569
|
|
|
570
|
+
describe("explorer A/B over run logs (forge#4418)", () => {
|
|
571
|
+
const use = (phase, turns, cr) => ({ event: "PHASE_ATTEMPT_USAGE", phase, turns, cost_usd: 0.5, usage: { cache_read_input_tokens: cr, cache_creation_input_tokens: 10, output_tokens: 1 } });
|
|
572
|
+
const slice = (issue, explore, { size = "M", turns = 5, cr = 1000, reason = "merged", triggers = ["plan", "build"], review = true } = {}) => [
|
|
573
|
+
{ event: "RUN_START", issue, repo: "o/r", run: `r${issue}${explore}` },
|
|
574
|
+
...triggers.map((t) => ({ ...use("implement", turns, cr), trigger: t })), ...(review ? [use("review", 2, 100)] : []),
|
|
575
|
+
{ event: "RUN_TERMINAL", reason, code: "x", ...(explore ? { explore } : {}), ...(size ? { turns: { size, budget: 100, build: 9 } } : {}) },
|
|
576
|
+
];
|
|
577
|
+
|
|
578
|
+
it("summariseLog carries the explore tag and the plan + build cache-read", () => {
|
|
579
|
+
const on = summariseLog(slice(1, "on"));
|
|
580
|
+
assert.deepEqual([on.explore, on.builderCacheRead, on.builderTurns, on.cacheTokens], ["on", 2000, 10, 2130]);
|
|
581
|
+
assert.equal(summariseLog(slice(1, "off")).explore, "off");
|
|
582
|
+
assert.equal(summariseLog(slice(1, null)).explore, null);
|
|
583
|
+
assert.equal(summariseLog(slice(1, "maybe")).explore, null);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("readLocalLaneRuns keeps every RUN_START slice of a log", () => {
|
|
587
|
+
const dir = tmpDir("explore-ab-");
|
|
588
|
+
try {
|
|
589
|
+
writeFileSync(join(dir, "o__r__7.jsonl"), [...slice(7, "on"), ...slice(7, "off", { reason: "needs-human" })].map((e) => JSON.stringify(e)).join("\n"));
|
|
590
|
+
const runs = readLocalLaneRuns(dir);
|
|
591
|
+
assert.deepEqual(runs.map((r) => [r.issue, r.explore, r.reason]), [[7, "on", "merged"], [7, "off", "needs-human"]]);
|
|
592
|
+
assert.equal(readLocalRuns(dir).length, 1);
|
|
593
|
+
assert.deepEqual(readLocalLaneRuns(join(dir, "missing")), []);
|
|
594
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("reports per size and mode, pairs same issues and leaves untagged runs out", () => {
|
|
598
|
+
const runs = [slice(1, "on", { size: "L", turns: 3, cr: 500 }), slice(1, "off", { size: "L", turns: 10, cr: 4000 }),
|
|
599
|
+
slice(2, "on", { size: "L", turns: 4, cr: 600, triggers: ["plan", "build", "conformance"] }), slice(3, "off", { size: "S", reason: "needs-human", review: false }),
|
|
600
|
+
slice(4, null), slice(5, "on", { size: null })].map((l) => summariseLog(l));
|
|
601
|
+
const ab = exploreAb(runs);
|
|
602
|
+
assert.deepEqual(Object.keys(ab.by_size), ["S", "M", "L", "unsized"]);
|
|
603
|
+
assert.deepEqual(ab.by_size.L.on, { n: 2, builder_turns: 9, builder_cache_read: 1400, merge_rate: 1, conformance_rate: 0.5, reviewed: 2 });
|
|
604
|
+
assert.deepEqual(ab.by_size.L.off, { n: 1, builder_turns: 20, builder_cache_read: 8000, merge_rate: 1, conformance_rate: 1, reviewed: 1 });
|
|
605
|
+
assert.deepEqual([ab.by_size.S.off.merge_rate, ab.by_size.S.off.conformance_rate, ab.by_size.M.on.n, ab.by_size.M.on.builder_turns], [0, null, 0, null]);
|
|
606
|
+
assert.equal(ab.by_size.unsized.on.n, 1);
|
|
607
|
+
assert.equal(ab.untagged, 1);
|
|
608
|
+
assert.equal(ab.paired.issues, 1);
|
|
609
|
+
assert.deepEqual([ab.paired.on.n, ab.paired.on.builder_cache_read, ab.paired.off.n, ab.paired.off.builder_cache_read], [1, 1000, 1, 8000]);
|
|
610
|
+
const md = renderExploreAb(ab);
|
|
611
|
+
assert.match(md, /\| L \| on \| 2 \| 9 \| 1400 \| 1 \| 0\.5 \(2\) \|/);
|
|
612
|
+
assert.match(md, /\| paired \| off \| 1 \| 20 \| 8000 \| 1 \| 1 \(1\) \|/);
|
|
613
|
+
assert.match(md, /Same issues in both modes: 1/);
|
|
614
|
+
assert.match(md, /1 finished run\(s\) without an explore tag/);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it("--explore-ab reads run logs only (no GitHub crawl)", async () => {
|
|
618
|
+
assert.equal(parseArgs(["--explore-ab"]).exploreAb, true);
|
|
619
|
+
assert.equal(parseArgs([]).exploreAb, false);
|
|
620
|
+
const rep = await buildReport({ repo: "o/r", exploreAb: true }, { localRuns: [summariseLog(slice(1, "on")), { ...summariseLog(slice(2, "on")), repo: "x/y" }], releaseTimes: [], gh: async () => { throw new Error("no network"); } });
|
|
621
|
+
assert.equal(rep.query.explore_ab.by_size.M.on.n, 1);
|
|
622
|
+
assert.equal(rep.sources.local.runs, 1);
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
describe("context-pack A/B per merge, rework and recovery vs first (#4444)", () => {
|
|
627
|
+
const use = (trigger, turns, cr, out) => ({ event: "PHASE_ATTEMPT_USAGE", phase: "implement", trigger, turns, cost_usd: 0.5, usage: { cache_read_input_tokens: cr, cache_creation_input_tokens: 10, output_tokens: out } });
|
|
628
|
+
const slice = (issue, lt, { reason = "merged", mode = "on", ab = false, calls = [["plan", 2, 100, 10], ["build", 10, 1000, 400]], extra = [] } = {}) => [
|
|
629
|
+
{ event: "RUN_START", issue, repo: "o/r", run: `r${issue}${lt}`, ts: lt === "rerun" ? "2026-10-02T00:00:00Z" : "2026-10-01T00:00:00Z" },
|
|
630
|
+
...calls.map((c) => use(...c)), ...extra,
|
|
631
|
+
{ event: "RUN_TERMINAL", reason, code: "x", context_pack: mode, lane_type: lt, ...(ab ? { context_ab: true } : {}) },
|
|
632
|
+
];
|
|
633
|
+
|
|
634
|
+
it("summariseLog: cache-read alone, the implement attempt without the plan call, the ab tag and the log's first-run baseline", () => {
|
|
635
|
+
const s = summariseLog([...slice(5, "first"), ...slice(5, "rerun", { ab: true, calls: [["recover", 3, 50, 100]] })]);
|
|
636
|
+
assert.equal(s.cacheReadTokens, 50);
|
|
637
|
+
assert.deepEqual(s.implementAttempt, { turns: 3, output_tokens: 100 });
|
|
638
|
+
assert.deepEqual(s.firstRunAttempt, { turns: 10, output_tokens: 400 });
|
|
639
|
+
assert.equal(s.contextPack.ab, true);
|
|
640
|
+
assert.equal(summariseLog(slice(5, "first")).firstRunAttempt, null);
|
|
641
|
+
assert.deepEqual(summariseLog(slice(6, "first", { calls: [] })).implementAttempt, null);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it("per-merge turns and cache-read, rework rate and the same-issue recovery ratio", () => {
|
|
645
|
+
const runs = [summariseLog(slice(1, "first")), summariseLog(slice(2, "first", { reason: "needs-human" })),
|
|
646
|
+
summariseLog(slice(3, "first", { extra: [{ event: "PHASE_COMMIT", phase: "remediate" }] })),
|
|
647
|
+
summariseLog([...slice(4, "first"), ...slice(4, "rerun", { calls: [["recover", 5, 0, 100]] })]),
|
|
648
|
+
summariseLog(slice(1, "rerun", { calls: [["recover", 2, 0, 200]] }))];
|
|
649
|
+
const ab = contextPackAb(runs);
|
|
650
|
+
// three runs: 12 builder turns each, 1100 cache-read each, two merged
|
|
651
|
+
assert.deepEqual([ab.first.on.merged, ab.first.on.turns_per_merge, ab.first.on.cache_read_per_merge, ab.first.on.rework_rate], [2, 18, 1650, 0.3333]);
|
|
652
|
+
assert.equal(ab.rerun.on.rework_rate, 1);
|
|
653
|
+
assert.equal(ab.child.on.turns_per_merge, null);
|
|
654
|
+
// issue 4: 5/10 turns, 100/400 output (own log); issue 1: 2/10 turns, 200/400 output (earlier first run in `runs`)
|
|
655
|
+
assert.deepEqual(ab.recovery, { pairs: 2, mean_turn_ratio: 0.35, mean_output_ratio: 0.375 });
|
|
656
|
+
const md = renderContextAb(ab);
|
|
657
|
+
assert.match(md, /\| Merged \| Turns\/merge \| Cache-read\/merge \| Rework rate \|/);
|
|
658
|
+
assert.match(md, /\| first \| on \| 3 \| .* \| 2 \| 18 \| 1650 \| 0\.3333 \|/);
|
|
659
|
+
assert.match(md, /Recovery vs first run \(same issue\): 2 pair\(s\), implement turns ratio 0\.35, output tokens ratio 0\.375/);
|
|
660
|
+
assert.deepEqual(contextPackAb([]).recovery, { pairs: 0, mean_turn_ratio: null, mean_output_ratio: null });
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
|
|
570
664
|
describe("builder model tiers over run logs (forge#4394)", () => {
|
|
571
665
|
const use = (phase, trigger, usd = 1) => ({ event: "PHASE_ATTEMPT_USAGE", phase, trigger, cost_usd: usd, usage: { cache_read_input_tokens: 1, output_tokens: 1 } });
|
|
572
666
|
const slice = (issue, { tier = "mid", cls = "small", escalated = false, reason = "merged", triggers = ["plan", "build"], review = true, shadow } = {}) => [
|
|
@@ -610,6 +704,71 @@ describe("builder model tiers over run logs (forge#4394)", () => {
|
|
|
610
704
|
});
|
|
611
705
|
});
|
|
612
706
|
|
|
707
|
+
describe("lane profile report (#4436)", () => {
|
|
708
|
+
const use = (phase, profile, baseline, trigger = "build") => ({ event: "PHASE_ATTEMPT_USAGE", phase, trigger, cost_usd: 1, usage: { cache_read_input_tokens: 1, output_tokens: 1 },
|
|
709
|
+
...(profile ? { lane_profile: profile } : {}), ...(baseline != null ? { baseline_tokens: baseline } : {}) });
|
|
710
|
+
const slice = (issue, profile, { impl = [40000], review = 20000, shadow, triggers } = {}) => [
|
|
711
|
+
{ event: "RUN_START", issue, repo: "o/r", run: `r${issue}${profile}` },
|
|
712
|
+
...impl.map((b, i) => use("implement", profile, b, triggers?.[i])), ...(review != null ? [use("review", profile, review, "review")] : []),
|
|
713
|
+
{ event: "RUN_TERMINAL", reason: "merged", ...(shadow ? { shadow_would: shadow } : {}) },
|
|
714
|
+
];
|
|
715
|
+
|
|
716
|
+
it("summariseLog keeps the slice's lane profile and per-phase baselines; resumed sessions add none", () => {
|
|
717
|
+
const s = summariseLog(slice(1, "lane", { impl: [30000, null] }));
|
|
718
|
+
assert.equal(s.laneProfile, "lane");
|
|
719
|
+
assert.deepEqual(s.baselines, { implement: [30000], review: [20000] });
|
|
720
|
+
const mixed = summariseLog([{ event: "RUN_START", issue: 2 }, use("implement", "memory-off", 1), use("review", "lane", 2), { event: "RUN_TERMINAL", reason: "merged" }]);
|
|
721
|
+
assert.equal(mixed.laneProfile, "mixed");
|
|
722
|
+
const old = summariseLog([{ event: "RUN_START", issue: 3 }, use("implement", null, null), { event: "RUN_TERMINAL", reason: "merged" }]);
|
|
723
|
+
assert.equal(old.laneProfile, null); assert.deepEqual(old.baselines, {});
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it("readLocalRuns keeps one summary per log by default and every RUN_START slice with allSlices", () => {
|
|
727
|
+
const dir = tmpDir("lane-profile-");
|
|
728
|
+
try {
|
|
729
|
+
writeFileSync(join(dir, "7.jsonl"), [...slice(7, "memory-off", { impl: [60000] }), ...slice(7, "lane", { impl: [25000] })].map((e) => JSON.stringify(e)).join("\n"));
|
|
730
|
+
const last = readLocalRuns(dir);
|
|
731
|
+
assert.equal(last.length, 1); assert.equal(last[0].laneProfile, "lane");
|
|
732
|
+
const all = readLocalRuns(dir, { allSlices: true });
|
|
733
|
+
assert.deepEqual(all.map((r) => [r.issue, r.laneProfile, r.baselines.implement[0]]), [[7, "memory-off", 60000], [7, "lane", 25000]]);
|
|
734
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it("compares baseline and conformance per profile on a pinned issue set", () => {
|
|
738
|
+
const runs = [
|
|
739
|
+
slice(1, "memory-off", { impl: [60000, 70000] }), slice(2, "memory-off", { impl: [50000], shadow: "conformance.scope" }),
|
|
740
|
+
slice(1, "lane", { impl: [20000, 30000], review: 10000 }), slice(2, "lane", { impl: [24000], review: 12000, triggers: ["conformance"] }),
|
|
741
|
+
slice(3, "lane", { impl: [99999] }), [{ event: "RUN_START", issue: 4 }, use("implement", null, null), { event: "RUN_TERMINAL", reason: "merged" }],
|
|
742
|
+
[{ event: "RUN_START", issue: 5 }, use("implement", "lane", 1)], // unfinished: left out
|
|
743
|
+
].map((l) => summariseLog(l));
|
|
744
|
+
const all = laneProfileReport(runs);
|
|
745
|
+
assert.deepEqual(all.profiles.map((p) => [p.profile, p.n]), [["memory-off", 2], ["lane", 3], ["unknown", 1]]);
|
|
746
|
+
assert.deepEqual(all.issues_in_all, []);
|
|
747
|
+
const rep = laneProfileReport(runs, { issues: [1, 2] });
|
|
748
|
+
assert.deepEqual(rep.profiles.map((p) => p.profile), ["memory-off", "lane"]);
|
|
749
|
+
const [before, after] = rep.profiles;
|
|
750
|
+
assert.deepEqual(before.phases, [{ phase: "implement", sessions: 3, median: 60000, mean: 60000 }, { phase: "review", sessions: 2, median: 20000, mean: 20000 }]);
|
|
751
|
+
assert.deepEqual(after.phases[0], { phase: "implement", sessions: 3, median: 24000, mean: 24667 });
|
|
752
|
+
assert.deepEqual([before.reviewed, before.conformance_rate, before.shadow_blocks], [2, 0.5, 1]);
|
|
753
|
+
assert.deepEqual([after.reviewed, after.conformance_rate, after.shadow_blocks], [2, 0.5, 0]);
|
|
754
|
+
assert.deepEqual(rep.issues_in_all, [1, 2]);
|
|
755
|
+
const md = renderLaneProfiles(rep);
|
|
756
|
+
assert.match(md, /\| lane \| implement \| 3 \| 24000 \| 24667 \|/);
|
|
757
|
+
assert.match(md, /\| memory-off \| 2 \| 0\.5 \(2\) \| 1 \|/);
|
|
758
|
+
assert.match(md, /every profile: #1, #2/);
|
|
759
|
+
assert.match(renderLaneProfiles(laneProfileReport([])), /\| none \|/);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it("--lane-profile reads run logs only (no GitHub crawl); --issues takes a comma list", async () => {
|
|
763
|
+
assert.equal(parseArgs(["--lane-profile"]).laneProfile, true); assert.equal(parseArgs([]).laneProfile, false);
|
|
764
|
+
assert.deepEqual(parseArgs(["--issues", "4,12"]).issues, [4, 12]); assert.deepEqual(parseArgs(["--issues=7"]).issues, [7]);
|
|
765
|
+
assert.throws(() => parseArgs(["--issues", "4,x"]), /--issues must be a comma list/);
|
|
766
|
+
const rep = await buildReport({ repo: "o/r", laneProfile: true, issues: [1] }, { localRuns: [summariseLog(slice(1, "lane")), summariseLog(slice(2, "lane"))],
|
|
767
|
+
releaseTimes: [], gh: async () => { throw new Error("no network"); } });
|
|
768
|
+
assert.deepEqual(rep.query.lane_profile.profiles.map((p) => [p.profile, p.n]), [["lane", 1]]);
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
|
|
613
772
|
describe("rework by trigger and first-pass rate (#4362)", () => {
|
|
614
773
|
const use = (attempt, trigger, usd, phase = "implement") => ({ event: "PHASE_ATTEMPT_USAGE", phase, attempt, ...(trigger ? { trigger } : {}), cost_usd: usd, usage: { cache_read_input_tokens: 10, output_tokens: 1 } });
|
|
615
774
|
const log = (issue, attempts) => [{ event: "RUN_START", issue, repo: "o/r", run: `r${issue}`, version: "1.30.0", ts: "2026-10-20T00:00:00Z" }, ...attempts,
|
|
@@ -783,3 +942,20 @@ describe("fail-soft alerts (#4354)", () => {
|
|
|
783
942
|
assert.equal(computeAlerts(localLanes, { repo: "o/r" }).length, 1);
|
|
784
943
|
});
|
|
785
944
|
});
|
|
945
|
+
|
|
946
|
+
describe("env.tool_missing per release (forge#4414)", () => {
|
|
947
|
+
// The #4393 replay criterion is read as outcome-code counts per engine version; pin that the report surfaces them.
|
|
948
|
+
const run = (issue, version, reason, code) => ({ issue, repo: "o/r", version, reason, code, finished: true, usageEvents: 1, endTs: "2026-10-23T10:00:00Z", phases: { implement: { cr: 1, out: 1, ms: 1 } } });
|
|
949
|
+
const runs = [run(1, "1.30.0", "needs-human", "env.tool_missing"), run(2, "1.30.0", "merged", "completed"),
|
|
950
|
+
run(3, "1.31.0", "merged", "completed"), run(4, "1.31.0", "needs-human", "dependency.open")];
|
|
951
|
+
const codesOf = (g) => Object.fromEntries(g.flow.codes.map((c) => [c.code, c.count]));
|
|
952
|
+
|
|
953
|
+
it("counts env.tool_missing per version and drops pre-release lanes with sinceVersion", () => {
|
|
954
|
+
const all = aggregate({ runs }, { releaseTimes: [] });
|
|
955
|
+
assert.equal(codesOf(all.groups.find((g) => g.version === "1.30.0"))["env.tool_missing"], 1);
|
|
956
|
+
const after = aggregate({ runs }, { releaseTimes: [], sinceVersion: "1.31.0" });
|
|
957
|
+
assert.deepEqual(after.groups.map((g) => g.version), ["1.31.0"]);
|
|
958
|
+
assert.equal(codesOf(after.groups[0])["env.tool_missing"], undefined);
|
|
959
|
+
assert.equal(after.groups[0].flow.codesN, 2);
|
|
960
|
+
});
|
|
961
|
+
});
|