@alecsibilia/luca 14.0.0-alpha.21 → 14.0.0-alpha.22

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.
@@ -1,35 +1,42 @@
1
- import type { FindingSeverity } from '../agents/role-results'
2
- import { ModelNameSchema, type ModelName } from '../config/engine-config'
1
+ import {
2
+ factsOf,
3
+ sum,
4
+ type RunFacts,
5
+ type TicketFacts,
6
+ } from './model-score-facts'
7
+ import {
8
+ EPSILON,
9
+ FINDING_WEIGHTS,
10
+ MIN_TICKETS,
11
+ SCORED_ROLES,
12
+ WORSE_BY,
13
+ type ScoredRole,
14
+ } from './model-score-rules'
15
+
16
+ import {
17
+ EffortLevelSchema,
18
+ ModelNameSchema,
19
+ type EffortLevel,
20
+ type ModelName,
21
+ } from '../config/engine-config'
3
22
  import type { JournalRecord } from '../journal/journal-record'
4
- import { countedTokens } from '../limits/plan-usage'
23
+
24
+ export {
25
+ FINDING_WEIGHTS,
26
+ MIN_TICKETS,
27
+ SCORED_ROLES,
28
+ WORSE_BY,
29
+ type ScoredRole,
30
+ } from './model-score-rules'
5
31
 
6
32
  /**
7
33
  * The model score report (#605): how good each routed role's work was on
8
- * each model it ran on, from run journals, so the owner knows when to lower
9
- * a model floor or raise one back. Pure: it reads records and never writes.
34
+ * each model and effort it ran at (#662), from run journals, so the owner
35
+ * knows when to lower a model floor or raise one back. A ticket is compared
36
+ * only with tickets whose ticket review ran at the same effort. Pure: it
37
+ * reads records and never writes.
10
38
  */
11
39
 
12
- /** How much each ticket review finding weighs. */
13
- export const FINDING_WEIGHTS: Record<FindingSeverity, number> = {
14
- blocker: 3,
15
- should_fix: 1,
16
- nit: 0,
17
- }
18
-
19
- /** The fewest tickets on each side before a verdict is given. */
20
- export const MIN_TICKETS = 10
21
-
22
- /** How far above the baseline a rate may be before it is "worse". */
23
- export const WORSE_BY = 0.2
24
-
25
- /** Rounding slack when a rate is compared with the baseline's. */
26
- const EPSILON = 1e-9
27
-
28
- /** The roles scored by the ticket review's findings. */
29
- export const SCORED_ROLES = ['test-writer', 'implementer'] as const
30
-
31
- export type ScoredRole = (typeof SCORED_ROLES)[number]
32
-
33
40
  export type Verdict = 'not enough data' | 'worse' | 'about the same'
34
41
 
35
42
  /** The rates a model is judged by, for its tickets or its baseline's. */
@@ -41,10 +48,14 @@ export type ScoreSide = {
41
48
  fix_rounds_per_ticket: number | null
42
49
  }
43
50
 
