@alecsibilia/luca 14.0.0-alpha.27 → 14.0.0-alpha.28

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/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @alecsibilia/luca
2
2
 
3
+ ## 14.0.0-alpha.28
4
+
5
+ No changes in this release.
6
+
3
7
  ## 14.0.0-alpha.27
4
8
 
5
9
  No changes in this release.
@@ -36,8 +36,8 @@ const capital = ({ text }: { text: string }): string =>
36
36
  * doesn't know.
37
37
  *
38
38
  * @example
39
- * clefLine({ model: { role: 'implementer', name: 'opus', reason: 'unsure', answer: { role: 'implementer', pick: 'sonnet', confidence: 0.56 } }, bar: 0.8 })
40
- * // { short: '0.56 split → stayed on opus', full: 'Clef picked sonnet but was only 0.56 sure (split: under 0.8), so Luca stayed on opus.', confidence: 0.56, sharp: false }
39
+ * clefLine({ model: { role: 'implementer', name: 'opus', reason: 'unsure', answer: { role: 'implementer', pick: 'sonnet', confidence: 0.36 } }, bar: 0.5 })
40
+ * // { short: '0.36 split → stayed on opus', full: 'Clef picked sonnet but was only 0.36 sure (split: under 0.5), so Luca stayed on opus.', confidence: 0.36, sharp: false }
41
41
  */
