@llm4ts/runner 3.4.0 → 4.0.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.
Files changed (84) hide show
  1. package/dist/Connectors.d.ts +0 -6
  2. package/dist/Connectors.d.ts.map +1 -1
  3. package/dist/Connectors.js +0 -9
  4. package/dist/Connectors.js.map +1 -1
  5. package/dist/EventPresentation.d.ts.map +1 -1
  6. package/dist/EventPresentation.js +0 -1
  7. package/dist/EventPresentation.js.map +1 -1
  8. package/dist/FlowRunner.d.ts +10 -3
  9. package/dist/FlowRunner.d.ts.map +1 -1
  10. package/dist/FlowRunner.js +20 -2
  11. package/dist/FlowRunner.js.map +1 -1
  12. package/dist/NodeGeminiCliExecutor.d.ts.map +1 -1
  13. package/dist/NodeGeminiCliExecutor.js +1 -0
  14. package/dist/NodeGeminiCliExecutor.js.map +1 -1
  15. package/dist/NodeGeminiModelBridge.d.ts.map +1 -1
  16. package/dist/NodeGeminiModelBridge.js +1 -0
  17. package/dist/NodeGeminiModelBridge.js.map +1 -1
  18. package/dist/NodeHttpClient.d.ts.map +1 -1
  19. package/dist/NodeHttpClient.js +1 -0
  20. package/dist/NodeHttpClient.js.map +1 -1
  21. package/dist/Otel.d.ts.map +1 -1
  22. package/dist/Otel.js +1 -0
  23. package/dist/Otel.js.map +1 -1
  24. package/dist/Package.d.ts.map +1 -1
  25. package/dist/Package.js +1 -0
  26. package/dist/Package.js.map +1 -1
  27. package/dist/Terminal.d.ts +1 -2
  28. package/dist/Terminal.d.ts.map +1 -1
  29. package/dist/Terminal.js +1 -4
  30. package/dist/Terminal.js.map +1 -1
  31. package/dist/WindowsCommand.d.ts.map +1 -1
  32. package/dist/WindowsCommand.js +1 -0
  33. package/dist/WindowsCommand.js.map +1 -1
  34. package/dist/index.d.ts +1 -2
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -2
  37. package/dist/index.js.map +1 -1
  38. package/package.json +8 -21
  39. package/src/Connectors.ts +0 -10
  40. package/src/EventPresentation.ts +0 -1
  41. package/src/FlowRunner.ts +26 -6
  42. package/src/NodeGeminiCliExecutor.ts +1 -0
  43. package/src/NodeGeminiModelBridge.ts +1 -0
  44. package/src/NodeHttpClient.ts +1 -0
  45. package/src/Otel.ts +1 -0
  46. package/src/Package.ts +1 -0
  47. package/src/Terminal.ts +1 -5
  48. package/src/WindowsCommand.ts +1 -0
  49. package/src/index.ts +0 -2
  50. package/dist/Costs.d.ts +0 -52
  51. package/dist/Costs.d.ts.map +0 -1
  52. package/dist/Costs.js +0 -95
  53. package/dist/Costs.js.map +0 -1
  54. package/dist/McpStdio.d.ts +0 -7
  55. package/dist/McpStdio.d.ts.map +0 -1
  56. package/dist/McpStdio.js +0 -19
  57. package/dist/McpStdio.js.map +0 -1
  58. package/dist/Profile.d.ts +0 -313
  59. package/dist/Profile.d.ts.map +0 -1
  60. package/dist/Profile.js +0 -937
  61. package/dist/Profile.js.map +0 -1
  62. package/dist/ReviewFingerprint.d.ts +0 -2
  63. package/dist/ReviewFingerprint.d.ts.map +0 -1
  64. package/dist/ReviewFingerprint.js +0 -13
  65. package/dist/ReviewFingerprint.js.map +0 -1
  66. package/dist/RunReaders.d.ts +0 -70
  67. package/dist/RunReaders.d.ts.map +0 -1
  68. package/dist/RunReaders.js +0 -236
  69. package/dist/RunReaders.js.map +0 -1
  70. package/dist/StoryReaders.d.ts +0 -16
  71. package/dist/StoryReaders.d.ts.map +0 -1
  72. package/dist/StoryReaders.js +0 -107
  73. package/dist/StoryReaders.js.map +0 -1
  74. package/dist/Watch.d.ts +0 -68
  75. package/dist/Watch.d.ts.map +0 -1
  76. package/dist/Watch.js +0 -261
  77. package/dist/Watch.js.map +0 -1
  78. package/src/Costs.ts +0 -148
  79. package/src/McpStdio.ts +0 -41
  80. package/src/Profile.ts +0 -1196
  81. package/src/ReviewFingerprint.ts +0 -13
  82. package/src/RunReaders.ts +0 -370
  83. package/src/StoryReaders.ts +0 -157
  84. package/src/Watch.ts +0 -446
