@@ -20,6 +20,11 @@ import { CHECKS } from "./checks/index.js";
2020 * - for a semantics-preserving rewrite, no individual route's score rises and no measured route
2121 * drops out of the measured set. The tree mean can hide a route going up by taking another down;
2222 * `[0].map(...)` is exactly that shape.
23+ * - the mirror of that, for a semantics-preserving rewrite: no individual route's score FALLS on
24+ * the routes measured in both runs. A fall is a false accusation, which is the direction that
25+ * gets the tool switched off, and it went unasserted for three rounds while 19 entries regressed
26+ * 104 routes (see `fallsIn`). Exactly two entries carry a permanent `lowers` exemption, with the
27+ * reason on the entry and the residual shape asserted instead of waived.
2328 *
2429 * Tree scale, not per-fixture, because that is where laundering pays. A shape that moves one
2530 * hand-written fixture by 50 points may move the tree by nothing; a shape that moves the tree is the
@@ -163,6 +168,45 @@ function risesIn(baseline: Measurement, after: Measurement): Rise[] {
163168 return rises . sort ( ( a , b ) => b . to - b . from - ( a . to - a . from ) ) ;
164169}
165170
171+ type Fall = { fileName : string ; from : number ; to : number ; before : string ; after : string } ;
172+
173+ /**
174+ * The mirror of `risesIn`: route files the mutation made look WORSE, worst first. A preserving
175+ * edit lowering a route's score is a false accusation, the direction that gets the tool switched
176+ * off, and for three rounds it was structurally invisible here because only rises were asserted.
177+ * 19 of 43 preserving entries were regressing 104 routes when it was first measured.
178+ *
179+ * Only routes measured in BOTH runs are compared. A route ENTERING the measured set is not a fall:
180+ * unmeasured routes score the vacuous 100, so a mutation that brings one in at 50 registers a
181+ * 100 -> 50 "fall" that is nothing of the kind. A route LEAVING the measured set is already
182+ * counted by `risesIn`, not double-counted here. `compared` is the size of the both-measured
183+ * population, asserted against `baseline.measured` in every preserving entry so a silently
184+ * shrunken comparison cannot pass.
185+ */
186+ function fallsIn ( baseline : Measurement , after : Measurement ) : { falls : Fall [ ] ; compared : number } {
187+ const falls : Fall [ ] = [ ] ;
188+ let compared = 0 ;
189+ for ( const [ fileName , before ] of baseline . perEntry ) {
190+ const now = after . perEntry . get ( fileName ) ;
191+ if ( ! now || ! before . measured || ! now . measured ) continue ;
192+ compared ++ ;
193+ if ( now . score >= before . score ) continue ;
194+ falls . push ( {
195+ fileName,
196+ from : before . score ,
197+ to : now . score ,
198+ before : before . checks ,
199+ after : now . checks ,
200+ } ) ;
201+ }
202+ return { falls : falls . sort ( ( a , b ) => a . to - a . from - ( b . to - b . from ) ) , compared } ;
203+ }
204+
205+ /** The `id=status` pairs of a `perEntry.checks` string, for the exemption shape assertion. */
206+ function checkStatuses ( checks : string ) : Map < string , string > {
207+ return new Map ( checks . split ( " " ) . map ( ( pair ) => pair . split ( "=" ) as [ string , string ] ) ) ;
208+ }
209+
166210/** Mean score over the routes measured in BOTH runs. The plain mean moves when the measured
167211 * population moves, which a mutation can do without making any route look better: an inert
168212 * try/catch takes 15 trivial routes off the exemption list and into the report, and a route joining
@@ -277,6 +321,10 @@ describeCorpus("mutation corpus over the real route tree", { timeout: ENTRY_TIME
277321 it ( "has a baseline worth mutating" , ( ) => {
278322 expect ( baseline ) . not . toBeNull ( ) ;
279323 expect ( baseline ! . entryPoints ) . toBeGreaterThan ( 300 ) ;
324+ // The falls assertions compare over the routes measured in both runs and pin that population
325+ // to `baseline.measured`, so the baseline itself has to be big enough that a broken scan
326+ // cannot produce a tiny population the mirror trivially holds over.
327+ expect ( baseline ! . measured ) . toBeGreaterThan ( 300 ) ;
280328 expect ( baseline ! . global ) . not . toBeNull ( ) ;
281329 console . log (
282330 `[corpus] baseline global=${ baseline ! . global } mean=${ baseline ! . exactMean . toFixed ( 3 ) } ` +
@@ -327,19 +375,27 @@ describeCorpus("mutation corpus over the real route tree", { timeout: ENTRY_TIME
327375 expect ( [ ...baseline ! . perEntry . keys ( ) ] . filter ( ( f ) => ! after . perEntry . has ( f ) ) ) . toEqual ( [ ] ) ;
328376
329377 const rises = risesIn ( baseline ! , after ) ;
378+ const { falls, compared } = fallsIn ( baseline ! , after ) ;
330379 const common = commonMean ( baseline ! , after ) ;
331380 console . log (
332381 `[corpus] ${ mutation . id } : global ${ baseline ! . global } -> ${ after . global } ` +
333382 `(mean ${ baseline ! . exactMean . toFixed ( 3 ) } -> ${ after . exactMean . toFixed ( 3 ) } , ` +
334383 `common mean ${ common . before . toFixed ( 3 ) } -> ${ common . after . toFixed ( 3 ) } , ` +
335384 `measured ${ baseline ! . measured } -> ${ after . measured } , files ${ changed } , sites ${ sites } , ` +
336- `routes raised ${ rises . length } )` +
385+ `routes raised ${ rises . length } , routes lowered ${ falls . length } )` +
337386 rises
338387 . slice ( 0 , 3 )
339388 . map (
340389 ( r ) =>
341390 `\n ${ r . fileName } ${ r . from } ->${ r . to } \n was: ${ r . before } \n now: ${ r . after } `
342391 )
392+ . join ( "" ) +
393+ falls
394+ . slice ( 0 , 3 )
395+ . map (
396+ ( f ) =>
397+ `\n ${ f . fileName } ${ f . from } ->${ f . to } \n was: ${ f . before } \n now: ${ f . after } `
398+ )
343399 . join ( "" )
344400 ) ;
345401
@@ -357,6 +413,37 @@ describeCorpus("mutation corpus over the real route tree", { timeout: ENTRY_TIME
357413 // figure above is where that trade is held to account.
358414 if ( mutation . kind === "preserving" ) {
359415 expect ( rises . map ( ( r ) => `${ r . fileName } ${ r . from } ->${ r . to } ` ) ) . toEqual ( [ ] ) ;
416+
417+ // The mirror direction. A preserving edit must not make any route look WORSE either: a
418+ // fall here is a false accusation, the direction that gets the tool switched off, and it
419+ // went unasserted for three rounds while 19 entries regressed 104 routes. Together with
420+ // the rises assertion and the entry-point guards above, this pins per-route score
421+ // EQUALITY for a preserving entry. The comparison population is pinned to the whole
422+ // measured baseline first, so a silently shrunken population cannot pass vacuously.
423+ expect ( compared ) . toBe ( baseline ! . measured ) ;
424+ if ( mutation . lowers === undefined ) {
425+ expect ( falls . map ( ( f ) => `${ f . fileName } ${ f . from } ->${ f . to } ` ) ) . toEqual ( [ ] ) ;
426+ } else {
427+ // An exempted entry must still be falling, or the exemption is stale and has to be
428+ // removed deliberately rather than sitting as cover for the next defect.
429+ expect ( falls . length ) . toBeGreaterThan ( 0 ) ;
430+ // And the falls must have exactly the measured residual shape the exemption was
431+ // granted for: `error-classification` moving pass -> not-applicable, every other
432+ // check's status unchanged, nothing anywhere moving to fail. Anything else is a new
433+ // defect hiding under the exemption.
434+ for ( const fall of falls ) {
435+ const before = checkStatuses ( fall . before ) ;
436+ const now = checkStatuses ( fall . after ) ;
437+ expect ( [ ...now . keys ( ) ] . sort ( ) ) . toEqual ( [ ...before . keys ( ) ] . sort ( ) ) ;
438+ for ( const [ id , was ] of before ) {
439+ const is = now . get ( id ) ;
440+ if ( is === was ) continue ;
441+ expect ( `${ fall . fileName } : ${ id } ${ was } ->${ is } ` ) . toBe (
442+ `${ fall . fileName } : error-classification pass->not-applicable`
443+ ) ;
444+ }
445+ }
446+ }
360447 }
361448 } ) ;
362449 }
0 commit comments