@@ -16,8 +16,8 @@ import { readFileSync, existsSync } from 'node:fs';
1616import { createRequire } from 'node:module' ;
1717import vsctm from 'vscode-textmate' ;
1818import onig from 'vscode-oniguruma' ;
19- import { gradeScopeStack , isCorrect , ROLE_SPEC } from './scope-roles.ts' ;
20- import type { RoleName } from './scope-roles.ts' ;
19+ import { gradeScopeStack , isCorrect , ROLE_SPEC , normScope } from './scope-roles.ts' ;
20+ import type { RoleName , Verdict } from './scope-roles.ts' ;
2121
2222// A parser-assigned token role over a span. The per-language oracle returns these (e.g.
2323// oracle.ts for TS/JS). Only start/end/text/role are required by the harness.
@@ -35,6 +35,21 @@ export interface ScopeGapAdapter {
3535 loadCorpus : ( ) => { name : string ; text : string } [ ] ;
3636 roleOracle : ( text : string ) => GoldToken [ ] ; // parser → per-token role (the neutral oracle)
3737 isGradable ?: ( text : string ) => boolean ; // skip inputs the oracle can't judge (default: all)
38+ // Grade every non-whitespace codepoint of an oracle token, not just its start offset. Needed when
39+ // the oracle emits COARSE spans (a multi-line YAML plain scalar, a block-scalar body) whose role
40+ // must hold across the whole span: a token correct at its start but wrong mid-span (a `%YAML`
41+ // folded into a plain scalar, a block-scalar line bailing out to a comment) is otherwise invisible.
42+ // Default false (start-only) — opt in only where the oracle's spans are role-HOMOGENEOUS, i.e. the
43+ // role applies to every char (NOT e.g. a TS template literal whose `${…}` holes are expressions).
44+ fullSpan ?: boolean ;
45+ // Run the DIFFERENTIAL pass: report every position where Monogram and the official grammar paint
46+ // DIFFERENT visual token classes (comment/string/number/keyword/name) AND the oracle has NO opinion
47+ // there (no non-lexical gold token covers it). These are UNADJUDICATED divergences — the metric
48+ // cannot say who is right, so they are flagged for human review. This is the structural fix for the
49+ // "oracle is silent → blind spot" failure mode: a metric that only grades where its oracle speaks
50+ // can never see a bug in a construct the oracle does not model; the differential pass catches the
51+ // disagreement regardless, so a clean run means "no Monogram-wrong AND no unexamined divergence".
52+ differential ?: boolean ;
3853}
3954
4055const { INITIAL , Registry, parseRawGrammar } = vsctm ;
@@ -89,6 +104,70 @@ function scopeAt(toks: TmToken[], pos: number): string[] {
89104 while ( lo <= hi ) { const mid = ( lo + hi ) >> 1 ; if ( toks [ mid ] . start <= pos ) { ans = mid ; lo = mid + 1 ; } else hi = mid - 1 ; }
90105 return ans >= 0 && toks [ ans ] . end > pos ? toks [ ans ] . scopes : [ ] ;
91106}
107+ const innerOf = ( s : string [ ] ) : string => ( s . length ? s [ s . length - 1 ] : '(none)' ) ;
108+ const isWs = ( c : number ) : boolean => c === 32 || c === 9 || c === 10 || c === 13 ;
109+ // Grade an oracle token. start-only (default) reproduces the historical single-point sampling; full
110+ // grades every non-whitespace codepoint in [start,end) and returns the WORST verdict (first WRONG
111+ // position wins, with its scope for reporting) — so a coarse span that is right at its start but
112+ // wrong mid-span reads WRONG. Whitespace is skipped (continuation indent is colourless either way).
113+ interface SpanGrade { v : Verdict ; scope : string ; wrongAt ?: number }
114+ function gradeSpan ( role : RoleName , toks : TmToken [ ] , start : number , end : number , text : string , full : boolean ) : SpanGrade {
115+ if ( ! full ) { const s = scopeAt ( toks , start ) ; return { v : gradeScopeStack ( role , s ) , scope : innerOf ( s ) } ; }
116+ let worst : Verdict = 'exact' ;
117+ for ( let p = start ; p < end ; p ++ ) {
118+ if ( isWs ( text . charCodeAt ( p ) ) ) continue ;
119+ const s = scopeAt ( toks , p ) ;
120+ const v = gradeScopeStack ( role , s ) ;
121+ if ( v === 'wrong' ) return { v : 'wrong' , scope : innerOf ( s ) , wrongAt : p } ;
122+ if ( v === 'family' && worst === 'exact' ) worst = 'family' ;
123+ }
124+ return { v : worst , scope : innerOf ( scopeAt ( toks , start ) ) } ;
125+ }
126+
127+ // ─── differential pass: oracle-INDEPENDENT divergence detector ───────────────────────────────────
128+ // Map a TM scope chain to a coarse VISUAL bucket — the level at which a highlight difference is
129+ // actually visible (a comment vs a string vs a number vs a keyword). `name` (identifiers/entities) and
130+ // `punct`/`none` are convention noise we don't flag on their own; a divergence is only interesting if
131+ // at least one side is a visually-distinct class. Innermost wins; scan inner→outer for the first hit.
132+ type Bucket = 'invalid' | 'comment' | 'string' | 'number' | 'keyword' | 'name' | 'punct' | 'none' ;
133+ function scopeBucket ( chain : string [ ] ) : Bucket {
134+ for ( let i = chain . length - 1 ; i >= 0 ; i -- ) {
135+ const s = normScope ( chain [ i ] ) ;
136+ if ( / ^ i n v a l i d / . test ( s ) ) return 'invalid' ; // an error overlay (official marks errors; a highlighter may legitimately highlight-normally instead)
137+ if ( / ^ c o m m e n t / . test ( s ) ) return 'comment' ;
138+ if ( / ^ c o n s t a n t \. n u m e r i c / . test ( s ) ) return 'number' ;
139+ if ( / ^ ( s t r i n g | c o n s t a n t \. c h a r a c t e r | c o n s t a n t \. o t h e r \. s y m b o l ) / . test ( s ) ) return 'string' ;
140+ if ( / ^ ( k e y w o r d | s t o r a g e | c o n s t a n t \. l a n g u a g e | s u p p o r t \. c o n s t a n t | v a r i a b l e \. l a n g u a g e ) / . test ( s ) ) return 'keyword' ;
141+ if ( / ^ ( e n t i t y | v a r i a b l e | s u p p o r t | c o n s t a n t ) / . test ( s ) ) return 'name' ;
142+ if ( / ^ p u n c t u a t i o n / . test ( s ) ) return 'punct' ;
143+ }
144+ return 'none' ;
145+ }
146+ // Visually-distinct classes whose confusion is a real (not convention-noise) difference. `invalid`
147+ // is reported but flagged separately below — official-marks-error vs Monogram-highlights-normally is a
148+ // design stance (cf. monogram#12 #3 "should still be highlighted normally"), not necessarily a bug.
149+ const DISTINCT = new Set < Bucket > ( [ 'invalid' , 'comment' , 'string' , 'number' , 'keyword' ] ) ;
150+ const involvesInvalid = ( a : Bucket , b : Bucket ) : boolean => a === 'invalid' || b === 'invalid' ;
151+ // a divergence matters when the two buckets differ AND at least one is a visually-distinct class
152+ const interestingDivergence = ( a : Bucket , b : Bucket ) : boolean => a !== b && ( DISTINCT . has ( a ) || DISTINCT . has ( b ) ) ;
153+
154+ export interface Divergence { pos : number ; text : string ; mono : string ; off : string ; bM : Bucket ; bO : Bucket }
155+ // Positions where the two grammars disagree visually AND no non-lexical oracle token adjudicates.
156+ function divergences ( off : TmToken [ ] , mono : TmToken [ ] , gold : GoldToken [ ] , text : string ) : Divergence [ ] {
157+ const cov = gold . filter ( ( g ) => { const t = ROLE_SPEC [ g . role ] ?. tier ; return t && t !== 'lexical' ; } ) . map ( ( g ) => [ g . start , g . end ] as const ) ;
158+ const isCovered = ( p : number ) => cov . some ( ( [ a , b ] ) => p >= a && p < b ) ;
159+ const positions = [ ...new Set ( [ ...mono . map ( ( t ) => t . start ) , ...off . map ( ( t ) => t . start ) ] ) ] . sort ( ( a , b ) => a - b ) ;
160+ const out : Divergence [ ] = [ ] ;
161+ for ( const pos of positions ) {
162+ if ( pos >= text . length || isWs ( text . charCodeAt ( pos ) ) ) continue ;
163+ if ( isCovered ( pos ) ) continue ; // oracle already has an opinion here → graded above
164+ const cm = scopeAt ( mono , pos ) , co = scopeAt ( off , pos ) ;
165+ const bM = scopeBucket ( cm ) , bO = scopeBucket ( co ) ;
166+ if ( ! interestingDivergence ( bM , bO ) ) continue ;
167+ out . push ( { pos, text : text . slice ( pos , pos + 12 ) , mono : innerOf ( cm ) , off : innerOf ( co ) , bM, bO } ) ;
168+ }
169+ return out ;
170+ }
92171
93172export async function run ( adapter : ScopeGapAdapter ) : Promise < void > {
94173 if ( ! existsSync ( adapter . officialPath ) ) { console . error ( `Official grammar not found:\n ${ adapter . officialPath } ` ) ; process . exit ( 1 ) ; }
@@ -100,6 +179,7 @@ export async function run(adapter: ScopeGapAdapter): Promise<void> {
100179
101180 const corpus = adapter . loadCorpus ( ) ;
102181 const gradable = adapter . isGradable ?? ( ( ) => true ) ;
182+ const fullSpan = adapter . fullSpan ?? false ;
103183
104184 let nFiles = 0 ;
105185 const tally = { oCorrect : 0 , oExact : 0 , mCorrect : 0 , mExact : 0 , total : 0 } ;
@@ -118,17 +198,17 @@ export async function run(adapter: ScopeGapAdapter): Promise<void> {
118198 for ( const t of gold ) {
119199 const tier = ROLE_SPEC [ t . role ] ?. tier ;
120200 if ( ! tier || tier === 'lexical' ) continue ; // lexical floor: excluded from the headline
121- const so = scopeAt ( tmO , t . start ) , sm = scopeAt ( tmM , t . start ) ; // full scope CHAINS
122- const vo = gradeScopeStack ( t . role , so ) , vm = gradeScopeStack ( t . role , sm ) ;
201+ const go = gradeSpan ( t . role , tmO , t . start , t . end , text , fullSpan ) ; // full scope CHAINS
202+ const gm = gradeSpan ( t . role , tmM , t . start , t . end , text , fullSpan ) ;
203+ const vo = go . v , vm = gm . v ;
123204 const oc = isCorrect ( vo ) , mc = isCorrect ( vm ) ;
124205 tally . total ++ ; gradedHere ++ ;
125206 if ( oc ) tally . oCorrect ++ ; if ( vo === 'exact' ) tally . oExact ++ ;
126207 if ( mc ) tally . mCorrect ++ ; if ( vm === 'exact' ) tally . mExact ++ ;
127208 const pr = perRole . get ( t . role ) ?? { n : 0 , oC : 0 , mC : 0 } ; pr . n ++ ; if ( oc ) pr . oC ++ ; if ( mc ) pr . mC ++ ; perRole . set ( t . role , pr ) ;
128209 if ( ! oc ) okO = false ; if ( ! mc ) okM = false ;
129- const inner = ( s : string [ ] ) => s . length ? s [ s . length - 1 ] : '(none)' ;
130- if ( mc && ! oc && onlyMono . length < 40 ) onlyMono . push ( { text : t . text , role : t . role , o : inner ( so ) , m : inner ( sm ) } ) ;
131- if ( oc && ! mc && onlyOff . length < 40 ) onlyOff . push ( { text : t . text , role : t . role , o : inner ( so ) , m : inner ( sm ) } ) ;
210+ if ( mc && ! oc && onlyMono . length < 40 ) onlyMono . push ( { text : t . text , role : t . role , o : go . scope , m : gm . scope } ) ;
211+ if ( oc && ! mc && onlyOff . length < 40 ) onlyOff . push ( { text : t . text , role : t . role , o : go . scope , m : gm . scope } ) ;
132212 }
133213 if ( gradedHere ) { snip . n ++ ; if ( okO ) snip . o ++ ; if ( okM ) snip . m ++ ; }
134214 }
@@ -158,11 +238,40 @@ export async function run(adapter: ScopeGapAdapter): Promise<void> {
158238 console . log ( `\n only-official-correct tokens (Monogram wrong) — ${ onlyOff . length } shown:` ) ;
159239 for ( const x of onlyOff . slice ( 0 , 12 ) ) console . log ( ` «${ x . text . slice ( 0 , 18 ) } » ${ x . role } : official «${ x . o } » → monogram «${ x . m } »` ) ;
160240 }
241+
242+ // ── DIFFERENTIAL pass: oracle-INDEPENDENT divergences (the blind-spot net) ───────────────────────
243+ let divTotal = 0 ;
244+ if ( adapter . differential ) {
245+ const all : Divergence [ ] = [ ] ;
246+ let divFiles = 0 ;
247+ for ( const { text } of corpus ) { // ALL inputs, incl. ones the oracle/grader skip
248+ let gold : GoldToken [ ] , tmO : TmToken [ ] , tmM : TmToken [ ] ;
249+ try { gold = adapter . roleOracle ( text ) ; tmO = tmTokenize ( official , text ) ; tmM = tmTokenize ( monogram , text ) ; } catch { continue ; }
250+ const ds = divergences ( tmO , tmM , gold , text ) ;
251+ if ( ds . length ) divFiles ++ ;
252+ all . push ( ...ds ) ;
253+ }
254+ divTotal = all . length ;
255+ const genuine = all . filter ( ( d ) => ! involvesInvalid ( d . bM , d . bO ) ) ; // real class confusion (not error-overlay)
256+ const overlay = divTotal - genuine . length ; // official-marks-error vs Monogram-normal
257+ const byPair = new Map < string , { n : number ; sample : Divergence } > ( ) ;
258+ for ( const d of all ) { const k = `${ d . bM } ≠${ d . bO } ` ; const e = byPair . get ( k ) ; if ( e ) e . n ++ ; else byPair . set ( k , { n : 1 , sample : d } ) ; }
259+ console . log ( `\n ── DIFFERENTIAL (oracle-independent) — UNADJUDICATED divergences over ${ divFiles } files ──` ) ;
260+ console . log ( ` positions where Monogram and official paint different VISUAL classes and the oracle is` ) ;
261+ console . log ( ` silent → the metric cannot adjudicate; each is a candidate bug for human review.` ) ;
262+ console . log ( ` ${ genuine . length } genuine class-confusion + ${ overlay } error-overlay (official invalid.illegal vs Monogram highlight-normally)` ) ;
263+ for ( const [ k , e ] of [ ...byPair . entries ( ) ] . sort ( ( a , b ) => b [ 1 ] . n - a [ 1 ] . n ) . slice ( 0 , 12 ) ) {
264+ const s = e . sample ;
265+ console . log ( ` ${ k . padEnd ( 18 ) } ×${ String ( e . n ) . padStart ( 4 ) } e.g. «${ s . text . replace ( / \n / g, '\\n' ) } » mono«${ s . mono } » off«${ s . off } »` ) ;
266+ }
267+ }
268+
161269 // Machine-readable summary for the README coverage-table generator (test/coverage-table.ts).
162270 console . log ( '##SCOPEGAP## ' + JSON . stringify ( {
163271 name : adapter . name , official : adapter . officialPath . replace ( / ^ .* \/ / , '' ) , tokens : tally . total ,
164272 officialPct : tally . total ? ( 100 * tally . oCorrect ) / tally . total : null ,
165273 monogramPct : tally . total ? ( 100 * tally . mCorrect ) / tally . total : null ,
274+ monogramWrong : onlyOff . length , unadjudicated : divTotal ,
166275 } ) ) ;
167276 console . log ( '\nDone.' ) ;
168277}
0 commit comments