42
42
  export const clefLine = ({
43
43
  model,
@@ -54,10 +54,11 @@ export const clefLine = ({
54
54
  const sure = confidence?.toFixed(2) ?? ''
55
55
  const judged = `${word}: ${sharp ? 'at or over' : 'under'} ${bar}`
56
56
  const plain = { confidence: null, sharp: false }
57
+ const picked = `${confidence === null ? 'clef' : `${sure} ${word}`} → ${name}`
57
58
  switch (reason) {
58
59
  case 'decision_model':
59
60
  return {
60
- short: `${confidence === null ? 'clef' : `${sure} ${word}`} → ${name}`,
61
+ short: picked,
61
62
  full:
62
63
  confidence === null
63
64
  ? `Clef picked ${name}, so Luca ran the ${role} on it.`
@@ -65,6 +66,16 @@ export const clefLine = ({
65
66
  confidence,
66
67
  sharp,
67
68
  }
69
+ case 'trial':
70
+ return {
71
+ short: `${picked} (trial)`,
72
+ full:
73
+ confidence === null
74
+ ? `Clef picked ${name}, and Luca ran the ${role} on it in a model trial.`
75
+ : `Clef picked ${name} and was ${sure} sure (${judged}), and Luca ran the ${role} on it in a model trial.`,
76
+ confidence,
77
+ sharp,
78
+ }
68
79
  case 'unsure':
69
80
  return {
70
81
  short: `${confidence === null ? 'unsure' : `${sure} ${word}`} → stayed on ${name}`,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luca/board",
3
- "version": "14.0.0-alpha.27",
3
+ "version": "14.0.0-alpha.28",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -1,2 +1,2 @@
1
1
  /** The Luca version of this board, stamped when the package was packed. */
2
- export const LUCA_VERSION: string = "14.0.0-alpha.27"
2
+ export const LUCA_VERSION: string = "14.0.0-alpha.28"
@@ -94,7 +94,7 @@ export const DEFAULT_RUN_BUDGET_TOKENS = 9_000_000
94
94
  * `engine/src/config/engine-config-schema.ts`): the decision model is sharp at or
95
95
  * over it, split under it. Kept here for the same reason.
96
96
  */
97
- export const DEFAULT_CONFIDENCE_BAR = 0.8
97
+ export const DEFAULT_CONFIDENCE_BAR = 0.5
98
98
 
99
99
  export const EngineEndedSchema = z.object({
100
100
  ok: z.boolean(),
@@ -216,12 +216,15 @@ const RoleEffortsSchema = z.strictObject(
216
216
  /**
217
217
  * The config's `models` block (#603): each routed role's model floor, each
218
218
  * writer's effort (#660), and how sure the decision model must be
219
- * (`confidence_bar`) before its model pick is used.
219
+ * (`confidence_bar`) before its model pick is used, and the share of its
220
+ * unsure but cheaper picks that run anyway in a model trial (`trial_share`,
221
+ * #698).
220
222
  */
221
223
  export const ModelsConfigSchema = z.object({
222
224
  floors: ModelFloorsSchema.prefault({}),
223
225
  effort: RoleEffortsSchema.prefault({}),
224
- confidence_bar: z.number().min(0).max(1).default(0.8),
226
+ confidence_bar: z.number().min(0).max(1).default(0.5),
227
+ trial_share: z.number().min(0).max(1).default(0),
225
228
  })
226
229
 
227
230
  export type ModelsConfig = z.infer<typeof ModelsConfigSchema>
@@ -3,6 +3,8 @@
3
3
  * ended, journaled. Also the `launch_agent` and `follow_up_agent` steps,
4
4
  * which start a turn.
5
5
  */
6
+ import { basename } from 'node:path'
7
+
6
8
  import { need, ticketWorktree, type BuildContext } from './build-context'
7
9
  import { mayEditTests, type BuildAction } from './decide-build'
8
10
  import { doChores } from './engine-chores'
@@ -19,7 +21,11 @@ import { DEFAULT_MODEL, type EffortLevel } from '../config/engine-config'
19
21
  import { describeViolations } from '../guards/after-turn-check'
20
22
  import { guardRoleOf } from '../guards/role-rules'
21
23
  import { enforceAfterTurn, snapshotWorktree } from '../guards/worktree-state'
22
- import { launchPick, type ModelReason } from '../jev/model-pick'
24
+ import {
25
+ launchPick,
26
+ type ModelReason,
27
+ type TrialGroup,
28
+ } from '../jev/model-pick'
23
29
  import type { AgentFailure, JournalRecord } from '../journal/journal-record'
24
30
  import type { ReplayedWorktree } from '../journal/replay'
25
31
  import { sessionSignal } from '../limits/plan-signals'
@@ -253,13 +259,15 @@ const sessionOf = (record: JournalRecord): string | null => {
253
259
  }
254
260
 
255
261
  /**
256
- * The model a session launched on, and why (`null` before #603), and the
257
- * effort it runs at (`null` before #659).
262
+ * The model a session launched on, and why (`null` before #603), the
263
+ * effort it runs at (`null` before #659), and its model trial group
264
+ * (`null` when not eligible, or before #698).
258
265
  */
259
266
  export type SessionModel = {
260
267
  model: string
261
268
  model_reason: ModelReason | null
262
269
  effort: EffortLevel | null
270
+ trial_group: TrialGroup | null
263
271
  }
264
272
 
265
273
  /**
@@ -302,8 +310,8 @@ export const sessionModel = ({
302
310
  record.content.follow_up_of === null
303
311
  ))
304
312
  if (started?.kind !== 'agent_started') return null
305
- const { model, model_reason, effort } = started.content
306
- return model === null ? null : { model, model_reason, effort }
313
+ const { model, model_reason, effort, trial_group } = started.content
314
+ return model === null ? null : { model, model_reason, effort, trial_group }
307
315
  }
308
316
 
309
317
  /**
@@ -321,6 +329,7 @@ export const followUpModel = ({
321
329
  model: DEFAULT_MODEL,
322
330
  model_reason: null,
323
331
  effort: null,
332
+ trial_group: null,
324
333
  }
325
334
 
326
335
  export const launchAgent = async ({
@@ -335,10 +344,11 @@ export const launchAgent = async ({
335
344
  // a routed role's model; a reviewer is locked on Opus. A redo keeps
336
345
  // the model its first try journaled. The role's effort applies on any
337
346
  // model (#659).
338
- const { model, model_reason } = launchPick({
347
+ const { model, model_reason, trial_group } = launchPick({
339
348
  records: context.journal.read(),
340
349
  state: context.state,
341
350
  config: context.config,
351
+ run_id: basename(context.run_dir),
342
352
  ticket,
343
353
  role,
344
354
  step: context.step,
@@ -355,6 +365,7 @@ export const launchAgent = async ({
355
365
  model,
356
366
  model_reason,
357
367
  effort,
368
+ trial_group,
358
369
  },
359
370
  })
360
371
  await runTurn({
@@ -394,7 +405,7 @@ export const followUpAgent = async ({
394
405
  value: context.state.snapshot?.tickets[ticket],
395
406
  what: `snapshot of #${ticket}`,
396
407
  })
397
- const { model, model_reason, effort } = followUpModel({
408
+ const { model, model_reason, effort, trial_group } = followUpModel({
398
409
  records: context.journal.read(),
399
410
  session_id,
400
411
  })
@@ -409,6 +420,7 @@ export const followUpAgent = async ({
409
420
  model,
410
421
  model_reason,
411
422
  effort,
423
+ trial_group,
412
424
  },
413
425
  })
414
426
  await runTurn({
@@ -265,6 +265,7 @@ export const executeFinalReviewAction = async ({
265
265
  model,
266
266
  model_reason,
267
267
  effort,
268
+ trial_group: null,
268
269
  },
269
270
  })
270
271
  await runTurn({
@@ -1,3 +1,5 @@
1
+ import { basename } from 'node:path'
2
+
1
3
  import type { MemoryAction, MemorySaveStep } from './decide-memory'
2
4
  import { buildContext, need, runTurn, type BuildDeps } from './execute-build'
3
5
 
@@ -413,6 +415,7 @@ export const executeMemoryAction = async ({
413
415
  records: journal.read(),
414
416
  state: built.state,
415
417
  config: built.config,
418
+ run_id: basename(built.run_dir),
416
419
  ticket: null,
417
420
  role: 'learner',
418
421
  step,
@@ -432,6 +435,8 @@ export const executeMemoryAction = async ({
432
435
  model,
433
436
  model_reason,
434
437
  effort,
438
+ // The learner is never in a model trial.
439
+ trial_group: null,
435
440
  },
436
441
  })
437
442
  await runTurn({
@@ -29,6 +29,8 @@ import type { RunState } from '../journal/replay'
29
29
  * - `unsure`: its confidence was below the bar or missing, or it picked a
30
30
  * model the ask didn't offer.
31
31
  * - `decision_model`: the decision model's pick.
32
+ * - `trial`: an unsure but cheaper pick that runs anyway, in a model
33
+ * trial's trial group (#698).
32
34
  */
33
35
  export const ModelReasonSchema = z.enum([
34
36
  'locked',
@@ -37,19 +39,79 @@ export const ModelReasonSchema = z.enum([
37
39
  'no_answer',
38
40
  'unsure',
39
41
  'decision_model',
42
+ 'trial',
40
43
  ])
41
44
 
42
45
  export type ModelReason = z.infer<typeof ModelReasonSchema>
43
46
 
44
- /** The model an agent launches on, and why. */
45
- export type ModelPick = { model: ModelName; model_reason: ModelReason }
47
+ /**
48
+ * A model trial's group (#698): `trial` runs Clef's unsure but cheaper
49
+ * pick, `control` stays on Opus.
50
+ */
51
+ export const TrialGroupSchema = z.enum(['trial', 'control'])
52
+
53
+ export type TrialGroup = z.infer<typeof TrialGroupSchema>
54
+
55
+ /**
56
+ * The model an agent launches on, why, and its model trial group (`null`
57
+ * when the launch isn't eligible for a trial).
58
+ */
59
+ export type ModelPick = {
60
+ model: ModelName
61
+ model_reason: ModelReason
62
+ trial_group: TrialGroup | null
63
+ }
46
64
 
47
65
  /** Reviewers, lenses, and the final review's fixers: always Opus. */
48
66
  export const LOCKED_PICK: ModelPick = {
49
67
  model: DEFAULT_MODEL,
50
68
  model_reason: 'locked',
69
+ trial_group: null,
51
70
  }
52
71
 
72
+ /**
73
+ * A launch's model trial group (#698), the same for the same key every
74
+ * time: the first 8 hex digits of the key's SHA-256, over `0x100000000`,
75
+ * below `share` is `trial`, else `control`.
76
+ *
77
+ * @example
78
+ * trialGroupOf({ key: 'luca-20261028-202119-k6q1:697:test-writer', share: 0.5 }) // 'trial'
79
+ */
80
+ export const trialGroupOf = ({
81
+ key,
82
+ share,
83
+ }: {
84
+ key: string
85
+ share: number
86
+ }): TrialGroup => {
87
+ const hex = new Bun.CryptoHasher('sha256').update(key).digest('hex')
88
+ return parseInt(hex.slice(0, 8), 16) / 0x100000000 < share
89
+ ? 'trial'
90
+ : 'control'
91
+ }
92
+
93
+ /**
94
+ * The text a launch's model trial group is hashed from: the run folder's
95
+ * name, the ticket's bare number and the role; `null` for a launch that
96
+ * can't be in a trial (the learner, or one with no ticket).
97
+ *
98
+ * @example
99
+ * trialKeyOf({ run_id: 'luca-20261028-202119-k6q1', ticket: 697, role: 'test-writer' })
100
+ * // 'luca-20261028-202119-k6q1:697:test-writer'
101
+ */
102
+ export const trialKeyOf = ({
103
+ run_id,
104
+ ticket,
105
+ role,
106
+ }: {
107
+ run_id: string
108
+ ticket: number | null
109
+ role: AgentRole
110
+ }): string | null =>
111
+ ticket === null || (role !== 'test-writer' && role !== 'implementer')
112
+ ? null
113
+ : `${run_id}:${ticket}:${role}`
114
+
53
115
  export type RoutedRole = (typeof ROUTED_ROLES)[number]
54
116
 
55
117
  /** Whether a role has a model floor, so the decision model may pick its model. */
@@ -84,38 +146,69 @@ export type ModelAnswer = {
84
146
  * offered is `unsure`; else the decision model's pick. Every fallback is
85
147
  * Opus.
86
148
  *
149
+ * An `unsure` pick is in a model trial (#698) when `trial_share` is above
150
+ * 0, the launch has a `trial_key`, its only fault is a confidence below
151
+ * the bar, and it picked an offered model at or above the floor and
152
+ * cheaper than Opus: its trial group runs the pick as `trial`, its control
153
+ * group stays on Opus as `unsure`.
154
+ *
87
155
  * @example
88
- * pickModel({ role: 'test-writer', models, failed_cheaper: false, answer: { offered: ['sonnet', 'opus'], pick: 'sonnet', confidence: 0.9 } })
89
- * // { model: 'sonnet', model_reason: 'decision_model' } with a sonnet floor and the 0.8 bar
156
+ * pickModel({ role: 'test-writer', models, failed_cheaper: false, answer: { offered: ['sonnet', 'opus'], pick: 'sonnet', confidence: 0.9 }, trial_key: null })
157
+ * // { model: 'sonnet', model_reason: 'decision_model', trial_group: null } with a sonnet floor and the 0.5 bar
90
158
  */
91
159
  export const pickModel = ({
92
160
  role,
93
161
  models,
94
162
  failed_cheaper,
95
163
  answer,
164
+ trial_key,
96
165
  }: {
97
166
  role: AgentRole
98
167
  models: ModelsConfig
99
168
  /** Whether the role already failed a try on its ticket on a cheaper model. */
100
169
  failed_cheaper: boolean
101
170
  answer: ModelAnswer | null
171
+ /** The text hashed for the trial group; `null` if it can't be in one. */
172
+ trial_key: string | null
102
173
  }): ModelPick => {
174
+ const opus = (model_reason: ModelReason): ModelPick => ({
175
+ model: 'opus',
176
+ model_reason,
177
+ trial_group: null,
178
+ })
103
179
  if (!isRoutedRole(role)) return LOCKED_PICK
104
- if (failed_cheaper) return { model: 'opus', model_reason: 'after_failure' }
105
- if (models.floors[role] === 'opus') {
106
- return { model: 'opus', model_reason: 'floor' }
107
- }
108
- if (answer === null) return { model: 'opus', model_reason: 'no_answer' }
180
+ if (failed_cheaper) return opus('after_failure')
181
+ const floor = models.floors[role]
182
+ if (floor === 'opus') return opus('floor')
183
+ if (answer === null) return opus('no_answer')
109
184
  const pick = ModelNameSchema.safeParse(answer.pick)
110
185
  if (
111
186
  !pick.success ||
112
187
  !answer.offered.includes(pick.data) ||
113
- answer.confidence === null ||
114
- answer.confidence < models.confidence_bar
188
+ answer.confidence === null
189
+ ) {
190
+ return opus('unsure')
191
+ }
192
+ if (answer.confidence >= models.confidence_bar) {
193
+ return {
194
+ model: pick.data,
195
+ model_reason: 'decision_model',
196
+ trial_group: null,
197
+ }
198
+ }
199
+ const all = ModelNameSchema.options
200
+ if (
201
+ trial_key === null ||
202
+ models.trial_share <= 0 ||
203
+ pick.data === 'opus' ||
204
+ all.indexOf(pick.data) > all.indexOf(floor)
115
205
  ) {
116
- return { model: 'opus', model_reason: 'unsure' }
206
+ return opus('unsure')
117
207
  }
118
- return { model: pick.data, model_reason: 'decision_model' }
208
+ return trialGroupOf({ key: trial_key, share: models.trial_share }) ===
209
+ 'trial'
210
+ ? { model: pick.data, model_reason: 'trial', trial_group: 'trial' }
211
+ : { ...opus('unsure'), trial_group: 'control' }
119
212
  }
120
213
 
121
214
  /**
@@ -211,9 +304,9 @@ export const redonePick = ({
211
304
  )
212
305
  if (started?.kind !== 'agent_started') return null
213
306
  const model = ModelNameSchema.safeParse(started.content.model)
214
- const { model_reason } = started.content
307
+ const { model_reason, trial_group } = started.content
215
308
  if (!model.success || model_reason === null) return null
216
- return { model: model.data, model_reason }
309
+ return { model: model.data, model_reason, trial_group }
217
310
  }
218
311
 
219
312
  /** Which try of its step a launch is: a redo names its first try's seq. */
@@ -246,13 +339,16 @@ export const asksModel = ({
246
339
 
247
340
  /**
248
341
  * The model a fresh agent launches on, from the run's config and the
249
- * answer the launch step just journaled. A redo launches on the model its
250
- * first try journaled.
342
+ * answer the launch step just journaled. A redo launches on the model, and
343
+ * in the trial group, its first try journaled.
344
+ *
345
+ * @param run_id - The run folder's name, for the model trial's key.
251
346
  */
252
347
  export const launchPick = ({
253
348
  records,
254
349
  state,
255
350
  config,
351
+ run_id,
256
352
  ticket,
257
353
  role,
258
354
  step,
@@ -260,6 +356,7 @@ export const launchPick = ({
260
356
  records: JournalRecord[]
261
357
  state: RunState
262
358
  config: EngineConfig
359
+ run_id: string
263
360
  ticket: number | null
264
361
  role: AgentRole
265
362
  step: LaunchTry
@@ -272,4 +369,5 @@ export const launchPick = ({
272
369
  models: modelsOf({ config }),
273
370
  failed_cheaper: failedCheaper({ state, ticket, role }),
274
371
  answer: modelAnswerOf({ records, ticket, role }),
372
+ trial_key: trialKeyOf({ run_id, ticket, role }),
275
373
  })
@@ -10,7 +10,7 @@ import {
10
10
  } from '../agents/role-results'
11
11
  import { EffortLevelSchema } from '../config/engine-config'
12
12
  import { GateCheckSchema } from '../gates/gate-schemas'
13
- import { ModelReasonSchema } from '../jev/model-pick'
13
+ import { ModelReasonSchema, TrialGroupSchema } from '../jev/model-pick'
14
14
 
15
15
  /**
16
16
  * The journal entries of agent turns: started, finished or failed, their
@@ -26,7 +26,9 @@ import { ModelReasonSchema } from '../jev/model-pick'
26
26
  * launched on that model (#603; a follow-up's is its session's); `null` in
27
27
  * journals from before it. `effort` is the effort it ran at (#659; a
28
28
  * follow-up's is its session's); `null` in journals from before it, when
29
- * every agent ran at `high`.
29
+ * every agent ran at `high`. `trial_group` is its model trial group (#698;
30
+ * a redo's is its first try's, a follow-up's is its session's); `null` when
31
+ * it wasn't eligible, and in journals from before it.
30
32
  */
31
33
  export const AgentStartedEntrySchema = z.object({
32
34
  ...ENTRY_FIELDS,
@@ -38,6 +40,7 @@ export const AgentStartedEntrySchema = z.object({
38
40
  model: z.string().nullable().default(null),
39
41
  model_reason: ModelReasonSchema.nullable().default(null),
40
42
  effort: EffortLevelSchema.nullable().default(null),
43
+ trial_group: TrialGroupSchema.nullable().default(null),
41
44
  }),
42
45
  })
43
46
 
@@ -6,14 +6,13 @@ import {
6
6
  } from './model-score-rules'
7
7
 
8
8
  import type { FindingSeverity } from '../agents/role-results'
9
- import { modelsOf } from '../config/config-fields'
10
9
  import {
11
10
  EffortLevelSchema,
12
11
  ModelNameSchema,
13
- ModelsConfigSchema,
14
12
  type EffortLevel,
15
13
  type ModelName,
16
14
  } from '../config/engine-config'
15
+ import type { ModelReason, TrialGroup } from '../jev/model-pick'
17
16
  import type { JournalRecord } from '../journal/journal-record'
18
17
  import { countedTokens } from '../limits/plan-usage'
19
18
 
@@ -31,6 +30,9 @@ export type Pick = {
31
30
  shadow: boolean
32
31
  }
33
32
 
33
+ /** Clef's effort pick for a role on a ticket, with its confidence. */
34
+ export type EffortPick = { effort: EffortLevel; confidence: number }
35
+
34
36
  /** What one run's journal says about one ticket. */
35
37
  export type TicketFacts = {
36
38
  /** The model of each role's first agent. */
@@ -40,11 +42,18 @@ export type TicketFacts = {
40
42
  /** The first ticket review's effort, `high` when none is journaled. */
41
43
  review_effort: EffortLevel
42
44
  picks: Partial<Record<ScoredRole, Pick>>
45
+ /** Each role's first agent's model trial group, when it had one (#698). */
46
+ trial_groups: Partial<Record<ScoredRole, TrialGroup>>
47
+ /**
48
+ * Clef's first `agent_effort` answer for each role that has a
49
+ * confidence, at any confidence (#697).
50
+ */
51
+ effort_picks: Partial<Record<ScoredRole, EffortPick>>
43
52
  /**
44
- * Clef's first `agent_effort` answer for each role at or above the run's
45
- * confidence bar.
53
+ * Why each role's first agent got its model; `null` for one from before
54
+ * #603.
46
55
  */
47
- effort_picks: Partial<Record<ScoredRole, EffortLevel>>
56
+ model_reasons: Partial<Record<ScoredRole, ModelReason | null>>
48
57
  reviews: number
49
58
  /** The first ticket review's weighted findings. */
50
59
  findings: number
@@ -100,7 +109,9 @@ const newTicket = (): TicketFacts => ({
100
109
  efforts: {},
101
110
  review_effort: EFFORT_BEFORE_659,
102
111
  picks: {},
112
+ trial_groups: {},
103
113
  effort_picks: {},
114
+ model_reasons: {},
104
115
  reviews: 0,
105
116
  findings: 0,
106
117
  fix_rounds: 0,
@@ -125,15 +136,11 @@ export const factsOf = (records: JournalRecord[]): RunFacts => {
125
136
  const effort_asks = new Map<number, string | null>()
126
137
  const reviewed = new Set<number>()
127
138
  const learner: LearnerRun[] = []
128
- let bar = ModelsConfigSchema.parse({}).confidence_bar
129
139
  let routed = false
130
140
  let final_rounds = 0
131
141
  let final_findings = 0
132
142
  for (const record of records) {
133
143
  switch (record.kind) {
134
- case 'run_started':
135
- bar = modelsOf({ config: record.content.config }).confidence_bar
136
- break
137
144
  case 'jev_asked':
138
145
  if (record.content.job === 'agent_effort') {
139
146
  effort_asks.set(record.seq, record.role)
@@ -159,11 +166,12 @@ export const factsOf = (records: JournalRecord[]): RunFacts => {
159
166
  if (
160
167
  isScored(effort_role) &&
161
168
  level.success &&
162
- confidence !== null &&
163
- confidence >= bar
169
+ confidence !== null
164
170
  ) {
165
- ticketOf(record.ticket).effort_picks[effort_role] ??=
166
- level.data
171
+ ticketOf(record.ticket).effort_picks[effort_role] ??= {
172
+ effort: level.data,
173
+ confidence,
174
+ }
167
175
  }
168
176
  break
169
177
  }
@@ -184,7 +192,14 @@ export const factsOf = (records: JournalRecord[]): RunFacts => {
184
192
  break
185
193
  }
186
194
  case 'agent_started': {
187
- const { role, model, effort, follow_up_of } = record.content
195
+ const {
196
+ role,
197
+ model,
198
+ effort,
199
+ follow_up_of,
200
+ model_reason,
201
+ trial_group,
202
+ } = record.content
188
203
  const family = familyOf(model) ?? 'opus'
189
204
  const level = effort ?? EFFORT_BEFORE_659
190
205
  if (follow_up_of !== null) break
@@ -197,8 +212,14 @@ export const factsOf = (records: JournalRecord[]): RunFacts => {
197
212
  if (record.ticket === null) break
198
213
  if (isScored(role)) {
199
214
  const facts = ticketOf(record.ticket)
200
- facts.models[role] ??= family
201
- facts.efforts[role] ??= level
215
+ if (facts.models[role] === undefined) {
216
+ facts.models[role] = family
217
+ facts.efforts[role] = level
218
+ facts.model_reasons[role] = model_reason
219
+ if (trial_group !== null) {
220
+ facts.trial_groups[role] = trial_group
221
+ }
222
+ }
202
223
  }
203
224
  if (
204
225
  role === 'ticket-reviewer' &&
@@ -0,0 +1,80 @@
1
+ import { sum, type TicketFacts } from './model-score-facts'
2
+ import { EPSILON, MIN_TICKETS, WORSE_BY } from './model-score-rules'
3
+
4
+ /**
5
+ * The model score report's sides (#605): the rates a set of tickets is
6
+ * judged by, the verdict between two sides, and a side as `luca models`
7
+ * prints it. Pure.
8
+ */
9
+
10
+ export type Verdict = 'not enough data' | 'worse' | 'about the same'
11
+
12
+ /** The rates a model is judged by, for its tickets or its baseline's. */
13
+ export type ScoreSide = {
14
+ tickets: number
15
+ /** Weighted first-review findings per 100 changed lines; `null` with no lines. */
16
+ findings_per_100_lines: number | null
17
+ /** `null` with no tickets. */
18
+ fix_rounds_per_ticket: number | null
19
+ }
20
+
21
+ /** Findings per 100 lines over `tickets`, those with no lines left out. */
22
+ const findingsRate = (tickets: TicketFacts[]): number | null => {
23
+ const rated = tickets.filter(({ lines }) => lines !== null)
24
+ const lines = sum(rated.map(({ lines }) => lines ?? 0))
25
+ return lines === 0
26
+ ? null
27
+ : (sum(rated.map(({ findings }) => findings)) * 100) / lines
28
+ }
29
+
30
+ export const sideOf = (tickets: TicketFacts[]): ScoreSide => ({
31
+ tickets: tickets.length,
32
+ findings_per_100_lines: findingsRate(tickets),
33
+ fix_rounds_per_ticket:
34
+ tickets.length === 0
35
+ ? null
36
+ : sum(tickets.map(({ fix_rounds }) => fix_rounds)) / tickets.length,
37
+ })
38
+
39
+ /** Whether `value` is more than `WORSE_BY` above `base`. */
40
+ const above = (value: number | null, base: number | null): boolean =>
41
+ value !== null && base !== null && value > base * (1 + WORSE_BY) + EPSILON
42
+
43
+ /** Whether either side has fewer than `MIN_TICKETS` tickets. */
44
+ export const tooFew = (a: ScoreSide, b: ScoreSide): boolean =>
45
+ a.tickets < MIN_TICKETS || b.tickets < MIN_TICKETS
46
+
47
+ /** Whether `side`'s findings rate or fix rounds per ticket is above `base`'s. */
48
+ export const sideAbove = (side: ScoreSide, base: ScoreSide): boolean =>
49
+ above(side.findings_per_100_lines, base.findings_per_100_lines) ||
50
+ above(side.fix_rounds_per_ticket, base.fix_rounds_per_ticket)
51
+
52
+ /**
53
+ * The verdict on a cheaper model against its baseline: "not enough data"
54
+ * while either side has fewer than `MIN_TICKETS` tickets, "worse" when its
55
+ * findings rate or fix rounds per ticket is more than `WORSE_BY` above the
56
+ * baseline's, else "about the same". Pure.
57
+ */
58
+ export const verdictOf = ({
59
+ side,
60
+ baseline,
61
+ }: {
62
+ side: ScoreSide
63
+ baseline: ScoreSide
64
+ }): Verdict => {
65
+ if (tooFew(side, baseline)) return 'not enough data'
66
+ return sideAbove(side, baseline) ? 'worse' : 'about the same'
67
+ }
68
+
69
+ export const plural = (count: number, word: string): string =>
70
+ `${count} ${word}${count === 1 ? '' : 's'}`
71
+
72
+ export const decimal = (value: number | null): string =>
73
+ value === null ? '-' : value.toFixed(2)
74
+
75
+ export const sideText = (side: ScoreSide): string =>
76
+ [
77
+ plural(side.tickets, 'ticket'),
78
+ `${decimal(side.findings_per_100_lines)} findings per 100 lines`,
79
+ `${decimal(side.fix_rounds_per_ticket)} fix rounds per ticket`,
80
+ ].join(', ')
@@ -5,13 +5,21 @@ import {
5
5
  type TicketFacts,
6
6
  } from './model-score-facts'
7
7
  import {
8
- EPSILON,
9
8
  FINDING_WEIGHTS,
10
- MIN_TICKETS,
11
9
  SCORED_ROLES,
12
- WORSE_BY,
13
10
  type ScoredRole,
14
11
  } from './model-score-rules'
12
+ import {
13
+ decimal,
14
+ plural,
15
+ sideAbove,
16
+ sideOf,
17
+ sideText,
18
+ tooFew,
19
+ verdictOf,
20
+ type ScoreSide,
21
+ type Verdict,
22
+ } from './model-score-sides'
15
23
 
16
24
  import {
17
25
  EffortLevelSchema,
@@ -28,6 +36,7 @@ export {
28
36
  WORSE_BY,
29
37
  type ScoredRole,
30
38
  } from './model-score-rules'
39
+ export { verdictOf, type ScoreSide, type Verdict } from './model-score-sides'
31
40
 
32
41
  /**
33
42
  * The model score report (#605): how good each routed role's work was on
@@ -37,17 +46,6 @@ export {
37
46
  * reads records and never writes.
38
47
  */
39
48
 
40
- export type Verdict = 'not enough data' | 'worse' | 'about the same'
41
-
42
- /** The rates a model is judged by, for its tickets or its baseline's. */
43
- export type ScoreSide = {
44
- tickets: number
45
- /** Weighted first-review findings per 100 changed lines; `null` with no lines. */
46
- findings_per_100_lines: number | null
47
- /** `null` with no tickets. */
48
- fix_rounds_per_ticket: number | null
49
- }
50
-
51
49
  /** One routed role on one model at one effort, its tickets reviewed at one effort. */
52
50
  export type ModelScore = ScoreSide & {
53
51
  role: ScoredRole
@@ -71,6 +69,17 @@ export type ModelScore = ScoreSide & {
71
69
  baseline: ScoreSide
72
70
  /** `null` for Opus, which is the baseline the others are judged by. */
73
71
  verdict: Verdict | null
72
+ /** Its tickets in a model trial's trial group (#698). */
73
+ trial_tickets: number
74
+ /**
75
+ * The role's control group tickets (#698) where Clef picked this model,
76
+ * at the same effort and reviewed at the same effort.
77
+ */
78
+ control: ScoreSide
79
+ /**
80
+ * Its trial tickets against its control tickets; `null` with neither.
81
+ */
82
+ trial_verdict: Verdict | null
74
83
  }
75
84
 
76
85
  export type EffortPicksVerdict =
@@ -78,17 +87,33 @@ export type EffortPicksVerdict =
78
87
  | 'Clef spots hard tickets'
79
88
  | 'no sign yet'
80
89
 
90
+ /** Clef's confidence bands its effort picks are broken down by (#697). */
91
+ const CONFIDENCE_BANDS = [
92
+ { band: 'below 0.3', from: -Infinity, below: 0.3 },
93
+ { band: '0.3 to 0.5', from: 0.3, below: 0.5 },
94
+ { band: '0.5 and up', from: 0.5, below: Infinity },
95
+ ] as const
96
+
97
+ /** The two sides of Clef's effort picks within one confidence band. */
98
+ export type EffortPicksBand = {
99
+ band: (typeof CONFIDENCE_BANDS)[number]['band']
100
+ wanted_more: ScoreSide
101
+ rest: ScoreSide
102
+ }
103
+
81
104
  /**
82
105
  * 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.
106
+ * where its first `agent_effort` answer with a confidence, at any
107
+ * confidence (#697), was higher than the effort the role's first agent ran
108
+ * at, against the tickets where it was the same or lower, then the same by
109
+ * Clef's confidence.
86
110
  */
87
111
  export type EffortPicks = {
88
112
  role: ScoredRole
89
113
  wanted_more: ScoreSide
90
114
  rest: ScoreSide
91
115
  verdict: EffortPicksVerdict
116
+ by_confidence: EffortPicksBand[]
92
117
  }
93
118
 
94
119
  /** The final review's findings over a set of runs. */
@@ -102,6 +127,11 @@ export type ModelReport = {
102
127
  scores: ModelScore[]
103
128
  /** One per scored role. */
104
129
  effort_picks: EffortPicks[]
130
+ /**
131
+ * The roles whose every counted ticket with a model reason ran Opus for
132
+ * its floor (#697): no cheaper model was ever tried.
133
+ */
134
+ floor_only: ScoredRole[]
105
135
  /** Fresh learner agents per model and effort; the learner isn't scored. */
106
136
  learner: { model: ModelName; effort: EffortLevel; agents: number }[]
107
137
  /** Runs with any agent on a cheaper model, and runs without. */
@@ -119,60 +149,13 @@ const median = (values: number[]): number | null => {
119
149
  : ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2
120
150
  }
121
151
 
122
- /** Findings per 100 lines over `tickets`, those with no lines left out. */
123
- const findingsRate = (tickets: TicketFacts[]): number | null => {
124
- const rated = tickets.filter(({ lines }) => lines !== null)
125
- const lines = sum(rated.map(({ lines }) => lines ?? 0))
126
- return lines === 0
127
- ? null
128
- : (sum(rated.map(({ findings }) => findings)) * 100) / lines
129
- }
130
-
131
- const sideOf = (tickets: TicketFacts[]): ScoreSide => ({
132
- tickets: tickets.length,
133
- findings_per_100_lines: findingsRate(tickets),
134
- fix_rounds_per_ticket:
135
- tickets.length === 0
136
- ? null
137
- : sum(tickets.map(({ fix_rounds }) => fix_rounds)) / tickets.length,
138
- })
139
-
140
- /** Whether `value` is more than `WORSE_BY` above `base`. */
141
- const above = (value: number | null, base: number | null): boolean =>
142
- value !== null && base !== null && value > base * (1 + WORSE_BY) + EPSILON
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
-
153
- /**
154
- * The verdict on a cheaper model against its baseline: "not enough data"
155
- * while either side has fewer than `MIN_TICKETS` tickets, "worse" when its
156
- * findings rate or fix rounds per ticket is more than `WORSE_BY` above the
157
- * baseline's, else "about the same". Pure.
158
- */
159
- export const verdictOf = ({
160
- side,
161
- baseline,
162
- }: {
163
- side: ScoreSide
164
- baseline: ScoreSide
165
- }): Verdict => {
166
- if (tooFew(side, baseline)) return 'not enough data'
167
- return sideAbove(side, baseline) ? 'worse' : 'about the same'
168
- }
169
-
170
152
  /**
171
153
  * Clef's effort picks for `role` over every run's tickets: a ticket wants
172
154
  * more when its pick is above the effort the role's first agent ran at, in
173
155
  * `EffortLevelSchema`'s order. "Clef spots hard tickets" when the "wanted
174
156
  * 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.
157
+ * `WORSE_BY` above the rest's, with `MIN_TICKETS` on each side. Each band
158
+ * holds the tickets whose pick's confidence is at or above its lower edge.
176
159
  */
177
160
  const effortPicksOf = ({
178
161
  tickets,
@@ -188,12 +171,23 @@ const effortPicksOf = ({
188
171
  const ran = ticket.efforts[role]
189
172
  return pick === undefined || ran === undefined
190
173
  ? []
191
- : [{ ticket, wants_more: rank(pick) > rank(ran) }]
174
+ : [
175
+ {
176
+ ticket,
177
+ confidence: pick.confidence,
178
+ wants_more: rank(pick.effort) > rank(ran),
179
+ },
180
+ ]
192
181
  })
193
- const sideWhere = (wants_more: boolean): ScoreSide =>
182
+ const sideWhere = (
183
+ wants_more: boolean,
184
+ within: (confidence: number) => boolean = () => true
185
+ ): ScoreSide =>
194
186
  sideOf(
195
187
  picked.flatMap((entry) =>
196
- entry.wants_more === wants_more ? [entry.ticket] : []
188
+ entry.wants_more === wants_more && within(entry.confidence)
189
+ ? [entry.ticket]
190
+ : []
197
191
  )
198
192
  )
199
193
  const wanted_more = sideWhere(true)
@@ -204,7 +198,35 @@ const effortPicksOf = ({
204
198
  ? 'Clef spots hard tickets'
205
199
  : 'no sign yet'
206
200
  }
207
- return { role, wanted_more, rest, verdict: verdictOn() }
201
+ const by_confidence = CONFIDENCE_BANDS.map(({ band, from, below }) => {
202
+ const within = (confidence: number) =>
203
+ confidence >= from && confidence < below
204
+ return {
205
+ band,
206
+ wanted_more: sideWhere(true, within),
207
+ rest: sideWhere(false, within),
208
+ }
209
+ })
210
+ return { role, wanted_more, rest, verdict: verdictOn(), by_confidence }
211
+ }
212
+
213
+ /**
214
+ * Whether every one of `role`'s tickets that has a model reason ran Opus
215
+ * for its floor, with at least one such ticket. A `null` reason, from
216
+ * before #603, counts neither way.
217
+ */
218
+ const isFloorOnly = ({
219
+ tickets,
220
+ role,
221
+ }: {
222
+ tickets: TicketFacts[]
223
+ role: ScoredRole
224
+ }): boolean => {
225
+ const reasons = tickets.flatMap(({ models, model_reasons }) => {
226
+ const reason = model_reasons[role] ?? null
227
+ return models[role] === undefined || reason === null ? [] : [reason]
228
+ })
229
+ return reasons.length > 0 && reasons.every((reason) => reason === 'floor')
208
230
  }
209
231
 
210
232
  /**
@@ -245,6 +267,15 @@ const scoreOf = ({
245
267
  )
246
268
  const side = sideOf(group)
247
269
  const baseline_side = sideOf(baseline)
270
+ const trial = group.filter(
271
+ ({ trial_groups }) => trial_groups[role] === 'trial'
272
+ )
273
+ const control = sideOf(
274
+ alike.filter(
275
+ ({ trial_groups, picks }) =>
276
+ trial_groups[role] === 'control' && picks[role]?.model === model
277
+ )
278
+ )
248
279
  return {
249
280
  role,
250
281
  model,
@@ -269,6 +300,12 @@ const scoreOf = ({
269
300
  model === 'opus'
270
301
  ? null
271
302
  : verdictOf({ side, baseline: baseline_side }),
303
+ trial_tickets: trial.length,
304
+ control,
305
+ trial_verdict:
306
+ trial.length === 0 && control.tickets === 0
307
+ ? null
308
+ : verdictOf({ side: sideOf(trial), baseline: control }),
272
309
  }
273
310
  }
274
311
 
@@ -319,7 +356,10 @@ export const scoreModels = ({
319
356
  )
320
357
  )
321
358
  )
322
- ).filter(({ tickets, baseline }) => tickets > 0 || baseline.tickets > 0)
359
+ ).filter(
360
+ ({ tickets, baseline, control }) =>
361
+ tickets > 0 || baseline.tickets > 0 || control.tickets > 0
362
+ )
323
363
  const learner = facts.flatMap((run) => run.learner)
324
364
  return {
325
365
  runs: runs.length,
@@ -327,6 +367,9 @@ export const scoreModels = ({
327
367
  effort_picks: SCORED_ROLES.map((role) =>
328
368
  effortPicksOf({ tickets, role })
329
369
  ),
370
+ floor_only: SCORED_ROLES.filter((role) =>
371
+ isFloorOnly({ tickets, role })
372
+ ),
330
373
  learner: ModelNameSchema.options
331
374
  .flatMap((model) =>
332
375
  levels.map((effort) => ({
@@ -348,19 +391,6 @@ export const scoreModels = ({
348
391
  }
349
392
  }
350
393
 
351
- const plural = (count: number, word: string): string =>
352
- `${count} ${word}${count === 1 ? '' : 's'}`
353
-
354
- const decimal = (value: number | null): string =>
355
- value === null ? '-' : value.toFixed(2)
356
-
357
- const sideText = (side: ScoreSide): string =>
358
- [
359
- plural(side.tickets, 'ticket'),
360
- `${decimal(side.findings_per_100_lines)} findings per 100 lines`,
361
- `${decimal(side.fix_rounds_per_ticket)} fix rounds per ticket`,
362
- ].join(', ')
363
-
364
394
  const scoreText = (score: ModelScore): string[] => {
365
395
  const other = SCORED_ROLES.find((role) => role !== score.role)
366
396
  const tokens = score.tokens_per_finished_ticket
@@ -368,6 +398,9 @@ const scoreText = (score: ModelScore): string[] => {
368
398
  ` ${score.model} at ${score.effort}: ${[
369
399
  sideText(score),
370
400
  `${score.stuck_tickets} stuck`,
401
+ ...(score.trial_tickets === 0
402
+ ? []
403
+ : [plural(score.trial_tickets, 'trial')]),
371
404
  `${tokens === null ? '-' : Math.round(tokens)} tokens per finished ticket`,
372
405
  `median confidence ${decimal(score.median_confidence)}`,
373
406
  `${other} cheaper on ${score.other_role_cheaper}`,
@@ -379,16 +412,43 @@ const scoreText = (score: ModelScore): string[] => {
379
412
  ` baseline (Clef picked ${score.model} in shadow mode, ran opus at ${score.effort}): ${sideText(score.baseline)}`,
380
413
  ` verdict: ${score.verdict}`,
381
414
  ]),
415
+ ...(score.trial_verdict === null
416
+ ? []
417
+ : [
418
+ ` control (Clef picked ${score.model}, left out of the trial, ran opus at ${score.effort}): ${sideText(score.control)}`,
419
+ ` trial verdict: ${score.trial_verdict}`,
420
+ ]),
382
421
  ]
383
422
  }
384
423
 
385
424
  const effortPicksText = (picks: EffortPicks): string[] => [
386
- " Clef's effort picks (shadow mode)",
425
+ " Clef's effort picks (shadow mode), every answer",
387
426
  ` wanted more effort than it ran at: ${sideText(picks.wanted_more)}`,
388
427
  ` the rest: ${sideText(picks.rest)}`,
389
428
  ` verdict: ${picks.verdict}`,
429
+ " by Clef's confidence:",
430
+ ...picks.by_confidence.map(
431
+ ({ band, wanted_more, rest }) =>
432
+ ` ${band}: wanted more ${sideText(wanted_more)}; the rest ${sideText(rest)}`
433
+ ),
390
434
  ]
391
435
 
436
+ const FLOOR_LINE =
437
+ " no cheaper model was ever tried: this role's floor is opus in every run. Set models.floors in .luca/config.json to let it."
438
+
439
+ /**
440
+ * A role's model lines: for a floor-only role, its Opus lines and the floor
441
+ * line in place of the rest.
442
+ */
443
+ const scoresText = (scores: ModelScore[], floor_only: boolean): string[] => {
444
+ if (scores.length === 0) return [' no tickets yet']
445
+ if (!floor_only) return scores.flatMap(scoreText)
446
+ return [
447
+ ...scores.filter(({ model }) => model === 'opus').flatMap(scoreText),
448
+ FLOOR_LINE,
449
+ ]
450
+ }
451
+
392
452
  const finalText = (label: string, side: FinalReviewSide): string =>
393
453
  `${decimal(side.findings_per_100_lines)} ${label} (${plural(side.runs, 'run')})`
394
454
 
@@ -411,9 +471,7 @@ export const formatModelReport = ({
411
471
  const picks = report.effort_picks.find((found) => found.role === role)
412
472
  return [
413
473
  role,
414
- ...(scores.length === 0
415
- ? [' no tickets yet']
416
- : scores.flatMap(scoreText)),
474
+ ...scoresText(scores, report.floor_only.includes(role)),
417
475
  ...(picks === undefined ? [] : effortPicksText(picks)),
418
476
  ]
419
477
  })
@@ -1,4 +1,5 @@
1
1
  import {
2
+ roleSection,
2
3
  runOf,
3
4
  ticketRun,
4
5
  type Built,
@@ -121,19 +122,17 @@ export const runWithBar = (
121
122
  export const effortPicksOf = (report: ModelReport, role: Built) =>
122
123
  report.effort_picks.find((picks) => picks.role === role)
123
124
 
125
+ /** The heading of a role's "Clef's effort picks" section, as printed. */
126
+ export const PICKS_HEADING = " Clef's effort picks (shadow mode), every answer"
127
+
124
128
  /**
125
129
  * The lines of `role`'s "Clef's effort picks (shadow mode)" section in the
126
130
  * printed report: the lines after its heading, within the role's part.
127
131
  */
128
132
  export const pickSection = (text: string, role: Built): string[] => {
129
- const lines = text.split('\n')
130
- const start = lines.indexOf(role)
131
- const end = lines.findIndex(
132
- (line, at) => at > start && line !== '' && !line.startsWith(' ')
133
- )
134
- const part = start < 0 ? [] : lines.slice(start + 1, end)
135
- const heading = part.findIndex(
136
- (line) => line.trim() === "Clef's effort picks (shadow mode)"
133
+ const part = roleSection(text, role)
134
+ const heading = part.findIndex((line) =>
135
+ line.trim().startsWith("Clef's effort picks (shadow mode)")
137
136
  )
138
137
  return heading < 0 ? [] : part.slice(heading + 1)
139
138
  }
@@ -12,6 +12,7 @@ import {
12
12
  import { recordsFrom } from './intake-fixtures'
13
13
 
14
14
  import type { AgentRole } from '../agents/role-results'
15
+ import type { ModelReason } from '../jev/model-pick'
15
16
  import type { JournalEntry, JournalRecord } from '../journal/journal-record'
16
17
  import type { ModelScore } from '../models/model-score'
17
18
 
@@ -29,6 +30,8 @@ export type Built = 'test-writer' | 'implementer'
29
30
  /**
30
31
  * An agent turn started on `model`; `null` writes one from before #600. It
31
32
  * ran at `effort`; left out writes one from before #659, with no effort.
33
+ * `model_reason` is why it got its model; left out writes one from before
34
+ * #603, with no reason.
32
35
  */
33
36
  export const started = ({
34
37
  ticket,
@@ -36,12 +39,14 @@ export const started = ({
36
39
  model,
37
40
  follow_up_of,
38
41
  effort,
42
+ model_reason,
39
43
  }: {
40
44
  ticket: number | null
41
45
  role: AgentRole
42
46
  model: Model | null
43
47
  follow_up_of?: string
44
48
  effort?: Effort
49
+ model_reason?: ModelReason | null
45
50
  }): JournalEntry => ({
46
51
  kind: 'agent_started',
47
52
  ticket,
@@ -52,6 +57,7 @@ export const started = ({
52
57
  follow_up_of: follow_up_of ?? null,
53
58
  ...(model === null ? {} : { model }),
54
59
  ...(effort === undefined ? {} : { effort }),
60
+ ...(model_reason === undefined ? {} : { model_reason }),
55
61
  },
56
62
  })
57
63
 
@@ -122,7 +128,8 @@ export type Ask = Omit<Parameters<typeof modelAsk>[0], 'ticket'>
122
128
  * (opus unless told), the first review's `findings`, then `rounds` fix
123
129
  * rounds (one when the findings block), each fix's review finding a
124
130
  * should-fix until the last approves, and a join of `lines` lines. Each
125
- * role's agents run at its effort, left out as before #659.
131
+ * role's agents run at its effort, left out as before #659, and with its
132
+ * model reason, left out as before #603.
126
133
  */
127
134
  export const ticketRun = ({
128
135
  ticket,
@@ -130,6 +137,8 @@ export const ticketRun = ({
130
137
  impl = 'opus',
131
138
  tw_effort,
132
139
  impl_effort,
140
+ tw_reason,
141
+ impl_reason,
133
142
  review_effort,
134
143
  asks = [],
135
144
  findings = [],
@@ -143,6 +152,8 @@ export const ticketRun = ({
143
152
  impl?: Model | null
144
153
  tw_effort?: Effort
145
154
  impl_effort?: Effort
155
+ tw_reason?: ModelReason | null
156
+ impl_reason?: ModelReason | null
146
157
  review_effort?: Effort
147
158
  asks?: Ask[]
148
159
  findings?: Severity[]
@@ -184,6 +195,7 @@ export const ticketRun = ({
184
195
  role: 'test-writer',
185
196
  model: tw,
186
197
  effort: tw_effort,
198
+ model_reason: tw_reason,
187
199
  }),
188
200
  agentFailed({
189
201
  ticket,
@@ -197,6 +209,7 @@ export const ticketRun = ({
197
209
  role: 'test-writer',
198
210
  model: tw_failed_first ? 'opus' : tw,
199
211
  effort: tw_effort,
212
+ model_reason: tw_failed_first ? 'after_failure' : tw_reason,
200
213
  }),
201
214
  testsWritten({ ticket }),
202
215
  agentSession({
@@ -210,6 +223,7 @@ export const ticketRun = ({
210
223
  role: 'implementer',
211
224
  model: impl,
212
225
  effort: impl_effort,
226
+ model_reason: impl_reason,
213
227
  }),
214
228
  implemented({ ticket }),
215
229
  ...review(findings, 0),
@@ -220,6 +234,7 @@ export const ticketRun = ({
220
234
  model: impl,
221
235
  follow_up_of: 'impl-1',
222
236
  effort: impl_effort,
237
+ model_reason: impl_reason,
223
238
  }),
224
239
  implemented({ ticket }),
225
240
  ...review(index + 1 < fixes ? ['should_fix'] : [], index + 1),
@@ -291,6 +306,20 @@ export const groups = (scores: ModelScore[]): string[] =>
291
306
  .map(({ role, model }) => `${role}/${model}`)
292
307
  .sort()
293
308
 
309
+ /**
310
+ * The lines of `role`'s part of the printed report: the lines after its
311
+ * heading, up to the next line that isn't indented.
312
+ */
313
+ export const roleSection = (text: string, role: Built): string[] => {
314
+ const lines = text.split('\n')
315
+ const start = lines.indexOf(role)
316
+ if (start < 0) return []
317
+ const end = lines.findIndex(
318
+ (line, at) => at > start && line !== '' && !line.startsWith(' ')
319
+ )
320
+ return lines.slice(start + 1, end < 0 ? undefined : end)
321
+ }
322
+
294
323
  /** Clef picked `pick` for the test-writer, sure enough to act on it. */
295
324
  export const sure = (pick: Model, confidence = 0.9): Ask => ({
296
325
  role: 'test-writer',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alecsibilia/luca",
3
3
  "description": "Luca: turns a spec on GitHub Issues into a reviewed pull request, run from Paseo with Claude Code",
4
- "version": "14.0.0-alpha.27",
4
+ "version": "14.0.0-alpha.28",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Alec Sibilia <sibilia.alec@gmail.com>",
7
7
  "homepage": "https://github.com/asibilia/luca-framework#readme",