package/src/Profile.ts DELETED
@@ -1,1196 +0,0 @@
1
- import * as Effect from "effect/Effect"
2
- import * as Schema from "effect/Schema"
3
- import type { FlowError } from "@llm4ts/flow/FlowError"
4
- import {
5
- LaneKind,
6
- laneClosed,
7
- laneOf,
8
- laneOpened,
9
- type FlowEvent,
10
- type Timed
11
- } from "@llm4ts/flow/FlowEvents"
12
- import { readTrace } from "@llm4ts/flow/Replay"
13
- import { duration, treeInputsOfTrace, type TreeInput } from "./AgentTree.ts"
14
-
15
- export { duration }
16
- import { presentationOf } from "./EventPresentation.ts"
17
- import { formatCount } from "./Terminal.ts"
18
- import {
19
- resolveTraceTarget,
20
- WatchTargetMissing,
21
- type TraceChoice,
22
- type TraceSources
23
- } from "./Watch.ts"
24
-
25
- /**
26
- * `llm4ts profile`: where a run's time went. A pure fold over the
27
- * same inputs as the agent tree: per story, its wall time split into model,
28
- * the coder's tools, gates, merge and waiting, the rest left as unaccounted;
29
- * per role and executor the model calls, per command the gates; and the
30
- * biggest sinks ranked in plain words.
31
- *
32
- * Content-free by design, so a report can leave a customer's server: story
33
- * ids, roles, executor ids, tool names, gate commands as configured, counts
34
- * and durations — never a prompt, a reply, an argument or an output.
35
- *
36
- * A trace from before the `Timed` event (2.22) is estimated: a model call is
37
- * the gap before its token report in the same lane, and the report says so.
38
- */
39
-
40
- const Ms = Schema.Number
41
-
42
- export const ProfileTime = Schema.Struct({
43
- model: Ms,
44
- tools: Ms,
45
- gates: Ms,
46
- merge: Ms,
47
- waiting: Ms,
48
- unaccounted: Ms
49
- })
50
- export type ProfileTime = typeof ProfileTime.Type
51
-
52
- export const TurnStats = Schema.Struct({
53
- count: Schema.Int,
54
- avgMs: Ms,
55
- maxMs: Ms,
56
- firstPrompt: Schema.optionalKey(Schema.Int),
57
- lastPrompt: Schema.optionalKey(Schema.Int),
58
- maxPrompt: Schema.optionalKey(Schema.Int),
59
- /** The last prompt is more than three times the first. */
60
- promptGrowth: Schema.Boolean,
61
- /** Tool calls per finished turn: each is a model round trip. */
62
- avgSteps: Schema.Number,
63
- /** The model's own time per round trip: a turn's time less its tools, over its steps + 1. */
64
- avgStepMs: Ms
65
- })
66
- export type TurnStats = typeof TurnStats.Type
67
-
68
- /** A stretch of a story's time no `Timed` event covers, named by the events around it. */
69
- export const Gap = Schema.Struct({
70
- ms: Ms,
71
- after: Schema.String,
72
- before: Schema.String
73
- })
74
- export type Gap = typeof Gap.Type
75
-
76
- /**
77
- * The coder's tool calls by kind of work (build, install and git count as
78
- * other). `script` is the ad-hoc Python/Node one-liners, heredocs and scratch
79
- * files it improvised; profiles saved before it existed decode it as 0.
80
- */
81
- export const ToolCalls = Schema.Struct({
82
- explore: Schema.Int,
83
- edit: Schema.Int,
84
- test: Schema.Int,
85
- script: Schema.Int.pipe(Schema.withDecodingDefaultKey(Effect.succeed(0))),
86
- other: Schema.Int
87
- })
88
- export type ToolCalls = typeof ToolCalls.Type
89
-
90
- export const StoryProfile = Schema.Struct({
91
- id: Schema.String,
92
- /** What the lane stands for when it is not a story: a roundtable attendee, a taskforce specialist. */
93
- kind: Schema.optionalKey(LaneKind),
94
- executor: Schema.optionalKey(Schema.String),
95
- status: Schema.Literals(["done", "failed", "running"]),
96
- wallMs: Ms,
97
- /** From the plan being ready to this story starting: dependencies and free coder slots. */
98
- queuedMs: Schema.optionalKey(Ms),
99
- time: ProfileTime,
100
- turns: TurnStats,
101
- /** Its largest untimed stretches, 30 seconds or more: where "unaccounted" sits. */
102
- gaps: Schema.Array(Gap),
103
- /**
104
- * A coder turn the trace shows working (tool calls after its last finished
105
- * call) but not ended: a run still going. Counted as model time so far.
106
- */
107
- openTurnMs: Ms,
108
- openTurnTools: Schema.Int,
109
- toolCalls: ToolCalls,
110
- /** Task stages the coder ran on this lane: the tasks it gave itself, plus any revision. */
111
- tasks: Schema.Int,
112
- /** From the coder's first task (after setup and planning) to its first edit; absent when it never edited. */
113
- firstEditMs: Schema.optionalKey(Ms),
114
- /** Explore calls (ls, find, grep, read) made before that first edit. */
115
- exploreBeforeEdit: Schema.Int,
116
- /** Commands the coder's Findings claimed to have run that no tool call carried (ADR 0027). */
117
- unverifiedClaims: Schema.Int,
118
- /** Tasks whose Findings said `confidence: low`. */
119
- lowConfidenceTasks: Schema.Int,
120
- /** The largest diff its reviewers read in one round, in lines (ADR 0044). */
121
- reviewDiffLines: Schema.optionalKey(Schema.Int)
122
- })
123
- export type StoryProfile = typeof StoryProfile.Type
124
-
125
- export const ModelRow = Schema.Struct({
126
- role: Schema.String,
127
- executor: Schema.optionalKey(Schema.String),
128
- calls: Schema.Int,
129
- ms: Ms,
130
- avgMs: Ms,
131
- avgFirstMs: Schema.optionalKey(Ms),
132
- /** As the backend reported them. */
133
- apiMs: Schema.optionalKey(Ms),
134
- toolMs: Schema.optionalKey(Ms)
135
- })
136
- export type ModelRow = typeof ModelRow.Type
137
-
138
- export const GateRow = Schema.Struct({
139
- command: Schema.String,
140
- runs: Schema.Int,
141
- ms: Ms,
142
- avgMs: Ms,
143
- failed: Schema.Int
144
- })
145
- export type GateRow = typeof GateRow.Type
146
-
147
- export const CountRow = Schema.Struct({ label: Schema.String, count: Schema.Int, ms: Ms })
148
- export type CountRow = typeof CountRow.Type
149
-
150
- export const Finding = Schema.Struct({ text: Schema.String, ms: Schema.optionalKey(Ms) })
151
- export type Finding = typeof Finding.Type
152
-
153
- export const ProfileReport = Schema.Struct({
154
- /** No `Timed` events: model time is estimated from gaps, the rest is unknown. */
155
- estimated: Schema.Boolean,
156
- wallMs: Ms,
157
- stories: Schema.Array(StoryProfile),
158
- /** Stories merged on an earlier run: this run only skipped them. */
159
- skipped: Schema.Array(Schema.String),
160
- totals: ProfileTime,
161
- models: Schema.Array(ModelRow),
162
- gates: Schema.Array(GateRow),
163
- tools: Schema.Array(CountRow),
164
- /** Coder tool time by kind of work: explore, edit, test, script, build, install, git, other. */
165
- toolCategories: Schema.Array(CountRow),
166
- waits: Schema.Array(CountRow),
167
- /** Timed on the run's events, in no story: the run's own planning, or a seat no story owns. */
168
- outside: Schema.Array(CountRow),
169
- findings: Schema.Array(Finding)
170
- })
171
- export type ProfileReport = typeof ProfileReport.Type
172
-
173
- // ── fold ────────────────────────────────────────────────────────────────────
174
-
175
- interface TimedAt {
176
- readonly at: number
177
- readonly event: Timed
178
- }
179
-
180
- interface Lane {
181
- readonly id: string
182
- readonly kind: LaneKind
183
- start: number
184
- end: number | undefined
185
- status: "done" | "failed" | "running"
186
- executor: string | undefined
187
- readonly timed: Array<TimedAt>
188
- /** Coder prompt sizes, in order. */
189
- readonly prompts: Array<number>
190
- /** Estimated mode: the gaps before each token report. */
191
- readonly estimatedCalls: Array<number>
192
- /** The last turn boundary: any lane event but a tool call inside a turn. */
193
- last: number
194
- skipped: boolean
195
- /** Every event on the lane, described without its content, for the gaps. */
196
- readonly moments: Array<{ readonly at: number; readonly what: string }>
197
- /** Since when a coder turn has been working without ending, and its tool calls. */
198
- openSince: number | undefined
199
- openTools: number
200
- tasks: number
201
- /** When the coder's first task began: setup and task planning come before it. */
202
- firstTask: number | undefined
203
- toolCalls: { explore: number; edit: number; test: number; script: number; other: number }
204
- firstEdit: number | undefined
205
- exploreBeforeEdit: number
206
- unverifiedClaims: number
207
- lowConfidenceTasks: number
208
- reviewDiffLines: number | undefined
209
- }
210
-
211
- interface Lease {
212
- readonly at: number
213
- readonly executor: string
214
- readonly role: string
215
- readonly label: string | undefined
216
- }
217
-
218
- /** Events a coder turn emits while it runs: not where a model call starts. */
219
- const withinTurn: ReadonlySet<FlowEvent["_tag"]> = new Set([
220
- "ToolUse",
221
- "UsageProgress",
222
- "CapabilityUsed",
223
- "AssistantMessage"
224
- ])
225
-
226
- const sum = (values: ReadonlyArray<number>): number =>
227
- values.reduce((total, value) => total + value, 0)
228
-
229
- const average = (values: ReadonlyArray<number>): number | undefined =>
230
- values.length === 0 ? undefined : sum(values) / values.length
231
-
232
- const groupBy = <A>(items: ReadonlyArray<A>, key: (item: A) => string): Map<string, Array<A>> => {
233
- const groups = new Map<string, Array<A>>()
234
- for (const item of items) {
235
- const group = groups.get(key(item)) ?? []
236
- group.push(item)
237
- groups.set(key(item), group)
238
- }
239
- return groups
240
- }
241
-
242
- /** An event as the gaps name it: its kind, never its content. */
243
- const describe = (event: FlowEvent): string => {
244
- const { kind } = presentationOf(event)
245
- return typeof kind === "function" ? kind(event) : kind
246
- }
247
-
248
- const bareLaneStage = /^(\S+) (\S+)$/u
249
-
250
- /**
251
- * A stage that is the lane's own and not one of its coder's tasks: its steps
252
- * (`story home: setup`) and a bare `<kind> <id>` that names another lane.
253
- */
254
- const ownStage = (lane: Lane, stage: string): boolean =>
255
- bareLaneStage.exec(stage)?.[1] === lane.kind || stage.startsWith(`${lane.kind} ${lane.id}:`)
256
-
257
- const gapThresholdMs = 30_000
258
-
259
- /** The parts of each step between two lane events that no timed interval covers, largest first. */
260
- const gapsOf = (lane: Lane): ReadonlyArray<Gap> => {
261
- const covered = [
262
- ...lane.timed.map((item) => [item.at - item.event.ms, item.at] as const),
263
- // A turn still open is model time, not a gap.
264
- ...(lane.openSince === undefined || lane.end !== undefined
265
- ? []
266
- : [[lane.openSince, Number.POSITIVE_INFINITY] as const])
267
- ].sort((left, right) => left[0] - right[0])
268
- const uncovered = (from: number, to: number): number => {
269
- let free = 0
270
- let cursor = from
271
- for (const [start, end] of covered) {
272
- if (end <= cursor || start >= to) {
273
- continue
274
- }
275
- free += Math.max(0, start - cursor)
276
- cursor = Math.max(cursor, end)
277
- }
278
- return free + Math.max(0, to - cursor)
279
- }
280
- const moments = [...lane.moments].sort((left, right) => left.at - right.at)
281
- return moments
282
- .slice(1)
283
- .flatMap((next, index) => {
284
- const previous = moments[index]
285
- if (previous === undefined) {
286
- return []
287
- }
288
- const ms = uncovered(previous.at, next.at)
289
- return ms >= gapThresholdMs ? [{ ms, after: previous.what, before: next.what }] : []
290
- })
291
- .sort((left, right) => right.ms - left.ms)
292
- .slice(0, 3)
293
- }
294
-
295
- /** The roster role a seat label stands for, to find its lease. */
296
- const leaseRole = (label: string): string => (label === "reasoning" ? "reviewer" : label)
297
-
298
- export const profileOf = (inputs: ReadonlyArray<TreeInput>): ProfileReport => {
299
- const lanes = new Map<string, Lane>()
300
- const leases: Array<Lease> = []
301
- const allTimed: Array<TimedAt> = []
302
- let first: number | undefined
303
- let lastAt = 0
304
- let planReady: number | undefined
305
-
306
- for (const input of inputs) {
307
- first = first ?? input.at
308
- lastAt = Math.max(lastAt, input.at)
309
- if (input._tag !== "Event") {
310
- continue
311
- }
312
- const { event, at } = input
313
- const tagged = laneOf(event)
314
- const lane = tagged === undefined ? undefined : lanes.get(tagged.lane)
315
- switch (event._tag) {
316
- case "StageStarted": {
317
- const opened = laneOpened(event)
318
- if (opened !== undefined) {
319
- lanes.set(opened.id, {
320
- id: opened.id,
321
- kind: opened.kind,
322
- start: at,
323
- end: undefined,
324
- status: "running",
325
- executor: event.executor,
326
- timed: [],
327
- prompts: [],
328
- estimatedCalls: [],
329
- last: at,
330
- skipped: false,
331
- moments: [{ at, what: describe(event) }],
332
- openSince: undefined,
333
- openTools: 0,
334
- tasks: 0,
335
- firstTask: undefined,
336
- toolCalls: { explore: 0, edit: 0, test: 0, script: 0, other: 0 },
337
- firstEdit: undefined,
338
- exploreBeforeEdit: 0,
339
- unverifiedClaims: 0,
340
- lowConfidenceTasks: 0,
341
- reviewDiffLines: undefined
342
- })
343
- continue
344
- }
345
- if (lane !== undefined && !ownStage(lane, event.stage)) {
346
- lane.tasks += 1
347
- lane.firstTask = lane.firstTask ?? at
348
- }
349
- break
350
- }
351
- case "StageCompleted":
352
- case "StageFailed":
353
- if (event.stage === "story plan" && event.lane === undefined) {
354
- planReady = at
355
- }
356
- if (lane !== undefined && laneClosed(event)?.kind === lane.kind) {
357
- lane.end = at
358
- lane.status = event._tag === "StageFailed" ? "failed" : "done"
359
- }
360
- break
361
- case "Timed": {
362
- allTimed.push({ at, event })
363
- lane?.timed.push({ at, event })
364
- if (lane !== undefined && event.kind === "tool" && event.category !== undefined) {
365
- const kind =
366
- event.category === "explore" ||
367
- event.category === "edit" ||
368
- event.category === "test" ||
369
- event.category === "script"
370
- ? event.category
371
- : "other"
372
- lane.toolCalls[kind] += 1
373
- if (kind === "edit" && lane.firstEdit === undefined) {
374
- lane.firstEdit = at - (lane.firstTask ?? lane.start)
375
- lane.exploreBeforeEdit = lane.toolCalls.explore
376
- }
377
- }
378
- if (lane !== undefined && event.kind === "model" && event.label === "coder") {
379
- lane.openSince = undefined
380
- lane.openTools = 0
381
- }
382
- break
383
- }
384
- case "ToolUse":
385
- if (lane !== undefined) {
386
- // A tool call opens a turn until a coder call ends: it began at
387
- // the last turn boundary.
388
- lane.openSince = lane.openSince ?? lane.last
389
- lane.openTools += 1
390
- }
391
- break
392
- case "TokensUsed":
393
- if (lane !== undefined) {
394
- if (event.agent === "coder") {
395
- lane.prompts.push(event.usage.prompt)
396
- }
397
- lane.estimatedCalls.push(at - lane.last)
398
- }
399
- break
400
- case "ExecutorLeased":
401
- leases.push({ at, executor: event.executor, role: event.role, label: event.label })
402
- break
403
- case "Info":
404
- if (lane !== undefined && event.message.startsWith(`story ${lane.id}: already merged`)) {
405
- lane.skipped = true
406
- }
407
- break
408
- case "ReviewFindings":
409
- if (lane !== undefined && event.diffLines !== undefined) {
410
- lane.reviewDiffLines = Math.max(lane.reviewDiffLines ?? 0, event.diffLines)
411
- }
412
- break
413
- case "EvidenceChecked":
414
- if (lane !== undefined) {
415
- lane.unverifiedClaims += event.unverified
416
- if (event.confidence === "low") {
417
- lane.lowConfidenceTasks += 1
418
- }
419
- }
420
- break
421
- default:
422
- break
423
- }
424
- if (lane !== undefined) {
425
- lane.moments.push({ at, what: describe(event) })
426
- lane.executor = lane.executor ?? tagged?.executor
427
- if (!withinTurn.has(event._tag)) {
428
- lane.last = at
429
- }
430
- }
431
- }
432
-
433
- const estimated = allTimed.length === 0
434
- const stories = [...lanes.values()]
435
- .filter((lane) => !lane.skipped)
436
- .map((lane) => storyProfile(lane, lastAt, planReady, estimated))
437
- const skipped = [...lanes.values()].filter((lane) => lane.skipped).map((lane) => lane.id)
438
-
439
- const executorOf = (item: TimedAt): string | undefined => {
440
- if (item.event.executor !== undefined) {
441
- return item.event.executor
442
- }
443
- const role = leaseRole(item.event.label)
444
- const lease = [...leases]
445
- .reverse()
446
- .find(
447
- (candidate) =>
448
- candidate.at <= item.at && candidate.role === role && candidate.label === item.event.lane
449
- )
450
- return lease?.executor
451
- }
452
-
453
- const modelCalls = allTimed.filter((item) => item.event.kind === "model")
454
- const models = [
455
- ...groupBy(modelCalls, (item) => `${item.event.label}\u0000${executorOf(item) ?? ""}`).values()
456
- ].map((calls): ModelRow => {
457
- const sample = calls[0]
458
- const executor = sample === undefined ? undefined : executorOf(sample)
459
- const ms = sum(calls.map((item) => item.event.ms))
460
- const firsts = calls.flatMap((item) =>
461
- item.event.firstMs === undefined ? [] : [item.event.firstMs]
462
- )
463
- const apis = calls.flatMap((item) => (item.event.apiMs === undefined ? [] : [item.event.apiMs]))
464
- const toolsReported = calls.flatMap((item) =>
465
- item.event.toolMs === undefined ? [] : [item.event.toolMs]
466
- )
467
- const avgFirstMs = average(firsts)
468
- return {
469
- role: sample?.event.label ?? "",
470
- ...(executor === undefined ? {} : { executor }),
471
- calls: calls.length,
472
- ms,
473
- avgMs: ms / calls.length,
474
- ...(avgFirstMs === undefined ? {} : { avgFirstMs }),
475
- ...(apis.length === 0 ? {} : { apiMs: sum(apis) }),
476
- ...(toolsReported.length === 0 ? {} : { toolMs: sum(toolsReported) })
477
- }
478
- })
479
-
480
- const gates = [
481
- ...groupBy(
482
- allTimed.filter((item) => item.event.kind === "gate"),
483
- (item) => item.event.label
484
- ).entries()
485
- ].map(([command, runs]): GateRow => {
486
- const ms = sum(runs.map((item) => item.event.ms))
487
- return {
488
- command,
489
- runs: runs.length,
490
- ms,
491
- avgMs: ms / runs.length,
492
- failed: runs.filter((item) => (item.event.exitCode ?? 0) !== 0 || item.event.failed === true)
493
- .length
494
- }
495
- })
496
-
497
- const counted = (kind: Timed["kind"]): ReadonlyArray<CountRow> =>
498
- [
499
- ...groupBy(
500
- allTimed.filter((item) => item.event.kind === kind),
501
- (item) => item.event.label
502
- ).entries()
503
- ].map(([label, items]) => ({
504
- label,
505
- count: items.length,
506
- ms: sum(items.map((item) => item.event.ms))
507
- }))
508
- const tools = counted("tool")
509
- const toolCategories = [
510
- ...groupBy(
511
- allTimed.filter((item) => item.event.kind === "tool" && item.event.category !== undefined),
512
- (item) => item.event.category ?? ""
513
- ).entries()
514
- ]
515
- .map(([label, items]) => ({
516
- label,
517
- count: items.length,
518
- ms: sum(items.map((item) => item.event.ms))
519
- }))
520
- .sort((left, right) => right.ms - left.ms)
521
- const waits = counted("wait")
522
- const steps = stepStats(estimated ? [] : [...lanes.values()].flatMap(turnSteps))
523
- const outside = [
524
- ...groupBy(
525
- allTimed.filter((item) => item.event.lane === undefined),
526
- (item) => item.event.label
527
- ).entries()
528
- ].map(([label, items]) => ({
529
- label,
530
- count: items.length,
531
- ms: sum(items.map((item) => item.event.ms))
532
- }))
533
- const merges = [...counted("merge"), ...counted("git")]
534
-
535
- const totals = stories.reduce<ProfileTime>(
536
- (total, story) => ({
537
- model: total.model + story.time.model,
538
- tools: total.tools + story.time.tools,
539
- gates: total.gates + story.time.gates,
540
- merge: total.merge + story.time.merge,
541
- waiting: total.waiting + story.time.waiting,
542
- unaccounted: total.unaccounted + story.time.unaccounted
543
- }),
544
- { model: 0, tools: 0, gates: 0, merge: 0, waiting: 0, unaccounted: 0 }
545
- )
546
- const storyTime = sum(stories.map((story) => story.wallMs))
547
-
548
- return {
549
- estimated,
550
- wallMs: first === undefined ? 0 : lastAt - first,
551
- stories,
552
- skipped,
553
- totals,
554
- models,
555
- gates,
556
- tools,
557
- toolCategories,
558
- waits,
559
- outside,
560
- findings: findingsOf({
561
- steps,
562
- toolCategories,
563
- stories,
564
- models,
565
- gates,
566
- tools,
567
- waits,
568
- merges,
569
- storyTime,
570
- estimated
571
- })
572
- }
573
- }
574
-
575
- const stepStats = (
576
- turns: ReadonlyArray<{ readonly steps: number; readonly stepMs: number }>
577
- ): { readonly avgSteps: number; readonly avgStepMs: number } => ({
578
- avgSteps: average(turns.map((turn) => turn.steps)) ?? 0,
579
- avgStepMs: average(turns.map((turn) => turn.stepMs)) ?? 0
580
- })
581
-
582
- /** Each finished coder turn's steps (its tool calls) and model time per step. */
583
- const turnSteps = (
584
- lane: Lane
585
- ): ReadonlyArray<{ readonly steps: number; readonly stepMs: number }> => {
586
- const tools = lane.timed.filter((item) => item.event.kind === "tool")
587
- return lane.timed
588
- .filter((item) => item.event.kind === "model" && item.event.label === "coder")
589
- .map((turn) => {
590
- const start = turn.at - turn.event.ms
591
- const inside = tools.filter((tool) => tool.at > start && tool.at <= turn.at)
592
- const toolMs = sum(inside.map((tool) => tool.event.ms))
593
- return {
594
- steps: inside.length,
595
- stepMs: Math.max(0, turn.event.ms - toolMs) / (inside.length + 1)
596
- }
597
- })
598
- }
599
-
600
- const storyProfile = (
601
- lane: Lane,
602
- lastAt: number,
603
- planReady: number | undefined,
604
- estimated: boolean
605
- ): StoryProfile => {
606
- const wallMs = (lane.end ?? lastAt) - lane.start
607
- const of = (kind: Timed["kind"]) =>
608
- sum(lane.timed.filter((item) => item.event.kind === kind).map((item) => item.event.ms))
609
- const coderCalls = lane.timed
610
- .filter((item) => item.event.kind === "model" && item.event.label === "coder")
611
- .map((item) => item.event.ms)
612
- const tools = of("tool")
613
- // Only a story still running has a turn in progress; in a finished one an
614
- // unclosed turn is a timing that never came, left to the gaps.
615
- const open = !estimated && lane.end === undefined && lane.openSince !== undefined
616
- const openTurnMs = open && lane.openSince !== undefined ? lastAt - lane.openSince : 0
617
- const model = estimated ? sum(lane.estimatedCalls) : Math.max(0, of("model") + openTurnMs - tools)
618
- const gates = of("gate")
619
- const merge = of("merge") + of("git")
620
- const waiting = of("wait")
621
- const turnTimes = estimated ? lane.estimatedCalls : coderCalls
622
- const firstPrompt = lane.prompts[0]
623
- const lastPrompt = lane.prompts.at(-1)
624
- const maxPrompt = lane.prompts.length === 0 ? undefined : Math.max(...lane.prompts)
625
- return {
626
- gaps: estimated ? [] : gapsOf(lane),
627
- openTurnMs,
628
- openTurnTools: open ? lane.openTools : 0,
629
- toolCalls: { ...lane.toolCalls },
630
- tasks: lane.tasks,
631
- ...(lane.firstEdit === undefined ? {} : { firstEditMs: lane.firstEdit }),
632
- exploreBeforeEdit: lane.exploreBeforeEdit,
633
- unverifiedClaims: lane.unverifiedClaims,
634
- lowConfidenceTasks: lane.lowConfidenceTasks,
635
- ...(lane.reviewDiffLines === undefined ? {} : { reviewDiffLines: lane.reviewDiffLines }),
636
- id: lane.id,
637
- ...(lane.kind === "story" ? {} : { kind: lane.kind }),
638
- ...(lane.executor === undefined ? {} : { executor: lane.executor }),
639
- status: lane.status,
640
- wallMs,
641
- ...(planReady === undefined ? {} : { queuedMs: Math.max(0, lane.start - planReady) }),
642
- time: {
643
- model,
644
- tools,
645
- gates,
646
- merge,
647
- waiting,
648
- unaccounted: Math.max(0, wallMs - model - tools - gates - merge - waiting)
649
- },
650
- turns: {
651
- count: turnTimes.length,
652
- avgMs: average(turnTimes) ?? 0,
653
- maxMs: turnTimes.length === 0 ? 0 : Math.max(...turnTimes),
654
- ...(firstPrompt === undefined ? {} : { firstPrompt }),
655
- ...(lastPrompt === undefined ? {} : { lastPrompt }),
656
- ...(maxPrompt === undefined ? {} : { maxPrompt }),
657
- ...stepStats(estimated ? [] : turnSteps(lane)),
658
- promptGrowth:
659
- firstPrompt !== undefined &&
660
- lastPrompt !== undefined &&
661
- firstPrompt > 0 &&
662
- lastPrompt > 3 * firstPrompt
663
- }
664
- }
665
- }
666
-
667
- // ── words ───────────────────────────────────────────────────────────────────
668
-
669
- const stepsText = (steps: number): string =>
670
- `${Number.isInteger(steps) ? steps : steps.toFixed(1)} step${steps === 1 ? "" : "s"}`
671
-
672
- const share = (ms: number, of: number): string => (of <= 0 ? "" : `${Math.round((ms / of) * 100)}%`)
673
-
674
- const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? "" : "s"}`
675
-
676
- interface LaneWord {
677
- readonly one: string
678
- readonly many: string
679
- }
680
-
681
- /** What a report calls its lanes: their kind when they share one, stories when it has none. */
682
- const laneWord = (stories: ReadonlyArray<StoryProfile>): LaneWord => {
683
- const kinds = new Set(stories.map((story) => story.kind ?? "story"))
684
- const [kind = "story"] = kinds
685
- return kinds.size > 1
686
- ? { one: "lane", many: "lanes" }
687
- : kind === "story"
688
- ? { one: "story", many: "stories" }
689
- : { one: kind, many: `${kind}s` }
690
- }
691
-
692
- const capitalized = (word: string): string => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`
693
-
694
- const findingsOf = (facts: {
695
- readonly steps: { readonly avgSteps: number; readonly avgStepMs: number }
696
- readonly toolCategories: ReadonlyArray<CountRow>
697
- readonly stories: ReadonlyArray<StoryProfile>
698
- readonly models: ReadonlyArray<ModelRow>
699
- readonly gates: ReadonlyArray<GateRow>
700
- readonly tools: ReadonlyArray<CountRow>
701
- readonly waits: ReadonlyArray<CountRow>
702
- readonly merges: ReadonlyArray<CountRow>
703
- readonly storyTime: number
704
- readonly estimated: boolean
705
- }): ReadonlyArray<Finding> => {
706
- const word = laneWord(facts.stories)
707
- const ofStories = (ms: number) =>
708
- `${duration(ms)} (${share(ms, facts.storyTime)} of ${word.one} time)`
709
- const byRole = [...groupBy(facts.models, (row) => row.role).entries()].map(([role, rows]) => {
710
- const calls = sum(rows.map((row) => row.calls))
711
- const ms = sum(rows.map((row) => row.ms))
712
- const firsts = rows.flatMap((row) => (row.avgFirstMs === undefined ? [] : [row.avgFirstMs]))
713
- const firstMs = average(firsts)
714
- const first = firstMs === undefined ? "" : `, first output after ${duration(firstMs)}`
715
- return {
716
- ms,
717
- text:
718
- role === "coder"
719
- ? `coder turns: ${calls}, ${ofStories(ms)}, avg ${duration(ms / calls)}${first}${
720
- facts.steps.avgSteps > 0
721
- ? `, ${stepsText(facts.steps.avgSteps)} per turn, ${duration(facts.steps.avgStepMs)} of model per step`
722
- : ""
723
- }`
724
- : `${role} calls: ${calls}, ${ofStories(ms)}, avg ${duration(ms / calls)}${first}`
725
- }
726
- })
727
- const candidates: Array<{ readonly ms: number; readonly text: string }> = [
728
- ...byRole,
729
- ...facts.gates.map((row) => ({
730
- ms: row.ms,
731
- text: `gate \`${row.command}\`: ${plural(row.runs, "run")}, ${ofStories(row.ms)}${
732
- row.failed === 0 ? "" : `, ${row.failed} failed`
733
- }`
734
- })),
735
- // By kind of work when the trace has it (2.23), else by tool name.
736
- ...(facts.toolCategories.length > 0
737
- ? facts.toolCategories.map((row) => ({
738
- ms: row.ms,
739
- text: `coder tools, ${row.label}: ${plural(row.count, "call")}, ${ofStories(row.ms)}`
740
- }))
741
- : facts.tools.map((row) => ({
742
- ms: row.ms,
743
- text: `coder tool \`${row.label}\`: ${plural(row.count, "call")}, ${ofStories(row.ms)}`
744
- }))),
745
- ...facts.waits.map((row) => ({
746
- ms: row.ms,
747
- text: `waiting for the ${row.label}: ${row.count}×, ${ofStories(row.ms)}`
748
- })),
749
- ...facts.merges.map((row) => ({
750
- ms: row.ms,
751
- text: `${row.label}: ${row.count}×, ${ofStories(row.ms)}`
752
- }))
753
- ]
754
- for (const story of facts.stories) {
755
- if (story.openTurnMs >= 1_000) {
756
- candidates.push({
757
- ms: story.openTurnMs,
758
- text: `${story.id}: a coder turn still running for ${duration(story.openTurnMs)} (${plural(story.openTurnTools, "tool call")} so far)`
759
- })
760
- }
761
- }
762
- for (const story of facts.stories) {
763
- const wandered =
764
- story.firstEditMs !== undefined &&
765
- story.wallMs >= 60_000 &&
766
- (story.exploreBeforeEdit >= 15 ||
767
- (story.exploreBeforeEdit >= 5 && story.firstEditMs >= story.wallMs / 4))
768
- if (wandered && story.firstEditMs !== undefined) {
769
- candidates.push({
770
- ms: story.firstEditMs,
771
- text: `${story.id}: ${plural(story.exploreBeforeEdit, "explore call")} and ${duration(story.firstEditMs)} before the coder's first edit — it found its way instead of being told where to go`
772
- })
773
- }
774
- }
775
- const queued = sum(facts.stories.map((story) => story.queuedMs ?? 0))
776
- if (queued >= 1_000) {
777
- candidates.push({
778
- ms: queued,
779
- text: `stories queued before starting (dependencies, free coder slots): ${duration(queued)} in all`
780
- })
781
- }
782
- const estimated = facts.estimated
783
- ? facts.stories.map((story) => ({
784
- ms: story.time.model,
785
- text: `${story.id}: model calls (estimated) ${duration(story.time.model)} of ${duration(story.wallMs)}`
786
- }))
787
- : []
788
- const ranked = [...candidates, ...estimated]
789
- .filter((candidate) => candidate.ms >= 1_000)
790
- .sort((left, right) => right.ms - left.ms)
791
- .slice(0, 3)
792
- const fabricated = facts.stories.flatMap((story) =>
793
- story.unverifiedClaims > 0
794
- ? [
795
- {
796
- text: `${story.id}: the coder claimed ${plural(story.unverifiedClaims, "verification command")} that never ran — its account of its own testing is not evidence`
797
- }
798
- ]
799
- : []
800
- )
801
- const improvised = facts.stories.flatMap((story) =>
802
- story.toolCalls.script >= 3
803
- ? [
804
- {
805
- text: `${story.id}: ${story.toolCalls.script} ad-hoc scripts — a pack tool preset (llm4ts tool) would make this repeatable`
806
- }
807
- ]
808
- : []
809
- )
810
- const growth = facts.stories.flatMap((story) =>
811
- story.turns.promptGrowth &&
812
- story.turns.firstPrompt !== undefined &&
813
- story.turns.lastPrompt !== undefined
814
- ? [
815
- {
816
- text: `${story.id}: coder prompts grew ${(story.turns.lastPrompt / story.turns.firstPrompt).toFixed(1)}× (${formatCount(story.turns.firstPrompt)} → ${formatCount(story.turns.lastPrompt)} tokens): every turn resends the history`
817
- }
818
- ]
819
- : []
820
- )
821
- return [...ranked, ...fabricated, ...improvised, ...growth]
822
- }
823
-
824
- const table = (
825
- header: ReadonlyArray<string>,
826
- rows: ReadonlyArray<ReadonlyArray<string>>
827
- ): Array<string> => {
828
- const widths = header.map((title, column) =>
829
- Math.max(title.length, ...rows.map((row) => (row[column] ?? "").length))
830
- )
831
- const line = (cells: ReadonlyArray<string>) =>
832
- ` ${cells.map((cell, column) => (column === 0 ? cell.padEnd(widths[column] ?? 0) : cell.padStart(widths[column] ?? 0))).join(" ")}`.trimEnd()
833
- return [line(header), ...rows.map(line)]
834
- }
835
-
836
- export const renderProfile = (report: ProfileReport): string => {
837
- const storyTime = sum(report.stories.map((story) => story.wallMs))
838
- const word = laneWord(report.stories)
839
- const categories: ReadonlyArray<readonly [string, number]> = [
840
- [report.estimated ? "model + tools + gates (estimated)" : "model", report.totals.model],
841
- ["coder tools", report.totals.tools],
842
- ["gates", report.totals.gates],
843
- ["git + merge", report.totals.merge],
844
- ["waiting", report.totals.waiting],
845
- ["unaccounted", report.totals.unaccounted]
846
- ]
847
- return [
848
- `llm4ts profile · ${duration(report.wallMs)} wall · ${report.stories.length} ${report.stories.length === 1 ? word.one : word.many}${
849
- report.estimated
850
- ? " · estimated: this trace has no timings (before 2.22), model time is read from gaps"
851
- : ""
852
- }`,
853
- "",
854
- "Where the time goes",
855
- ...(report.findings.length === 0
856
- ? [" nothing timed yet"]
857
- : report.findings.map((finding, index) => ` ${index + 1}. ${finding.text}`)),
858
- "",
859
- `${capitalized(word.one)} time`,
860
- ...table(
861
- ["category", "time", "share"],
862
- categories.map(([name, ms]) => [name, duration(ms), share(ms, storyTime)])
863
- ),
864
- "",
865
- capitalized(word.many),
866
- ...table(
867
- [
868
- word.one,
869
- "status",
870
- "wall",
871
- "queued",
872
- "model",
873
- "tools",
874
- "gates",
875
- "git+merge",
876
- "wait",
877
- "other",
878
- "turns",
879
- "avg turn",
880
- "steps/turn",
881
- "model/step",
882
- "prompt ×"
883
- ],
884
- report.stories.map((story) => [
885
- story.id,
886
- story.status,
887
- duration(story.wallMs),
888
- story.queuedMs === undefined ? "" : duration(story.queuedMs),
889
- duration(story.time.model),
890
- duration(story.time.tools),
891
- duration(story.time.gates),
892
- duration(story.time.merge),
893
- duration(story.time.waiting),
894
- duration(story.time.unaccounted),
895
- String(story.turns.count),
896
- duration(story.turns.avgMs),
897
- story.turns.avgSteps === 0 ? "" : story.turns.avgSteps.toFixed(1),
898
- story.turns.avgSteps === 0 ? "" : duration(story.turns.avgStepMs),
899
- story.turns.firstPrompt !== undefined &&
900
- story.turns.lastPrompt !== undefined &&
901
- story.turns.firstPrompt > 0
902
- ? `${(story.turns.lastPrompt / story.turns.firstPrompt).toFixed(1)}×`
903
- : ""
904
- ])
905
- ),
906
- ...(report.stories.every(
907
- (story) =>
908
- story.toolCalls.explore +
909
- story.toolCalls.edit +
910
- story.toolCalls.test +
911
- story.toolCalls.script +
912
- story.toolCalls.other ===
913
- 0
914
- )
915
- ? []
916
- : [
917
- "",
918
- `Coder work per ${word.one}`,
919
- ...table(
920
- [
921
- word.one,
922
- "tasks",
923
- "explore",
924
- "edit",
925
- "test",
926
- "script",
927
- "other",
928
- "before 1st edit",
929
- "1st edit at"
930
- ],
931
- report.stories.map((story) => [
932
- story.id,
933
- String(story.tasks),
934
- String(story.toolCalls.explore),
935
- String(story.toolCalls.edit),
936
- String(story.toolCalls.test),
937
- String(story.toolCalls.script),
938
- String(story.toolCalls.other),
939
- story.firstEditMs === undefined ? "" : `${story.exploreBeforeEdit} explore`,
940
- story.firstEditMs === undefined ? "never" : duration(story.firstEditMs)
941
- ])
942
- )
943
- ]),
944
- ...(report.stories.every((story) => story.reviewDiffLines === undefined)
945
- ? []
946
- : [
947
- "",
948
- `Largest review diff per ${word.one}`,
949
- ...report.stories.flatMap((story) =>
950
- story.reviewDiffLines === undefined
951
- ? []
952
- : [` ${story.id}: review diff ${story.reviewDiffLines} lines`]
953
- )
954
- ]),
955
- ...(report.skipped.length === 0
956
- ? []
957
- : [` skipped (merged on an earlier run): ${report.skipped.join(", ")}`]),
958
- ...(report.stories.every((story) => story.gaps.length === 0)
959
- ? []
960
- : [
961
- "",
962
- "Unaccounted, largest gaps (no timing covers them)",
963
- ...report.stories.flatMap((story) =>
964
- story.gaps.map(
965
- (gap) => ` ${story.id}: ${duration(gap.ms)} between ${gap.after} and ${gap.before}`
966
- )
967
- )
968
- ]),
969
- ...(report.outside.length === 0
970
- ? []
971
- : [
972
- "",
973
- `Timed outside any ${word.one} (the run's own planning, or a seat no ${word.one} owns)`,
974
- ...table(
975
- ["label", "count", "total"],
976
- report.outside.map((row) => [row.label, String(row.count), duration(row.ms)])
977
- )
978
- ]),
979
- ...(report.models.length === 0
980
- ? []
981
- : [
982
- "",
983
- "Model calls",
984
- ...table(
985
- [
986
- "role",
987
- "executor",
988
- "calls",
989
- "total",
990
- "avg",
991
- "first output",
992
- "api (reported)",
993
- "tools (reported)"
994
- ],
995
- report.models.map((row) => [
996
- row.role,
997
- row.executor ?? "",
998
- String(row.calls),
999
- duration(row.ms),
1000
- duration(row.avgMs),
1001
- row.avgFirstMs === undefined ? "" : duration(row.avgFirstMs),
1002
- row.apiMs === undefined ? "" : duration(row.apiMs),
1003
- row.toolMs === undefined ? "" : duration(row.toolMs)
1004
- ])
1005
- )
1006
- ]),
1007
- ...(report.gates.length === 0
1008
- ? []
1009
- : [
1010
- "",
1011
- "Gates",
1012
- ...table(
1013
- ["command", "runs", "total", "avg", "failed"],
1014
- report.gates.map((row) => [
1015
- row.command,
1016
- String(row.runs),
1017
- duration(row.ms),
1018
- duration(row.avgMs),
1019
- String(row.failed)
1020
- ])
1021
- )
1022
- ]),
1023
- ...(report.toolCategories.length === 0
1024
- ? []
1025
- : [
1026
- "",
1027
- "Coder tools by kind",
1028
- ...table(
1029
- ["kind", "calls", "total", "avg"],
1030
- report.toolCategories.map((row) => [
1031
- row.label,
1032
- String(row.count),
1033
- duration(row.ms),
1034
- duration(row.ms / row.count)
1035
- ])
1036
- )
1037
- ])
1038
- ].join("\n")
1039
- }
1040
-
1041
- // ── delta ───────────────────────────────────────────────────────────────────
1042
-
1043
- /** One line of a before/after comparison (ADR 0029): a count or a duration. */
1044
- export const DeltaRow = Schema.Struct({
1045
- label: Schema.String,
1046
- before: Schema.Number,
1047
- after: Schema.Number,
1048
- unit: Schema.Literals(["count", "ms"]),
1049
- /** Which direction is better, for the arrow; `none` for neutral facts. */
1050
- better: Schema.Literals(["lower", "higher", "none"])
1051
- })
1052
- export type DeltaRow = typeof DeltaRow.Type
1053
-
1054
- export const ProfileDelta = Schema.Struct({
1055
- rows: Schema.Array(DeltaRow)
1056
- })
1057
- export type ProfileDelta = typeof ProfileDelta.Type
1058
-
1059
- const roleCalls = (report: ProfileReport, role: string): number =>
1060
- sum(report.models.filter((row) => row.role === role).map((row) => row.calls))
1061
-
1062
- /**
1063
- * The harness-evals comparison (`docs/harness-evals/README.md`), in its
1064
- * order: how much the coders explored before editing, how often review and
1065
- * judgment were called, what the gates said, how honest the claims were,
1066
- * then where the time went. Tokens and cost are `llm4ts costs`' business.
1067
- */
1068
- export const profileDelta = (before: ProfileReport, after: ProfileReport): ProfileDelta => {
1069
- const count = (
1070
- label: string,
1071
- pick: (report: ProfileReport) => number,
1072
- better: DeltaRow["better"] = "lower"
1073
- ): DeltaRow => ({
1074
- label,
1075
- before: pick(before),
1076
- after: pick(after),
1077
- unit: "count",
1078
- better
1079
- })
1080
- const time = (label: string, pick: (report: ProfileReport) => number): DeltaRow => ({
1081
- label,
1082
- before: pick(before),
1083
- after: pick(after),
1084
- unit: "ms",
1085
- better: "lower"
1086
- })
1087
- return {
1088
- rows: [
1089
- count("stories done", (r) => r.stories.filter((s) => s.status === "done").length, "higher"),
1090
- count("stories failed", (r) => r.stories.filter((s) => s.status === "failed").length),
1091
- count("explore calls before the first edit", (r) =>
1092
- sum(r.stories.map((s) => s.exploreBeforeEdit))
1093
- ),
1094
- count("coder turns", (r) => sum(r.stories.map((s) => s.turns.count))),
1095
- count("coder edits", (r) => sum(r.stories.map((s) => s.toolCalls.edit)), "none"),
1096
- count("coder test runs", (r) => sum(r.stories.map((s) => s.toolCalls.test)), "none"),
1097
- count("coder scripts", (r) => sum(r.stories.map((s) => s.toolCalls.script))),
1098
- count("reviewer calls", (r) => roleCalls(r, "reviewer")),
1099
- count("judge calls", (r) => roleCalls(r, "judge")),
1100
- count("gate runs", (r) => sum(r.gates.map((g) => g.runs))),
1101
- count("gate failures", (r) => sum(r.gates.map((g) => g.failed))),
1102
- count("fabricated verification claims", (r) => sum(r.stories.map((s) => s.unverifiedClaims))),
1103
- count("low-confidence tasks", (r) => sum(r.stories.map((s) => s.lowConfidenceTasks))),
1104
- count(
1105
- "stories whose prompt grew over 3×",
1106
- (r) => r.stories.filter((s) => s.turns.promptGrowth).length
1107
- ),
1108
- time("wall", (r) => r.wallMs),
1109
- time("model time", (r) => r.totals.model),
1110
- time("coder tool time", (r) => r.totals.tools),
1111
- time("gate time", (r) => r.totals.gates),
1112
- time("waiting", (r) => r.totals.waiting)
1113
- ]
1114
- }
1115
- }
1116
-
1117
- const deltaCell = (row: DeltaRow): string => {
1118
- const diff = row.after - row.before
1119
- if (diff === 0) {
1120
- return "="
1121
- }
1122
- const shown = row.unit === "ms" ? duration(Math.abs(diff)) : String(Math.abs(diff))
1123
- const sign = diff > 0 ? "+" : "-"
1124
- const verdict =
1125
- row.better === "none" ? "" : diff < 0 === (row.better === "lower") ? " better" : " worse"
1126
- const percent = row.before === 0 ? "" : ` (${Math.round((diff / row.before) * 100)}%)`
1127
- return `${sign}${shown}${percent}${verdict}`
1128
- }
1129
-
1130
- export const renderProfileDelta = (
1131
- delta: ProfileDelta,
1132
- labels: { readonly before: string; readonly after: string }
1133
- ): string =>
1134
- [
1135
- `llm4ts profile · ${labels.before} → ${labels.after}`,
1136
- "",
1137
- ...table(
1138
- ["measure", "before", "after", "change"],
1139
- delta.rows.map((row) => [
1140
- row.label,
1141
- row.unit === "ms" ? duration(row.before) : String(row.before),
1142
- row.unit === "ms" ? duration(row.after) : String(row.after),
1143
- deltaCell(row)
1144
- ])
1145
- ),
1146
- "",
1147
- "* tokens and cost per role: llm4ts costs --repo <repo>"
1148
- ].join("\n")
1149
-
1150
- export interface ProfileOptions extends TraceChoice {
1151
- readonly json?: boolean
1152
- /** A trace, or a `--json` report saved earlier, to compare the profiled run against (ADR 0029). */
1153
- readonly against?: string
1154
- }
1155
-
1156
- const encodeReport = Schema.encodeSync(Schema.fromJsonString(ProfileReport))
1157
- const decodeReport = Schema.decodeUnknownOption(Schema.fromJsonString(ProfileReport))
1158
- const encodeDelta = Schema.encodeSync(Schema.fromJsonString(ProfileDelta))
1159
-
1160
- const reportAt = Effect.fn("@llm4ts/runner/Profile.reportAt")(function* (
1161
- files: TraceSources["files"],
1162
- path: string
1163
- ): Effect.fn.Return<ProfileReport, FlowError | WatchTargetMissing> {
1164
- if (path.endsWith(".json")) {
1165
- const text = yield* files.read(path)
1166
- const decoded = text === undefined ? undefined : decodeReport(text)
1167
- if (decoded === undefined || decoded._tag === "None") {
1168
- return yield* WatchTargetMissing.make({
1169
- message: `${path} is not a profile report (write one with llm4ts profile --json)`
1170
- })
1171
- }
1172
- return decoded.value
1173
- }
1174
- return profileOf(treeInputsOfTrace(yield* readTrace(files, path)))
1175
- })
1176
-
1177
- /**
1178
- * The report for a trace, an epic's latest run, or the newest trace: text,
1179
- * or JSON for comparing runs; with `against`, the delta from that earlier
1180
- * run to this one.
1181
- */
1182
- export const makeProfileProgram = Effect.fn("@llm4ts/runner/Profile.make")(function* (
1183
- options: ProfileOptions,
1184
- sources: TraceSources
1185
- ): Effect.fn.Return<string, FlowError | WatchTargetMissing> {
1186
- const target = yield* resolveTraceTarget(options, sources)
1187
- const report = profileOf(treeInputsOfTrace(yield* readTrace(sources.files, target.tracePath)))
1188
- if (options.against !== undefined) {
1189
- const before = yield* reportAt(sources.files, options.against)
1190
- const delta = profileDelta(before, report)
1191
- return options.json === true
1192
- ? encodeDelta(delta)
1193
- : renderProfileDelta(delta, { before: options.against, after: target.tracePath })
1194
- }
1195
- return options.json === true ? encodeReport(report) : renderProfile(report)
1196
- })