Skip to content

test(db): golden regression dataset for traceable totals - #220

Merged
todorkolev merged 7 commits into
midt-bg:mainfrom
ydimitrof:test/golden-dataset
Sep 2, 2026
Merged

test(db): golden regression dataset for traceable totals#220
todorkolev merged 7 commits into
midt-bg:mainfrom
ydimitrof:test/golden-dataset

Conversation

@ydimitrof

@ydimitrof ydimitrof commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Какво

Closes #99.

Синтетичен golden корпус (2 възложители × 3 изпълнители × 10 договора), прекаран end-to-end през реалния production derive ред (derive-amendments.sqlload-nuts.sqlseed-state-owned.sqlnormalize-raw.sqlpromote-amendments.sqlprecompute.sql, огледално на import.mjs runFullDerive) срещу sqlite3, с абсолютни, ръчно сметнати очаквани стойности на всяко зърно.

Покритие на fixture-а

  • Всичките 5 value_flag изхода: ok, value_suspect (300×估 → repair към估имацията), value_low (signing 0), annex_suspect (анекс 100× signing), review (15×).
  • Трите валутни пътя: BGN (÷1.95583), EUR (as-is), USD (× fx_rate от fx_rates, 10-дневния carry-forward прозорец).
  • Анекси през derive-amendmentspromote-amendments (един suspicious 100×, един легитимен 1.5×, чийто current_value влиза в amount_eur).
  • Два гранични случая: (1) чуждовалутен ред, подписан един ден извън 10-дневния FX carry-forward прозорец → amount_eur NULL, брои се в корпусните COUNT(*), но пада от всяка value SUM — заковава amount_eur IS NOT NULL предиката на шестте rollup-а; (2) евро анекс върху лев договор ([Бъг]: Анекси в евро от 2026 на 1/2 от стойността #245)current_value вече е в евро, тъй че не се дели повторно на 1.95583; деривацията тръгва от current_value_currency, не от contracts.currency.
  • Реален state-owned ЕИК (121396123) през реалния seed-state-owned.sql; name-keyed изпълнител без валиден ЕИК (eik_valid=0); NUTS/settlement обогатяване през OCDS parties + реалния load-nuts.sql.

Асърти (9 теста)

Per-contract recount (id/amount_eur/value_flag/EUR timeline/fx одит) — зърното, което reconciliation gate-ът (#97) структурно не може да провери; company_totals, authority_totals, sector_totals, facet_counts, flow_pairs, home_totals, разпределение на value_flag, search surface, data_freshness, плюс самия integrity gate (двете мрежи са допълващи се).

Всяко golden число носи деривацията си в коментар (напр. 195583 ÷ 1.95583 = 100000). Умишлено няма --update скрипт — регенериране от pipeline-а би благословило точно бъга, който тестът пази. Процедурата за умишлена промяна е документирана в docs/review-testing.md („Golden dataset"); docs/integrity-gate.md бележките за blind spot-а вече сочат към теста.

Доказана чувствителност (mutation checks, dev-time, reverted)

  • Промяна на peg-а 1.95583 → 1.95584 в normalize-raw.sql: 5/9 теста червени.
  • Разхлабване на annex_suspect прага ≥100× → ≥1000×: 6/9 червени.
  • [Бъг]: Анекси в евро от 2026 на 1/2 от стойността #245 регресия в precompute.sql (current_value_eur по валутата на договора вместо по current_value_currency): 2/9 червениcurrent_value_eur 150000 → 76693.78.
  • Същата регресия в trusted_currency на normalize-raw.sql: 6/9 червени — халвирането се разлива през amount_eur, won_eur, spent_eur, секторните и home тоталите.

Проверки

  • packages/db: 192 теста / 28 файла зелени (вкл. новите 9); pnpm test зелен и в 6-те workspace-а.
  • pnpm typecheck зелен; node scripts/check-docs.mjs зелен.

Свързано

#97 (gate, чиито граници този тест допълва), #98 (при промяна на стойностната база golden числата се преизчисляват ръчно по документираната процедура), #217 (derive слоят се покрива поведенчески — този PR е този крак).

@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

Проверих отново (c98e4ef) — истински дълбок regression тест, не cheater:

  • Независим oracle: GOLDEN са ръчно сметнати литерали (аритметиката е в коментарите), всяко assert е toEqual(GOLDEN.x) — нищо не се смята с кода под тест.
  • Върти реалния derive pipeline (normalize-raw → promote-amendments → precompute), 9/9 зелени, и е mutation-sensitive: peg 1.95583→1.95584 → 5/9 падат; анекс праг ≥100×→≥1000× → 6/9 (точно както твърди PR-ът). Per-grain absolutes хващат misattribution, който запазва grand total — точно каквото gate-ът на Данни: твърди проверки за цялост в pipeline-а (reconciliation gate) #97 структурно не може да види.
  • Покрива ok / value_suspect / annex_suspect / value_low / review. Покрива Данни: регресионен golden-dataset за проследими тотали #99.

Should-fix (не блокер): инвариантът amount_eur IS NOT NULL е asserted-adjacent, но не се упражнява — няма нито един ред с NULL amount_eur. Доказано: махането на предиката и в шестте rollup-а е no-op (9/9 пак минават). Регресия, при която чуждовалутен ред без FX курс изтече в сумите (или се маркира като value_suspect), няма да се хване. Добави един договор в чужда валута без fx_rates ред (amount_eur NULL, изключен от всички rollup-и, suspect KPI непроменен) — това упражнява и 10-дневния FX carry-forward на границата на отхвърляне.

Nit: readScript няма -bail (за разлика от сестринските тестове) — SQL грешка по средата на .read скрипт не вдига beforeAll (sqlite3 излиза с 0).

Одобрявам; should-fix-ът е малък и си струва преди merge.

@lyubomir-bozhinov lyubomir-bozhinov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Издържано — точно това, което #99 иска. Датасетът е реален: ръчно изведени очаквани стойности, прекарани през истинския production SQL pipeline, всяка value_flag пътека ударена по веднъж. Кръстосах homeTotals.value_eur = 241100 (150100 + 91000, hand-derived) — бие. assertIntegrity върви срещу същата база → golden + reconciliation мрежите са комплементарни. Липсата на --update скрипт е правилният избор — авто-регенерация би благословила точно бъга, който датасетът лови.

Дребно: assertIntegrity се внася по суров relative path извън пакета (../../../scripts/integrity-checks.mjs) — при разместване на scripts/ тестът ще пада с module-not-found вместо assertion; workspace alias/entry point би ги развързал.

@ydimitrof
ydimitrof force-pushed the test/golden-dataset branch from c98e4ef to c137871 Compare July 13, 2026 08:07
@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

Аритметиката на golden set-а е коректна (проверих c1/c6/c8, rollup-ите и NULL c9 срещу пега).

Пропуск: euro-annex double-conversion капанът (#245 — 2026 EUR анекс върху BGN договор, current_value_eur ÷1.95583 повторно → наполовина) не се упражнява. И двата анекса във fixture-а (c6, c8) са BGN; c2/c4/c5 са EUR, но без анекс. Регресия, връщаща #245, би минала и 9-те golden проверки.

Добави 10-ти договор: BGN-подписан с EUR анекс, и assert-ни current_value_eur == суровата EUR сума (не ÷1.95583). Това е дискриминиращият случай, който документира прод-бъга.

@nedda76 nedda76 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Прегледах fixture-а, golden числата и асъртите. Чист, чисто адитивен PR (нов тест + docs, нула продукционен код) с висока стойност като регресионна мрежа — прекарва синтетичния корпус през реалния derive ред и сравнява с ръчно сметнати абсолютни числа, тъй че мисатрибуция, която пази грандтотала, тук пада (точно каквото integrity gate-ът структурно не хваща).

Проверих на ръка (съвпадат)

  • Per-contract: c1 195583÷1.95583=100000, c3 50000×0.9=45000, c4 300×оценка→value_suspect→repair до 1000, c5 15×→review, c6 100×→annex_suspect→връщане към 100, c8 анекс 1.5×→30000. Всичко излиза.
  • c9 (граничният случай) е най-хубавото зърно: USD, подписан на 06-12, единствен курс на 06-01; date(06-12,'-10d')=06-02 > 06-01 → извън 10-дневния carry-forward → amount_eur NULL. И проверява точно правилното следствие: c9 влиза в корпусните COUNT(*) (home/facet/search/freshness), но пада от всеки value SUM и сдвоената му бройка; as_of=06-12. Заковава amount_eur IS NOT NULL предиката на шестте rollup-а.
  • Агрегатите се затварят: company_totals (B1 160000/4/2 авт.), authority_totals (A1 150100, A2 91000, avg-ите), flow_pairs (A2→B1 60000/2 без c9), home_totals 241100, facet_counts eu '0' 8/191100. Всичко се сумира.

Бележка (дребно)

  • Описанието на PR-а казва „3 изпълнители × 8 договора", а fixture-ът има 9 (деветият е именно c9 — граничният fx случай). Струва си да се оправи текстът, за да не подведе ревюъра, че липсва ред.

Отличен тест. Само преглед — самият мърдж не е мой.

ydimitrof and others added 5 commits August 18, 2026 14:44
Synthetic 8-contract corpus (all 5 value_flag outcomes, BGN/EUR/USD
paths, amendments, state-owned EIK) run through the real production
derive order (derive-amendments -> normalize-raw -> promote-amendments
-> precompute) against sqlite3, asserting hand-computed absolute
numbers at every grain: per-contract amount_eur/value_flag/EUR
timeline, company_totals, authority_totals, sector_totals,
facet_counts, flow_pairs, home_totals, value_flag distribution, search
surface, freshness boundary, plus the reconciliation gate.

Sensitivity proven by throwaway mutations: changing the BGN peg fails
5/9 tests; loosening the annex_suspect threshold fails 6/9.
…bg#99)

review-testing.md gains the golden-dataset description and the manual
update procedure (recompute by hand from the fixture, never copy
pipeline output; deliberately no --update script). integrity-gate.md's
two blind-spot notes now point at the implemented test.
The file was committed with data rows exceeding printWidth 100; CI gates on
`prettier --check .` (midt-bg#88), so format it before the substantive change lands.
Pure reformatting, no semantic change.
Add c9 — a USD contract signed one day past the 10-day fx carry-forward
window (only USD rate is dated 06-01, signing 06-12), so it derives with
amount_eur NULL. It probes two paths the prior fixture left un-exercised:

- the 10-day carry-forward at its rejection boundary (widen the window and
  the row would convert), and
- the amount_eur IS NOT NULL predicate on all six value rollups: c9 counts
  in the corpus COUNT(*) tallies (home/facet/search/freshness) but is
  excluded from every value SUM and its paired count, and is 'ok' not
  value_suspect so the suspect KPI is untouched. Dropping the predicate now
  leaks a NULL into a sum instead of being a no-op.

Also pass -bail to readScript so a SQL error inside a .read derive step fails
beforeAll instead of exiting 0 (matches the sibling db tests).

GOLDEN constants hand-derived and reconciled against the real pipeline
(clean_total 241100, 9 contracts, integrity gate green).
…the fixture current

The rebase onto main left the dataset unrunnable, and three things had to move
before a new case could be added at all:

- the fixture built its schema from 0000_init.sql alone, but normalize-raw.sql
  now writes current_value_currency (migration 0002) — apply every migration in
  the directory, sorted, the way precompute-cohort.test.ts does, so a pinned
  subset cannot drift out of the served schema again;
- B1's ЕИК 111111111 fails the ЕИК/Булстат control digit that normalize-raw.sql
  now enforces (midt-bg#195), so the bidder silently fell back to the name-keyed rung
  and every id, key and rollup shifted. 111111113 carries the correct control
  digit and restores the "valid ЕИК, private" role the fixture documents;
- assertIntegrity is async now; the gate assertion ran against a pending Promise
  and its queries landed after afterAll had removed the temp DB.

c10 is the case itself: a BGN-signed contract with a 2026 EUR amendment. Its
current_value is already euros, so amount_eur and current_value_eur must take it
as-is — the derive keys the conversion off current_value_currency rather than
contracts.currency. Regressing either site yields 150000 ÷ 1.95583 = 76693.78,
exactly the halving midt-bg#245 reported in production, and the 1.5× step is legitimate
so the row stays 'ok' and IS summed: the wrong number would be published, not
suppressed. Verified by mutation — dropping the current_value_currency term from
precompute.sql fails 2 of 9, from normalize-raw.sql's trusted_currency 6 of 9.

Every dependent constant recomputed by hand: won_eur, spent_eur, sector, facet,
flow, home totals, the FTS row count and the freshness tally. docs/review-testing.md
said 8 contracts where the fixture had 9; it now says 10 and names both boundary
cases.
@ydimitrof
ydimitrof force-pushed the test/golden-dataset branch from c137871 to dee1013 Compare August 18, 2026 11:51
@ydimitrof

Copy link
Copy Markdown
Contributor Author

Пребазирано върху main (acfcdce) — без конфликти, но rebase-ът извади три неща, които правеха датасета неизпълним срещу текущия main, плюс липсващия случай от ревюто.

Ремонт след пребазирането (тестът падаше в beforeAll преди тези три)

  1. Схемата се строеше само от 0000_init.sql, а normalize-raw.sql вече пише current_value_currency (миграция 0002) → Parse error: table contracts has no column named current_value_currency. Сега прилага всички миграции от директорията, сортирани — същия модел като precompute-cohort.test.ts, тъй че закован подсписък не може пак да се разсинхронизира.
  2. ЕИК 111111111 на B1 не минава контролната цифра, която normalize-raw.sql вече налага (fix(etl): eik_valid е само синтактична проверка без контролна сума — служебни/сгрешени ЕИК обединяват несвързани изпълнители #195) → изпълнителят тихо падаше на name:-рунга и всички id-та, ключове и rollup-и се разместваха. 111111113 носи коректната контролна цифра и връща ролята „валиден ЕИК, частен", която fixture-ът документира.
  3. assertIntegrity вече е async — асъртът вървеше срещу pending Promise (results.length = undefined), а заявките му се изпълняваха след като afterAll беше изтрил временната база.

1) Евро анекс върху лев договор (#245) — @lyubomir-bozhinov, добавен

c10: BGN-подписан договор с анекс от 2026 в евро (current_value 150000 EUR, легитимен 1.5× ръст). И amount_eur, и current_value_eur го вземат както е — деривацията ключова по current_value_currency, не по contracts.currency.

Mutation-проверка, точно както поиска:

Регресия Резултат
precompute.sql: current_value_eur по валутата на договора 2/9 червениcurrent_value_eur 150000 → 76693.78
normalize-raw.sql: trusted_currency по валутата на договора 6/9 червени — халвирането се разлива през amount_eur, won_eur 310000→236693.78, spent_eur 300100→226793.78, секторните и home тоталите

Ключово за дискриминиращата сила: 1.5× стъпката е легитимна, тъй че редът остава 'ok' и влиза в сумите — сгрешеното число би било публикувано, не потиснато. Правилата за annex_total_suspect пък изискват валутата на анекса да съвпада с тази на договора, тъй че кросвалутният случай не се хваща отникъде другаде.

Всички зависими константи преизчислени на ръка (won/spent/sector/facet/flow/home, FTS брой, freshness).

2) „8 договора" в описанието — @nedda76, поправено

Описанието на PR-а и docs/review-testing.md казваха 8; вече казват 10 и именуват двата гранични случая.

3) -bail в readScript — вече е налице

Добавен беше в c98e4ef заедно с обяснителния коментар.

4) assertIntegrity по суров relative path — оставям го, ето защо

../../../scripts/*.mjs е конвенцията в цялото репо, не изключение на този тест: refresh-slice.test.ts, integrity-checks.test.ts, anomaly-report.test.ts и продукционният apps/etl/src/integrity.ts внасят по същия начин. Въвеждането на workspace alias само тук би било непоследователно и извън обхвата на test-only PR — ако alias-ът си струва, редно е да е отделна промяна върху всичките пет места. Кажи, ако предпочиташ обратното.


packages/db/src/golden-dataset.test.ts: 9/9 зелени. check:docs зелен, prettier чист.

@todorkolev

Copy link
Copy Markdown
Collaborator

@ydimitrof, PR-ът е готов по същество, остана само да го изравниш с main.

Проверих съдържанието срещу текущия main (20d2ed9, вече включва #335): сливането минава без конфликти, а golden тестът е 9/9 зелен, тоест датасетът преживява #313/#323/#332/#335 без промяна на числата.

Единствената спънка е, че клонът е BEHIND, а branch protection иска главата да е up-to-date преди мърдж. От наша страна не можем да обновим fork-клона (нямаме право върху главата на fork-а), затова молбата е към теб:

  • пре-базирай test/golden-dataset върху последния main и пушни (или натисни „Update branch" в UI-то на PR-а),
  • след това CI минава и мърджваме.

Благодаря за търпението!

@todorkolev
todorkolev merged commit 8529c12 into midt-bg:main Sep 2, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Данни: регресионен golden-dataset за проследими тотали

4 participants