mirror of
https://github.com/tiennm99/thptqg2017.git
synced 2026-09-18 04:20:27 +00:00
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.
158 lines
5.4 KiB
JavaScript
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.");
|