test/align.property.test.ts (13438 bytes)
1 import fc from 'fast-check'; 2 import { describe, expect, it } from 'vitest'; 3 import { 4 alignCuesToTokens, 5 createAligner, 6 normalizeText, 7 risingChain, 8 shiftSpans, 9 type TokenSpan, 10 } from '../src/align'; 11 import { books, char, grams, pagesOfCue, readPages, word, type Book, type Token } from './book'; 12 13 /** One token for each character of `text`, all on `page`. */ 14 function tokensOf(text: string, page = 0): Token[] { 15 return [...text].map((ch) => ({ text: ch, page })); 16 } 17 18 /** Books without noise that are long enough for anchors. */ 19 const exactBooks = books({ cues: [15, 60], cueLength: [5, 40], pageLength: [16, 200], noise: 0 }); 20 21 /** A book and a random choice of read pages. About one page in three is read. */ 22 function withReadPages(arb: fc.Arbitrary<Book>): fc.Arbitrary<[Book, boolean[]]> { 23 return arb.chain((book) => 24 fc.tuple( 25 fc.constant(book), 26 fc.array(fc.constantFrom(true, false, false), { 27 minLength: book.pages.length, 28 maxLength: book.pages.length, 29 }), 30 ), 31 ); 32 } 33 34 /** 35 * For a book without noise: the index of each character in the tokens of 36 * the read pages, joined in page order. -1 for a character on an unread page. 37 */ 38 function tokenIndex(book: Book, read: readonly boolean[]): number[] { 39 let next = 0; 40 return book.pageOf.map((page) => (read[page] ? next++ : -1)); 41 } 42 43 /** The spans of the matched cues rise and stay inside [0, total]. */ 44 function expectRising(spans: readonly TokenSpan[], total: number, disjoint: boolean): void { 45 let last: TokenSpan | null = null; 46 for (const span of spans) { 47 if (!span.matched) { 48 expect(span).toEqual({ start: 0, end: 0, matched: false }); 49 continue; 50 } 51 expect(span.start).toBeGreaterThanOrEqual(0); 52 expect(span.end).toBeGreaterThan(span.start); 53 expect(span.end).toBeLessThanOrEqual(total); 54 if (last) { 55 expect(span.start).toBeGreaterThanOrEqual(disjoint ? last.end : last.start); 56 expect(span.end).toBeGreaterThanOrEqual(last.end); 57 } 58 last = span; 59 } 60 } 61 62 describe('normalizeText', () => { 63 it('is idempotent', () => { 64 fc.assert( 65 fc.property(fc.string({ unit: 'grapheme' }), (s) => { 66 const once = normalizeText(s); 67 expect(normalizeText(once)).toBe(once); 68 }), 69 ); 70 }); 71 }); 72 73 describe('risingChain', () => { 74 /** The length of a longest strictly rising chain, by the slow O(n²) method. */ 75 function slowLength(values: readonly number[]): number { 76 const best = values.map(() => 1); 77 for (let i = 0; i < values.length; i++) { 78 for (let j = 0; j < i; j++) { 79 if (values[j]! < values[i]!) best[i] = Math.max(best[i]!, best[j]! + 1); 80 } 81 } 82 return Math.max(0, ...best); 83 } 84 85 it('gives a strictly rising chain as long as the slow reference', () => { 86 fc.assert( 87 fc.property(fc.array(fc.integer({ min: 0, max: 20 }), { maxLength: 40 }), (values) => { 88 const chain = risingChain(values); 89 for (let k = 1; k < chain.length; k++) { 90 expect(chain[k]!).toBeGreaterThan(chain[k - 1]!); 91 expect(values[chain[k]!]!).toBeGreaterThan(values[chain[k - 1]!]!); 92 } 93 expect(chain.length).toBe(slowLength(values)); 94 }), 95 ); 96 }); 97 }); 98 99 describe('createAligner properties', () => { 100 it('maps exact text on all pages to exactly the tokens of each cue', () => { 101 fc.assert( 102 fc.property(exactBooks, (book) => { 103 const spans = createAligner(book.cues).spans(book.pages); 104 book.cues.forEach((_, i) => { 105 expect(spans[i]).toEqual({ 106 start: book.offsets[i], 107 end: book.offsets[i + 1], 108 matched: true, 109 }); 110 }); 111 }), 112 ); 113 }); 114 115 it('matches cues only on read pages, and each cue there to exactly its tokens', () => { 116 // The regression: with a few pages read, cues matched to wrong places. 117 fc.assert( 118 fc.property(withReadPages(exactBooks), ([book, read]) => { 119 const spans = createAligner(book.cues).spans(readPages(book, read)); 120 const index = tokenIndex(book, read); 121 book.cues.forEach((_, i) => { 122 const tokens = index.slice(book.offsets[i], book.offsets[i + 1]).filter((t) => t >= 0); 123 if (tokens.length === 0) { 124 // The cue lies wholly on unread pages: no false positive. 125 expect(spans[i]!.matched).toBe(false); 126 } else { 127 expect(spans[i]).toEqual({ 128 start: tokens[0], 129 end: tokens[tokens.length - 1]! + 1, 130 matched: true, 131 }); 132 } 133 }); 134 }), 135 ); 136 }); 137 138 it('matches no cue when the cues share no 8-character run with the pages', () => { 139 // Cues from one random text, pages from another. Both use one alphabet, 140 // so chance runs of a few characters are common. The cue stream is 141 // longer than a short stream, which is compared with the pages in full. 142 fc.assert( 143 fc.property( 144 fc.array(word(15, 40), { minLength: 5, maxLength: 30 }), 145 exactBooks, 146 (cues, book) => { 147 const cueGrams = grams(cues.join('')); 148 fc.pre( 149 book.pages.every((page) => 150 [...grams(page.map((t) => t.text).join(''))].every((g) => !cueGrams.has(g)), 151 ), 152 ); 153 const spans = createAligner(cues).spans(book.pages); 154 expect(spans.filter((s) => s.matched)).toEqual([]); 155 }, 156 ), 157 ); 158 }); 159 160 it('gives the same spans with the page cache as a new aligner', () => { 161 fc.assert( 162 fc.property(withReadPages(exactBooks), ([book, read]) => { 163 const aligner = createAligner(book.cues); 164 aligner.spans(readPages(book, read)); 165 // Read the other pages one by one, as the reader does. 166 const now = [...read]; 167 for (let p = 0; p < now.length; p++) { 168 if (now[p]) continue; 169 now[p] = true; 170 const pages = readPages(book, now); 171 expect(aligner.spans(pages)).toEqual(createAligner(book.cues).spans(pages)); 172 } 173 }), 174 { numRuns: 30 }, 175 ); 176 }); 177 178 it('gives rising, in-range spans when pages repeat, overlap or come out of order', () => { 179 const slices = (length: number) => 180 fc.array( 181 fc.option(fc.record({ start: fc.nat({ max: length }), length: fc.nat({ max: 300 }) }), { 182 nil: undefined, 183 }), 184 { maxLength: 12 }, 185 ); 186 fc.assert( 187 fc.property( 188 exactBooks.chain((book) => fc.tuple(fc.constant(book), slices(book.pageOf.length))), 189 ([book, parts]) => { 190 const text = book.cues.join(''); 191 const pages = parts.map( 192 (s, p) => s && tokensOf(text.slice(s.start, s.start + s.length), p), 193 ); 194 const total = pages.reduce((n, p) => n + (p?.length ?? 0), 0); 195 // One character per token: the spans do not overlap. 196 expectRising(createAligner(book.cues).spans(pages), total, true); 197 }, 198 ), 199 ); 200 }); 201 202 it('gives rising, in-range spans for any text', () => { 203 fc.assert( 204 fc.property( 205 fc.array(fc.string({ unit: 'grapheme' }), { maxLength: 20 }), 206 fc.array(fc.option(fc.array(fc.string({ unit: 'grapheme' })), { nil: undefined }), { 207 maxLength: 5, 208 }), 209 (cues, texts) => { 210 const pages = texts.map((page, p) => page?.map((text) => ({ text, page: p }))); 211 const total = pages.reduce((n, p) => n + (p?.length ?? 0), 0); 212 const spans = createAligner(cues).spans(pages); 213 expect(spans).toHaveLength(cues.length); 214 // A token can hold characters of two cues, so two spans can share a token. 215 expectRising(spans, total, false); 216 }, 217 ), 218 ); 219 }); 220 221 it('keeps 95% of the cues on read pages on their own pages under 5% noise', () => { 222 const noisy = books({ 223 cues: [100, 150], 224 cueLength: [10, 40], 225 pageLength: [100, 400], 226 noise: 0.05, 227 }); 228 fc.assert( 229 fc.property(withReadPages(noisy), ([book, read]) => { 230 const pages = readPages(book, read); 231 const tokens = pages.flatMap((p) => p ?? []); 232 const spans = createAligner(book.cues).spans(pages); 233 let onRead = 0; 234 let right = 0; 235 book.cues.forEach((_, i) => { 236 const own = pagesOfCue(book, i); 237 if (![...own].every((p) => read[p])) return; 238 onRead++; 239 const span = spans[i]!; 240 if (span.matched && tokens.slice(span.start, span.end).every((t) => own.has(t.page))) { 241 right++; 242 } 243 }); 244 expect(right).toBeGreaterThanOrEqual(0.95 * onRead); 245 }), 246 { numRuns: 30 }, 247 ); 248 }); 249 }); 250 251 describe('shiftSpans', () => { 252 const unmatched: TokenSpan = { start: 0, end: 0, matched: false }; 253 254 /** A span of `n` tokens: matched and inside [0, n], or unmatched. */ 255 function span(n: number): fc.Arbitrary<TokenSpan> { 256 if (n === 0) return fc.constant(unmatched); 257 const matched = fc 258 .nat({ max: n - 1 }) 259 .chain((start) => 260 fc.integer({ min: start + 1, max: n }).map((end) => ({ start, end, matched: true })), 261 ); 262 return fc.oneof(fc.constant(unmatched), matched); 263 } 264 265 it('keeps the first and the last token of each span when tokens go in', () => { 266 // The finding: page 19, read after page 20, put its tokens in front of 267 // the tokens of page 20, and the spans pointed at other tokens until 268 // the next alignment. 269 const input = fc 270 .nat({ max: 40 }) 271 .chain((n) => 272 fc.tuple(fc.constant(n), fc.array(span(n)), fc.nat({ max: n }), fc.nat({ max: 20 })), 273 ); 274 fc.assert( 275 fc.property(input, ([n, spans, at, count]) => { 276 const before = Array.from({ length: n }, (_, i) => `old ${i}`); 277 const after = [...before]; 278 after.splice(at, 0, ...Array.from({ length: count }, (_, i) => `new ${i}`)); 279 const shifted = shiftSpans(spans, at, count); 280 expect(shifted).toHaveLength(spans.length); 281 spans.forEach((s, i) => { 282 const moved = shifted[i]!; 283 if (!s.matched) { 284 expect(moved).toEqual(unmatched); 285 return; 286 } 287 expect(moved.matched).toBe(true); 288 expect(after[moved.start]).toBe(before[s.start]); 289 expect(after[moved.end - 1]).toBe(before[s.end - 1]); 290 }); 291 }), 292 ); 293 }); 294 295 it('moves the spans of page 20 behind the tokens of page 19', () => { 296 // Page 20 has 5 tokens and two cues. Page 19 puts 4 tokens in front. 297 const spans = [ 298 { start: 0, end: 3, matched: true }, 299 { start: 3, end: 5, matched: true }, 300 unmatched, 301 ]; 302 expect(shiftSpans(spans, 0, 4)).toEqual([ 303 { start: 4, end: 7, matched: true }, 304 { start: 7, end: 9, matched: true }, 305 unmatched, 306 ]); 307 }); 308 309 it('keeps a span before the new tokens, and stretches a span across them', () => { 310 const spans = [ 311 { start: 0, end: 2, matched: true }, 312 { start: 2, end: 6, matched: true }, 313 ]; 314 expect(shiftSpans(spans, 4, 3)).toEqual([ 315 { start: 0, end: 2, matched: true }, 316 { start: 2, end: 9, matched: true }, 317 ]); 318 }); 319 }); 320 321 describe('alignCuesToTokens on one page', () => { 322 it('maps exact text to exact spans, for short cues too', () => { 323 fc.assert( 324 fc.property(fc.array(word(1, 30), { minLength: 1, maxLength: 20 }), (cues) => { 325 const spans = alignCuesToTokens(cues, tokensOf(cues.join(''))); 326 let offset = 0; 327 cues.forEach((cue, i) => { 328 expect(spans[i]).toEqual({ start: offset, end: offset + cue.length, matched: true }); 329 offset += cue.length; 330 }); 331 }), 332 ); 333 }); 334 335 it('keeps spans near the truth under modest random character noise', () => { 336 // One cue with up to one edit per ten characters. 337 const noisyCue = word(15, 40).chain((text) => 338 fc.tuple( 339 fc.constant(text), 340 fc.array( 341 fc.record({ 342 pos: fc.nat({ max: text.length - 1 }), 343 kind: fc.constantFrom('sub', 'del', 'ins'), 344 ch: char, 345 }), 346 { maxLength: Math.floor(text.length / 10) }, 347 ), 348 ), 349 ); 350 351 fc.assert( 352 fc.property(fc.array(noisyCue, { minLength: 1, maxLength: 15 }), (items) => { 353 const cues = items.map(([text]) => text); 354 const noisy = items.map(([text, edits]) => applyEdits(text, edits)); 355 const spans = alignCuesToTokens(cues, tokensOf(noisy.join(''))); 356 357 let offset = 0; 358 noisy.forEach((truth, i) => { 359 const trueStart = offset; 360 const trueEnd = offset + truth.length; 361 offset = trueEnd; 362 const s = spans[i]!; 363 const overlap = Math.max(0, Math.min(s.end, trueEnd) - Math.max(s.start, trueStart)); 364 expect(overlap).toBeGreaterThanOrEqual(0.6 * truth.length); 365 expect(s.start).toBeGreaterThanOrEqual(trueStart - 4); 366 expect(s.end).toBeLessThanOrEqual(trueEnd + 4); 367 }); 368 }), 369 ); 370 }); 371 }); 372 373 interface Edit { 374 pos: number; 375 kind: 'sub' | 'del' | 'ins'; 376 ch: string; 377 } 378 379 function applyEdits(text: string, edits: readonly Edit[]): string { 380 const chars = [...text]; 381 // Apply from the end so earlier positions stay valid. 382 for (const e of [...edits].sort((x, y) => y.pos - x.pos)) { 383 if (e.kind === 'sub') chars[e.pos] = e.ch; 384 else if (e.kind === 'del') chars.splice(e.pos, 1); 385 else chars.splice(e.pos, 0, e.ch); 386 } 387 return chars.join(''); 388 }