mirror of
https://github.com/tiennm99/thptqg.git
synced 2026-10-13 06:15:48 +00:00
The 2016 and 2017 parsers were the same crate with divergent SQL. DDL, INSERT and the subject regex table lived in each dataset's TOML config, so four copies had to be kept in step by hand — which is how the two schemas drifted apart. Move all of it into src/schema.rs as a single 22-column definition: 6 identity columns (adding ten_cum_thi and gioi_tinh, previously 2016-only) and 16 subject columns (the union of both exam years). Columns a dataset carries no data for bind NULL, costing ~1 byte per row. This collapses writer.rs to one insert path: insert_row_2016, SCORE_FIELDS_2016, SCORE_FIELDS_2017 and the SCORE_FIELDS alias all go away. Configs shrink to the parse rules that genuinely vary per dataset — sheet mode, column indices, SBD validation, header tokens, blank-row stripping — and carry no SQL at all. Config parsing now denies unknown fields, so a leftover [schema] block fails loudly instead of looking effective while schema.rs drives the build. idx_ten_cum_thi is partial, so it holds zero entries on the three 2017 datasets where the column is always NULL. Adds db-stats.js and verify-parity.js to prove no data moved: row counts, per-column non-NULL counts and a deterministic student sample are compared against databases built from the previous code. Verified across all four datasets — row counts, every pre-existing column count, and all sampled students are identical. Database size grows 1.4-2.2%.
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.");
|