44
- /** One routed role on one model. */
51
+ /** One routed role on one model at one effort, its tickets reviewed at one effort. */
45
52
  export type ModelScore = ScoreSide & {
46
53
  role: ScoredRole
47
54
  model: ModelName
55
+ /** The effort the role's first agent on each ticket ran at. */
56
+ effort: EffortLevel
57
+ /** The effort each ticket's first ticket review ran at. */
58
+ review_effort: EffortLevel
48
59
  stuck_tickets: number
49
60
  /** `null` when no finished ticket has its usage journaled. */
50
61
  tokens_per_finished_ticket: number | null
@@ -54,13 +65,32 @@ export type ModelScore = ScoreSide & {
54
65
  other_role_cheaper: number
55
66
  /**
56
67
  * The tickets where Clef picked this model in shadow mode but the role
57
- * ran Opus. Empty for Opus.
68
+ * ran Opus, at the same effort and reviewed at the same effort. Empty
69
+ * for Opus.
58
70
  */
59
71
  baseline: ScoreSide
60
72
  /** `null` for Opus, which is the baseline the others are judged by. */
61
73
  verdict: Verdict | null
62
74
  }
63
75
 
76
+ export type EffortPicksVerdict =
77
+ | 'not enough data'
78
+ | 'Clef spots hard tickets'
79
+ | 'no sign yet'
80
+
81
+ /**
82
+ * Clef's shadow-mode effort picks for one writer role (#663): the tickets
83
+ * where its `agent_effort` answer, at or above the run's confidence bar,
84
+ * was higher than the effort the role's first agent ran at, against the
85
+ * tickets where it was the same or lower.
86
+ */
87
+ export type EffortPicks = {
88
+ role: ScoredRole
89
+ wanted_more: ScoreSide
90
+ rest: ScoreSide
91
+ verdict: EffortPicksVerdict
92
+ }
93
+
64
94
  /** The final review's findings over a set of runs. */
65
95
  export type FinalReviewSide = {
66
96
  runs: number
@@ -70,69 +100,16 @@ export type FinalReviewSide = {
70
100
  export type ModelReport = {
71
101
  runs: number
72
102
  scores: ModelScore[]
73
- /** Fresh learner agents per model; the learner isn't scored. */
74
- learner: { model: ModelName; agents: number }[]
103
+ /** One per scored role. */
104
+ effort_picks: EffortPicks[]
105
+ /** Fresh learner agents per model and effort; the learner isn't scored. */
106
+ learner: { model: ModelName; effort: EffortLevel; agents: number }[]
75
107
  /** Runs with any agent on a cheaper model, and runs without. */
76
108
  final_review: { routed: FinalReviewSide; not_routed: FinalReviewSide }
77
109
  /** Joined tickets with no `lines_changed`, left out of the rates. */
78
110
  without_lines_changed: number
79
111
  }
80
112
 
81
- /** Clef's first model pick for a role on a ticket. */
82
- type Pick = {
83
- model: ModelName | null
84
- confidence: number | null
85
- /** Asked in shadow mode: `acted: false`, or an older `ticket_model` ask. */
86
- shadow: boolean
87
- }
88
-
89
- /** What one run's journal says about one ticket. */
90
- type TicketFacts = {
91
- /** The model of each role's first agent. */
92
- models: Partial<Record<ScoredRole, ModelName>>
93
- picks: Partial<Record<ScoredRole, Pick>>
94
- reviews: number
95
- /** The first ticket review's weighted findings. */
96
- findings: number
97
- fix_rounds: number
98
- joined: boolean
99
- lines: number | null
100
- stuck: boolean
101
- tokens: number | null
102
- }
103
-
104
- /** What one run's journal says. */
105
- type RunFacts = {
106
- tickets: TicketFacts[]
107
- routed: boolean
108
- learner: ModelName[]
109
- /** The first final review round's weighted findings, or `null` with none. */
110
- final_findings: number | null
111
- /** The joined tickets' lines, or `null` when one has none. */
112
- lines: number | null
113
- }
114
-
115
- /**
116
- * The model family a short name or full model id (as older `ticket_model`
117
- * asks named them) belongs to, or `null` for none.
118
- *
119
- * @example
120
- * familyOf('sonnet') // 'sonnet'
121
- */
122
- const familyOf = (name: unknown): ModelName | null => {
123
- if (typeof name !== 'string') return null
124
- const lower = name.toLowerCase()
125
- return (
126
- ModelNameSchema.options.find((model) => lower.includes(model)) ?? null
127
- )
128
- }
129
-
130
- const isScored = (role: string | null): role is ScoredRole =>
131
- SCORED_ROLES.some((scored) => scored === role)
132
-
133
- const sum = (values: number[]): number =>
134
- values.reduce((total, value) => total + value, 0)
135
-
136
113
  const median = (values: number[]): number | null => {
137
114
  if (values.length === 0) return null
138
115
  const sorted = values.toSorted((a, b) => a - b)
@@ -142,144 +119,6 @@ const median = (values: number[]): number | null => {
142
119
  : ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2
143
120
  }
144
121
 
145
- const weigh = (counts: Record<FindingSeverity, number>): number =>
146
- counts.blocker * FINDING_WEIGHTS.blocker +
147
- counts.should_fix * FINDING_WEIGHTS.should_fix +
148
- counts.nit * FINDING_WEIGHTS.nit
149
-
150
- const newTicket = (): TicketFacts => ({
151
- models: {},
152
- picks: {},
153
- reviews: 0,
154
- findings: 0,
155
- fix_rounds: 0,
156
- joined: false,
157
- lines: null,
158
- stuck: false,
159
- tokens: null,
160
- })
161
-
162
- /** What one run's records say, ticket by ticket. */
163
- const factsOf = (records: JournalRecord[]): RunFacts => {
164
- const tickets = new Map<number, TicketFacts>()
165
- const ticketOf = (number: number): TicketFacts => {
166
- const found = tickets.get(number) ?? newTicket()
167
- tickets.set(number, found)
168
- return found
169
- }
170
- const asks = new Map<number, { role: string | null; shadow: boolean }>()
171
- const learner: ModelName[] = []
172
- let routed = false
173
- let final_rounds = 0
174
- let final_findings = 0
175
- for (const record of records) {
176
- switch (record.kind) {
177
- case 'jev_asked':
178
- if (
179
- record.content.job === 'agent_model' ||
180
- record.content.job === 'ticket_model'
181
- ) {
182
- asks.set(record.seq, {
183
- role: record.role,
184
- shadow:
185
- record.content.job === 'ticket_model' ||
186
- !record.content.acted,
187
- })
188
- }
189
- break
190
- case 'jev_answered': {
191
- const ask = asks.get(record.content.asked_seq)
192
- if (ask === undefined || record.ticket === null) break
193
- const answer = record.content.answers.model
194
- const pick: Pick = {
195
- model: familyOf(answer?.value),
196
- confidence: answer?.confidence ?? null,
197
- shadow: ask.shadow,
198
- }
199
- const roles =
200
- record.content.job === 'ticket_model'
201
- ? SCORED_ROLES
202
- : SCORED_ROLES.filter((role) => role === ask.role)
203
- const facts = ticketOf(record.ticket)
204
- for (const role of roles) facts.picks[role] ??= pick
205
- break
206
- }
207
- case 'agent_started': {
208
- const { role, model, follow_up_of } = record.content
209
- const family = familyOf(model) ?? 'opus'
210
- if (follow_up_of !== null) break
211
- if (role === 'learner') learner.push(family)
212
- if (isScored(role) || role === 'learner') {
213
- routed ||= family !== 'opus'
214
- }
215
- if (isScored(role) && record.ticket !== null) {
216
- ticketOf(record.ticket).models[role] ??= family
217
- }
218
- break
219
- }
220
- case 'agent_finished': {
221
- const { content } = record
222
- if (
223
- content.role !== 'ticket-reviewer' ||
224
- record.ticket === null
225
- ) {
226
- break
227
- }
228
- const facts = ticketOf(record.ticket)
229
- if (facts.reviews === 0) {
230
- facts.findings = sum(
231
- content.result.findings.map(
232
- ({ severity }) => FINDING_WEIGHTS[severity]
233
- )
234
- )
235
- }
236
- facts.reviews += 1
237
- if (content.result.verdict === 'changes_requested') {
238
- facts.fix_rounds += 1
239
- }
240
- break
241
- }
242
- case 'ticket_joined':
243
- if (record.ticket !== null && record.content.ok) {
244
- const facts = ticketOf(record.ticket)
245
- facts.joined = true
246
- facts.lines = record.content.lines_changed
247
- }
248
- break
249
- case 'ticket_stuck':
250
- if (record.ticket !== null) ticketOf(record.ticket).stuck = true
251
- break
252
- case 'usage_recorded': {
253
- const { scope, ticket, tokens } = record.content
254
- if (scope === 'ticket' && ticket !== null) {
255
- ticketOf(ticket).tokens = countedTokens({ tokens })
256
- }
257
- break
258
- }
259
- case 'final_review_started':
260
- final_rounds += 1
261
- break
262
- case 'lens_finished':
263
- if (final_rounds === 1) {
264
- final_findings += weigh(record.content.findings)
265
- }
266
- break
267
- default:
268
- break
269
- }
270
- }
271
- const joined = [...tickets.values()].filter(({ joined }) => joined)
272
- return {
273
- tickets: [...tickets.values()],
274
- routed,
275
- learner,
276
- final_findings: final_rounds > 0 ? final_findings : null,
277
- lines: joined.some(({ lines }) => lines === null)
278
- ? null
279
- : sum(joined.map(({ lines }) => lines ?? 0)),
280
- }
281
- }
282
-
283
122
  /** Findings per 100 lines over `tickets`, those with no lines left out. */
284
123
  const findingsRate = (tickets: TicketFacts[]): number | null => {
285
124
  const rated = tickets.filter(({ lines }) => lines !== null)
@@ -302,6 +141,15 @@ const sideOf = (tickets: TicketFacts[]): ScoreSide => ({
302
141
  const above = (value: number | null, base: number | null): boolean =>
303
142
  value !== null && base !== null && value > base * (1 + WORSE_BY) + EPSILON
304
143
 
144
+ /** Whether either side has fewer than `MIN_TICKETS` tickets. */
145
+ const tooFew = (a: ScoreSide, b: ScoreSide): boolean =>
146
+ a.tickets < MIN_TICKETS || b.tickets < MIN_TICKETS
147
+
148
+ /** Whether `side`'s findings rate or fix rounds per ticket is above `base`'s. */
149
+ const sideAbove = (side: ScoreSide, base: ScoreSide): boolean =>
150
+ above(side.findings_per_100_lines, base.findings_per_100_lines) ||
151
+ above(side.fix_rounds_per_ticket, base.fix_rounds_per_ticket)
152
+
305
153
  /**
306
154
  * The verdict on a cheaper model against its baseline: "not enough data"
307
155
  * while either side has fewer than `MIN_TICKETS` tickets, "worse" when its
@@ -315,34 +163,81 @@ export const verdictOf = ({
315
163
  side: ScoreSide
316
164
  baseline: ScoreSide
317
165
  }): Verdict => {
318
- if (side.tickets < MIN_TICKETS || baseline.tickets < MIN_TICKETS) {
319
- return 'not enough data'
166
+ if (tooFew(side, baseline)) return 'not enough data'
167
+ return sideAbove(side, baseline) ? 'worse' : 'about the same'
168
+ }
169
+
170
+ /**
171
+ * Clef's effort picks for `role` over every run's tickets: a ticket wants
172
+ * more when its pick is above the effort the role's first agent ran at, in
173
+ * `EffortLevelSchema`'s order. "Clef spots hard tickets" when the "wanted
174
+ * more" side's findings rate or fix rounds per ticket is more than
175
+ * `WORSE_BY` above the rest's, with `MIN_TICKETS` on each side.
176
+ */
177
+ const effortPicksOf = ({
178
+ tickets,
179
+ role,
180
+ }: {
181
+ tickets: TicketFacts[]
182
+ role: ScoredRole
183
+ }): EffortPicks => {
184
+ const rank = (level: EffortLevel): number =>
185
+ EffortLevelSchema.options.indexOf(level)
186
+ const picked = tickets.flatMap((ticket) => {
187
+ const pick = ticket.effort_picks[role]
188
+ const ran = ticket.efforts[role]
189
+ return pick === undefined || ran === undefined
190
+ ? []
191
+ : [{ ticket, wants_more: rank(pick) > rank(ran) }]
192
+ })
193
+ const sideWhere = (wants_more: boolean): ScoreSide =>
194
+ sideOf(
195
+ picked.flatMap((entry) =>
196
+ entry.wants_more === wants_more ? [entry.ticket] : []
197
+ )
198
+ )
199
+ const wanted_more = sideWhere(true)
200
+ const rest = sideWhere(false)
201
+ const verdictOn = (): EffortPicksVerdict => {
202
+ if (tooFew(wanted_more, rest)) return 'not enough data'
203
+ return sideAbove(wanted_more, rest)
204
+ ? 'Clef spots hard tickets'
205
+ : 'no sign yet'
320
206
  }
321
- const worse =
322
- above(side.findings_per_100_lines, baseline.findings_per_100_lines) ||
323
- above(side.fix_rounds_per_ticket, baseline.fix_rounds_per_ticket)
324
- return worse ? 'worse' : 'about the same'
207
+ return { role, wanted_more, rest, verdict: verdictOn() }
325
208
  }
326
209
 
327
- /** One routed role on one model, over every run's tickets. */
210
+ /**
211
+ * One routed role on one model at one effort, over every run's tickets
212
+ * whose first ticket review ran at `review_effort`.
213
+ */
328
214
  const scoreOf = ({
329
215
  tickets,
330
216
  role,
331
217
  model,
218
+ effort,
219
+ review_effort,
332
220
  }: {
333
221
  tickets: TicketFacts[]
334
222
  role: ScoredRole
335
223
  model: ModelName
224
+ effort: EffortLevel
225
+ review_effort: EffortLevel
336
226
  }): ModelScore => {
337
227
  const other = SCORED_ROLES.find((scored) => scored !== role) ?? role
338
- const group = tickets.filter((ticket) => ticket.models[role] === model)
228
+ const alike = tickets.filter(
229
+ (ticket) =>
230
+ ticket.efforts[role] === effort &&
231
+ ticket.review_effort === review_effort
232
+ )
233
+ const group = alike.filter((ticket) => ticket.models[role] === model)
339
234
  const finished = group.flatMap(({ joined, tokens }) =>
340
235
  joined && tokens !== null ? [tokens] : []
341
236
  )
342
237
  const baseline =
343
238
  model === 'opus'
344
239
  ? []
345
- : tickets.filter(
240
+ : alike.filter(
346
241
  ({ models, picks }) =>
347
242
  models[role] === 'opus' &&
348
243
  picks[role]?.shadow === true &&
@@ -353,6 +248,8 @@ const scoreOf = ({
353
248
  return {
354
249
  role,
355
250
  model,
251
+ effort,
252
+ review_effort,
356
253
  ...side,
357
254
  stuck_tickets: group.filter(({ stuck }) => stuck).length,
358
255
  tokens_per_finished_ticket:
@@ -395,8 +292,10 @@ const finalReviewOf = (runs: RunFacts[]): FinalReviewSide => {
395
292
 
396
293
  /**
397
294
  * The model score report from run journals, each run's records oldest
398
- * first. A ticket counts under the model of each role's first agent on it
399
- * (an agent from before #600, with no model, ran Opus). Pure.
295
+ * first. A ticket counts under the model and effort of each role's first
296
+ * agent on it (an agent from before #600, with no model, ran Opus; one from
297
+ * before #659, with no effort, ran at high), and under its first ticket
298
+ * review's effort. Pure.
400
299
  *
401
300
  * @example
402
301
  * const report = scoreModels({ runs: [createJournal({ file }).read()] })
@@ -411,20 +310,33 @@ export const scoreModels = ({
411
310
  const tickets = facts
412
311
  .flatMap((run) => run.tickets)
413
312
  .filter(({ models }) => Object.keys(models).length > 0)
313
+ const levels = EffortLevelSchema.options
414
314
  const scores = SCORED_ROLES.flatMap((role) =>
415
- ModelNameSchema.options.map((model) =>
416
- scoreOf({ tickets, role, model })
315
+ ModelNameSchema.options.flatMap((model) =>
316
+ levels.flatMap((effort) =>
317
+ levels.map((review_effort) =>
318
+ scoreOf({ tickets, role, model, effort, review_effort })
319
+ )
320
+ )
417
321
  )
418
322
  ).filter(({ tickets, baseline }) => tickets > 0 || baseline.tickets > 0)
419
323
  const learner = facts.flatMap((run) => run.learner)
420
324
  return {
421
325
  runs: runs.length,
422
326
  scores,
327
+ effort_picks: SCORED_ROLES.map((role) =>
328
+ effortPicksOf({ tickets, role })
329
+ ),
423
330
  learner: ModelNameSchema.options
424
- .map((model) => ({
425
- model,
426
- agents: learner.filter((ran) => ran === model).length,
427
- }))
331
+ .flatMap((model) =>
332
+ levels.map((effort) => ({
333
+ model,
334
+ effort,
335
+ agents: learner.filter(
336
+ (ran) => ran.model === model && ran.effort === effort
337
+ ).length,
338
+ }))
339
+ )
428
340
  .filter(({ agents }) => agents > 0),
429
341
  final_review: {
430
342
  routed: finalReviewOf(facts.filter(({ routed }) => routed)),
@@ -453,29 +365,37 @@ const scoreText = (score: ModelScore): string[] => {
453
365
  const other = SCORED_ROLES.find((role) => role !== score.role)
454
366
  const tokens = score.tokens_per_finished_ticket
455
367
  return [
456
- ` ${score.model}: ${[
368
+ ` ${score.model} at ${score.effort}: ${[
457
369
  sideText(score),
458
370
  `${score.stuck_tickets} stuck`,
459
371
  `${tokens === null ? '-' : Math.round(tokens)} tokens per finished ticket`,
460
372
  `median confidence ${decimal(score.median_confidence)}`,
461
373
  `${other} cheaper on ${score.other_role_cheaper}`,
374
+ `reviewed at ${score.review_effort}`,
462
375
  ].join(', ')}`,
463
376
  ...(score.verdict === null
464
377
  ? []
465
378
  : [
466
- ` baseline (Clef picked ${score.model} in shadow mode, ran opus): ${sideText(score.baseline)}`,
379
+ ` baseline (Clef picked ${score.model} in shadow mode, ran opus at ${score.effort}): ${sideText(score.baseline)}`,
467
380
  ` verdict: ${score.verdict}`,
468
381
  ]),
469
382
  ]
470
383
  }
471
384
 
385
+ const effortPicksText = (picks: EffortPicks): string[] => [
386
+ " Clef's effort picks (shadow mode)",
387
+ ` wanted more effort than it ran at: ${sideText(picks.wanted_more)}`,
388
+ ` the rest: ${sideText(picks.rest)}`,
389
+ ` verdict: ${picks.verdict}`,
390
+ ]
391
+
472
392
  const finalText = (label: string, side: FinalReviewSide): string =>
473
393
  `${decimal(side.findings_per_100_lines)} ${label} (${plural(side.runs, 'run')})`
474
394
 
475
395
  /**
476
- * The report as `luca models` prints it: each routed role's models with
477
- * their verdicts, the learner, the final review's line, and the tickets
478
- * left out of the rates. Pure.
396
+ * The report as `luca models` prints it: each routed role's models and
397
+ * efforts with their verdicts and Clef's effort picks, the learner, the
398
+ * final review's line, and the tickets left out of the rates. Pure.
479
399
  *
480
400
  * @example
481
401
  * console.log(formatModelReport({ report: scoreModels({ runs }) }))
@@ -488,20 +408,23 @@ export const formatModelReport = ({
488
408
  const weights = `blocker ${FINDING_WEIGHTS.blocker}, should-fix ${FINDING_WEIGHTS.should_fix}, nit ${FINDING_WEIGHTS.nit}`
489
409
  const roles = SCORED_ROLES.flatMap((role) => {
490
410
  const scores = report.scores.filter((score) => score.role === role)
411
+ const picks = report.effort_picks.find((found) => found.role === role)
491
412
  return [
492
413
  role,
493
414
  ...(scores.length === 0
494
415
  ? [' no tickets yet']
495
416
  : scores.flatMap(scoreText)),
417
+ ...(picks === undefined ? [] : effortPicksText(picks)),
496
418
  ]
497
419
  })
498
420
  const learner =
499
421
  report.learner.length === 0
500
422
  ? ''
501
- : ` (${report.learner.map(({ model, agents }) => `${plural(agents, 'agent')} on ${model}`).join(', ')})`
423
+ : ` (${report.learner.map(({ model, effort, agents }) => `${plural(agents, 'agent')} on ${model} at ${effort}`).join(', ')})`
502
424
  const { routed, not_routed } = report.final_review
503
425
  return [
504
426
  `Model scores from ${plural(report.runs, 'run')}: the first ticket review's findings weighted ${weights}, per 100 changed lines.`,
427
+ 'Each line is a model at an effort, compared only with tickets reviewed at the same effort.',
505
428
  '',
506
429
  ...roles,
507
430
  `learner: not measured by findings${learner}`,