TOOL | Evaluation design
Build a normalization pipeline and check it against your harness
Choose normalization steps in order, load a sample file, and see which rows each step flips, whether the pipeline reproduces your harness, and where the gaps are.
A browser check and a harness can grade the same outputs differently when each normalizes text
its own way. Build the pipeline, load a sample file, and see which rows each step flips and whether
the pipeline reproduces the harness verdicts. One trap: JavaScript's trim() strips 25
whitespace characters and Python's str.strip() strips 29. Each step mirrors Python, and
the pipeline exports as Python or TypeScript. Test one pair on the
grader playground. Your file stays in this tab.
The figures below use the returns-desk example: 40 cases with harness verdicts, under trim and lowercase.
- No steps selected. This is equivalent to exact match.
- No file loaded. You can still build the pipeline, trace a typed pair, and export snippets and test vectors.
- Parity depends on each runtime's Unicode version. Characters added in a Unicode version that only one side supports may normalize differently.
| Step | Passes after | Rows flipped | Flips if removed | Effect on sample |
|---|
Harness passes, browser fails:
Browser passes, harness fails:
Trace one case
Pick a case from the sample, or type an expected answer and an output, to see the text after each step.
Check this value.
Check this value.
Check this value.
| Step | Expected after this step | Output after this step |
|---|---|---|
| Original |
The exported snippets include a self-check over the test vectors. Run the snippet in your environment to verify it matches.
Python
# Normalization pipeline, library version 1. Steps: (none).
import re
import string
import unicodedata
def normalize(s):
return s
def matches(expected, output):
return normalize(output) == normalize(expected)
VECTORS = [
('\ufeffx', '\ufeffx'),
('\u0085x\u001c', '\u0085x\u001c'),
('\u00a0x ', '\u00a0x '),
('\u200bx', '\u200bx'),
('a\u0085b', 'a\u0085b'),
('a\ufeffb', 'a\ufeffb'),
('a \t\n b', 'a \t\n b'),
('wrong_item.\n', 'wrong_item.\n'),
('wrong_item.!', 'wrong_item.!'),
('a\u00f1adir a cart', 'a\u00f1adir a cart'),
('the cat', 'the cat'),
('An apple', 'An apple'),
('\u0130', '\u0130'),
('\u039f\u0394\u039f\u03a3', '\u039f\u0394\u039f\u03a3'),
('\u1e9e', '\u1e9e'),
('caf\u00e9!', 'caf\u00e9!'),
('\u00bfqu\u00e9?', '\u00bfqu\u00e9?'),
('\u00bfD\u00f3nde est\u00e1?', '\u00bfD\u00f3nde est\u00e1?'),
('\ufb01\uff21\uff22\uff23\u00b2', '\ufb01\uff21\uff22\uff23\u00b2'),
('P\u00e9rez', 'P\u00e9rez'),
('refund - status', 'refund - status'),
('"return_eligible"', '"return_eligible"'),
('\u201cescalate\u201d', '\u201cescalate\u201d'),
('"', '"'),
('```json\n{"a":1}\n```', '```json\n{"a":1}\n```'),
('```\n{"a":1}```', '```\n{"a":1}```'),
('Stra\u00dfe', 'Stra\u00dfe'),
]
if __name__ == '__main__':
for i, (given, want) in enumerate(VECTORS):
got = normalize(given)
assert got == want, f'vector {i}: {given!r} gave {got!r}, expected {want!r}'
print(f'{len(VECTORS)} vectors match')
TypeScript
// Normalization pipeline, library version 1. Steps: (none).
const PY_WS = '[\\t\\n\\u000B\\f\\r\\u001C-\\u001F \\u0085\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000]';
export function normalize(input: string): string {
let s = input;
return s;
}
export function matches(expected: string, output: string): boolean {
return normalize(output) === normalize(expected);
}
export const VECTORS: [string, string][] = [
['\ufeffx', '\ufeffx'],
['\u0085x\u001c', '\u0085x\u001c'],
['\u00a0x ', '\u00a0x '],
['\u200bx', '\u200bx'],
['a\u0085b', 'a\u0085b'],
['a\ufeffb', 'a\ufeffb'],
['a \t\n b', 'a \t\n b'],
['wrong_item.\n', 'wrong_item.\n'],
['wrong_item.!', 'wrong_item.!'],
['a\u00f1adir a cart', 'a\u00f1adir a cart'],
['the cat', 'the cat'],
['An apple', 'An apple'],
['\u0130', '\u0130'],
['\u039f\u0394\u039f\u03a3', '\u039f\u0394\u039f\u03a3'],
['\u1e9e', '\u1e9e'],
['caf\u00e9!', 'caf\u00e9!'],
['\u00bfqu\u00e9?', '\u00bfqu\u00e9?'],
['\u00bfD\u00f3nde est\u00e1?', '\u00bfD\u00f3nde est\u00e1?'],
['\ufb01\uff21\uff22\uff23\u00b2', '\ufb01\uff21\uff22\uff23\u00b2'],
['P\u00e9rez', 'P\u00e9rez'],
['refund - status', 'refund - status'],
['"return_eligible"', '"return_eligible"'],
['\u201cescalate\u201d', '\u201cescalate\u201d'],
['"', '"'],
['```json\n{"a":1}\n```', '```json\n{"a":1}\n```'],
['```\n{"a":1}```', '```\n{"a":1}```'],
['Stra\u00dfe', 'Stra\u00dfe'],
];
export function selfCheck(): number[] {
return VECTORS.flatMap(([given, want], i) => (normalize(given) === want ? [] : [i]));
}
Test vectors
[
[
"x",
"x"
],
[
"
x\u001c",
"
x\u001c"
],
[
" x ",
" x "
],
[
"x",
"x"
],
[
"a
b",
"a
b"
],
[
"ab",
"ab"
],
[
"a \t\n b",
"a \t\n b"
],
[
"wrong_item.\n",
"wrong_item.\n"
],
[
"wrong_item.!",
"wrong_item.!"
],
[
"añadir a cart",
"añadir a cart"
],
[
"the cat",
"the cat"
],
[
"An apple",
"An apple"
],
[
"İ",
"İ"
],
[
"ΟΔΟΣ",
"ΟΔΟΣ"
],
[
"ẞ",
"ẞ"
],
[
"café!",
"café!"
],
[
"¿qué?",
"¿qué?"
],
[
"¿Dónde está?",
"¿Dónde está?"
],
[
"fiABC²",
"fiABC²"
],
[
"Pérez",
"Pérez"
],
[
"refund - status",
"refund - status"
],
[
"\"return_eligible\"",
"\"return_eligible\""
],
[
"“escalate”",
"“escalate”"
],
[
"\"",
"\""
],
[
"```json\n{\"a\":1}\n```",
"```json\n{\"a\":1}\n```"
],
[
"```\n{\"a\":1}```",
"```\n{\"a\":1}```"
],
[
"Straße",
"Straße"
]
]
This page shows which normalization steps close the gap between your browser grader and your harness, and which rows each step affects.
If you cannot change your harness's normalization, the score continuity checker estimates the offset between two harnesses on an overlap run.
Each step mirrors the Python built-in and runs in the stated order.How?
How this is calculated
Each step is defined by its Python form. Where JavaScript's built-ins behave differently, the
browser spells out the logic to match. The reference is what Python does: "trim" means
str.strip(), which removes 29 whitespace code points (U+0009 through U+000D, U+001C
through U+001F, U+0020, U+0085, U+00A0, U+1680, U+2000 through U+200A, U+2028, U+2029, U+202F,
U+205F, U+3000). JavaScript's String.prototype.trim() removes 25: it adds U+FEFF and
misses U+001C through U+001F and U+0085.
Case fold goes beyond lowercase. Python's str.casefold() maps German sharp s to ss and
applies Full and Common case folding rules. JavaScript has no built-in case fold, so the browser
applies the Unicode CaseFolding table code point by code point.
Article removal uses Unicode-aware word boundaries. The JavaScript \b treats only ASCII
letters, digits and underscore as word characters, so on añadir a cart it would remove
the first a of añadir, giving ñadir cart. The
browser uses Unicode property boundaries, which give añadir cart.
The per-step table is cumulative from no normalization: "passes after" counts how many rows pass after all steps up to and including this one. "Rows flipped" names the rows that changed from fail to pass (or pass to fail) at this step. For "Flips if removed", the page removes that one step from the full pipeline and counts the rows whose verdict changes.
Parity compares the browser's exact-match verdict (after applying the full pipeline) against the harness verdict column, row by row. Disagreements split in two directions: the harness passed and the browser failed, or the reverse. Gap closers show what happens when you append each library step on its own to the end of your pipeline: how many disagreements it closes and how many it opens.
Formula: Each step mirrors the Python built-in and runs in the stated order.
Questions
Why does "trim" here remove 29 whitespace characters?
JavaScript's trim() removes 25 characters. It removes U+FEFF (byte order mark), which
Python keeps, and misses U+001C through U+001F (file, group, record and unit separators) and U+0085
(next line), which Python removes. Python's str.strip() removes 29. A Python harness
that trims calls str.strip(), so the browser matches the Python set.
Why does the Python snippet use \Z?
Python's $ also matches before a final newline. A text ending in
wrong_item.\n loses its period under $ but keeps the newline.
\Z matches only at the true end of the string. JavaScript's $ without the
m flag matches the end of the string, so the TypeScript snippet uses $.
Why does article removal use Unicode boundaries?
The JavaScript \b assertion counts only ASCII letters, digits and underscore as word
characters. In añadir a cart, it finds a boundary between a and
ñ, so the first a of añadir is removed as an article and the
text becomes ñadir cart. Unicode property boundaries treat
ñ as a letter, so only the standalone a is removed.
When should I use lowercase and when case fold?
Lowercase maps each character to its lowercase form. Case fold goes further: it also changes some
characters that are already lowercase, such as German sharp s, which becomes ss. If your harness
calls str.casefold() in Python, use case fold here. If it calls str.lower(),
use lowercase.
Does parity on my sample file prove parity on all inputs?
No. Parity on a sample means no disagreements on the rows you tested, and characters the sample lacks can still diverge. On the returns-desk example, trim, lowercase, "Join words with underscore" and "Remove ASCII punctuation" in that order reaches 40 of 40, though it is not the harness's pipeline. The exported test vectors cover the known traps, such as the six code points where JavaScript and Python differ on whitespace. Parity also depends on each side's Unicode version. Run the vectors in your harness.
Why does step order matter?
Each step changes the text that the next step sees. For example, with trim first,
wrong_item.\n becomes wrong_item., and stripping the trailing period then
gives wrong_item. With the period step first, it sees the newline and leaves the period.
The order check flags each adjacent pair where a swap would change the result.
Both sides or output only?
Some harnesses normalize only the model output and compare it against the reference as written. "Both sides" normalizes the expected answer too. On the returns-desk example, both settings give the same verdicts on all 40 rows. On free-text tasks where the reference itself has inconsistent formatting, normalizing both sides can close gaps that output-only normalization misses.
How does this differ from the score continuity checker?
The score continuity checker estimates the overall offset between two harnesses on an overlap run, with a paired interval and a restated baseline. This page finds the per-row cause at the normalization step and exports a fix. If you can change the normalization in your harness, start here. If you cannot change the harness, the score continuity checker estimates how far the harness moved the score.
Is my file sent anywhere?
Your file is processed in this browser tab, and you can check by going offline after the page loads. This page never uploads it.