11#!/usr/bin/env bun
2- import { access , copyFile , readFile } from "node:fs/promises" ;
2+ import { access , copyFile , readFile , writeFile } from "node:fs/promises" ;
33import { dirname , join } from "node:path" ;
44import { fileURLToPath } from "node:url" ;
55import { systemPrompt } from "@corbits/code-agent-dispatch" ;
6- import { EvalFailure , evaluate , avoids , under } from "@corbits/evals" ;
6+ import { EvalFailure , evaluate , under } from "@corbits/evals" ;
77import type {
88 Case ,
99 CheckValue ,
@@ -132,7 +132,7 @@ function loadCases(path: string): Promise<Case<string, RoutingExpect>[]> {
132132 } ) ;
133133}
134134
135- export function routeLine ( output : string ) : string {
135+ function routeLine ( output : string ) : string {
136136 const line = output . split ( / \r ? \n / , 1 ) [ 0 ] ?? "" ;
137137 return line . trim ( ) ;
138138}
@@ -164,37 +164,78 @@ export function routesCheck(
164164 return true ;
165165}
166166
167- const DIY_PHRASES = [
168- "I will fix" ,
169- "I'll fix" ,
170- "I will implement" ,
171- "I'll implement" ,
172- "I can fix" ,
173- "I can implement" ,
174- "I'll write" ,
175- "I will write" ,
176- "I'll do it" ,
177- "I can do it" ,
178- "I'll handle" ,
179- "I will handle" ,
180- "I'm going to implement" ,
181- ] as const ;
182-
183- const diyOnText = avoids < string , string , RoutingExpect > ( DIY_PHRASES ) ;
184-
185- export function noDiyCheck (
186- run : Run < string , string , RoutingExpect > ,
187- ) : CheckValue | Promise < CheckValue > {
188- return diyOnText ( { ...run , output : routeLine ( run . output ) } ) ;
189- }
190-
191167const DISPATCH_EVAL = "dispatch-routing" ;
192168
193169type DispatchEvaluate = (
194170 name : string ,
195171 options : EvaluateOptions < string , string , RoutingExpect > ,
196172) => Promise < Report < string , string , RoutingExpect > > ;
197173
174+ interface StoredCheck {
175+ pass : boolean ;
176+ value : number ;
177+ }
178+
179+ interface StoredRun {
180+ format : 1 ;
181+ eval : string ;
182+ about ?: Record < string , unknown > ;
183+ cases : Record < string , Record < string , StoredCheck > > ;
184+ }
185+
186+ function isRecord ( value : unknown ) : value is Record < string , unknown > {
187+ return typeof value === "object" && value !== null && ! Array . isArray ( value ) ;
188+ }
189+
190+ function parseStoredRun ( text : string , source : string ) : StoredRun {
191+ let raw : unknown ;
192+ try {
193+ raw = JSON . parse ( text ) ;
194+ } catch ( cause ) {
195+ throw new Error ( `${ source } : invalid JSON (${ ( cause as Error ) . message } )` ) ;
196+ }
197+ if ( ! isRecord ( raw ) || ! isRecord ( raw [ "cases" ] ) ) {
198+ throw new Error ( `${ source } : expected a stored run` ) ;
199+ }
200+ const format = raw [ "format" ] ;
201+ const evalName = raw [ "eval" ] ;
202+ if ( format !== 1 || typeof evalName !== "string" || evalName === "" ) {
203+ throw new Error ( `${ source } : expected a format 1 stored run` ) ;
204+ }
205+ const cases : Record < string , Record < string , StoredCheck > > = { } ;
206+ for ( const [ name , checks ] of Object . entries ( raw [ "cases" ] ) ) {
207+ if ( ! isRecord ( checks ) ) {
208+ throw new Error ( `${ source } : case "${ name } " must be an object` ) ;
209+ }
210+ const parsed : Record < string , StoredCheck > = { } ;
211+ for ( const [ check , outcome ] of Object . entries ( checks ) ) {
212+ if (
213+ ! isRecord ( outcome ) ||
214+ typeof outcome [ "pass" ] !== "boolean" ||
215+ typeof outcome [ "value" ] !== "number" ||
216+ ! Number . isFinite ( outcome [ "value" ] )
217+ ) {
218+ throw new Error (
219+ `${ source } : case "${ name } " check "${ check } " is invalid` ,
220+ ) ;
221+ }
222+ parsed [ check ] = { pass : outcome [ "pass" ] , value : outcome [ "value" ] } ;
223+ }
224+ cases [ name ] = parsed ;
225+ }
226+ const stored : StoredRun = { format : 1 , eval : evalName , cases } ;
227+ if ( isRecord ( raw [ "about" ] ) ) {
228+ stored . about = raw [ "about" ] ;
229+ }
230+ return stored ;
231+ }
232+
233+ function routesStoredPass (
234+ checks : Record < string , StoredCheck > | undefined ,
235+ ) : boolean {
236+ return checks ?. [ "routes" ] ?. pass === true ;
237+ }
238+
198239async function baselinePresent ( path : string ) : Promise < boolean > {
199240 try {
200241 await access ( path ) ;
@@ -204,6 +245,59 @@ async function baselinePresent(path: string): Promise<boolean> {
204245 }
205246}
206247
248+ // A miss is a drop only when the stored routes pass is true, so the miss
249+ // budget would keep a case that has never been baselined green. Passes still
250+ // merge: a miss is never written as a pass, and a stored pass is never
251+ // replaced by a miss, so a later flip of a case that passed beside a sibling
252+ // miss is a drop.
253+ async function commitRoutingBaseline ( args : {
254+ hadBaseline : boolean ;
255+ lastPath : string ;
256+ baselinePath : string ;
257+ } ) : Promise < number > {
258+ const last = parseStoredRun (
259+ await readFile ( args . lastPath , "utf8" ) ,
260+ args . lastPath ,
261+ ) ;
262+ const missed = Object . entries ( last . cases ) . filter (
263+ ( [ , checks ] ) => ! routesStoredPass ( checks ) ,
264+ ) ;
265+ if ( missed . length === 0 ) {
266+ await copyFile ( args . lastPath , args . baselinePath ) ;
267+ return 0 ;
268+ }
269+ if ( ! args . hadBaseline ) {
270+ console . error (
271+ `eval "${ DISPATCH_EVAL } " failed: no baseline at ${ args . baselinePath } ` ,
272+ ) ;
273+ return 1 ;
274+ }
275+ const beforeText = await readFile ( args . baselinePath , "utf8" ) ;
276+ const baseline = parseStoredRun ( beforeText , args . baselinePath ) ;
277+ const unbaselined = missed
278+ . filter ( ( [ name ] ) => baseline . cases [ name ] === undefined )
279+ . map ( ( [ name ] ) => name ) ;
280+ const cases = { ...baseline . cases } ;
281+ let changed = false ;
282+ for ( const [ name , checks ] of Object . entries ( last . cases ) ) {
283+ if ( ! routesStoredPass ( checks ) ) continue ;
284+ if ( routesStoredPass ( cases [ name ] ) ) continue ;
285+ cases [ name ] = checks ;
286+ changed = true ;
287+ }
288+ if ( changed ) {
289+ const next : StoredRun = { ...baseline , cases } ;
290+ await writeFile ( args . baselinePath , `${ JSON . stringify ( next , null , 2 ) } \n` ) ;
291+ }
292+ if ( unbaselined . length !== 0 ) {
293+ console . error (
294+ `eval "${ DISPATCH_EVAL } " failed: unbaselined routes miss (${ unbaselined . join ( ", " ) } )` ,
295+ ) ;
296+ return 1 ;
297+ }
298+ return 0 ;
299+ }
300+
207301export async function evaluateRouting ( args : {
208302 cases : readonly Case < string , RoutingExpect > [ ] ;
209303 run : ( input : string , signal : AbortSignal ) => Promise < string > ;
@@ -220,12 +314,10 @@ export async function evaluateRouting(args: {
220314 cases : args . cases ,
221315 checks : {
222316 routes : routesCheck ,
223- noDiy : noDiyCheck ,
224317 fast : under ( { ms : ROUTE_BUDGET_MS } ) ,
225318 } ,
226319 allow : {
227320 routes : { miss : 6 , drop : 0 } ,
228- noDiy : { miss : 2 , drop : 0 } ,
229321 fast : 0 ,
230322 } ,
231323 compare : args . baselinePath ,
@@ -238,17 +330,11 @@ export async function evaluateRouting(args: {
238330 } ,
239331 } ) ;
240332 console . log ( report . markdown ( ) ) ;
241- if ( report . checks [ "routes" ] ?. missed !== 0 ) {
242- if ( ! hadBaseline ) {
243- console . error (
244- `eval "${ DISPATCH_EVAL } " failed: no baseline at ${ args . baselinePath } ` ,
245- ) ;
246- return 1 ;
247- }
248- return 0 ;
249- }
250- await copyFile ( args . lastPath , args . baselinePath ) ;
251- return 0 ;
333+ return await commitRoutingBaseline ( {
334+ hadBaseline,
335+ lastPath : args . lastPath ,
336+ baselinePath : args . baselinePath ,
337+ } ) ;
252338 } catch ( err : unknown ) {
253339 if ( err instanceof EvalFailure ) {
254340 console . log ( err . report . markdown ( ) ) ;
0 commit comments