Skip to content

fix(etl): link namespace-mismatched annexes via a value anchor (#306) - #308

Merged
todorkolev merged 7 commits into
midt-bg:mainfrom
cefothe:feat/306-amendment-contract-resolve
Aug 14, 2026
Merged

fix(etl): link namespace-mismatched annexes via a value anchor (#306)#308
todorkolev merged 7 commits into
midt-bg:mainfrom
cefothe:feat/306-amendment-contract-resolve

Conversation

@cefothe

@cefothe cefothe commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Fixes part of #306.

Problem

1,937 EOP annexes (7.2%) don't link to any contract. #286 fixed the OCDS/procedure axis; this is the contract-number axis: the annex carries an internal annex-side number (148846, 2886) while the contract carries the buyer's filing number (Д-226, 388-2020). The exact (unp, contract_number) join fails, so the annex drops out of every annex→contract→company/authority rollup. String normalisation recovers almost none of them (measured: TRIM+UPPER 0, digits-only 11, substring 19) — they are genuinely unrelated identifiers.

Fix

Link by value: an annex's value_before is the contract's value at amendment time, so it equals the target contract's signing_value. A resolver in scripts/derive-amendments.sql rewrites raw_amendments.contract_number in place (exactly like the #286 УНП bridge) when value_before:

  • matches exactly (< 0.5 стотинка), currency-guarded,
  • uniquely one contract on the procedure.

A chain of annexes shares one annex-side number but only the first carries value_before = signing_value (later steps carry the running cumulative), so once any sibling resolves, that target is propagated across the (unp, annex-number) group (refused if members disagree) — so the whole chain links and current_value reflects the last step. Value-ambiguous (matches 2+) and no-match (target not yet ingested — the #249 class) annexes are left unlinked: an honest gap beats a wrong contract. Downstream (rollup, promote-amendments.sql, serving join) needs no change; the block is idempotent.

Validated on the real corpus

Exported the live corpus (26,770 EOP amendments, 198,123 contracts), loaded it into ETL staging, and ran the actual derive-amendments.sql:

check result
Recovery 1,937 unlinked → 1,563 linked (80.7%), 374 remaining
Precision (ground-truth blanking of 13,804 linked multi-contract annexes) 9,348 correct, 1 wrong (99.99%)
Idempotency re-run links 0 more, annex_count stable
Proof records 00011 multi-lot chain → all 5 2886 annexes to 388-2020 (cur = last step 53580.21); 00017ОП-3-016/…; 0000430

The single wrong link (0.011%) is the irreducible value-collision: an annex whose value had drifted to exactly a sibling contract's signing value — which is why the gate is exact-match + unique.

Scope (deliberate)

  • Safe scope: only value-confirmed links. The 374 residual (value-ambiguous / no-match) stay unlinked by design; e.g. 00009-2021-0007 (value_before 90550 ≠ signing 97350) is left unlinked rather than guessed. A max-recall variant (link single-contract procedures regardless of value, minus known multi-lot) is possible but unverifiable — deferred.
  • Slice/go-forward path deferred: refresh-slice.sql's windowed raw_contracts and touched-contracts scoping need a served-table candidate source + touched wiring, so it warrants its own PR. The full rebuild is authoritative and fixes the backlog (per the [Данни]: анексите от OCDS не се свързват с нито един договор (OCID вместо УНП) #286 "slice is best-effort" precedent).

Tests

packages/db/src/amendments-contract-resolve.test.ts runs the real derive-amendments.sql: single-contract link, multi-lot value disambiguation, chain propagation + current_value, ambiguous/no-match left unlinked, currency guard, already-linked untouched, diagnostic counts. @sigma/db 395 green, tsc + prettier clean. Plan + real-corpus validation in docs/implementation-plans/306-amendment-contract-namespace-link.md.

…-bg#306)

1,937 EOP annexes (7.2%) don't link to any contract: the annex carries an internal
annex-side number (148846, 2886) while the contract carries the buyer's filing number
(Д-226, 388-2020), so the exact (unp, contract_number) join drops them out of every
annex→contract→company/authority rollup. String normalisation recovers almost none
(different namespaces, not dirty strings).

Resolve by value: an annex's value_before equals its target contract's signing_value.
A resolver in derive-amendments.sql rewrites raw_amendments.contract_number (like the
midt-bg#286 УНП bridge) when value_before EXACTLY matches (<0.5 стотинка), currency-guarded,
exactly ONE contract on the procedure — measured 99.99% precision on the already-linked
corpus (9348/9349). Chain propagation carries one agreed target across annexes sharing
the annex-side number, so later steps (whose value_before is the prior cumulative) link
too and current_value reflects the last step. Value-ambiguous and no-match annexes are
left honestly unlinked. Recovers ~1,379 of 1,937 (71%) at high confidence on the live
corpus; downstream joins, promotion, and serving need no change.

Slice path (refresh-slice.sql) deferred to a follow-up: its windowed raw_contracts and
touched-contracts scoping need a served-table candidate source + touched wiring, so it
warrants its own tests — the full path is authoritative and fixes the backlog on the
next full rebuild (midt-bg#286 precedent: the slice is best-effort).

Tests run the real derive-amendments.sql: single-contract link, multi-lot value
disambiguation, chain propagation, ambiguous/no-match left unlinked, currency guard,
already-linked untouched, diagnostic counts. @sigma/db 395 green, tsc clean. Plan +
real-corpus validation in docs/implementation-plans/306-amendment-contract-namespace-link.md.
@cefothe

cefothe commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Independent validation against the live sigma-dev corpus ✅

Reproduced the PR's resolver logic (derive-amendments.sql) directly against the live sigma-dev D1 (26,770 EOP amendments, ~198k contracts). The deployed DB is still in the pre-fix state, so it doubles as a clean "before" baseline and a ground-truth precision test. Every headline number reproduces exactly.

Check PR / plan Measured on live D1
EOP annexes unlinked (the defect) 1,937 1,937
empty cnum / empty unp 0 / 0 0 / 0
unp∉tenders / proc-no-contract / none-match 3 / 15 / 1,919 3 / 15 / 1,919
Recovery after resolver 1,563 (80.7%), 374 left 1,563 / 374
Direct unique value-matches ~1,377 1,377
Precision (GT blank of 13,804 multi-contract linked) 9,348 correct, 1 wrong 9,349 unique → 9,348 / 1 (99.99%)

Proof records verified on real data:

  • 00011-2020-0002 (multi-lot): 2886 chain of 5, value_before 56000 → uniquely matches 388-2020 (not 387-2020@64000); last step value_after = 53580.21current_value. (Today 388-2020 sits at annex_count=0 — exactly the defect.)
  • 00017-2020-0041: annex 11725 vb 5800 → ОП-3-016/…. ✅
  • 00004-2020-0022: annex 24035 vb 700000 → 30. ✅
  • 00009-2021-0007: vb 90550 ≠ signing 97350 → correctly left unlinked (honest gap, not guessed). ✅

Shipped test suite amendments-contract-resolve.test.ts: 7/7 green (runs the real SQL). Gate logic (exact cent-match + currency guard + n_match=1 uniqueness + chain propagation with disagreement refusal) is sound and idempotent by construction.

Two boundaries worth a conscious sign-off (both disclosed here, not defects):

  1. Precision over the ticket's "safe reserve": the issue proposed blanket-linking all 1,012 single-contract procedures by УНП; this PR requires value confirmation even there (e.g. 00009 stays unlinked). The right call for a transparency site — a wrong attribution beats an honest gap — but it means the fix is deliberately narrower than the ticket's max-recall option.
  2. Full-rebuild only: the fix lands via derive-amendments.sql; refresh-slice.sql is intentionally untouched (candidate-source + refresh_touched_contracts wiring would need to change, else go-forward links leave contract totals stale). So the deployed corpus won't reflect the fix until a full rebuild runs, and new go-forward mismatches accumulate until the follow-up slice PR. Consistent with the [Данни]: анексите от OCDS не се свързват с нито един договор (OCID вместо УНП) #286 precedent.

Verdict: does what it claims — 80.7% recovery at 99.99% precision, residual left honestly unlinked. Title's "Fixes part of #306" is accurate. LGTM. 👍

@todorkolev

Copy link
Copy Markdown
Collaborator

Прегледах PR-а на 3c6fb34 и възпроизведох две неща срещу истинските скриптове. Замисълът е верен - връзването по стойност е точният ход, след като низовата нормализация е измерена и не върши работа, а валидацията срещу реалния корпус е истинска. Три неща преди мърдж, едното от които е блокер.

1. Блокер: резолверът работи СЛЕД prefer-EOP dedup-а и възкресява близнаците

Блокът #306 е сложен след DELETE-а от #286. Понеже пренаписва contract_number на EOP анекси, той може да ги премести върху договор, за който вече е оцелял OCDS анекс - а по време на DELETE-а той е оцелял именно защото EOP анексът още е носел анексния номер.

Построих го и го пуснах през истинския derive-amendments.sql + promote-amendments.sql:

преди:  eop   UNP-A          148846
        ocds  ocds-e82gsb-1  Д-226

след:   eop   UNP-A          Д-226
        ocds  UNP-A          Д-226

Последиците са две, и втората е спираща:

Тоест PR-ът чупи точно пътя, на който сам разчита.

Поправката е разместване, не нова логика: преместих блока #306 преди prefer-EOP DELETE-а и пуснах пак същия сценарий - annex_count = 1, нула нарушения. Резолверът пипа само source LIKE 'eop:%' и се допира до raw_contracts, тъй че няма зависимост от OCDS моста и може спокойно да стои по-рано. Струва си и тест точно за тази наредба, защото нищо друго няма да я задържи на място.

2. Двусмислените анекси не остават несвързани

Описанието и коментарът твърдят, че двусмислените (съвпадащи с 2+ договора) остават несвързани. Разпространението по веригата отменя това: последният SELECT пада на group_target, когато няма direct попадение, а двусмисленият ред няма такова.

Процедура с C-1 и C-2 по 500 и C-3 за 1000; два анекса делят анексния номер 999:

A1  value_before 1000  → C-3   (уникално - вярно)
A2  value_before 500   → C-3   (съвпада с C-1 И C-2, тоест двусмислен)

A2 се закача за договор, чиято стойност никога не е съвпадала. И понеже е последната стъпка по дата, подменя текущата стойност на C-3: 600 вместо 1100.

Тестът за двусмислие ползва самотен анекс (7777), затова минава - гаранцията важи извън верига, но вътре във верига се отменя мълчаливо. Или разпространението да прескача редове с n_match >= 2 (те носят собствено, противоречащо доказателство), или описанието да каже какво реално прави. Първото ми се вижда по-вярно: съвпадение с два други договора е доказателство ПРОТИВ целта на веригата, не липса на доказателство.

3. Дневните обновявания също трябва да работят

Отлагането на slice пътя не е приемливо за нас - искаме поправката да работи и в дневния цикъл, не само при пълно презареждане.

Практиката го налага: кронът пуска само slice, а import.mjs отказва пълен derive върху зареден корпус, освен ако прозорецът стига до началото на емисията. Тоест както е сега, блокът остава бездействен, а новите разминаващи се анекси продължават да се трупат след всяко зареждане.

Признавам, че е по-трудно - raw_contracts в slice пътя държи само текущия прозорец, тъй че кандидат-договорите трябва да идват и от сервираната contracts, плюс закачане към touched-таблиците. Прецедентът от #286 обаче е точно обратният на цитирания: там мостът влезе и в двата файла, а „best-effort" се отнасяше за данните извън прозореца, не за липсващ код.

Какво проверих и е наред

  • CI е зелено; седемте теста в amendments-contract-resolve.test.ts минават и карат истинския скрипт.
  • Блокът е идемпотентен - unlinked иска NOT EXISTS съвпадащ договор, тъй че второ пускане не намира нищо.
  • Валутният пазач и точното съвпадение под 0,5 стотинка са на място; отказът при 2+ съвпадения работи за самотните редове.
  • Валидацията срещу реалния корпус е направена както трябва: 9 348 от 9 349 при заслепяване на вече свързаните е честна мярка, а единствената грешка е обяснена, не скрита.
  • Надолу по веригата наистина не се иска промяна - rollup-ът, promote-amendments.sql и сервиращият join се връзват сами.

Обобщение

Първата точка е блокираща и се оправя с разместване. Втората иска или стеснение, или поправено описание. Третата е решение от наша страна: искаме и дневния път.

Благодаря за измерването - това, че низовата нормализация е пробвана и отчетена като безполезна (0 / 11 / 19), спести спора дали изобщо трябва стойностна котва.

@nikimilenkov nikimilenkov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Обстоен преглед — PR #308 @ 3c6fb34 (поправя част от #306)

Благодаря за този PR — свързването по стойност е правилният ход след като низовата нормализация е измерена като задънена улица, гейтът „точно съвпадение + уникалност + отказ при съмнение" е вярната посока за сайт за прозрачност, а валидацията на @cefothe срещу живата база възпроизвежда всички числа. Прегледът мина по строгия протокол (пет паралелни измерения; всяка находка проследена и възпроизведена през истинските скриптове върху чисто копие на този HEAD), стъпва върху коментарите на @cefothe и @todorkolev без да повтаря находките им — по-долу са независимите потвърждения и новите неща.

Предложение: връщане за промени. Общо на PR-а: блокерът на @todorkolev (потвърден с възпроизвеждане) + 2 нови високи + 7 нови средни + ниски. (Съветодателно — решението е на поддържащите.)

Изпълнени проверки: packages/db 394/395 (единственият неуспех е познатият env артефакт ship-domain.test.ts), packages/ingest 61/61; 7 мутации върху резолвера (2 убити, 4 оцелели, 1 ред-съвместима); четири механични репродукции през реалния derive-amendments.sql (двете находки на @todorkolev + две нови мои); производителност измерена при мащаб 198k/30k и 10×; план-документът прочетен и сверен ред по ред; CI зелен; сканът за сигурност чист (без тайни/URL-и/зависимости; argv-безопасен harness; никакъв нов injection път).

Потвърждения на предишните коментари (не ги повтарям като нови)

  • Блокерът на @todorkolev (възкресяване на близнаците) — възпроизведен независимо през реалния скрипт: annex_count = 2 на договор с един анекс. Допълнение към предписанието му: преместването трябва да е над двете #286 диагностики, не само над DELETE-а — иначе ocds_annexes_dropped спира да е горна граница на реално изтритите (диагностиката се смята върху стария contract_number). Проверих и независимостта: резолверът чете само eop:% редове + raw_contracts, недокоснати нито от моста, нито от DELETE-а — преместването веднага след моста е безопасно, а и седемте нови теста минават с разместения ред (проверено с мутация; нищо обаче не заковава реда в никоя посока — тест за наредбата си струва). Бонус: checkAmendmentTwins ще хване възкресените близнаци като твърд отказ на целия derive — т.е. дефектът се проявява като спиране на pipeline-а, не като тихо двойно броене.
  • Точка 2 на @todorkolev (двусмислените в верига се закачат) — възпроизведена: C-3 получава current_value = 600 вместо 1100 от анекс, чиято стойност никога не е съвпадала с него. Съгласен съм с предписанието: разпространението да прескача редове с n_match ≥ 2 (те носят собствено противоречащо доказателство).
  • Точка 3 на @todorkolev (slice пътят) — двете страни се оказват верни едновременно, виж НОВА ВИСОКА 1: Worker-ът наистина не изпълнява блока (неговият recall аргумент стои), но CLI slice пътят го изпълнява — с прозоречни кандидати.
  • Валидацията на @cefothe — числата се възпроизвеждат; методологическата уговорка към нея е в НОВА ВИСОКА 2.

НОВА ВИСОКА 1 — „Slice path deferred" е фактически невярно: резолверът вече тече по инкременталния CLI път, с кандидати само от прозореца

scripts/import.mjs:301 (runSliceDerive) изпълнява целия derive-amendments.sql — включително резолвера — при всяко --derive=slice, върху преходно staging само с текущия прозорец. Там n_match = 1 означава „уникален в прозореца", не в корпуса: корпусно-двусмислен анекс изглежда уникален в тесен прозорец и се закача, а измерените 99,99% точност са върху пълния корпус и не се пренасят — колкото по-тесен прозорецът, толкова по-висок рискът (обратната посока на обичайното натоварване). Планът твърди обратното на три места (Status; §4; „go-forward анексите остават несвързани до следващия пълен rebuild"). Отделно §4-ият втори аргумент за отлагането е неверен на този HEAD: refresh_touched_contracts вече се попълва и през raw_amendments съединението (refresh-slice.sql:1855-1863), а rollup-ът пали и по EXISTS raw_amendments клона — т.е. „touched wiring" препятствието, както е описано, не съществува. Посока: или блокът да се гейтне за пълен derive (напр. изпълнение само когато staging-ът е пълен), или прозоречната семантика да се приеме изрично, документира и ограничи (напр. кандидати и от сервираната contracts, както планът сам предлага за бъдещия slice resolver).

НОВА ВИСОКА 2 — кандидатите идват от недедупнат кумулативен staging: срив на recall при истински пълен rebuild + грешни връзки през заместени редове

vmatch чете raw_contracts суров, а собственият инвариант на pipeline-а (normalize-raw.sql:1010-1012) казва: дневните EOP емисии са кумулативни — същият договор се повтаря през дните, и дедупликацията до „точно един ред на логически договор" става след derive. Значи COUNT(*) OVER (PARTITION BY amendment_id) брои staging редове, не договори. Възпроизведено през реалния скрипт, в двете посоки:

  • Срив на recall (fail-closed): един договор в три дневни bucket-а → n_match = 3 → отказ. При истински пълен rebuild от суровите емисии почти всеки договор присъства в много дни — правилото отказва масово и обещаните 1 563 връзки не се материализират.
  • Грешна връзка (fail-open): договор, престейджнат в ден 5 с коригирана стойност 120 000; анексът съвпада само със заместения ред от ден 1 (100 000) → n_match = 1 → закача се по остаряло число, при чист вид на диагностиката.

Валидацията в описанието не го е видяла по конструкция: изнесеният от живата база корпус е вече дедупнат (по един ред на договор) и е зареден така в staging — т.е. мери резолвера върху вход, който реалният pipeline не му дава. Поправка: дедуп CTE преди съпоставянето (ROW_NUMBER() OVER (PARTITION BY unp, contract_number ORDER BY source DESC, id DESC) = 1 — огледало на правилото „последният кумулативен bucket печели" от normalize-raw), плюс тест с един договор, стейджнат от два eop:contracts:<ден> източника — нито една от сегашните фикстури няма дублиран staging ред.

НОВИ СРЕДНИ

  1. Разцепена верига: противоречащите direct попадения се прилагат въпреки засеченото несъгласие. Възпроизведено: верига 777 с първи анекс → D-1 (вярно) и втори, чиято текуща натрупана стойност случайно съвпада уникално с D-2 → D-2 (грешно). HAVING-ът отказва разпространението, но двете противоречащи си пренаписвания остават — D-2 получава чужд анекс и чужда стойност. Несъгласието в група е доказателство, че съпоставянето по стойност е сбъркало за поне един член — редно е да анулира и direct попаденията на групата, не само разпространението.
  2. NULL-стойностните членове на верига никога не се разпространяватunlinked изисква value_before > 0, така че административен/срочен анекс по средата на верига остава на анексния номер. Възпроизведено: верига от 3 → annex_count = 2, а „the whole chain links" в описанието е свръхобещание. Същият клас обяснява и защо сметката на плана не се затваря: 1 379 + 326 изброени остатъка = 1 705 ≠ 1 937 — 232 реда (12% от backlog-а) са точно неназованият клас value_before IS NULL/≤0. Поправката е евтина: изискването за стойност е нужно на съпоставянето, не на разпространението (групата се дефинира от анексния номер).
  3. Пренаписването влиза в идентичността на реда и това има три остри ръба (natural_key = am:unp:contract_number:document_number): (а) възпроизведено — резолвиран ред се сблъсква с местен анекс със същия document_number на целевия договор и дедупликацията тихо изтрива истинския анекс от сервираната история (новодошлият печели по ORDER BY source DESC, id DESC); (б) по slice пътя престейджнат резолвиран анекс се промотира под стария си ключ → мъртъв дубликат/сирак до следващия пълен rebuild (сервираният DELETE пипа само ocds:% редове); (в) ключовете не са монотонни между ingest цикли — по-късно пристигнал втори договор със същата стойност връща анекса към двусмислие и той сменя обратно ключа си, а annex_count на целта пада. Посока: анексният номер да остане част от идентичността (или изричен integrity assert за колизии на резолвиран срещу местен ключ).
  4. Нулева проследимост на пренаписването. За разлика от #286 (OCID-ът оцелява в tender_ext_id и в source), тук анексният номер се унищожава без копие, amendment_contract_resolve се дропва, а следата е два цели числа в wrangler лог. След пуска никой не може да изброи кои редове са стойностно-свързани — твърдението за 99,99% е непроверимо в продукция, оплакване „този анекс не е наш" е неразследваемо, а integrity gate за брой resolved не може да се напише. Поправка: contract_number_raw + link_method колони (staging + served, през promote), и броячите в pipeline_stats по прецедента на checkStagingReconciliation.
  5. Собственото противоречие на реда се игнорира: анексът носи contractor_eik, договорите също — възпроизведено закачане на анекс с ЕИК 222222222 върху договор на 111111111 (чужди company_totals, чужда current_value). Едноредов null-толерантен пазач (u.contractor_eik IS NULL OR c.contractor_eik IS NULL OR u.contractor_eik = c.contractor_eik) затваря най-стойностната дупка безплатно — противоречието вече е в самия ред.
  6. Документален клъстер: (а) коментарът в derive-amendments.sql:128-131 описва slice имплементация, която не съществува („runs the same logic but also draws candidates from served contracts") — двойно проверено, refresh-slice.sql няма нито един resolver ред; (б) прецизността в коментара е „8348/8349", а планът/описанието/валидацията казват 9 348/9 349 — и разликата не е козметична: 8 349 < 9 061 (1%-кандидатите) би обърнало твърдението „exact печели и по recall", т.е. правилната двойка е тази на плана, коментарът носи грешка при препис; (в) планът предписва точно счупения ред („resolver runs after the prefer-EOP dedup" §3) — поправката на реда трябва да пипне и него; (г) §5 препраща към annex_total_suspect/value_suspect — флагове, които не съществуват на този клон (те са #307, все още в „връщане за промени") — т.е. има незаявена зависимост от реда на сливане: слеят ли се ~23-те новосвързани ≥2× анекса преди #307, влизат в агрегатите нефлагнати.
  7. Тестови пропуски (мутационно доказани): оцеляват мутациите върху отказа при несъгласие (HAVING), толеранса 0.005 (разширяване до < 5 минава — а върху тази константа стои цялото твърдение за точност), eop:% пазача и > 0 филтъра; нито един тест не комбинира резолвера с OCDS редове (точно опасността от блокера), с #286 моста или с promote; няма тест за идемпотентност (декларирана, непазена); диагностиката се идентифицира позиционно („последният 2-колонен ред") и ще се счупи при изискваното разместване.

НОВИ НИСКИ

  1. Валутният пазач дегенерира при празно-срещу-празно (двете страни стават 'BGN' по подразбиране) — за правило с толкова тесен гейт е по-последователно да изисква изрична валута от двете страни, в духа на „честна празнина пред грешен договор".
  2. amendment_contract_resolve не е в списъка за почистване на преходния staging (refresh.ts:86-100) — прекъснат ход я оставя в D1; водещият DROP IF EXISTS я самолекува при следващ ход, но чистачът за отказните пътища не я познава.
  3. Двете диагностики не са взаимно допълващи се (втората пропуска value_before > 0 предиката) и нямат заявен праг — всяка друга диагностика във файла казва какво е „здраво".
  4. UPDATE-ът на :172 е квадратичен по броя несвързани (SCAN на temp таблицата на ред; измерено 32 ms днес, 2,6 s при 10×) — един ред CREATE INDEX ... ON amendment_contract_resolve(amendment_id) между :170 и :172 го маха (измерено 39× при 10×). Застраховка, не днешен проблем.
  5. Предсъществуващо сляпо петно за протокола: анекс, чийто анексен номер случайно съвпада с реален (но грешен) договорен номер, никога не влиза в unlinked и не участва в проверката за несъгласие на групата си — не е внесено от този PR, но резолверът не може да го спаси.

Извън обхвата, но си заслужава отделен issue

Измерено покрай прегледа: предсъществуващият rollup на :190-233 е 99,5% от времето на целия скрипт (59,3 s от 61 s при мащаб на корпуса — двете SET подзаявки правят пълен скан на 30k-редовия dedup за всеки от 198k договора). Пренаписване с материализирана индексирана temp таблица дава 59,3 s → 0,28 s при нула разлики по (annex_count, current_value) на всичките 198 123 договора. Отделен PR, но е там, където реално живее времето на batch-а.

Какво беше проверено (чеклист)

  • Скан за сигурност чист; нулев нов примитив за автор на анекс (той и днес контролира номера директно) — новата повърхност е само косвено насочване през чужди редове, покрито в СРЕДНА 5 / ВИСОКА 2
  • Пакетите изпълнени тук (394/395 + 61/61); 7 мутации; 4 механични репродукции през реалния скрипт
  • Блокерът и точка 2 на @todorkolev възпроизведени; преместването проверено като безопасно и тест-съвместимо; уточнена правилната цел на преместването (над диагностиките)
  • Числата на плана сверени (вкл. незатварящата се сметка: 232 неназовани реда); 8348-срещу-9348 адюдицирано в полза на плана
  • REAL прецизност опровергана като риск (ULP при 1,5e8 е ~150 000× под толеранса; един и същ parser от двете страни); валутният idiom сверен с конвенцията на repo-то; индексното покритие потвърдено (планове през EXPLAIN, линейни; CTE-рескан капанът не хапе)
  • Производителност: блокът е 70 ms в 60-секунден скрипт; един латентен квадратичен UPDATE с едноредова поправка

Здравна оценка: 5.5 / 11

Коректност (пълен път) ◐ (ВИСОКА 2, СРЕДНИ 1-3) · Коректност (slice) ✗ (блокерът + ВИСОКА 1) · Сигурност ✓ · Производителност ✓ · Тестове ◐ (истински SQL, но половината пазачи неохранявани — СРЕДНА 7) · Документация/план ✗ (СРЕДНА 6) · Идемпотентност ◐ (в един ход да, между ingest цикли не — СРЕДНА 3в) · Наблюдаемост ✗ (СРЕДНА 4) · Обхват ✓ · Съвместимост с конвенциите ✓ (scratch-таблицата, диагностиките и месторазположението следват прецедента) · Честност на валидацията ◐ (числата реални, но входът на валидацията не е входът на pipeline-а — ВИСОКА 2).

Преди сливане

  1. Блокерът на @todorkolev — преместване над #286 диагностиките + тест за наредбата + поправка на §3 в плана.
  2. ВИСОКА 2 — дедуп на кандидатите (rn=1 по прецедента на normalize-raw) + тест с дублиран staging ред + ревалидация на recall/precision върху суров кумулативен staging.
  3. ВИСОКА 1 — решение за CLI slice пътя: гейт или изрична, документирана и ограничена прозоречна семантика; плюс поправка на плана (Status/§4, вкл. неверния touched-wiring аргумент).
  4. Точка 2 на @todorkolev + СРЕДНИ 1-2 — трите заедно оформят едно правило: група с каквото и да е вътрешно противоречие (двусмислен член / разцепени direct попадения) се отказва цялата, а разпространението не изисква стойност.
  5. СРЕДНИ 3-5 — идентичност/проследимост/ЕИК пазач; 4 и 5 са евтини и режат клас бъдещи спорове.
  6. Ниските и тестовите допълвания — по преценка; редът-с-индекса (НИСКА 4) е безплатен.

Идеята е точната и измерването зад нея е сериозно — точно затова си струва свързването по стойност да стъпи на дедупнати кандидати, да остави следа след себе си и да важи само там, където гаранциите ѝ важат. С блокера преместен, кандидатите дедупнати и провенансът записан това ще е достойният втори етаж върху #286. Благодаря — и на @cefothe за възпроизводимата валидация, и на @todorkolev за двата остри улова.

Move the annex→contract value resolver out of derive-amendments.sql into
its own script that runs BEFORE the prefer-EOP dedup, full-derive path
only. Running after the dedup resurrected OCDS twins (annex_count=2 on a
one-annex contract) and tripped the midt-bg#303 twin gate (todorkolev #1); the
slice path's windowed raw_contracts can't answer "unique on the
procedure" so it is gated off (nikimilenkov HIGH 1).

- dedup cumulative raw_contracts candidates before matching, else a
  contract in N daily buckets reads as n_match=N (nikimilenkov HIGH 2)
- one group rule: refuse a (unp, annex-number) group on any internal
  contradiction (ambiguous member or disagreeing anchors), voiding even
  its direct hits; value-less members inherit the agreed target
  (todorkolev #2, nikimilenkov MEDIUM 1 & 2)
- null-tolerant contractor-EIK guard; explicit currency both sides
  (MEDIUM 5, LOW 1); index the resolve scratch table (LOW 4)
- provenance: contract_number_raw + link_method through staging, served
  amendments (migration 0006), and promote; the raw number also keys the
  amendment natural_key so a resolved row never collides with a native
  annex sharing document_number on the target (MEDIUM 3, MEDIUM 4)
- sweep the resolve scratch table in transient cleanup (LOW 2)
- fix comments and plan §3/§4/§5 (8348→9348, order, slice gating, the
  midt-bg#307-flag merge-order dependency) (MEDIUM 6)

Tests run the real resolver+derive composition: twin-ordering, group
contradiction, cumulative-dup, value-less inherit, EIK, exact-cent
tolerance, gate, idempotency, natural-key collision, provenance.
@cefothe

cefothe commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Review addressed — pushed 15a0621

Thanks @todorkolev and @nikimilenkov — both reviews reproduced cleanly against the code, and every finding is now fixed or consciously deferred. Summary of the resolution.

Blocker + HIGH

MEDIUM

LOW

Explicit-currency-both-sides (no blank-vs-blank via BGN default); scratch table swept in transient cleanup; complementary diagnostics; CREATE INDEX on the resolve table.

Owed / deferred (flagged honestly)

  • Re-validation of the recovery/precision numbers against raw cumulative staging — the description's numbers were measured on a pre-deduped export (@nikimilenkov HIGH 2's point), and candidate dedup + value-less propagation shift them. Running it against the live corpus next.
  • Rollup perf (59.3s→0.28s) — separate issue, as suggested.

@sigma/db 407 green, @sigma/ingest 61 green, tsc + prettier clean.

Live-corpus validation showed the whole-group-refusal rule (nikimilenkov
MEDIUM 1) is net-negative: the sole anchor-disagreement group in the
entire corpus is a legitimate lot-base annex number spanning two lots
(00026-2020-0027 → …-Л01 @ 22569.98, …-Л03 @ 28557.50), each annex
exactly-uniquely matching its own lot. Refusing it dropped 2
confirmed-correct links and prevented zero wrong ones.

Revised rule: a member's own unique exact match always applies;
disagreement withholds only propagation to no-own-match members, never
the direct hits; ambiguous members still never link (todorkolev #2).
Recovery back to 1,563 (1,377 direct + 186 propagated), precision
9,348/9,349 unchanged.
@cefothe

cefothe commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Live-corpus validation (sigma-dev) — and one rule revision (e4ad2b4)

Re-measured read-only against the live sigma-dev corpus (26,770 EOP annexes), reproducing the defect + value-anchor logic in SQL (the served DB has no raw_* staging, so the resolver can't run there directly):

Check plan live sigma-dev
EOP annexes unlinked 1,937 1,937
Recovery (1,377 direct + 186 propagated) ~1,563 1,563
Precision (GT blank, exact+currency) 9,348/9,349 9,348 / 9,349 (99.99%)

Two things the data corrected:

1. @nikimilenkov MEDIUM 1 (whole-group refusal) was net-negative — revised in e4ad2b4. There is exactly one anchor-disagreement group in the entire corpus, and it's correct:

unp 00026-2020-0027, annex '20РП-У50А015' (a lot-BASE):
  value_before 22569.98 → 20РП-У50А015-Л01 (signing 22569.98)  ✓
  value_before 28557.50 → 20РП-У50А015-Л03 (signing 28557.50)  ✓

The annex number is a lot-base shared across two lots; each annex exactly-uniquely matches its own lot. Refusing the group dropped these 2 confirmed-correct links and prevented zero wrong ones (ambiguous_would_inherit_old = 0). So I kept your insight where it pays — a direct exact-cent + unique-on-procedure hit is trustworthy on its own (the 99.99%) — and applied refusal only to propagation: disagreement now withholds inheritance to no-own-match members, but never voids the direct hits. Ambiguous members (n_match ≥ 2) still never link (todorkolev #2). Recovery returns to 1,563; a new test pins both the lot-base link and the withheld-propagation case.

2. The value_before IS NULL/≤0 class is empty — all 1,937 unlinked annexes carry a value, so the 1,379+326≠1,937 gap is tier-estimate rounding, not a value-less class. The value-less-propagation code stays (defensive, correct) but recovers 0 rows today.

Still owed: the served corpus is deduped one-row-per-contract, so this run does not exercise the cumulative-staging dedup (HIGH 2) — that needs a local full backfill on raw cumulative staging. Doing that next.

@sigma/db 408 green, @sigma/ingest 61 green, tsc + prettier clean.

@todorkolev

Copy link
Copy Markdown
Collaborator

Прегледах наново на 15a0621. И двете ми находки са затворени - пуснах същите сценарии през истинските скриптове в реалния им ред (resolve-amendment-contracts.sqlderive-amendments.sqlpromote-amendments.sql):

  • близнаците: резолверът вече тече ПРЕДИ prefer-EOP триенето. Същият сценарий сега дава annex_count = 1 и нула нарушения на amendment-twin-dedup, вместо 2 и едно нарушение. Изнасянето в отделен файл го прави и по-трудно за случайно разместване, което е по-добре от простото преместване, което предложих.
  • двусмислените във верига: анексът с value_before 500, съвпадащ с два договора, вече остава несвързан (999), вместо да се закача за C-3. Текущата стойност на C-3 е 1100, тоест от вярната стъпка. Поведението вече отговаря на описанието.

Тестовете минават (db 406, единственото падане е предсъществуващият счупен глобален wrangler в средата). Добавената проверка по ЕИК на изпълнителя е добро затягане - стеснява стойностната котва с второ независимо условие.

Остават две неща.

1. Дневните обновявания - това е изискване от наша страна

Коментарът в import.mjs казва „Full-derive only". Проверих: runSliceDerive() не вика резолвера, refresh-slice.sql няма нито едно споменаване на #306, а Worker-ът пуска само refresh-slice.

Искаме поправката да работи и в дневния цикъл. Причината не е предпочитание: кронът пуска само slice, а import.mjs отказва пълен derive върху зареден корпус, освен ако прозорецът стига до началото на емисията. Както е сега, блокът остава бездействен в производствения ритъм, а новите разминаващи се анекси се трупат след всяко зареждане.

Възражението „прозоречният raw_contracts не може да отговори дали е уникално на процедурата" е вярно, но има отговор, и той е във вашия собствен по-ранен коментар: кандидат-договорите да идват от сервираната contracts, която държи целия корпус, а не от прозореца. Уникалността се пита там, а touched таблиците се закачат за резултата.

2. Сблъсък на номера на миграции с #307

И двата отворени PR-а добавят миграция 0006:

Който влезе втори, ще носи дублиран номер. Понеже сервираните бази се пълнят и извън ledger-а, това е точно мястото, където подредбата тихо се разпада. Който и от двата да мърджнем пръв, вторият трябва да се преномерира преди мърдж - за #308 това значи 0008, ако #307 влезе първи.

Обобщение

Механиката е наред и доказана. Остава дневният път, който е нашето изискване, и преномерирането. Благодаря за бързия и точен завой по двете находки - изнасянето на резолвера в самостоятелен скрипт е по-добро решение от това, което предложих.

midt-bg#306)

The value-anchor resolver only ran on the full derive, so the cron — which
runs only refresh-slice.sql — left new namespace-mismatched EOP annexes
unlinked between full rebuilds. Add a corpus-safe resolver inside
refresh-slice.sql: candidate contracts come from the served `contracts`
corpus UNIONed with the window's raw_contracts, so "unique on the procedure"
is asked corpus-wide, not just within the window. It runs before the
prefer-EOP dedup (same ordering the full path uses before derive-amendments),
carries contract_number_raw + link_method provenance through the served
amendments promotion, and keys the amendment natural_key on the raw annex
number so slice and full keys agree. Resolved prior-window targets land in
refresh_touched_contracts via the existing amendment touch join.

Renumber 0006_amendment_provenance.sql -> 0008 to avoid the migration-number
collision with midt-bg#307 (0006/0007), assuming midt-bg#307 merges first.

Addresses PR midt-bg#308 review (todorkolev): daily-path requirement + migration renumber.
@cefothe

cefothe commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

@todorkolev Благодаря за прегледа. Адресирах и двете оставащи неща в 26f9db1.

1. Дневният път — резолверът вече тече в slice цикъла

Изнесох стойностния резолвер в самата refresh-slice.sql (не в отделния скрипт, който кронът не вика), в setup батча преди prefer-EOP триенето — същата подредба, която пълният път ползва преди derive-amendments.sql. Кандидат-договорите идват от сервираната contracts (целия корпус; unp през tender_id, ЕИК на изпълнителя през bidders.eik_normalized), обединени с raw_contracts на текущия прозорец — точно както предложи: уникалността се пита корпусно, не в прозореца. Обхватът е ограничен до процедурите с анекс в прозореца (window_unps), така че четенето от сервираната таблица е точков lookup (idx_contracts_tender_id).

  • Провенансът минава през промоцията: natural_key (winners + промоция) ключи по суровия анекс-номер (COALESCE(NULLIF(contract_number_raw,''), contract_number, '')), а contract_number_raw + link_method се записват в сервираните amendments.
  • Разрешените таргети от предишни прозорци влизат в refresh_touched_contracts даром — пренаписването слага таргетния contract_number върху raw_amendments, а съществуващият touch-join в @refresh-batch amendments ги хваща.

Проверка срещу реалния sigma-dev D1 (read-only, 31 597 анекса / 198 123 договора):

  • 1937 EOP анекса с разминаване (unp, contract_number) → не сочат договор по номер.
  • Slice резолверът би свързал 990 уникално, задържа 247 двусмислени (проверено: всеки сочи ≥2 различни договора на същата стойност — точно случаят, който прозоречен резолвер би сбъркал), а ~700 нямат стойностно съвпадение и остават честно несвързани.

2. Номер на миграцията

Преномерирах 0006_amendment_provenance.sql0008_amendment_provenance.sql (приемайки, че #307 влиза първи — зависимостта по double-count флаговете от plan §5 го налага). Ако #308 влезе пръв, се връща на 0006 и #307 се измества.


Тестове: 61 db + 20 etl зелени, вкл. нов amendments-slice-resolve.test.ts — свързване през корпусен кандидат + провенанс през сервираните amendments + регресията „двусмислен в корпуса, но уникален в прозореца остава несвързан".

Странична бележка: d1_migrations на живия sigma-dev е дрифтнал спрямо репото (записва 0002_contracts_is_synthetic / 0003_assistant_prompts, спира на 0003) — отделен операционен проблем със средата, не от този PR.

…+ test fixes

Completes commit 26f9db1, which (due to a partial `git add`) shipped only the
migration rename and the new test — not the resolver itself. This adds the rest:

- refresh-slice.sql: a corpus-safe value-anchor resolver in the setup batch,
  before the prefer-EOP dedup. Candidates come from the served `contracts`
  corpus UNIONed with the window's raw_contracts, so "unique on the procedure"
  is asked corpus-wide, not just within the window. Carries contract_number_raw
  + link_method provenance through the served amendments promotion and keys the
  amendment natural_key on the raw annex number so slice and full keys agree.
  Resolved prior-window targets land in refresh_touched_contracts via the
  existing amendment touch join.
- import.mjs / resolve-amendment-contracts.sql: comments corrected (the slice
  path is no longer gated off; the standalone script is the full-derive form).
- docs plan §4/§5: slice path marked implemented; migration renumber noted.
- Tests: point the 4 migration references at 0008 (the rename); apply the 0008
  provenance migration in contractor-identity / etl-entity-canonicalization /
  value-flag-stotinki (they run refresh-slice.sql, which now writes the
  provenance columns); whole-array asserts in the slice test for
  noUncheckedIndexedAccess.

Addresses PR midt-bg#308 review (todorkolev): daily-path requirement + migration renumber.
@todorkolev

Copy link
Copy Markdown
Collaborator

Прегледах наново на 07199de. И двете оставащи неща са направени - дневният път го има, а миграцията е преномерирана на 0008.

Проверих slice резолвера, не го приемам по описание: подредбата е вярна (тече след моста и преди prefer-EOP триенето, тъй че близнаците пак си остават грижа само на дедупа), кандидатите наистина идват от сервираната contracts, обхватът е стеснен до window_unps, а ключът natural_key се пази по суровия анекс-номер - това е важно, защото значи, че вече сервиран несвързан ред се пренаписва на място, а не се удвоява. Двадесет и двата нови теста минават, целият db е зелен. Промените по заварените тестове са само добавени редове за изграждане на схемата, без разхлабени очаквания - проверих ред по ред.

Остават един блокер и едно разминаване.

Блокер: миграция 0008 не стига до сервираната база и кронът пада

refresh-slice.sql вече пише contract_number_raw и link_method в сервираните amendments, но нищо не създава тези колони на разгърнатата база.

deploy.yml прилага само 0003 (като файл, защото е идемпотентна) и 0002 (зад сонда). Този PR не пипа deploy.yml.

Живата stage база в момента:

SELECT (SELECT COUNT(*) FROM pragma_table_info('amendments') WHERE name='contract_number_raw') AS has_raw,
       (SELECT COUNT(*) FROM pragma_table_info('amendments') WHERE name='link_method')        AS has_method,
       (SELECT COUNT(*) FROM amendments) AS n;
-- has_raw 0 | has_method 0 | n 30623

И какво се случва тогава - пуснах вашия refresh-slice.sql върху база, изградена от 0000-0003 без 0008:

Parse error near line 1737: table amendments has no column named contract_number_raw

Тоест: деплоят минава (той не пуска refresh-slice), Worker-ът тръгва, и първият крон след това пада - точно дневният цикъл, заради който добавихме пътя.

Причината да не се вижда в тестовете е, че apps/etl/src/index.test.ts изгражда фикстурата, като прилага всички файлове от packages/db/migrations. Фикстурата твърди нещо за разгърнатата схема, което не е вярно, и зеленото е спрямо този неверен свят.

Иска се стъпка в deploy.yml преди деплоя на Worker-а, по образеца, който #307 вече въведе за своите колони: сонда с pragma_table_info, ALTER само на липсващите, неясен отговор е фатален. Само --file, както е за 0003, няма да свърши работа - голото ALTER TABLE ADD COLUMN не е идемпотентно и вторият деплой ще падне с „duplicate column name".

Ако #307 влезе пръв, най-чисто е неговата стъпка да се разшири с двете колони, вместо да се пише втора.

Разминаване: двата пътя дават различен резултат за анекс върху договор с нулева стойност

Пълният път пита „има ли изобщо такъв договор":

NOT EXISTS (SELECT 1 FROM raw_contracts c WHERE c.unp = a.unp AND c.contract_number = a.contract_number)

Slice пътят пита „има ли такъв кандидат", а contract_candidates изисква signing_value > 0:

NOT EXISTS (SELECT 1 FROM contract_candidates c WHERE c.unp = a.unp AND c.contract_number = a.contract_number)

Значи анекс, който сочи договора си по номер, но този договор е с нулева стойност, минава за разминат по пространство от имена и може да бъде закачен за съсед по стойност. Коментарът точно над блока обещава обратното: „an annex that DOES match a corpus contract directly is correctly excluded - it links by number, not value."

Възпроизведено през истинския refresh-slice.sql (процедура с Д-1 при стойност 0 и Д-2 при 5000; анекс с номер Д-1 и value_before 5000):

slice: contract_number Д-2, contract_number_raw Д-1, link_method value_anchor
       Д-2 → annex_count 1, current_value 9000   |   Д-1 → annex_count 0
пълен: contract_number Д-1, contract_number_raw NULL, link_method NULL     ← вярното

Днес не хапе. Измерих на живия корпус: 201 договора със signing_value <= 0, 25 анекса ги сочат по номер, и нула от тях биха намерили стойностно съвпадение при съсед. Тъй че това не е дефект в производството, а заредена дупка - блокът тече при всеки slice, корпусът расте, а пълният derive после тихо ще върне връзката обратно. Разминаване между двата пътя е точно това, което прави сервираните числа невъзпроизводими.

Оправя се с една CTE, така че въпросът да е „истински ли е договорът", а не „съпоставим ли е":

all_contract_numbers AS (
  SELECT unp, contract_number FROM raw_contracts
  WHERE contract_number IS NOT NULL AND unp IN (SELECT unp FROM window_unps)
  UNION
  SELECT substr(tender_id, 3) AS unp, contract_number FROM contracts
  WHERE contract_number IS NOT NULL AND tender_id IN (SELECT 't:' || unp FROM window_unps)
),

и grp да пита нея. Пробвах го: горният случай става верен, а db остава 410/410 зелен.

Дребно

Коментарът над дедупа на кандидатите казва „collapse to the latest source-day/id, mirroring normalize-raw", но подредбата е ORDER BY src_rank, ord DESC, където ord е raw_contracts.id - денят на източника отпада. Понеже id е INTEGER PRIMARY KEY, в прозорец, зареден хронологично, обикновено съвпада, но пълният път подрежда по source DESC, id DESC и коментарът обещава именно това.

Обобщение

Дневният път е направен както трябва и това беше същественото. Блокерът е един и е операционен: колоните не стигат до базата, която обслужва сайта, и кронът пада на първото завъртане. Разминаването с нулевите стойности е тясно и днес не хапе, но е на един ред разстояние от поправка.

…/full on zero-value contracts

Two follow-ups from PR midt-bg#308 review (todorkolev):

- Blocker: refresh-slice.sql writes contract_number_raw/link_method into served
  `amendments`, but nothing added those columns to the deployed DB, so the first
  cron after release would crash. Add an idempotent deploy.yml step (probe
  pragma_table_info, ALTER only the missing columns, malformed response fatal)
  before the Worker deploys — mirroring the 0002/midt-bg#307 pattern. The base schema is
  created out-of-band, so `d1 migrations apply` can't be used.
- Discrepancy: the slice resolver asked "is there a matchable candidate" (which
  requires signing_value > 0), so an annex pointing BY NUMBER to a zero-value
  contract was treated as namespace-mismatched and could be value-linked to a
  neighbour — diverging from the full path. Ask a value-agnostic
  all_contract_numbers CTE instead, so a by-number match always links by number.
  Regression test added.
- Nit: candidate dedup now orders by source-day then id (source DESC, id DESC),
  matching normalize-raw and the comment.

Addresses PR midt-bg#308 review (todorkolev): deploy blocker + zero-value discrepancy + ordering nit.
@cefothe

cefothe commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

@todorkolev Благодаря за прегледа ред по ред. И трите неща са адресирани в 09e40fe; CI е зелено.

Блокер: колоните вече стигат до базата

Добавих идемпотентна стъпка в deploy.yml преди деплоя на Worker-а, по образеца на 0002 / #307: сонда с pragma_table_info, ALTER само на липсващите колони (двете независимо, тъй че прекъснат деплой между тях се възобновява чисто), а неясен/малформиран отговор е фатален. Голото ALTER не е идемпотентно — затова сондата, а не --file.

Потвърдено на живия sigma-dev (read-only):

has_raw 0 | has_method 0 | n 31597

— точно както каза: колоните ги няма, значи първият крон след деплой на този клон би паднал без стъпката. С нея деплоят ги добавя преди Worker-ът да тръгне. (Ако #307 влезе пръв, най-чисто е двете колони да се сложат в неговата стъпка вместо да остане тази — отбелязано в кода и в plan §4.)

Разминаване: анекс върху нулев договор

Права си — grp питаше „има ли кандидат" (а contract_candidates иска signing_value > 0), не „има ли договор". Добавих value-агностична CTE all_contract_numbers (всеки номер на договор по процедурата, от прозоречния raw_contracts и сервираната contracts, без филтър по стойност) и grp пита нея. Сега анекс, който сочи договора си по номер, се хваща по номер — дори договорът да е с нулева стойност — точно както обещава коментарът и както прави пълният път.

Възпроизведох обхвата на живия корпус:

zero-value contracts:            204
EOP анекси, сочещи ги по номер:    25
от тях биха намерили съсед днес:    0

— съвпада с твоите 201/25/0: не хапе в производството днес, но разминаването между двата пътя е затворено. Добавих регресионен тест (Д-1 при 0 и Д-2 при 5000; анекс с номер Д-1 и value_before 5000 → остава на Д-1).

Дребно

Дедупът на кандидатите вече подрежда ORDER BY src_rank, cand_source DESC, ord DESC — денят на източника е обратно вътре, както обещава коментарът и както прави normalize-raw.


Тестове: db 411 (нов zero-value тест), etl 20, зелени. CI зелено на 09e40fe.

@todorkolev

Copy link
Copy Markdown
Collaborator

Прегледах на 09e40fe. Готово от моя страна.

Блокерът е затворен - проверено, не прието по описание

Стъпката е по образеца на #307: сондира двете колони поотделно, ALTER-ва само липсващите, неясен отговор е фатален, и стои преди деплоя на Worker-а. Разделянето на двата ALTER-а значи, че и деплой, паднал между тях, се възстановява чисто.

Пуснах истинския сценарий: база от 0000-0003 без 0008, после двата ALTER-а, които стъпката издава, после refresh-slice.sql:

колони преди: 0
колони след:  2
refresh-slice: exit 0     ← преди беше „Parse error near line 1737"

Разминаването с нулевите стойности е затворено

all_contract_numbers е сложена и grp пита нея. Проверих и по-трудния случай, който вашият тест не покрива - целевият договор съществува само в сервирания корпус, не в прозореца:

Прозорец 1: Д-1 (стойност 0) и Д-2 (стойност 5000) се сервират.
Прозорец 2: пристига САМО анексът, номериран Д-1, value_before 5000.
→ contract_number Д-1, link_method NULL   |  Д-1 annex_count 1, current_value 9000  |  Д-2 недокоснат

Тоест обединението със сервираната contracts носи тежест и в двете посоки, не само за стойностното съпоставяне.

Оправили сте и дребното с деня на източника - cand_source DESC вече е в подредбата, тъй че коментарът и SQL-ът казват едно и също.

db 411/411.

Остава само редът на мърдж. 0008 е верният номер, ако #307 влезе пръв. И двата PR-а обаче вмъкват нова стъпка непосредствено преди „Deploy explorer" в deploy.yml, тъй че вторият ще има дребен конфликт там - очакван, не дефект.

@nikimilenkov nikimilenkov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Прегледах петте нови commit-а до 09e40fe със същата дисциплина: повторих цялата си батерия от репродукции през новите скриптове в реалния им ред (resolve-amendment-contracts.sqlderive-amendments.sqlpromote-amendments.sql), плюс пакетите. Всичко от прегледа ми е адресирано; вдигам „връщане за промени" — от моя страна това е одобрение (съветодателно; бележката за реда на сливане с #307 остава на дневен ред, виж края).

Повторени репродукции — всички вече дават верния резултат

Сценарий (моят оригинален вход) Преди На 09e40fe
ВИСОКА 2а — договор в 3 кумулативни bucket-а отказ (n_match = 3) свързан (дедупът брои договори)
ВИСОКА 2б — анексът съвпада само със заместен staging ред грешна връзка по остаряла стойност честно несвързан (печели последният bucket)
СРЕДНА 2 — административен анекс по средата на верига annex_count = 2 от 3 3 от 3 (безстойностните членове наследяват)
СРЕДНА 3а — колизия по natural_key изтриваше истински анекс само натрапникът оцелява двата реда се сервират (am:…:7070:N1 и am:…:Д-9:N1 — ключът пази суровия номер)
СРЕДНА 5 — анекс с чужд contractor_eik закачаше се за чужд договор отказан (link_method NULL)
Блокерът на @todorkolev — възкресяване на близнак annex_count = 2 1 (резолверът тече преди дедупа — и над диагностиките)
Точка 2 на @todorkolev — двусмислен във верига закачен, current_value 600 несвързан, current_value 1100

Плюс: packages/db 410/410 (без познатия env артефакт), дървото чисто, 9348/9349 поправено в новия скрипт, тестове за идемпотентност и за OCDS-комбинацията са налице, gate тестът доказва, че derive сам по себе си не резолвира.

Двете ревизии на мои находки — приемам ги, с данните е трудно да се спори

  • СРЕДНА 1 (отказ на цялата група при несъгласие): доказателството от живата база е убедително — единствената несъгласна група в целия корпус е lot-base случай, в който двете direct попадения са верни, т.е. пълният отказ би махнал 2 верни връзки срещу 0 предотвратени грешни. Ревизията в e4ad2b4 (отказ само на разпространението; direct попаденията остават; n_match ≥ 2 никога не се свързва) взима моя механизъм там, където плаща, и го оставя там, където вреди. Остатъчният ми синтетичен сценарий (натрупана стойност, съвпадаща случайно с чужд договор в несъгласна група) остава теоретично възможен, но измерено празен днес — честен компромис, записан с тест.
  • СРЕДНА 2 (незатварящата се сметка): класът value_before IS NULL/≤0 се оказва празен на живия корпус — разликата е закръгляне на оценките по tier-ове, не скрит клас. Кодът за безстойностното наследяване все пак остава (и моята репродукция потвърждава, че работи) — защитно и правилно.

Отделно признание

Двата нови улова на @todorkolev върху свежия код — колоните, които не стигат до сервираната база (същият клас като при #307), и разминаването при нулеви договори — бяха точно каквото прегледът-след-поправките трябва да хваща; проверих деплой стъпката и all_contract_numbers в диff-а и потвърждавам механичните му проверки. Изнасянето на резолвера в самостоятелен скрипт + гейт тестът е по-стабилно решение и от двете ни първоначални предложения.

Оставащо (координация, не дефект)

Редът на сливане с #307: 0008 е верният номер, ако #307 влезе пръв, а двете деплой стъпки ще се допрат в deploy.yml (очакван, дребен конфликт). Ако #308 изпревари — преномериране обратно и разширяване на неговата стъпка, както вече е отбелязано в кода и плана.

Образцов цикъл: три прегледа, пет commit-а, всяка поправка с тест, а двете спорни точки решени с измерване вместо с мнение. Благодаря на всички!

Разрешени конфликти след midt-bg#307 (двойно броене в анексите) и midt-bg#310.

deploy.yml — двете колони на midt-bg#306 се сливат в стъпката на midt-bg#307, вместо да се
държи втора стъпка. Точно каквото искаше бележката в самия midt-bg#308: „If midt-bg#307
merges first, fold these two columns into its provenance step instead of
keeping this one." Една сонда, един механизъм; втора ръчно написана стъпка е
още един шанс за грешката, която midt-bg#310 трябваше да оправи. Заявката вече пита
за пет колони, а ensure_column се вика пет пъти. Проверено срещу живата база:
{value_restated: 1, value_treatment: 1, value_suspect: 1, contract_number_raw: 0,
link_method: 0} — трите на midt-bg#307 ги има, двете на midt-bg#306 ще се добавят.

promote-amendments.sql и refresh-slice.sql — списъкът с колони в INSERT-а
събира и двете страни: contract_number_raw/link_method от midt-bg#306 и
value_restated/value_treatment/value_suspect от midt-bg#305. Редът отговаря на
SELECT-а, който git вече беше слял правилно.

Тестове — всяко от двете подавания добавяше своята миграция към веригата, с
която строи схемата. Сега всеки файл, който сервира amendments, прилага и
0006/0007, и 0008; иначе скриптовете падат на липсваща колона от другата
страна. Това важи в двете посоки: файловете на midt-bg#305 получиха 0008, а тези на
midt-bg#306 получиха 0006/0007.

Пълният суит е зелен: db 438, web 482, ingest 84, etl 20, shared 45, config 10.
Typecheck и prettier чисти.
@todorkolev

Copy link
Copy Markdown
Collaborator

Слях main в клона и разреших конфликтите след #307 и #310 - подадено като c25a8c2d, без пренаписване на историята ти.

deploy.yml: двете колони на #306 се сливат в стъпката на #307, вместо да остане втора стъпка - точно както казваше бележката в самия PR („If #307 merges first, fold these two columns into its provenance step"). Заявката вече пита за пет колони, ensure_column се вика пет пъти. Проверено срещу живата база:

{ value_restated: 1, value_treatment: 1, value_suspect: 1, contract_number_raw: 0, link_method: 0 }

тоест трите на #307 ги има, а твоите две ще се добавят на следващия деплой. Пуснах и сондата върху точно този отговор: 0/0/0 за наличните, 1/1 за липсващите - никога 2. (Контекст: първоначалната стъпка на #307 имаше разминаване в псевдонимите и не можеше да мине изобщо; оправено в #310.)

promote-amendments.sql / refresh-slice.sql: списъкът с колони в INSERT събира и двете страни - contract_number_raw/link_method плюс value_restated/value_treatment/value_suspect, в реда на SELECT-а.

Тестове: всяко от двете подавания добавяше само своята миграция към веригата, с която строи схемата, тъй че след сливането всеки от двата набора падаше на липсваща колона от другия. Сега всеки файл, който сервира amendments, прилага и 0006/0007, и 0008.

Пълният суит е зелен: db 438, web 482, ingest 84, etl 20, shared 45, config 10. Typecheck и prettier чисти. CI на PR-а също.

Мърджвам.

@todorkolev todorkolev 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.

Одобрявам на c25a8c2d. Двете находки от последния кръг са затворени и проверени срещу истинските скриптове: деплой стъпката вече добавя колоните (пуснах я върху база без тях - refresh-slice минава), а разминаването с нулевите стойности е закрито и в по-трудния случай, където целевият договор е само в сервирания корпус.

@todorkolev
todorkolev merged commit b17e70c into midt-bg:main Aug 14, 2026
5 checks passed
todorkolev added a commit to ydimitrof/sigma that referenced this pull request Aug 14, 2026
…ара за 0010

Преномериране: midt-bg#307 взе 0006/0007, midt-bg#308 взе 0008, тъй че тези две се местят на
0009 и 0010. Обновени са всички препратки - тестове, deploy.yml,
related-persons-data.yml, scripts/cacbg/load.mjs, docs/deploy.md.

Коментарът над прилагането на 0010 в deploy.yml беше останал от предишния
замисъл и твърдеше три неверни неща: че 0003 обявявала CHECK-овете (тя е
върната непокътната и не обявява нищо), че се слагало control_hash NOT NULL
(остава NULL-ируема нарочно - регистърът я пропуска за част от декларациите,
а индексът по естествен ключ ги сгъва с COALESCE) и че миграцията е
„rebuild-based" (вече е тригерна; заглавието ѝ обяснява защо пресъздаването е
опасно - оголва външните ключове и обира доказателствените печати).

Последното беше най-неприятно: обещаваше на следващия четец точно операцията,
която самата миграция отхвърля, а „0003 declares them now" щеше да го прати
обратно да добавя CHECK-ове в приложена миграция.

docs/deploy.md вече изброява и стъпките, които сондират таблицата и добавят
само липсващите колони, за да не изглежда, че --file е единственият механизъм.

db 424 зелени, typecheck и prettier чисти, scripts/tr 134/134.
todorkolev added a commit to ydimitrof/sigma that referenced this pull request Aug 14, 2026
Преномерирането смени пътищата, но остави имената migration6Path/migration7Path,
което е подвеждащо и се сблъсква с едноименните променливи, които midt-bg#307 и midt-bg#308
въведоха за 0006/0007/0008.
todorkolev added a commit to ydimitrof/sigma that referenced this pull request Aug 14, 2026
Разрешени конфликти след midt-bg#307, midt-bg#310 и midt-bg#308.

Всички са от един и същ вид: и двете страни добавяха своя миграция към
веригата, с която тестът строи схемата, и понякога под едно и също име на
променлива. Сега всеки тест, който сервира amendments или пуска refresh-slice/
normalize-raw, прилага цялата верига - 0006/0007 (стойност на анекса),
0008 (провенанс) и 0009 (доказателствен печат). Иначе всеки набор пада на
липсваща таблица или колона от другия.

Пълният суит е зелен: db 474, web 493, ingest 84, etl 20, shared 45, config 10.
Typecheck и prettier чисти.
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.

3 participants