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.
@@ -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
+ }