Files
thptqg2017/parser/scripts/verify-parity.js
T
tiennm99 6ff2ed99ec refactor: collapse the two projects into one tree and move to npm
The repo held two near-duplicate projects. 2016/ and 2017/ each carried their
own React frontend, their own copy of the same Rust crate, and their own
package manager setup. 2016/tools/sync-from-thptqg2017.sh existed purely to
copy the parser source between them.

New layout:

  index.html + src/  the 2017 frontend, now the only one
  data/<id>/         2016, 2017, 2017-old, 2017-old2
  parser/            the single Rust crate, configs renamed to <id>.toml
  docs/              both projects' docs, 2016 copies suffixed -2016-legacy
                     pending the merge pass

<id> is now one identifier end to end: data/<id>/ feeds parser/configs/<id>.toml
and produces db/<id>.db.gz.

pnpm gives way to npm. pnpm-workspace.yaml existed only to whitelist
better-sqlite3's native build, which npm permits by default, so it has no
equivalent and is simply gone. Lockfiles cannot be converted; package-lock.json
is generated fresh. The migration direction is safe — pnpm's strict layout
forbids phantom dependencies, so anything that resolved under pnpm resolves
under npm's flat tree.

Adds parser/scripts/build-db.js and src/datasets.js: the four dataset IDs are
declared once and read by both the build tooling and (from the next phase) the
frontend.

Follow-on fixes the move made necessary:
  - eslint's Node-globals override pointed at scripts/, now parser/scripts/
  - crawl-baotintuc.js wrote to <root>/data, now data/2017
  - golden tests loaded configs by their old thptqg*-data.toml names

Drops the #[ignore]d Rust-vs-Node golden test. It shelled out to pnpm to run
scripts/build-database.js, a file removed when the parser was ported to Rust,
so it could never pass. check-duplicates.js and diff-datasets.js were already
broken before this change and are annotated as such rather than half-fixed.

63 Rust tests pass and clippy is clean from the new location.
2026-08-13 11:27:01 +07:00

158 lines
5.4 KiB
JavaScript

#!/usr/bin/env node
/**
* Compare rebuilt databases against the pre-refactor parity baseline.
*
* The schema deliberately changed shape, so a whole-file hash is meaningless.
* What must hold instead:
*
* 1. Row count per dataset is unchanged.
* 2. Every column that existed before has the same non-NULL count.
* 3. Every column newly added to a dataset has a non-NULL count of exactly 0.
* This is the check that catches the union-regex risk — if the 16-pattern
* map starts matching text the narrower per-year map ignored, it shows up
* here rather than silently corrupting the dataset.
* 4. A deterministic sample of students is identical field by field.
*
* Exits non-zero on any mismatch.
*
* Usage:
* node verify-parity.js <baseline.json> <current.json>
*/
import { readFileSync } from "node:fs";
/**
* Foreign-language scores the pre-refactor configs silently discarded.
*
* The 2016 config listed 12 subject regexes and the 2017 configs listed 14;
* neither list was complete. Candidates could sit German, Japanese and Russian
* in both exam years, so every one of these students previously ended up with
* no foreign-language score at all.
*
* Unifying to the canonical 16 patterns recovers them. Verified real, not
* spurious matches: across all four datasets every student holds either zero
* or exactly one foreign language — never two — and each affected student had
* all language columns NULL beforehand.
*
* These exact counts are approved. Any other newly-populated column, or any
* drift in these numbers, still fails the gate.
*/
const APPROVED_RECOVERY = {
"2016": { tieng_nga: 182 },
"2017": { tieng_duc: 93, tieng_nhat: 512 },
"2017-old": { tieng_duc: 85, tieng_nhat: 484 },
"2017-old2": { tieng_duc: 22, tieng_nhat: 313 },
};
const [baselinePath, currentPath] = process.argv.slice(2);
if (!baselinePath || !currentPath) {
console.error("usage: verify-parity.js <baseline.json> <current.json>");
process.exit(2);
}
const baseline = JSON.parse(readFileSync(baselinePath, "utf8"));
const current = JSON.parse(readFileSync(currentPath, "utf8"));
const failures = [];
const notes = [];
for (const dataset of Object.keys(baseline)) {
const b = baseline[dataset];
const c = current[dataset];
if (!c) {
failures.push(`${dataset}: missing from current stats`);
continue;
}
// 1. Row count
if (b.rowCount !== c.rowCount) {
failures.push(
`${dataset}: row count ${b.rowCount}${c.rowCount} (${c.rowCount - b.rowCount >= 0 ? "+" : ""}${c.rowCount - b.rowCount})`,
);
}
// 2. Pre-existing columns keep their non-NULL counts
for (const col of b.columns) {
if (!(col in c.nonNull)) {
failures.push(`${dataset}.${col}: column disappeared from schema`);
continue;
}
if (b.nonNull[col] !== c.nonNull[col]) {
failures.push(
`${dataset}.${col}: non-NULL ${b.nonNull[col]}${c.nonNull[col]} (${c.nonNull[col] - b.nonNull[col] >= 0 ? "+" : ""}${c.nonNull[col] - b.nonNull[col]})`,
);
}
}
// 3. Newly added columns must be NULL — except the approved recoveries,
// which must match their approved count exactly.
const approved = APPROVED_RECOVERY[dataset] ?? {};
const added = c.columns.filter((col) => !b.columns.includes(col));
const recovered = [];
for (const col of added) {
const expected = approved[col] ?? 0;
if (c.nonNull[col] !== expected) {
failures.push(
expected === 0
? `${dataset}.${col}: new column has ${c.nonNull[col]} non-NULL values, expected 0`
: `${dataset}.${col}: recovered ${c.nonNull[col]} values, approved count is ${expected}`,
);
} else if (expected > 0) {
recovered.push(`${col}=${expected}`);
}
}
// An approved recovery that vanished means the union patterns regressed.
for (const [col, expected] of Object.entries(approved)) {
if (!added.includes(col)) {
failures.push(
`${dataset}.${col}: expected ${expected} recovered values but column is not new`,
);
}
}
if (added.length) {
const nulls = added.length - recovered.length;
notes.push(
`${dataset}: +${added.length} new columns (${nulls} all-NULL as expected` +
(recovered.length ? `, recovered ${recovered.join(", ")}` : "") +
")",
);
}
// 4. Deterministic sample compared field by field
if (b.sampleCount !== c.sampleCount) {
failures.push(
`${dataset}: sample size ${b.sampleCount}${c.sampleCount}`,
);
}
for (const [sbd, bRow] of Object.entries(b.sample)) {
const cRow = c.sample[sbd];
if (!cRow) {
failures.push(`${dataset}: sampled student ${sbd} missing after rebuild`);
continue;
}
for (const [field, bVal] of Object.entries(bRow)) {
if (cRow[field] !== bVal) {
failures.push(
`${dataset}: student ${sbd} field ${field}: ${JSON.stringify(bVal)}${JSON.stringify(cRow[field])}`,
);
}
}
}
const sizeDelta = ((c.sizeBytes - b.sizeBytes) / b.sizeBytes) * 100;
notes.push(
`${dataset}: ${c.rowCount} rows, ${b.columns.length}${c.columns.length} cols, size ${sizeDelta >= 0 ? "+" : ""}${sizeDelta.toFixed(1)}%`,
);
}
for (const n of notes) console.log(` ${n}`);
if (failures.length) {
console.error(`\nPARITY FAILED — ${failures.length} mismatch(es):\n`);
for (const f of failures) console.error(` ✗ ${f}`);
process.exit(1);
}
console.log("\nPARITY OK — row counts, per-column non-NULL counts and samples all match.");