faberun 0.20.0 → 0.22.0
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/package.json +2 -1
- package/skills/faberun/references/operations.md +7 -7
- package/src/campaign/brief-cli.mjs +174 -0
- package/src/campaign/brief-text.mjs +96 -0
- package/src/campaign/campaign-brief.mjs +772 -0
- package/src/campaign/chain.mjs +30 -1
- package/src/campaign/projection.mjs +34 -0
- package/src/cli/campaign.mjs +22 -2
- package/src/contract/index.mjs +5 -3
- package/src/contract/runtime.mjs +22 -1
- package/src/contract/scope-findings.mjs +12 -0
- package/src/contract/snapshot.mjs +16 -5
- package/src/engine/dispatch.mjs +6 -4
- package/src/engine/process.mjs +1 -0
- package/src/engine/result-file.mjs +13 -1
- package/src/engine/settle.mjs +1 -0
- package/src/engine/verify.mjs +37 -2
- package/src/harnesses/codex/index.mjs +1 -0
- package/src/harnesses/index.mjs +18 -1
- package/src/plan/freeze.mjs +240 -35
- package/src/plan/pipeline-shape.mjs +100 -0
- package/src/plan/pipeline.mjs +68 -116
- package/src/plan/template.mjs +88 -25
- package/src/repo/declared-paths.mjs +16 -0
- package/src/repo/workspace.mjs +42 -3
- package/src/repo/worktree.mjs +34 -2
- package/src/report/campaign-brief-estimate.mjs +450 -0
- package/src/report/campaign-brief-html.mjs +439 -0
- package/src/report/campaign-brief.mjs +409 -0
- package/src/report/final.mjs +5 -4
- package/src/report/mdhtml-release.json +30 -0
- package/src/report/render.mjs +4 -3
- package/src/run/usage.mjs +265 -0
- package/src/web/campaign-brief-server.mjs +401 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The R5 expense estimate: turn this target project's durable completed
|
|
3
|
+
* execution nodes into the cost and elapsed-duration ranges the Campaign Brief
|
|
4
|
+
* renders. The module is presentation-adjacent but owns no rendering; it reads
|
|
5
|
+
* a validated contract and one recorded pool, and returns a `BriefEstimateInput`
|
|
6
|
+
* the campaign core normalizes.
|
|
7
|
+
*
|
|
8
|
+
* Two rules shape everything here. First, a measure is only a range when every
|
|
9
|
+
* assigned role has at least five distinct comparable completed nodes that
|
|
10
|
+
* carry the evidence that measure needs — priced `usage.jsonl` cost for cost,
|
|
11
|
+
* recorded actual node and verification elapsed times for duration. Anything
|
|
12
|
+
* less is `insufficient data` with the reason, never zero and never a point
|
|
13
|
+
* estimate. Second, elapsed duration is scheduled through the real `dependsOn`
|
|
14
|
+
* graph under `maxParallel` and each runtime's `maxConcurrent`, so the range
|
|
15
|
+
* reflects capacity, not merely which nodes are dependency-independent.
|
|
16
|
+
*
|
|
17
|
+
* `timeoutSec` is never read. It is a ceiling on a planned command, not a
|
|
18
|
+
* measurement of a completed one; the estimate uses only recorded history and
|
|
19
|
+
* says so in its method and assumptions.
|
|
20
|
+
*/
|
|
21
|
+
import { collectCompletedExecutionNodes } from "../run/usage.mjs";
|
|
22
|
+
import { asArray, unique } from "../campaign/brief-text.mjs";
|
|
23
|
+
import { scheduleUnderCapacity } from "../campaign/campaign-brief.mjs";
|
|
24
|
+
|
|
25
|
+
/** @typedef {import("../run/usage.mjs").CompletedExecutionNode} CompletedExecutionNode */
|
|
26
|
+
/** @typedef {import("../run/usage.mjs").CompletedExecutionPool} CompletedExecutionPool */
|
|
27
|
+
/** @typedef {import("../campaign/campaign-brief.mjs").BriefEstimateInput} BriefEstimateInput */
|
|
28
|
+
/** @typedef {import("../campaign/campaign-brief.mjs").BriefMeasureInput} BriefMeasureInput */
|
|
29
|
+
/** @typedef {BriefEstimateInput & {cost: BriefMeasureInput, duration: BriefMeasureInput, method: string[], assumptions: string[]}} BriefExpenseInput */
|
|
30
|
+
/** @typedef {Record<string, any>} AnyRecord */
|
|
31
|
+
/** @typedef {"worker"|"judge"} ExecutionRole */
|
|
32
|
+
/** @typedef {{nodeId: string, role: ExecutionRole, taskKind: string, runtimeId: string|null, model: string|null}} AssignedRole */
|
|
33
|
+
/** @typedef {{id: string, runtimeId: string|null, dependsOn: string[]}} ScheduledNode */
|
|
34
|
+
|
|
35
|
+
/** The comparable-sample floor R5 sets for every assigned role. */
|
|
36
|
+
export const SAMPLE_FLOOR = 5;
|
|
37
|
+
const DEFAULT_WINDOW_DAYS = 90;
|
|
38
|
+
const MINUTE_MS = 60 * 1000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Estimate the contract's expense from its recorded completed execution nodes.
|
|
42
|
+
*
|
|
43
|
+
* When `pool` is supplied it is used as the recorded pool (its samples are
|
|
44
|
+
* still confined to the window); otherwise `runsRoot` and `cutoff` are handed
|
|
45
|
+
* to `collectCompletedExecutionNodes`. A missing cutoff, an unreadable pool,
|
|
46
|
+
* or a role without enough comparable evidence produces `insufficient data`
|
|
47
|
+
* for the affected measure, never a zero.
|
|
48
|
+
*
|
|
49
|
+
* @param {{
|
|
50
|
+
* contract: AnyRecord,
|
|
51
|
+
* cutoff?: string|null,
|
|
52
|
+
* runsRoot?: string,
|
|
53
|
+
* pool?: CompletedExecutionPool,
|
|
54
|
+
* windowDays?: number,
|
|
55
|
+
* }} options
|
|
56
|
+
* @returns {BriefExpenseInput}
|
|
57
|
+
*/
|
|
58
|
+
export function estimateBriefExpense(options) {
|
|
59
|
+
const contract = options?.contract && typeof options.contract === "object" ? options.contract : {};
|
|
60
|
+
const cutoff = typeof options?.cutoff === "string" && options.cutoff.trim() ? options.cutoff : null;
|
|
61
|
+
const cutoffMs = cutoff ? Date.parse(cutoff) : NaN;
|
|
62
|
+
const windowDays = typeof options?.windowDays === "number" && Number.isFinite(options.windowDays) && options.windowDays > 0
|
|
63
|
+
? options.windowDays
|
|
64
|
+
: DEFAULT_WINDOW_DAYS;
|
|
65
|
+
const assignments = assignedRoles(contract);
|
|
66
|
+
const scheduledNodes = plannedWorkerNodes(contract);
|
|
67
|
+
const runtimes = unique(assignments.map((assignment) => assignment.runtimeId).filter((id) => id !== null).map(String));
|
|
68
|
+
const models = unique(assignments.map((assignment) => assignment.model).filter((model) => model !== null).map(String));
|
|
69
|
+
const nodeCount = Array.isArray(contract.nodes) ? contract.nodes.length : 0;
|
|
70
|
+
const workerCount = assignments.filter((assignment) => assignment.role === "worker").length;
|
|
71
|
+
const maxParallel = Number.isInteger(contract.maxParallel) && contract.maxParallel > 0 ? contract.maxParallel : 1;
|
|
72
|
+
const maxConcurrent = concurrencyLimits(contract);
|
|
73
|
+
const effectiveConcurrency = scheduledNodes.length > 0
|
|
74
|
+
? Math.max(1, scheduleUnderCapacity(scheduledNodes, () => 1, maxParallel, maxConcurrent).peakConcurrency)
|
|
75
|
+
: 1;
|
|
76
|
+
|
|
77
|
+
const method = [
|
|
78
|
+
`Comparable completed nodes share task kind, runtime id, model and worker/judge role; a range needs at least ${SAMPLE_FLOOR} distinct nodes for every assigned role.`,
|
|
79
|
+
"Cost is the sum of each comparable role's priced usage.jsonl invocation costs; duration is each comparable node's actual elapsed time plus its recorded verification elapsed time.",
|
|
80
|
+
"Elapsed duration is scheduled through the dependsOn graph under maxParallel and each runtime's maxConcurrent capacity.",
|
|
81
|
+
"Historical timings are proxies for planned commands that have not run yet; timeoutSec is a ceiling and is never used as a measurement.",
|
|
82
|
+
];
|
|
83
|
+
const assumptions = [
|
|
84
|
+
`Pool is this target project's durable runs completed in the ${windowDays} days before the recorded usage cutoff.`,
|
|
85
|
+
"Planning/discovery runs and incomplete nodes are excluded.",
|
|
86
|
+
"Ranges are advisory, never spend or time ceilings.",
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
/** @param {string} reason @returns {BriefExpenseInput} */
|
|
90
|
+
const bothInsufficient = (reason) => ({
|
|
91
|
+
cost: measure("insufficient data", reason, { provenance: "priced usage.jsonl invocations" }),
|
|
92
|
+
duration: measure("insufficient data", reason, { provenance: "recorded actual node and verification elapsed times" }),
|
|
93
|
+
runtimes,
|
|
94
|
+
models,
|
|
95
|
+
effectiveConcurrency,
|
|
96
|
+
nodeCount,
|
|
97
|
+
workerCount,
|
|
98
|
+
sampleCutoff: cutoff,
|
|
99
|
+
method,
|
|
100
|
+
assumptions,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (!Number.isFinite(cutoffMs)) {
|
|
104
|
+
return bothInsufficient("usage sample cutoff is not recorded, so no history window can be established");
|
|
105
|
+
}
|
|
106
|
+
if (assignments.length === 0) {
|
|
107
|
+
return bothInsufficient("the contract assigns no worker or judge role");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const pool = resolvePool(options, cutoff, windowDays);
|
|
111
|
+
if (!pool.readable) {
|
|
112
|
+
return bothInsufficient(`history pool is unreadable: ${describeUnreadable(pool)}`);
|
|
113
|
+
}
|
|
114
|
+
const windowStartMs = cutoffMs - windowDays * 24 * 60 * 60 * 1000;
|
|
115
|
+
const samples = pool.nodes.filter((sample) => withinWindow(sample.completedAt, cutoffMs, windowStartMs));
|
|
116
|
+
const unreadableNote = pool.unreadable.length > 0 ? [`Unreadable runs were skipped: ${describeUnreadable(pool)}.`] : [];
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
cost: costMeasure(assignments, samples),
|
|
120
|
+
duration: durationMeasure(assignments, samples, scheduledNodes, maxParallel, maxConcurrent),
|
|
121
|
+
runtimes,
|
|
122
|
+
models,
|
|
123
|
+
effectiveConcurrency,
|
|
124
|
+
nodeCount,
|
|
125
|
+
workerCount,
|
|
126
|
+
sampleCutoff: cutoff,
|
|
127
|
+
method,
|
|
128
|
+
assumptions: [...assumptions, ...unreadableNote],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @param {{
|
|
134
|
+
* pool?: CompletedExecutionPool,
|
|
135
|
+
* runsRoot?: string,
|
|
136
|
+
* windowDays?: number,
|
|
137
|
+
* }} options
|
|
138
|
+
* @param {string|null} cutoff
|
|
139
|
+
* @param {number} windowDays
|
|
140
|
+
* @returns {CompletedExecutionPool}
|
|
141
|
+
*/
|
|
142
|
+
function resolvePool(options, cutoff, windowDays) {
|
|
143
|
+
if (options?.pool && Array.isArray(options.pool.nodes)) return options.pool;
|
|
144
|
+
if (typeof options?.runsRoot === "string" && options.runsRoot) {
|
|
145
|
+
return collectCompletedExecutionNodes({ runsRoot: options.runsRoot, cutoff: cutoff ?? "", windowDays });
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
nodes: [],
|
|
149
|
+
sourceRuns: [],
|
|
150
|
+
unreadable: [{ runId: null, reason: "no completed-execution pool or runs root was provided" }],
|
|
151
|
+
scannedRuns: 0,
|
|
152
|
+
readable: false,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The assigned role keys of the contract: every node has a worker role, and a
|
|
158
|
+
* node whose gate is enabled with a non-`none` review also has a judge role.
|
|
159
|
+
*
|
|
160
|
+
* @param {AnyRecord} contract
|
|
161
|
+
* @returns {AssignedRole[]}
|
|
162
|
+
*/
|
|
163
|
+
function assignedRoles(contract) {
|
|
164
|
+
const nodes = Array.isArray(contract.nodes) ? contract.nodes : [];
|
|
165
|
+
const defaults = contract.runtimeDefaults && typeof contract.runtimeDefaults === "object" ? contract.runtimeDefaults : {};
|
|
166
|
+
const runtimes = runtimeTable(contract);
|
|
167
|
+
/** @type {AssignedRole[]} */
|
|
168
|
+
const assignments = [];
|
|
169
|
+
for (const node of nodes) {
|
|
170
|
+
const nodeId = String(node?.id ?? "");
|
|
171
|
+
const taskKind = typeof node?.type === "string" ? node.type : "";
|
|
172
|
+
const workerRuntimeId = typeof node?.runtime === "string" && node.runtime
|
|
173
|
+
? node.runtime
|
|
174
|
+
: typeof defaults.worker === "string" && defaults.worker ? defaults.worker : null;
|
|
175
|
+
assignments.push({
|
|
176
|
+
nodeId,
|
|
177
|
+
role: "worker",
|
|
178
|
+
taskKind,
|
|
179
|
+
runtimeId: workerRuntimeId,
|
|
180
|
+
model: modelFor(runtimes, workerRuntimeId),
|
|
181
|
+
});
|
|
182
|
+
const gate = node?.gate && typeof node.gate === "object" ? node.gate : null;
|
|
183
|
+
const judgeEnabled = gate !== null && gate.enabled === true && (gate.review ?? "blocking") !== "none";
|
|
184
|
+
if (!judgeEnabled) continue;
|
|
185
|
+
const judgeRuntimeId = typeof gate.runtime === "string" && gate.runtime
|
|
186
|
+
? gate.runtime
|
|
187
|
+
: typeof defaults.judge === "string" && defaults.judge ? defaults.judge : null;
|
|
188
|
+
assignments.push({
|
|
189
|
+
nodeId,
|
|
190
|
+
role: "judge",
|
|
191
|
+
taskKind,
|
|
192
|
+
runtimeId: judgeRuntimeId,
|
|
193
|
+
model: modelFor(runtimes, judgeRuntimeId),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return assignments;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* @param {AnyRecord} contract
|
|
201
|
+
* @returns {ScheduledNode[]}
|
|
202
|
+
*/
|
|
203
|
+
function plannedWorkerNodes(contract) {
|
|
204
|
+
const nodes = Array.isArray(contract.nodes) ? contract.nodes : [];
|
|
205
|
+
const defaults = contract.runtimeDefaults && typeof contract.runtimeDefaults === "object" ? contract.runtimeDefaults : {};
|
|
206
|
+
return nodes.map((node) => ({
|
|
207
|
+
id: String(node?.id ?? ""),
|
|
208
|
+
runtimeId: typeof node?.runtime === "string" && node.runtime
|
|
209
|
+
? node.runtime
|
|
210
|
+
: typeof defaults.worker === "string" && defaults.worker ? defaults.worker : null,
|
|
211
|
+
dependsOn: asArray(node?.dependsOn).map(String),
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* @param {AnyRecord} contract
|
|
217
|
+
* @returns {Record<string, number>}
|
|
218
|
+
*/
|
|
219
|
+
function concurrencyLimits(contract) {
|
|
220
|
+
const runtimes = runtimeTable(contract);
|
|
221
|
+
/** @type {Record<string, number>} */
|
|
222
|
+
const limits = {};
|
|
223
|
+
for (const [id, runtime] of Object.entries(runtimes)) {
|
|
224
|
+
limits[id] = Number.isInteger(runtime?.maxConcurrent) && runtime.maxConcurrent > 0 ? runtime.maxConcurrent : 1;
|
|
225
|
+
}
|
|
226
|
+
return limits;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @param {AnyRecord} contract
|
|
231
|
+
* @returns {Record<string, AnyRecord>}
|
|
232
|
+
*/
|
|
233
|
+
function runtimeTable(contract) {
|
|
234
|
+
return contract.runtimes && typeof contract.runtimes === "object" && !Array.isArray(contract.runtimes)
|
|
235
|
+
? contract.runtimes
|
|
236
|
+
: {};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* @param {Record<string, AnyRecord>} runtimes
|
|
241
|
+
* @param {string|null} runtimeId
|
|
242
|
+
* @returns {string|null}
|
|
243
|
+
*/
|
|
244
|
+
function modelFor(runtimes, runtimeId) {
|
|
245
|
+
if (runtimeId === null) return null;
|
|
246
|
+
const model = runtimes[runtimeId]?.model;
|
|
247
|
+
return typeof model === "string" && model ? model : null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The distinct comparable completed nodes for one assigned role and measure.
|
|
252
|
+
* The comparability key is the full tuple of task kind, runtime id, model and
|
|
253
|
+
* role; the measure adds its own evidence requirement.
|
|
254
|
+
*
|
|
255
|
+
* @param {AssignedRole} key
|
|
256
|
+
* @param {CompletedExecutionNode[]} samples
|
|
257
|
+
* @param {"cost"|"duration"} measureName
|
|
258
|
+
* @returns {CompletedExecutionNode[]}
|
|
259
|
+
*/
|
|
260
|
+
function comparable(key, samples, measureName) {
|
|
261
|
+
const seen = new Set();
|
|
262
|
+
/** @type {CompletedExecutionNode[]} */
|
|
263
|
+
const result = [];
|
|
264
|
+
for (const sample of samples) {
|
|
265
|
+
if (sample.taskKind !== key.taskKind) continue;
|
|
266
|
+
const role = key.role === "worker" ? sample.worker : sample.judge;
|
|
267
|
+
if (!role || role.runtimeId !== key.runtimeId || role.model !== key.model) continue;
|
|
268
|
+
if (measureName === "cost" && !(typeof role.costUsd === "number" && Number.isFinite(role.costUsd) && role.costProvenance === "priced")) continue;
|
|
269
|
+
if (measureName === "duration" && !(typeof sample.durationMs === "number" && Number.isFinite(sample.durationMs))) continue;
|
|
270
|
+
const identity = `${sample.runId}\u0000${sample.nodeId}`;
|
|
271
|
+
if (seen.has(identity)) continue;
|
|
272
|
+
seen.add(identity);
|
|
273
|
+
result.push(sample);
|
|
274
|
+
}
|
|
275
|
+
return result;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* @param {AssignedRole[]} assignments
|
|
280
|
+
* @param {CompletedExecutionNode[]} samples
|
|
281
|
+
* @returns {BriefMeasureInput}
|
|
282
|
+
*/
|
|
283
|
+
function costMeasure(assignments, samples) {
|
|
284
|
+
const byNode = groupByNode(assignments);
|
|
285
|
+
let min = 0;
|
|
286
|
+
let max = 0;
|
|
287
|
+
let minSamples = Infinity;
|
|
288
|
+
const contributors = new Set();
|
|
289
|
+
for (const [nodeId, roles] of byNode) {
|
|
290
|
+
for (const key of roles) {
|
|
291
|
+
const eligible = comparable(key, samples, "cost");
|
|
292
|
+
if (eligible.length < SAMPLE_FLOOR) {
|
|
293
|
+
return measure("insufficient data", `${describeRole(key)} for node ${nodeId} has ${eligible.length} priced comparable completed node(s), fewer than ${SAMPLE_FLOOR}`, {
|
|
294
|
+
samples: eligible.length,
|
|
295
|
+
provenance: "priced usage.jsonl invocations",
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
const costs = eligible.map((sample) => /** @type {number} */ (key.role === "worker" ? sample.worker.costUsd : sample.judge?.costUsd));
|
|
299
|
+
min += Math.min(...costs);
|
|
300
|
+
max += Math.max(...costs);
|
|
301
|
+
minSamples = Math.min(minSamples, eligible.length);
|
|
302
|
+
for (const sample of eligible) contributors.add(`${sample.runId}\u0000${sample.nodeId}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return measure("range", null, {
|
|
306
|
+
min: round(min, 4),
|
|
307
|
+
max: round(max, 4),
|
|
308
|
+
samples: Number.isFinite(minSamples) ? minSamples : 0,
|
|
309
|
+
sourceRuns: sourceRuns(contributors),
|
|
310
|
+
method: "sum of each comparable role's priced usage.jsonl invocation costs, bounded by the least and most expensive comparable node per role",
|
|
311
|
+
provenance: "priced usage.jsonl invocations",
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* @param {AssignedRole[]} assignments
|
|
317
|
+
* @param {CompletedExecutionNode[]} samples
|
|
318
|
+
* @param {ScheduledNode[]} scheduledNodes
|
|
319
|
+
* @param {number} maxParallel
|
|
320
|
+
* @param {Record<string, number>} maxConcurrent
|
|
321
|
+
* @returns {BriefMeasureInput}
|
|
322
|
+
*/
|
|
323
|
+
function durationMeasure(assignments, samples, scheduledNodes, maxParallel, maxConcurrent) {
|
|
324
|
+
const byNode = groupByNode(assignments);
|
|
325
|
+
/** @type {Map<string, number>} */
|
|
326
|
+
const minByNode = new Map();
|
|
327
|
+
/** @type {Map<string, number>} */
|
|
328
|
+
const maxByNode = new Map();
|
|
329
|
+
let minSamples = Infinity;
|
|
330
|
+
const contributors = new Set();
|
|
331
|
+
for (const [nodeId, roles] of byNode) {
|
|
332
|
+
for (const key of roles) {
|
|
333
|
+
const eligible = comparable(key, samples, "duration");
|
|
334
|
+
if (eligible.length < SAMPLE_FLOOR) {
|
|
335
|
+
return measure("insufficient data", `${describeRole(key)} for node ${nodeId} has ${eligible.length} comparable completed node(s) with recorded node and verification elapsed times, fewer than ${SAMPLE_FLOOR}`, {
|
|
336
|
+
samples: eligible.length,
|
|
337
|
+
provenance: "recorded actual node and verification elapsed times",
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
minSamples = Math.min(minSamples, eligible.length);
|
|
341
|
+
for (const sample of eligible) contributors.add(`${sample.runId}\u0000${sample.nodeId}`);
|
|
342
|
+
// The worker role carries the planned node's elapsed cost; the judge
|
|
343
|
+
// role is separate evidence and is counted, but its node is the same
|
|
344
|
+
// node, so adding it again would double-count the elapsed time.
|
|
345
|
+
if (key.role !== "worker") continue;
|
|
346
|
+
const durations = eligible.map((sample) => /** @type {number} */ (sample.durationMs));
|
|
347
|
+
minByNode.set(nodeId, Math.min(...durations));
|
|
348
|
+
maxByNode.set(nodeId, Math.max(...durations));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const lower = scheduleUnderCapacity(scheduledNodes, (node) => minByNode.get(node.id) ?? 0, maxParallel, maxConcurrent);
|
|
352
|
+
const upper = scheduleUnderCapacity(scheduledNodes, (node) => maxByNode.get(node.id) ?? 0, maxParallel, maxConcurrent);
|
|
353
|
+
return measure("range", null, {
|
|
354
|
+
min: round(lower.makespanMs / MINUTE_MS, 1),
|
|
355
|
+
max: round(upper.makespanMs / MINUTE_MS, 1),
|
|
356
|
+
samples: Number.isFinite(minSamples) ? minSamples : 0,
|
|
357
|
+
sourceRuns: sourceRuns(contributors),
|
|
358
|
+
method: "dependency-graph schedule under maxParallel and each runtime's maxConcurrent, using each comparable node's actual elapsed time plus recorded verification elapsed time",
|
|
359
|
+
provenance: "recorded actual node and verification elapsed times",
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* @param {AssignedRole[]} assignments
|
|
365
|
+
* @returns {Map<string, AssignedRole[]>}
|
|
366
|
+
*/
|
|
367
|
+
function groupByNode(assignments) {
|
|
368
|
+
/** @type {Map<string, AssignedRole[]>} */
|
|
369
|
+
const grouped = new Map();
|
|
370
|
+
for (const assignment of assignments) {
|
|
371
|
+
const list = grouped.get(assignment.nodeId) ?? [];
|
|
372
|
+
list.push(assignment);
|
|
373
|
+
grouped.set(assignment.nodeId, list);
|
|
374
|
+
}
|
|
375
|
+
return grouped;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* @param {AssignedRole} key
|
|
380
|
+
* @returns {string}
|
|
381
|
+
*/
|
|
382
|
+
function describeRole(key) {
|
|
383
|
+
return `${key.role} ${key.taskKind || "(no task kind)"}/${key.runtimeId ?? "(no runtime)"}/${key.model ?? "(no model)"}`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* @param {CompletedExecutionPool} pool
|
|
388
|
+
* @returns {string}
|
|
389
|
+
*/
|
|
390
|
+
function describeUnreadable(pool) {
|
|
391
|
+
const reasons = pool.unreadable.map((entry) => entry.runId ? `${entry.runId}: ${entry.reason}` : entry.reason);
|
|
392
|
+
return reasons.length > 0 ? reasons.join("; ") : "no readable runs";
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* @param {Set<string>} identities
|
|
397
|
+
* @returns {string[]}
|
|
398
|
+
*/
|
|
399
|
+
function sourceRuns(identities) {
|
|
400
|
+
const runs = new Set();
|
|
401
|
+
for (const identity of identities) {
|
|
402
|
+
const runId = identity.split("\u0000")[0];
|
|
403
|
+
if (runId) runs.add(runId);
|
|
404
|
+
}
|
|
405
|
+
return [...runs].sort();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* @param {string|null} completedAt
|
|
410
|
+
* @param {number} cutoffMs
|
|
411
|
+
* @param {number} windowStartMs
|
|
412
|
+
* @returns {boolean}
|
|
413
|
+
*/
|
|
414
|
+
function withinWindow(completedAt, cutoffMs, windowStartMs) {
|
|
415
|
+
if (typeof completedAt !== "string") return false;
|
|
416
|
+
const completedMs = Date.parse(completedAt);
|
|
417
|
+
return Number.isFinite(completedMs) && completedMs <= cutoffMs && completedMs >= windowStartMs;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Build a measure object. A `range` carries finite bounds; `insufficient data`
|
|
422
|
+
* carries the reason and never a zero bound.
|
|
423
|
+
*
|
|
424
|
+
* @param {"range"|"insufficient data"} status
|
|
425
|
+
* @param {string|null} reason
|
|
426
|
+
* @param {{min?: number, max?: number, samples?: number, sourceRuns?: string[], method?: string, provenance?: string}} [values]
|
|
427
|
+
* @returns {BriefMeasureInput}
|
|
428
|
+
*/
|
|
429
|
+
function measure(status, reason, values = {}) {
|
|
430
|
+
return {
|
|
431
|
+
status,
|
|
432
|
+
min: status === "range" ? values.min ?? null : null,
|
|
433
|
+
max: status === "range" ? values.max ?? null : null,
|
|
434
|
+
samples: typeof values.samples === "number" ? values.samples : null,
|
|
435
|
+
reason: status === "range" ? null : reason,
|
|
436
|
+
sourceRuns: values.sourceRuns ?? [],
|
|
437
|
+
method: values.method ?? null,
|
|
438
|
+
provenance: values.provenance ?? null,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* @param {number} value
|
|
444
|
+
* @param {number} digits
|
|
445
|
+
* @returns {number}
|
|
446
|
+
*/
|
|
447
|
+
function round(value, digits) {
|
|
448
|
+
const factor = 10 ** digits;
|
|
449
|
+
return Math.round(value * factor) / factor;
|
|
450
|
+
}
|