fix(etl): canonical authority name by frequency-mode + curated override, not MIN() (#194) - #215
fix(etl): canonical authority name by frequency-mode + curated override, not MIN() (#194)#215cefothe wants to merge 1 commit into
Conversation
…de, not MIN() (midt-bg#194) Authorities dedupe on ЕИК, but the display name was chosen with MIN(authority_name), which returns the alphabetically-first string. Under a SHARED ЕИК that lets a second-order spending unit outrank the parent body — e.g. МОН (000695114) surfaced as the school „БСУ Д-р Петър Берон" because „Б" sorts before „М" in Cyrillic, while the ministry name дominates by row count. - normalize-raw.sql / refresh-slice.sql: pick the name by FREQUENCY MODE (most-recorded variant), tie-broken on shorter length then lexically → deterministic. - seed-authority-names.sql: curated authority_name_overrides table (mirrors state_owned_eik) pinning authoritative names for shared-ЕИК cases; wired into all three import.mjs pipeline paths before normalize/refresh. Stand-in until the parked Търговски регистър pipeline lands. - refresh path also corrects existing rows and marks them touched so rollup + FTS refresh incrementally. - Label-only: id (`auth:'||ЕИК`) and profile URLs stay stable; also improves search (midt-bg#25). - docs/etl.md + ADR-0007 record the rule.
14e5fe9 to
2d92ac8
Compare
Ревю —
|
| Проверка | Резултат |
|---|---|
Честотната мода бие MIN() — МОН (620 реда) печели пред „БСУ…" (Б<М) |
✅ |
Празни низове се филтрират от модата (777 → „Реално име", не '') |
✅ подобрение спрямо стария MIN() |
ЕИК само с NULL имена се пропуска без срив (INSERT OR IGNORE) |
✅ |
Override пинва името; IS NOT guard-ът в refresh обновява само различаващия се ред |
✅ |
| Touched-маркирането обхваща само коригираните от override редове | ✅ |
Проследих разпространението и по двата пътя: full (normalize мода+override → precompute пре-строи authority_totals/flow_pairs/search_index от authorities.name) и slice (refresh прилага override върху съществуващи редове → маркира touched → authority_totals за touched → search_index изцяло от totals; flow_pairs се пре-строи изцяло). Всеки надпис надолу стига до новото име — touched-маркирането на ред 69 е необходимата връзка и е коректно ограничено/самоограничаващо се.
Силни страни
- Детерминистичен избор чрез пълна наредба (
COUNT(*) DESC, LENGTH ASC, name ASC) — стабилен между rebuild-и, идентично правило в двата пътя. - Самодостатъчен SQL:
CREATE TABLE IF NOT EXISTS authority_name_overridesи в двата скрипта — точно това пазиrefresh-slice.test.tsзелен, ако seed-ът е пропуснат. - Коректен рефактор на
srcфилтъра (source LIKE …вкаран във всеки UNION клон — семантично еквивалентен на стария post-unionWHERE). - Отлично ADR-0007 — документира отхвърлените алтернативи (регистров lookup недостъпен; под-профили искат ключ отвъд ЕИК; case-fold tiebreak ненадежден заради ASCII-only
UPPER), и коректно отбелязва, че промяната е само на надписа, така че integrity gate-ът минава без промяна.
Незадължителни бележки
- Няма отделен регресионен тест за fix(etl): каноничното име на възложителя (MIN на името) подвежда при споделен ЕИК — поръчки на МОН излизат под училище #194. Съществуващият тест пази изпълнението на batch-овете, но не пинва поведението мода>
MIN. Малък тест със споделен ЕИК, който проверява печелившото име, би заключил поправката за в бъдеще. - Корелирана подзаявка в mode
INSERT(COALESCE((SELECT … rn=1), MIN(…))) прави по едно търсене на всяка група;LEFT JOIN name_mode … AND rn=1е един pass. Работи приемливо на реалния корпус (цитирани 620/444) — бъдещо опростяване, не дефект. MIN()fallback теоретично може да върне''при ЕИК само с празни низове — козметично, невероятно, не по-зле от досегашното.
Заключение: Солидна, добре документирана поправка само на надписа с коректно разпространение надолу. Препоръчвам добавяне на един насочен тест за случая със споделен ЕИК, преди да стане критична зависимост.
ydimitrof
left a comment
There was a problem hiding this comment.
Преглед на PR #194 — канонично име на възложителя (мода + курирана override, вместо MIN())
Обобщение
Много добре обоснована и добре документирана промяна. Проблемът е реален (MIN() над кирилски текст избира азбучно най-ранния низ и при споделен ЕИК име на второстепенен разпоредител измества родителския орган), а решението — честотна мода с детерминистичен тайбрек (COUNT(*) DESC, LENGTH ASC, name ASC) плюс курирана override таблица — е коректно и прагматично. ADR-0007, docs/etl.md и seed скриптът дават отличен одит на решението и на отхвърлените алтернативи.
Сигурност (Фаза 0)
- Няма hardcoded secrets, нови зависимости или промени по URL. Единствената „твърдо кодирана“ стойност е ЕИК
000695114(МОН) в seed таблицата — публична данна, не тайна. Няма SQL injection повърхност (статичен SQL, без вход от потребител). CLEAN — не блокира.
Коректност
- SQL логиката за модата е коректна и идентична между
normalize-raw.sql(стъпка 1) иrefresh-slice.sql.COALESCE(...MIN())fallback покрива ЕИК само с празни/NULL имена.id(auth:<ЕИК>) и профилният URL остават стабилни — промяната е наистина само на надписа. - Виж инлайн бележки за: (1) INNER JOIN към
authority_totalsпри маркирането на „touched“ в refresh; (2) фрагментиране на гласовете при варианти, различаващи се само по регистър/интервали; (3) дублиране на mode-логиката иCREATE TABLEв три файла (риск от разминаване).
Тестове
- В PR-а липсват автоматизирани тестове, които да фиксират новото поведение (мода печели пред
MIN, override пинва името, стабилност наid/URL, детерминизъм между rebuild-и). ADR се позовава на integrity gate-а, но той проверява редове/EUR, не избора на име. Препоръка: fixture с споделен ЕИК + assert, че каноничното име е модата/override-а. Това е основната причина да не давам APPROVE спрямо изискването за покритие.
Изход
COMMENT — промяната е издържана и готова по същество; моля адресирайте бележката за INNER JOIN (реален, макар граничен пропуск за FTS refresh) и добавете поне един целеви тест.
| SELECT a.id | ||
| FROM authorities a | ||
| JOIN authority_totals at ON at.authority_id = a.id | ||
| WHERE a.name IS NOT at.name; |
There was a problem hiding this comment.
Възможен пропуск при маркиране на „touched“. INNER JOIN authority_totals at означава, че възложител, който няма ред в authority_totals (напр. без сумирани договори/обявления), няма да бъде маркиран като touched дори когато override-ът току-що е сменил името му. Коментарът по-горе обещава да се пресметнат наново „rollup + FTS“, но search_index/FTS редът за такъв възложител няма да се опресни. Ако инвариантът е, че всеки възложител в authorities винаги има ред в authority_totals, това е безопасно — но си струва да се потвърди, или да се използва LEFT JOIN / отделно маркиране на override-натите редове директно (те вече са известни от предходния UPDATE).
| SELECT authority_eik, authority_name, | ||
| ROW_NUMBER() OVER ( | ||
| PARTITION BY authority_eik | ||
| ORDER BY COUNT(*) DESC, LENGTH(authority_name) ASC, authority_name ASC |
There was a problem hiding this comment.
Фрагментиране на гласовете при модата. GROUP BY authority_eik, authority_name брои всеки различаващ се низ като отделен вариант, затова именувания, различаващи се само по регистър или интервали (напр. МОН срещу мон, или trailing spaces), разделят гласа и може да занижат истинската доминираща форма. За текущия казус (#194) курираната override таблица компенсира, но си струва бележка/follow-up за нормализация на белите полета/регистъра преди преброяването, за да е модата по-устойчива в общия случай.
| -- rows, so any authority whose name it changes is marked touched below to refresh its rollup + FTS. | ||
| -- Table created here too so refresh is self-sufficient if the seed was skipped. No-op when empty. (#194) | ||
| CREATE TABLE IF NOT EXISTS authority_name_overrides ( | ||
| eik TEXT PRIMARY KEY, |
There was a problem hiding this comment.
Дублиране на дефиницията и на mode-логиката. CREATE TABLE IF NOT EXISTS authority_name_overrides и целият mode SQL сега съществуват в три файла (seed-authority-names.sql, normalize-raw.sql, тук). Дублирането е съзнателно (self-sufficiency), но носи риск от разминаване — ако тайбрекът/схемата се промени на едно място. Ако проектът има начин за споделен SQL fragment/include, обмислете го; иначе поне добавете кръстосана бележка, че трите копия трябва да се редактират заедно (частично покрито от коментарите).
lyubomir-bozhinov
left a comment
There was a problem hiding this comment.
За #194 сравних този PR със #203 (двата се застъпват на същите SQL блокове — само единият може да влезе). Този е по-чистият избор и е коректен: честотен режим с детерминиран tiebreak (ROW_NUMBER() OVER (PARTITION BY authority_eik ORDER BY COUNT(*) DESC, LENGTH(authority_name) ASC, authority_name ASC)), закърпени са и normalize-raw.sql, и refresh-slice.sql — daily refresh пътят носеше същия MIN() bug, иначе имената пак се чупят на следващия cron — плюс ADR-0007 и authority_name_overrides таблица.
Дребно (не блокира): финалният COALESCE(..., MIN(s.authority_name)) fallback е практически недостижим при непразно име (name_mode винаги дава rn = 1 ред) — ОК като защита.
Издържано — това е линията за #194. (Забележка: ЕИК checksum-ът за #195 от #203 е верен и заслужава да се пренесе в самостоятелен PR — виж коментара ми там.)
|
@cefothe - затварям този PR, и започвам с това, което е наш пропуск: по #194 стояха отворени три PR-а едновременно - твоят #215, #203 на @StanislavBG и #251 от мен. Не сме ги свързали навреме и си писал срещу проблем, по който вече е имало работа. Съжалявам за загубеното време - това е наша организационна грешка, не твоя. Какво влиза вместо него. #251 (канонична идентичност - име и вид по мода) е част от поредица #251 → #252 → #253, която пипа същите места в Два въпроса по същество, където се разминаваме - казвам ги открито, защото на единия може да си прав: 1. Подредбата при равен брой. Ти взимаш по-краткия надпис ( 2. ADR-0007 отхвърля точно подхода, който ползваме - но на основание, което е заобиколено. В документа пишеш, че тайбрек „предпочети смесен регистър" е ненадежден, защото За таблицата с презаписи. Единственият запис в Отделно: #251 добавя Какво остава от работата ти. ADR-ът трябва да се напише за решението, което наистина пускаме, и ще го напиша аз - но описанието на случая с ЕИК Ако предпочиташ сам да напишеш ADR-а за окончателния вид - кажи и е твой. |
Closes #194.
Проблем
Възложителите се дедупликират по ЕИК (коректно), но изписваното име се избираше с
MIN(authority_name)— азбучно най-ранния низ. При споделен ЕИК това дава грешен надпис: под ЕИК000695114(МОН) „БСУ „Д-р Петър Берон"" изпреварва „Министерство…" (в кирилицата „Б“ е преди „М“), затова профилът на МОН (285 договора, ~296 млн. EUR) излизаше като чуждестранно училище — при положение, че министерското име доминира по брой редове (620 договорни / 444 обявления).Решение
normalize-raw.sql(стъпка 1) +refresh-slice.sql: каноничното име е честотната мода (най-често записаният вариант), а неMIN(). Тайбрек: по-кратко име (концизната форма пред композита „дете – родител“), после лексикографски → детерминистично.seed-authority-names.sql: курирана таблицаauthority_name_overrides(по модел наstate_owned_eik), която пинва авторитетното име за споделен-ЕИК случаите; заредена и в трите пътя наimport.mjsпреди normalize/refresh. Прагматичен заместител, докато Търговският регистър не влезе.id(auth:'||ЕИК) и URL на профила остават стабилни; подобрява и търсенето (Качество на търсенето: възложител с дълъг списък имена изпреварва точните съвпадения #25).docs/etl.md+ ADR-0007.Компромиси / обхват
raw_tr_companiesне е зареден; училището в чужбина легитимно няма собствен ЕИК.MAX(authority_type)остава произволен-но-стабилен (храни самоtype_group) — извън обхвата.Проверка
check-docs.mjsминава;load-eop+check-docsunit тестове 16/16;node --checkи Prettier чисти.authority_totals/flow_pairsсе ключат поauthority_id, integrity gate не се променя.