Files
pxpipe/eval/lib/diff.mjs
T
teamchong 2b7b98f685 feat(render): add R3 reflow to recover line-end dead margin (~29% glyph fill → dense)
Pack text into a continuous sentinel-delimited stream (↵ = U+21B5) so
wrapLines fills every row to `cols` instead of leaving dead right-margin.
Adds reflow/dereflow, renderTextToPngsReflow{,MultiCol} variants, a full
test suite, and an A/B eval harness with L1 OCR + L2 session results.
2026-05-21 23:14:00 -04:00

134 lines
4.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* eval/lib/diff.mjs
*
* Character-level accuracy / edit-distance utilities for the L1 OCR eval.
*
* Uses WagnerFischer dynamic programming for Levenshtein distance.
* We operate on Unicode codepoints (not UTF-16 code units) so that
* multi-byte characters like ↵ are counted as single edits.
*/
/**
* Convert a string to an array of Unicode codepoints.
* @param {string} s
* @returns {number[]}
*/
function codepoints(s) {
return [...s].map(c => c.codePointAt(0));
}
/**
* Levenshtein edit distance between two strings, operating at the
* Unicode codepoint level.
*
* Space-optimised: O(min(|a|,|b|)) memory.
*
* @param {string} a
* @param {string} b
* @returns {number}
*/
export function levenshtein(a, b) {
const sa = codepoints(a);
const sb = codepoints(b);
if (sa.length === 0) return sb.length;
if (sb.length === 0) return sa.length;
// Keep shorter string in the inner dimension for cache efficiency
const [long, short] = sa.length >= sb.length ? [sa, sb] : [sb, sa];
let prev = Array.from({ length: short.length + 1 }, (_, i) => i);
for (let i = 1; i <= long.length; i++) {
const curr = [i];
for (let j = 1; j <= short.length; j++) {
const cost = long[i - 1] === short[j - 1] ? 0 : 1;
curr[j] = Math.min(
curr[j - 1] + 1, // insert
prev[j] + 1, // delete
prev[j - 1] + cost, // substitute
);
}
prev = curr;
}
return prev[short.length];
}
/**
* Character-level accuracy (01) where 1 = perfect transcription.
*
* accuracy = 1 (editDistance / max(|ref|, |hyp|))
*
* Clamped to [0, 1].
*
* @param {string} reference The source / ground-truth text
* @param {string} hypothesis The OCR / model transcription
* @returns {number}
*/
export function charAccuracy(reference, hypothesis) {
const refLen = codepoints(reference).length;
const hypLen = codepoints(hypothesis).length;
const maxLen = Math.max(refLen, hypLen, 1);
const dist = levenshtein(reference, hypothesis);
return Math.max(0, 1 - dist / maxLen);
}
/**
* Normalise text for comparison: collapse whitespace runs and trim.
* Used before scoring so minor whitespace artefacts from OCR don't
* unfairly penalise the reflow path.
*
* @param {string} text
* @returns {string}
*/
export function normaliseForDiff(text) {
return text
.replace(/\r\n/g, '\n') // CRLF → LF
.replace(/[ \t]+/g, ' ') // collapse horizontal whitespace
.replace(/\n{3,}/g, '\n\n') // collapse 3+ blank lines
.trim();
}
/**
* Score a single block transcription.
*
* @param {{ reference: string, hypothesis: string }} params
* @returns {{ editDistance: number, charAccuracy: number, refLen: number, hypLen: number }}
*/
export function scoreTranscription({ reference, hypothesis }) {
const ref = normaliseForDiff(reference);
const hyp = normaliseForDiff(hypothesis);
const dist = levenshtein(ref, hyp);
const acc = charAccuracy(ref, hyp);
return {
editDistance: dist,
charAccuracy: acc,
refLen: codepoints(ref).length,
hypLen: codepoints(hyp).length,
};
}
/**
* Aggregate an array of per-block scores into summary stats.
*
* @param {Array<{ editDistance: number, charAccuracy: number, refLen: number }>} scores
* @returns {{ meanAccuracy: number, medianAccuracy: number, minAccuracy: number, totalEdits: number, totalChars: number, macroAccuracy: number }}
*/
export function aggregateScores(scores) {
if (scores.length === 0) {
return { meanAccuracy: 0, medianAccuracy: 0, minAccuracy: 0, totalEdits: 0, totalChars: 0, macroAccuracy: 0 };
}
const accs = scores.map(s => s.charAccuracy).sort((a, b) => a - b);
const totalEdits = scores.reduce((s, r) => s + r.editDistance, 0);
const totalChars = scores.reduce((s, r) => s + r.refLen, 0);
return {
meanAccuracy: accs.reduce((s, v) => s + v, 0) / accs.length,
medianAccuracy: accs[Math.floor(accs.length / 2)],
minAccuracy: accs[0],
totalEdits,
totalChars,
/** Micro-averaged: treats all chars equally regardless of block size. */
macroAccuracy: totalChars > 0 ? Math.max(0, 1 - totalEdits / totalChars) : 0,
};
}