Skip to content

feat(risk): обобщен рисков индикатор за възложители и изпълнители от базовите флагове - #244

Open
nikimilenkov wants to merge 19 commits into
midt-bg:mainfrom
nikimilenkov:feat/subject-risk-composite
Open

nikimilenkov wants to merge 19 commits into
midt-bg:mainfrom
nikimilenkov:feat/subject-risk-composite

Conversation

@nikimilenkov

@nikimilenkov nikimilenkov commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Какво прави този PR

Въвежда композитен рисков индикатор на ниво субект (възложител/изпълнител) — обобщава вече съществуващите базови флагове на ниво договор до профила на субекта, в CRI-стил. Индикаторът стои на профилните страници на компании и възложители като неутрален, обясним и проследим сигнал — не като обвинение.

Затваря #229.


Защо (контекст на проблема)

Досега базовите рискови признаци (една оферта, високо оскъпяване) съществуваха само на ниво отделен договор (riskLogic.ts). Нямаше начин потребителят да види „колко често“ даден субект попада в тези признаци — а именно този агрегиран поглед е същината на CRI методологията. Изчисляването му на всяка заявка би било скъпо (D1 таксува по сканирани редове), затова обобщението се пресмята предварително и се чете наготово.

Понеже платформата е публична и с висока обществена видимост, всяка цифра трябва да е проследима до договорите зад нея и формулирана непристрастно — рискът от неоснователно обвинение е първостепенно съображение.


Решение — по слоеве

Избран е подход „материализирани флагове“ (одобрен в ADR-0043): каноничните булеви колони се смятат веднъж и служат за единствен източник — и за агрегата, и за страницата на договора. Така прагът за „риск“ е дефиниран на едно място и не може да се разсинхронизира.

1. Данни / ETL

  • packages/db/migrations/0014_subject_risk_columns.sql — нова номерирана миграция (ALTER TABLE ADD COLUMN): is_single_offer / is_high_markup на contracts; по 6 рискови колони на company_totals и authority_totals (single_offer_k/n/value_share, high_markup_k/n/value_share). Колоните не стоят в 0000_init.sql: обслужваната D1 е персистентна, а wrangler d1 migrations apply е filename-tracked — редакция на вече приложения 0000_init стига до work базата (и зелено локално CI), но никога до прод (капанът feat: индекс на качеството на договорите (ETL оценка 0..1 + страница) #188/feat(anomalies): add automated price-anomaly screen #239). SQLite няма ADD COLUMN IF NOT EXISTS, тъй че колоните живеят само тук.
  • scripts/precompute.sql — материализира флаговете за всички договори, после обобщава по субект:
    • is_single_offer = (bids_received = 1); is_high_markup = (current−signing)/signing > 0.2, само когато стойността е достоверна (value_flag = 'ok').
    • Всеки компонент има собствена приемлива съвкупност за знаменател: за single-offer знаменателят е броят договори с ≥1 оферта (изключва провалени процедури с 0 оферти; съвпада с competition.ts); за high-markup — броят договори с оценима стойност.
    • Дяловете по стойност претеглят само положителен amount_eur, за да не може ред с отрицателна стойност (value_low) да изкара дял извън [0,1].
  • scripts/refresh-slice.sql — същата логика, ограничена до засегнатите договори при дневно опресняване; тества се, че пълното и частичното пресмятане дават идентични стойности.

Деплой (runbook) — поредността е задължителна

refresh-slice.sql реферира новите колони, а apps/etl RefreshWorkflow не пуска migrations apply самостоятелно — разчита на деплой веригата:

  1. wrangler d1 migrations apply прилага 0014_subject_risk_columns.sql към обслужваната D1;
  2. пълен precompute.sql върху обслужваната D1 — безусловният UPDATE contracts SET is_single_offer = …, is_high_markup = … (без WHERE) попълва флаговете на всички договори, не само на тъчнатите от слайса;
  3. чак тогава инкременталният дневен refresh.

Ако (1) не е приложена преди/заедно със следващия деплой, refresh в междинния прозорец гърми с „no such column" — очакваната цена на номерирания подход.

Номера на миграцията и на ADR-а

0014 е най-ниският свободен номер спрямо main (…0010) и спрямо всеки отворен PR — 0005 (#239), 0006 (#209), 0011 (#169/#170/#171/#172/#193/#324/#79), 0012 (#188/#324/#79), 0013 (#324) — тъй че никой друг клон не претендира за същия префикс. По същата причина ADR-ът е ADR-0043 (0036–0042 са заети от #324/#79, 0039–0040 и от #183).

2. Прочит (read layer)

  • packages/api-contract/src/index.tsSubjectRiskAggregate + поле risk на CompanyDetail/AuthorityDetail; isSingleOffer/isHighMarkup на ContractDetail.
  • packages/db/src/queries/details.ts — мапва суровите колони към DTO агрегата; за физически лица (ЕТ) агрегатът се изключва още на ниво данни (risk: null), за да не напускат сурови данни за конкретен човек.
  • apps/web/app/lib/subjectRisk.ts — извежда композита/категорията/докладваемостта; композитът е средно от само докладваемите компоненти (n ≥ 5).
  • apps/web/app/lib/riskLogic.ts — рефакториран да чете материализираните флагове, вместо да пресмята праговете наново (без дублиране на прага).

3. UI

  • apps/web/app/components/SubjectRiskIndicator.tsx — неутрален блок в Callout: категория без обвинителни думи, разбивка „K от N договора · X% от стойността“, и линк „виж договорите“ към точно тези договори.
  • apps/web/app/routes/company.tsx / authority.tsx — вграждане на индикатора; показва се заедно с индикаторите за конкуренция (feat(web): direct-award share and EU-benchmarked competition indicators #153) и eu-benchmark панела (feat(web): eu-benchmark competition panel on the authority page #242).
  • apps/web/app/routes/methodology.tsx — методологична секция + преформулирано обещание за неутралност. ⚠️ Изолирано в отделен commit: изисква изричен sign-off от maintainer преди merge.

Мерки срещу неоснователно обвинение (M1–M9)

Целият индикатор е проектиран около таблицата с мерки от плана:

  • M2 — категориите описват индикаторите („Малко/Единични/Множество индикатори“), никога „критичен/корупция/нередност“.
  • M3 — компонент е докладваем само при знаменател n ≥ 5; иначе не се показва категория.
  • M4 — винаги „34 от 120 договора“, никога гол процент.
  • M5 — съмнителните по стойност редове не могат да бъдат is_high_markup = 1.
  • M6 — категорията се извежда от броя договори (претеглянето по стойност е само контекст).
  • M7проследимост: всеки компонент линква към договорите зад него.
  • M8 — категория + дисклеймър + брой + линк са един атомарен блок; изключен от <meta>/OG, за да не стане търсеща извадка.
  • M9 — праговете са сървърни константи (никога query-параметри); блокът се скрива изцяло за профили на физически лица.

Коректност и сигурност

  • Интегритетна проверка (scripts/integrity-checks.mjs, checkSubjectRiskBounds) — гарантира дяловете ∈ [0,1], k ≤ n, и че is_high_markup е зададен само при value_flag = 'ok'. Проверката се пропуска структурирано, ако колоните още липсват. При пребазирането е приведена към async API-то на гейта (rows/scalar/tableExists вече връщат Promise), заедно с новия columnExists — иначе num(Promise) дава NaN и проверката би падала на всяко пускане.
  • Кеш — новият филтър markup=high е регистриран във всички кеш-класификатори, за да не може филтрирана справка/CSV да се сервира от нефилтрирания кеш обект (клас contracts.csv не филтрира по bids — разминаване между показано и експортирано #138/Web cache poisoning: /contracts edge-cache key omits a response-affecting query parameter #56).
  • SQL е изцяло статичен, върху доверени данни от изграждащия етап — без интерполация на непроверен вход.

Какво беше надградено

По време на разработката бяха подсилени няколко направления:

  • Дяловете по стойност вече не могат да излязат извън [0,1] — претеглят само положителен amount_eur; регресионен тест пази ръба срещу отрицателни value_low редове.
  • is_high_markup е ограничен до value_flag = 'ok' (точното допълнение на скритото множество), така че агрегатът и страницата на договора броят едно и също множество.
  • Проследимост — всеки рисков компонент линква към точно своите договори чрез симетричния филтър markup=high (M7), а не към пълния списък.
  • Скриване за физически лица — става на ниво данни (getCompany), не само при рендер, така че суровите стойности не напускат сървъра; рендер-гардът остава като втори слой.
  • Единна споделена проверка isNaturalPersonSubject() — скриването не може да се разсинхронизира между маршрута и слоя с данни.

Оценени и умишлено оставени без промяна (документирани, не са дефекти): рязката граница при n = 5 (съзнателна консервативност при малка извадка, M3) и различните знаменатели на двата компонента (различни приемливи съвкупности по дизайн — промяната би върнала провалени процедури в знаменателя и би счупила съвпадението с competition.ts).


Тестове / верификация

  • Клонът е пребазиран върху актуалния main (конфликтите са разрешени; вж. бележката за интегритетната проверка по-горе).
  • pnpm typecheck → 0 грешки (8/8 пакета).
  • pnpm test → 7/7 задачи зелени; @sigma/db 53 файла / 514 теста.
  • pnpm check:docs и pnpm check:fake-d1 — чисти. Prettier чист.
  • Нови тестове: гранични случаи на флаговете, дивергенция брой-vs-стойност (вкл. адверсариален отрицателен value_low), пълно-vs-частично пресмятане (parity), категория/min-N, интегритетни граници, симетричен markup=high филтър.

Извън обхват (v2)

Непроцедурни флагове (#153), CPV-кохорти (#41/#210), времеви прозорци и нови доставчици (няма данни) — отложени за следваща версия, както е записано в ADR-0043.

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

Прегледах стриктно на връх 526a11f (draft — добро време да се хване това). Основата е силна, но има един блокер преди ready-for-merge.

Блокер (Critical — проверих го емпирично)

packages/db/migrations/0000_init.sql — 14-те нови колони (is_single_offer/is_high_markup на contracts; single_offer_*/high_markup_* ×6 на company_totals и authority_totals) са добавени само в-схема в 0000_init (in-line в CREATE TABLE, без ALTER, и няма нов migration файл). На прод 0000_init е вече приложен → wrangler d1 migrations apply го прескача безусловно, а CREATE TABLE IF NOT EXISTS company_totals (…) в precompute.sql е no-op върху съществуваща таблица → колоните не се създават никога. Тогава precompute.sql's UPDATE … SET single_offer_k = … гърми с no such column: single_offer_k и ship-domain спира. Integrity gate-ът self-skip-ва при липсващи колони → няма ранно предупреждение.

Fix: изнесете 14-те колони в нов номериран migration чрез ALTER TABLE ADD COLUMN и ги извадете от 0000_init (да останат и в двете би гръмнало fresh DB — SQLite няма ADD COLUMN IF NOT EXISTS). Номерът да се съгласува с 0002-претендентите (#226/#193/#172) — вземете следващия свободен (0006+). Това е същият applied-migration капан от #188/#239, но тук е твърд prod-breaker (не self-healing).

Каквото проверих, че държи (силни страни)

  • Natural-person suppression държи на ВСИЧКИ 4 повърхности: data слой (details.ts:263 isNaturalPerson ? null : …), компонент (втори guard), CSV (risk никога не е в export-а) и .data twin — suppression-ът е на data слоя, не path-middleware, тъй че /companies/eik:X.data връща risk:null. isNaturalPersonSubject покрива ЕТ/ЕДНОЛИЧЕН ТЪРГОВЕЦ/SOLE TRADER/INDIVIDUAL + name-prefix (по-широко от плиткия ЕТ-only филтър). → уговорката „за физически лица не се показва" е code-backed.
  • Value basis чист: amount_eur IS NOT NULL AND > 0 в двата value-share знаменателя, тестван с отрицателен value_low; is_high_markup само на value_flag='ok', тъй че annex_suspect не може да го надуе.
  • Precompute↔refresh-slice drift е guard-нат (byte-identical parity тест, non-vacuous). Cache-key чист (markup keyed). Тестовете са реални (real SQLite, adversarial fixtures, граница n=4 vs 5) — no cheater tests.

Дребни

  • SubjectRiskIndicator.tsx — „виж договорите" tap target е <44px на мобилен; band-ът (Множество индикатори) няма role="status"/ARIA — на тази defamation-чувствителна повърхност си струва семантика.
  • Документирайте value_flag='ok' ≡ NOT suspect еквивалентността (нов flag вариант би дрейфнал тихо).
  • getAuthority/toReportable (k ?? 0) fail-safe-ват към suppression (не към грешни данни) — latent hardening, не блокер.

Текст (UI — точност/стил)

  • Band-стълбицата „Малко → Единични → Множество → Много" не е монотонна — „Единични" звучи по-малко от „Малко"; преформулирайте за ясна градация.
  • „по методологията CRI на Government Transparency Institute" over-claim-ва — 2 индикатора са CRI-inspired, не пълен CRI; → „по подхода CRI".
  • „N от M договора" през plural() (21 → „договор", не „договора").
  • §3 променя публично обещание в methodology.tsx („не маркира фирми като рискови") — формулировката е добра, но промяната иска одобрение от maintainer.

Изисквам промяна (migration блокерът) преди ready; иначе отлична, добре тествана основа.

@nikimilenkov

Copy link
Copy Markdown
Contributor Author

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

Блокерът (миграцията) — приемам го. План:

Един въпрос преди да го напиша, за да не разсинхронизираме документацията: 0000_init.sql:1-8 още описва модела „fresh DB при всеки import" и казва инкрементални миграции да се връщат „само когато има deployed данни, които не можеш да дропнеш". От бележката ти (#188/#239) разбирам, че прод вече е точно в този режим. Да обновя ли и този коментар в 0000_init, че занапред минаваме на инкрементални миграции? Така следващият човек няма да падне в същия капан, редактирайки 0000_init.

Дребните — приемам всичките:

  • role="status" + по-голям tap target на „виж договорите".
  • Документирам еквивалентността value_flag='ok' ≡ NOT suspect.
  • Коментар за fail-safe посоката на toReportable/getAuthority.

Текст:

  • „по подхода CRI" — съгласен, сменям го.
  • „N от M договора" през plural() — оправям бройната форма (21 → „договор").
  • Стълбицата на категориите — прав си, „Единични" разваля градацията; ще предложа монотонен вариант и ще го прекарам през копирайтърския преглед (заедно с §3, което така или иначе съгласувам отделно).

Пускам фикса, щом потвърдиш посоката за миграциите.

@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

Планът е коректен и по четирите точки — потвърждавам посоката, пускай фикса.

На въпроса за коментара в 0000_init:3-5 — да, обнови го, но прецизно. Той конфлатира две бази:

  • „fresh DB при всеки import" важи за work DB — import pipeline-ът (load-eop → … → promote-amendments) я пресъздава.
  • Served D1 (прод) е персистентен. ship-domain пуска wrangler d1 migrations apply срещу нея — filename-tracked, тъй че приложените файлове (вкл. 0000_init) са замразени.

Точно това разграничение спира следващия човек: редакция на 0000_init стига до work DB (минава локално, зелено CI), но никога до прод. Формулирай коментара така, че занапред нови обекти да минават през нов номериран migration, а 0000_init да се пипа само при пълен rebuild.

Останалото (дребните + текста) — приемам плана. §3 и стълбицата на категориите — прекарай ги през копирайтърския преглед отделно, както предлагаш.

nikimilenkov added a commit to nikimilenkov/sigma that referenced this pull request Jul 16, 2026
… review)

The 14 subject-risk columns were added by editing 0000_init.sql. The served D1
is persistent and `wrangler d1 migrations apply` is filename-tracked, so an
edit to the already-applied 0000_init reaches the work DB (green local CI) but
never prod — precompute's UPDATE would then fail on missing columns (the
midt-bg#188/midt-bg#239 trap the reviewer reproduced).

Extract the columns into 0006_subject_risk_columns.sql (ALTER TABLE ADD
COLUMN) and remove them from 0000_init (SQLite has no ADD COLUMN IF NOT EXISTS,
so a fresh DB can't carry both). 0006 clears the 0002 claimants
(midt-bg#226/midt-bg#193/midt-bg#172) to avoid a duplicate-version at apply. The precompute
CREATE TABLE IF NOT EXISTS mirror stays a no-op on the existing tables. The
four risk tests now apply 0006 after 0000_init, mirroring the served-D1 chain.

Reword the 0000_init header to distinguish the rebuilt-every-import work DB
from the persistent served D1, so the next person adds objects via a new
migration instead of editing 0000_init. Also document that value_flag='ok' is
the exact complement of the hidden suspect set.
nikimilenkov added a commit to nikimilenkov/sigma that referenced this pull request Jul 16, 2026
- Enlarge the "виж договорите" touch target on mobile (WCAG 2.5.8 ≥24px) via
  row spacing + link padding, sized so the two components' links never overlap
  (a mis-tap would swap bids=1 vs markup=high).
- Pluralise "N от M договор(а)" via plural() so 21 renders "договор", not
  "договора".
- Reword the methodology glossary: "подхода CRI", not "методологията CRI" —
  two indicators are CRI-inspired, not the full index.
- Note that toReportable's null-k handling fails toward under-reporting, never
  fabrication.

The band stays plain text (not role="status"): on subject→subject SPA
navigation a polite live region would spuriously re-announce the changed label,
and the preceding disclaimer already frames it in reading order.
@nikimilenkov

Copy link
Copy Markdown
Contributor Author

Готово, пушнах фикса (f37c764). Резюме по точките:

Блокерът (миграцията). 14-те колони излязоха от 0000_init в нова номерирана миграция 0006_subject_risk_columns.sql (ALTER TABLE ADD COLUMN); CREATE TABLE IF NOT EXISTS копието в precompute.sql остава no-op при съществуваща таблица. Взех 0006, за да не се сблъска с 0002-претендентите (#226/#193/#172). Хедърът на 0000_init е преформулиран така, че да разграничава пресъздаваната work база от персистентната обслужвана D1 — нови обекти минават през нова номерирана миграция. Четирите risk теста вече прилагат 0006 след 0000_init.

⚠️ Runbook бележка: apps/etl RefreshWorkflow чете refresh-slice.sql (реферира новите колони) и не пуска migrations apply самостоятелно — разчита на деплой веригата. Значи 0006 трябва да е приложена към прод D1 преди/заедно със следващия деплой, иначе refresh в междинния прозорец ще гръмне с „no such column". Това е очакваната цена на номерирания подход.

С нисък приоритет: value_flag='ok' ≡ NOT suspect е документирано (+ предупреждение за drift при нов flag вариант); коментар за fail-safe посоката на toReportable; по-голям tap target за „виж договорите" на мобилен.

Едно уточнение по a11y: пуснах свеж code-review и той хвана, че role="status" на band-а би пре-обявявал при SPA навигация субект→субект (един и същ route, in-place reconcile). Затова го оставих като чист текст — предхождащата уговорка така или иначе го рамкира в reading order. Ако предпочиташ явна семантика (heading), казвай.

Текст. „по подхода CRI" (не „методологията"); „N от M договор(а)" през plural() (21 → „договор").

Документация. ADR-0002 и etl.md още описваха „свежа база при всеки импорт, без верига миграции" — приведох ги в съответствие с новия хедър.

Проверено: typecheck 0 · web 377/377 · db 219/220 (само предходният ship-domain ENOENT) · prettier чист.

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

Прегледах фикса 526a11f→f37c764. Блокерът е затворен коректно — одобрявам.

Миграцията (Critical) — решена точно:

  • 0006_subject_risk_columns.sql изнася и 14-те колони през ALTER TABLE ADD COLUMN (2 на contracts + по 6 на company_totals/authority_totals). ✓
  • От 0000_init.sql са премахнати изцяло (grep не намира нито single_offer, нито high_markup в-схема) → fresh DB не гърми на двойно дефиниране. ✓
  • 0006 е безсблъсъчен: на main има само 0000/0001; 0002 (#226/#193/#172/#171), 0003 (#188), 0005 (#239) са под него — взел си точно следващия свободен. Пролуката при merge е безвредна (filename-tracked), както сам отбелязваш. ✓
  • precompute.sql — логиката е непроменена (само документира value_flag='ok' ≡ NOT suspect), а UPDATE … SET single_offer_k … вече минава на прод, защото 0006 създава колоните там. ✓

Header-коментарът в 0000_init вече разграничава work DB (пресъздава се) от served D1 (персистентен, frozen след apply) — точно това спира следващия в капана. И самата 0006 носи същото обяснение. 👍

Дребните — приети: role="status" + tap target, „по подхода CRI", plural() за бройната форма, документираната еквивалентност на флага. Тестовете за миграцията/роловете са реални.

Една бележка (не блокира merge, а поредността): 0006 (този PR) е над 0005 (#239, още неслят). Двете колони/таблици са независими DDL-и, тъй че редът на merge е без значение — само отбележи, че ако #244 слее преди #239, на main остава пролука 0002–0005, която wrangler запълва при следващото apply. Няма зависимост, само за протокола.

Одобрявам.

nikimilenkov added a commit to nikimilenkov/sigma that referenced this pull request Jul 17, 2026
… review)

The 14 subject-risk columns were added by editing 0000_init.sql. The served D1
is persistent and `wrangler d1 migrations apply` is filename-tracked, so an
edit to the already-applied 0000_init reaches the work DB (green local CI) but
never prod — precompute's UPDATE would then fail on missing columns (the
midt-bg#188/midt-bg#239 trap the reviewer reproduced).

Extract the columns into 0006_subject_risk_columns.sql (ALTER TABLE ADD
COLUMN) and remove them from 0000_init (SQLite has no ADD COLUMN IF NOT EXISTS,
so a fresh DB can't carry both). 0006 clears the 0002 claimants
(midt-bg#226/midt-bg#193/midt-bg#172) to avoid a duplicate-version at apply. The precompute
CREATE TABLE IF NOT EXISTS mirror stays a no-op on the existing tables. The
four risk tests now apply 0006 after 0000_init, mirroring the served-D1 chain.

Reword the 0000_init header to distinguish the rebuilt-every-import work DB
from the persistent served D1, so the next person adds objects via a new
migration instead of editing 0000_init. Also document that value_flag='ok' is
the exact complement of the hidden suspect set.
@nikimilenkov
nikimilenkov force-pushed the feat/subject-risk-composite branch from f37c764 to e43e05d Compare July 17, 2026 07:46
nikimilenkov added a commit to nikimilenkov/sigma that referenced this pull request Jul 17, 2026
- Enlarge the "виж договорите" touch target on mobile (WCAG 2.5.8 ≥24px) via
  row spacing + link padding, sized so the two components' links never overlap
  (a mis-tap would swap bids=1 vs markup=high).
- Pluralise "N от M договор(а)" via plural() so 21 renders "договор", not
  "договора".
- Reword the methodology glossary: "подхода CRI", not "методологията CRI" —
  two indicators are CRI-inspired, not the full index.
- Note that toReportable's null-k handling fails toward under-reporting, never
  fabrication.

The band stays plain text (not role="status"): on subject→subject SPA
navigation a polite live region would spuriously re-announce the changed label,
and the preceding disclaimer already frames it in reading order.
@nikimilenkov
nikimilenkov marked this pull request as ready for review July 17, 2026 07:46

@ydimitrof ydimitrof 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: обобщен рисков индикатор за субекти (#229)

ВЕРДИКТ: COMMENT — качеството е много високо; преди merge потвърдете 2 неща (drill-down линкът за изпълнители и прилагането на миграцията в прод пътя). Няма намерени блокиращи уязвимости.

Прегледът е направен изцяло върху предоставения diff (нямам достъп до целия репозиторий, затова две от бележките са „за потвърждение", а не сигурни дефекти).


Фаза 0 — Сигурност (задължителна проверка) → ЧИСТО

  • Няма hardcoded тайни (API ключове/пароли/токени) в diff-а.
  • Няма нови/променени URL-и и няма нови зависимости.
  • SQL инжекция: новите филтри минават през параметризиран ? binding (c.is_high_markup = 1 е константа, не вход от потребител). Интерполацията на имена в scripts/integrity-checks.mjs (columnExists, циклите по ['company_totals','authority_totals']) е само върху фиксирани литерали, не върху потребителски вход — приемливо. Интерполациите в тестовите файлове са върху seed данни, не са продукционен път.
  • Cache poisoning (#138 клас): markup е добавен коректно и на трите места, които го изискват — SCALAR_FILTERS (csv-export), CANONICAL_QUERY_PARAMS и PARAM_ORDER. Няма да бъде третиран като „unfiltered" експорт. Много добре покрито с тест.
  • OWASP: входните стойности са whitelisted (bids: '1'→'one', markup: 'high'→'high', иначе null); няма reflected/stored XSS повърхност (React екранира; линковете се строят от вътрешни идентификатори).

Заключение по Фаза 0: не е блокирано, не е флагнато.


Данни и клеветнически риск (най-чувствителната част) → добре овладяно

Проектът явно е обмислил риска „етикет до име на субект":

  • M9 — физически лица: потиснато на два слоя — в details.ts (getCompany не връща risk за ЕТ/физ. лице) и в маршрута (buildSubjectRisk(..., { isNaturalPerson })). Дедупликацията на isSingleNaturalPersonProfile в един споделен isNaturalPersonSubject премахва драйф — правилен ход.
  • M3 — минимална извадка: компонент се показва само при n ≥ 5 допустими договора; ако няма нито един репортабилен компонент → няма нито band, нито композит (buildSubjectRisk връща null).
  • M4 — числа до дяловете: UI винаги показва „K от N договора".
  • Неутрална рамка: етикетите описват индикаторите, не субекта; преформулирането на обещанието в methodology.tsx е изолирано и maintainer-gated (по план). ✔
  • Целостност на стойностния дял [0,1]: числителят е строго подмножество на знаменателя (is_single_offer=1 ⟹ bids_received≥1; is_high_markup=1 ⟹ is_high_markup IS NOT NULL) и теглото е само по amount_eur > 0, така че отрицателен value_low ред не може да изкара дял над 1. Adversarial тестът с -500 ред (0.75, не 2.0) и integrity-check checkSubjectRiskBounds са отлична защита.
  • Suspect редове: is_high_markup се материализира само при value_flag='ok', което съвпада точно с правилото за скриване на бейджа на страницата на договора — тестван е и на review/value_low/value_suspect. Съответствието е застраховано и от integrity-check.

Тестове → отлични (>90% де факто по логиката)

risk-flags, risk-rollups, refresh-slice drift-parity (slice == full), subjectRisk, riskLogic, integrity-checks с инжектирани нарушения (200% дял, k>n, suspect markup), non-vacuity guard. Границата 0.20 (не флаг) / 0.21 (флаг) е покрита. Това надхвърля обичайното.

Единна дефиниция „една оферта" → добро решение

Обединяването на bids_received = 1 навсякъде (per-contract флаг + rollup + вече показваната стойност в competition.ts) отстранява двусмислието admitted===1. Промяната в поведението (3 оферти / 2 отхвърлени вече не флагва) е документирана в ADR-0007 и в плана — това е съзнателен, по-защитим избор.


За потвърждение преди merge (виж inline)

  1. Drill-down линк за изпълнители (company.tsx). Компанията подава contractsBase={/contracts?bidder=${c.slug}}, докато възложителят подава ${a.eik}buildFilters слага префикс auth: за authority). Тази асиметрия и коментарът в компонента (// e.g. '/contracts?bidder=eik:123') повдигат въпроса дали c.slug резолвва точно същото множество, което single_offer_k/high_markup_k броят. Ако bidder филтърът очаква EIK, а c.slug е име-базиран слъг, линкът „виж договорите" ще води до различно/празно множество — а точно проследимостта (M7) е ключова за неклеветническата рамка. Моля потвърдете с реален субект.

  2. Прилагане на миграция 0006 в прод пътя. docs/etl.md и заглавието на 0000_init.sql казват, че work базата се пресъздава само от 0000_init.sql, а 0000_init не е редактиран да съдържа новите колони. Кодът разчита, че: (а) contracts и *_totals вече съществуват в 0000_init (за да мине ALTER TABLE в 0006 върху свежа served D1), и (б) wrangler d1 migrations apply пуска 0006 преди precompute/ship-domain. Потвърдете и че номерът 0006 не се сблъсква с реално съществуваща миграция (коментарът споменава „изчиства 0002 претендентите"). Ако някое от двете не е вярно, precompute ще падне с „no such column"/„no such table" в прод, докато локалните тестове (които четат 0006 ръчно) остават зелени.


CLAUDE.md / обхват

Без частична имплементация, без TODO, без дублиране (isNaturalPersonSubject е обединен), без dead code, тестове за всяка нова функция, консистентно наименуване, чисти concern-и (презентационните прагове са сървърни константи, не query params — ADR-0007). Обхватът е атомарен спрямо #229.

Обобщена оценка: ~9.2/10 — понижена само защото двата въпроса по-горе не могат да бъдат затворени от diff-а. Затворят ли се положително, това е чист APPROVE.


{risk ? (
<Section id="risk" title="Обобщени индикатори">
<SubjectRiskIndicator risk={risk} contractsBase={`/contracts?bidder=${c.slug}`} />

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.

За потвърждение (проследимост M7). Тук contractsBase е /contracts?bidder=${c.slug}, докато authority.tsx подава /contracts?authority=${a.eik}buildFilters слага префикс auth: за authority). Коментарът в SubjectRiskIndicator дори дава пример // e.g. '/contracts?bidder=eik:123'.

Линкът „виж договорите" трябва да върне точно множеството, което single_offer_k/high_markup_k броят. Ако bidder филтърът резолвва по EIK, а c.slug е име-базиран слъг (различен от eik:...), drill-down-ът ще води до различно или празно множество — което подкопава неклеветническата рамка (всяко число трябва да е проследимо). Моля проверете с реален субект, че bidder=${c.slug} дава същите договори като флага; ако не — подайте EIK-а, както прави authority страницата.

-- duplicate-version at apply; a gap is harmless for filename-tracked application.

-- contracts: canonical per-contract flags, materialized by scripts/precompute.sql + refresh-slice.sql.
ALTER TABLE contracts ADD COLUMN is_single_offer INTEGER; -- 1/0 = bids_received = 1; NULL = bid count unknown (never counted as 0 by the rollup shares)

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.

За потвърждение (deployment readiness). Тези колони се добавят само тук чрез ALTER TABLE, а 0000_init.sql не е редактиран да ги съдържа. За да работи в прод, трябва да са изпълнени три предпоставки — моля потвърдете ги:

  1. contracts и company_totals/authority_totals вече съществуват в 0000_init.sql, иначе ALTER TABLE ... ADD COLUMN пада с „no such table" върху свежа served D1 (precompute.sql използва CREATE TABLE IF NOT EXISTS, което подсказва, че тези таблици може да се създават от precompute, не от 0000_init).
  2. wrangler d1 migrations apply пуска 0006 преди precompute/ship-domain на served D1.
  3. Номер 0006 не се сблъсква с реално прилагана миграция (коментарът споменава „изчиства 0002 претендентите feat: свързани лица — детерминистична основа за данни за конфликт на интереси #226/docs(web): методологията описва точно таблата — формули, обхват, изключения #193/feat(web): analyze index — five equal analysis cards #172").

Тестовете четат 0006 ръчно (readScript(riskColumnsPath)), затова остават зелени дори ако прод пътят на импорта не приложи миграцията — т.е. тестовете не покриват този риск. Ако предпоставка (1) или (2) не е вярна, precompute ще падне с „no such column"/„no such table" при реален импорт.

@nikimilenkov

Copy link
Copy Markdown
Contributor Author

Благодаря за прегледа — и двете за-потвърждение точки са проверени срещу кода.

1. Drill-down линкът за изпълнители — потвърдено, води до точното множество. Асиметрията е само в имената на параметрите; резолюцията е симетрична и обратима:

  • Рисковите броячи идват от getCompany(db, bidderId)SELECT … FROM company_totals WHERE bidder_id = ? (details.ts:138), т.е. ключът е bidder_id (напр. eik:103267194).
  • c.slug = companySlug(bidderId) (details.ts:47) — при валиден ЕИК маха префикса (eik:103267194 → 103267194); при name-keyed субект → n+base64url(name).
  • Линкът /contracts?bidder=${c.slug} минава през bidderIdFromSlug(c.slug)c.bidder_id = ? (contracts.ts:167-170). Round-trip-ът е точен и тестван: identity.test.ts:15-16 (companySlug('eik:103267194') → '103267194' → bidderIdFromSlug(…) → 'eik:103267194'; EIK_RE = /^\d{9}(\d{4})?$/).

Решаващото: getCompany сам вече вика listContracts(db, { bidder: companySlug(bidderId), … }) (details.ts:193-194), за да напълни списъка с договори на самата страница на фирмата — т.е. bidder=companySlug(bidder_id) е вече доказаният път, който връща реалните договори на субекта; drill-down-ът преизползва точно него. Хипотетичният случай „c.slug е име-базиран текст → празно множество" не може да се случи: c.slug никога не е свободен текст, а обратимата companySlug стойност (name-keyed субектите също round-trip-ват). Физическите лица така или иначе са потиснати до risk=null, тъй че за тях линк не се показва.

Източникът на съмнението беше подвеждащ коментар, не кодът: SubjectRiskIndicator.tsx:35 даваше пример '/contracts?bidder=eik:123', а реалният slug е без префикса (bidder=123). Оправих го в 6a8e379, за да не подведе следващия (bidder=eik:123 наистина би резолвнало към празно множество, но кодът подава c.slug, не такъв литерал).

2. Прилагане на миграция 0006 в прод пътя — вече затворено. Точно това беше блокерът от първия преглед на @lyubomir-bozhinov; фиксът f37c764 изнесе 14-те колони в 0006_subject_risk_columns.sql (ALTER TABLE ADD COLUMN), извади ги от 0000_init, и 0006 е безсблъсъчен (на main са само 0000/0001; 0002/0003/0005 са под него). Той го препотвърди емпирично във втория си ревю и одобри. Runbook-бележката (0006 да е приложена към прод D1 преди/заедно със следващия деплой, иначе refresh в междинния прозорец гърми с „no such column") е записана в PR-описанието — очакваната цена на номерирания подход.

@ydimitrof ydimitrof 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: обобщен рисков индикатор за субекти (#229)

ВЕРДИКТ: COMMENT — няма блокиращи проблеми; препоръчвам одобрение след потвърждение по точките по-долу (сливането е обвързано с sign-off от maintainer за преформулирането в methodology.tsx).

Обобщение

Много добре структуриран PR с изключително внимание към целостта на данните и към анти-обвинителната рамка. Прегледът покрива сигурност, целост на данните, SQL-инжекции, cache-poisoning и коректност.

Фаза 0 — Сканиране за сигурност: ЧИСТО

  • Тайни: няма твърдо кодирани ключове/пароли/токени.
  • URL адреси: няма нови или променени външни URL адреси; линковете за drill-down се изграждат сървърно (/contracts?authority=<eik> / ?bidder=<slug>) и се рендерират през react-router (екранирани).
  • Зависимости: няма нови пакети или промени във версии.
  • Злонамерени шаблони: няма eval/obfuscation/backdoor.
  • SQL-инжекции: интерполацията на низове в SQL се среща само в тестови помощници (sqlite()/readScript) върху константни литерали и имена на таблици/колони — без потребителски вход. Продукционният слой (contracts.ts, details.ts) ползва параметрично свързване (? + params.push). integrity-checks.mjs интерполира само фиксирани имена на таблици/колони. OWASP A03 (Injection): чисто.

Целост на данните (силна страна на PR)

  • Стойностните дялове са доказуемо в [0,1]: и числителят, и знаменателят ограничават amount_eur > 0, а флагнатото множество е подмножество на допустимото (is_single_offer=1 ⇒ bids_received=1 ⇒ bids_received>=1; is_high_markup=1 ⊆ is_high_markup IS NOT NULL). Отрицателният value_low тест (eik:2 → 0.75, а не 2.0) го доказва.
  • Новата integrity-проверка checkSubjectRiskBounds пази диапазона [0,1], k <= n и „high-markup само на value_flag='ok'“ и коректно се самопропуска при липсващи колони (структурна проверка, не само home_totals).
  • Cache-poisoning защитата (#138) за новия markup филтър е разпространена последователно: SCALAR_FILTERS, CANONICAL_QUERY_PARAMS, PARAM_ORDER, CONTRACT_FILTER_KEYS (с compile-time satisfies guard) + обновени тестове (csv-export, keyset).
  • is_high_markup е ограничен до value_flag='ok', точно допълнението на подозрителните флагове, което съответства на скриването на бейджа на страницата на договора — предотвратява разминаване rollup↔страница.

Тестове

Отлично покритие: per-contract флагове (вкл. NULL/граница 0.20 vs 0.21/suspect редове), per-subject rollups (count-share 0.75 ≠ value-share 0.35), full-vs-slice parity guard, идемпотентност, non-vacuity, natural-person потискане, integrity граници. Тестовете са смислени, не тривиални.

Съответствие с тикета/ADR

Имплементацията съответства на ADR-0007 и плана: унифициране на „една оферта“ на bids_received = 1, материализирани флагове, count-weighted композит, min-N ≥ 5, потискане за физически лица, изключване от <meta>/OG. Обхватът е атомарен и фокусиран.

Наблюдения (без блокиране)

  1. Разреждане на композита при чист компонентsubjectRisk.ts: композитът е средно на докладваемите компоненти. Субект с 5/5 „една оферта“ и 0/5 „оскъпяване“ дава композит 0.5 → „Множество“. Това е нарочен избор по ADR, но си струва да се провери спрямо реалното разпределение при калибрирането на праговете.
  2. Scoping на дневния refreshrefresh-slice.sql обновява флаговете само за refresh_touched_contracts, докато rollup-ите агрегират върху всички договори на засегнатите субекти. Разчита се на пълен precompute след миграцията, за да са попълнени флаговете на нетъчнатите договори. Моля потвърдете последователността на деплоя (миграция 0006 → пълен precompute на обслужваната D1 → инкрементален refresh).
  3. Пренумериране на миграцията на 0006 (изчиства 0002 претендентите) е документирано; уверете се, че никой отворен PR не претендира отново за 0006, за да няма duplicate-version при apply.
  4. methodology.tsx преформулиране на публичното обещание изисква изричен sign-off от maintainer (изолирано в отделен commit — правилно).

Няма открити пропуски в CLAUDE.md правилата (без частична имплементация, без TODO, без дублиран код, без dead code, тестове за всяка функция, консистентно именуване, разделени отговорности, без течове на ресурси).

if (components.length === 0) return null;

// Mean over the REPORTABLE components only — a thin (< MIN_ELIGIBLE) component is dropped, not scored
// as zero (M3 small-sample conservatism). Subjects stand alone (no cross-subject ranking).

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.

Композитът е непретеглено средно на докладваемите компоненти, така че чист компонент (напр. „оскъпяване“ 0/5) разрежда силен рисков компонент („една оферта“ 5/5 → композит 0.5 = „Множество“ вместо „Много“). Това е нарочен избор по ADR-0007 (равни тегла за защитимост), но моля потвърдете, че поведението е желаното при калибрирането на праговете спрямо реалното разпределение — иначе субект с концентриран риск по един признак може да изглежда по-нисък, отколкото е.

Comment thread scripts/refresh-slice.sql
-- derivation as precompute.sql section 0b; runs after the recalc UPDATE above refreshed signing/current
-- EUR, so is_high_markup reads current figures.
UPDATE contracts SET
is_single_offer = CASE WHEN bids_received IS NOT NULL THEN (bids_received = 1) END,

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.

Този UPDATE обновява флаговете само за refresh_touched_contracts, а rollup-ите по-долу агрегират върху ВСИЧКИ договори на засегнатите субекти. Това е коректно само ако флаговете на нетъчнатите договори вече са материализирани от предходен пълен precompute. Моля потвърдете, че последователността на деплоя гарантира пълен precompute на обслужваната D1 след прилагане на миграция 0006 (иначе нетъчнати договори без флагове ще влязат в знаменателите като неоценими, което е ОК, но всеки договор, чиито стойностни полета се преизчисляват извън refresh_touched_contracts, би оставил stale флаг).

@nikimilenkov

Copy link
Copy Markdown
Contributor Author

Благодаря за повторния преглед. По наблюденията:

2. Обхват на дневния refresh — потвърждавам последователността на деплоя. Точно както отбелязваш: refresh-slice.sql обновява флаговете само за refresh_touched_contracts (инкрементално), а rollup-ите агрегират по всички договори на засегнатите субекти. Затова коректната поредност е 0006 (ALTER ADD COLUMN) → пълен precompute на обслужваната D1 → инкрементален refresh. Пълният precompute е този, който попълва флаговете на нетъчнатите договори: precompute.sql прави безусловен UPDATE contracts SET is_single_offer = …, is_high_markup = … (без WHERE, ред 52-55 — „recomputes every row and clears stale values"), тъй че всеки договор получава флаг, не само тъчнатите. Инкременталният refresh след това е достатъчен, защото останалите вече са материализирани. Тази поредност е записана и в runbook-бележката към PR-а.

3. Номерът 0006 — проверих, и не е свободен: PR #209 (0006_recent_feed_indexes.sql) също претендира за 0006. Двата обаче не се чупят взаимно, защото wrangler d1 migrations apply е filename-tracked: таблицата d1_migrations се индексира по име на файл, не по числов префикс, тъй че 0006_recent_feed_indexes.sql и 0006_subject_risk_columns.sql са два отделни записа — и двата се прилагат като неприложени файлове, без „duplicate-version" грешка. DDL-ите са независими (CREATE INDEX срещу ALTER TABLE ADD COLUMN), а редът на прилагане е лексикографски (recent_feed преди subject_risk), тъй че поредността е без значение.

Нарушава се само конвенцията за уникален префикс, не самото прилагане. Предлагам да го решим по ред на сливане: който слее втори, вдига номера на следващия свободен (0007 е незает при всички отворени PR-и днес). Оставям избора на maintainer при мърджа — ако предпочиташ да пренумерирам 0006→0007 в този PR превантивно, ще го направя веднага.

1. Разреждане на композита (5/5 „една оферта" + 0/5 „оскъпяване" → 0.5 → „Множество") — съгласен, това е нарочният избор по ADR-0007, но ще го валидирам спрямо реалното разпределение при калибрирането на праговете (отделно от този PR, както и §3).

4. Преформулирането в methodology.tsx — да, това е gating елементът и е изолирано в отделен commit точно за да получи изричен sign-off от maintainer.

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

Ре-верифицирах на HEAD (6a8e379b): поправката на JSDoc примера (bidder=103267194, companySlug маха префикса eik:) е коректна. Останалото по PR-а стои от предишния ми преглед. Одобрявам.

@nedda76

nedda76 commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Този клон е в конфликт с main, тъй че към момента не може да се ревюира — дифът, който GitHub показва, вече не отговаря на това, което би влязло. Ще го пребазираш ли върху актуалния main (или merge на main в клона) и да разрешиш конфликтите? След това веднага го поглеждам. Благодаря! 🙏

The subject-risk indicator's „виж договорите" link resolved the single-offer
component to a filtered list (?bids=1) but the high-markup component to the
subject's full contract list — the displayed count could not be verified
against the contracts behind it, breaking the drill-down control (M7).

Add a symmetric URL-only markup=high filter (c.is_high_markup = 1) mirroring
bids=1 across the query layer, the CSV export, and both cache classifiers, so a
markup-filtered view can never collapse into the unfiltered cache object
(midt-bg#138/midt-bg#56 class). Each risk component now links to exactly the contracts it
counts.

Also document that the composite means over reportable-only components by
design (small-sample suppression, M3).
Natural-person suppression (M9) rested only on the route-level render guard, so
getCompany always populated CompanyDetail.risk — the raw K/N counts for a named
individual shipped in the SSR hydration payload even though the indicator was
hidden, and a single misclassification would render a band for a person.

Consolidate the split/duplicated natural-person check into one shared
isNaturalPersonSubject() predicate and gate risk: null in getCompany, so the
aggregate never leaves the data layer for an individual; the render guard is
now a second layer, not the only one.

Also document that single_offer_n and high_markup_n use intentionally different
eligible universes (≥1-bid vs value-assessable), not drift.
… review)

The 14 subject-risk columns were added by editing 0000_init.sql. The served D1
is persistent and `wrangler d1 migrations apply` is filename-tracked, so an
edit to the already-applied 0000_init reaches the work DB (green local CI) but
never prod — precompute's UPDATE would then fail on missing columns (the

Extract the columns into 0006_subject_risk_columns.sql (ALTER TABLE ADD
COLUMN) and remove them from 0000_init (SQLite has no ADD COLUMN IF NOT EXISTS,
so a fresh DB can't carry both). 0006 clears the 0002 claimants
(midt-bg#226/midt-bg#193/midt-bg#172) to avoid a duplicate-version at apply. The precompute
CREATE TABLE IF NOT EXISTS mirror stays a no-op on the existing tables. The
four risk tests now apply 0006 after 0000_init, mirroring the served-D1 chain.

Reword the 0000_init header to distinguish the rebuilt-every-import work DB
from the persistent served D1, so the next person adds objects via a new
migration instead of editing 0000_init. Also document that value_flag='ok' is
the exact complement of the hidden suspect set.
- Enlarge the "виж договорите" touch target on mobile (WCAG 2.5.8 ≥24px) via
  row spacing + link padding, sized so the two components' links never overlap
  (a mis-tap would swap bids=1 vs markup=high).
- Pluralise "N от M договор(а)" via plural() so 21 renders "договор", not
  "договора".
- Reword the methodology glossary: "подхода CRI", not "методологията CRI" —
  two indicators are CRI-inspired, not the full index.
- Note that toReportable's null-k handling fails toward under-reporting, never
  fabrication.

The band stays plain text (not role="status"): on subject→subject SPA
navigation a polite live region would spuriously re-announce the changed label,
and the preceding disclaimer already frames it in reading order.
The 0000_init header now distinguishes the rebuilt-every-import work DB from
the persistent served D1 (filename-tracked migrations apply), but ADR-0002 and
etl.md still described "fresh DB every import, no incremental chain" — the
opposite instruction. Reword both so new schema objects go through a numbered
migration, not by editing the applied 0000_init.
…ate)

The subject-risk implementation plan was committed unlinked, so the check:docs
gate flagged it as an orphan and failed CI. Link it from the docs index
(plans live in docs/ per AGENTS.md).
The shared fake D1 (midt-bg#325) throws on an unrouted query, and checkSubjectRiskBounds
reaches it with pragma_table_info + the bounds SELECT once home_totals exists.
Model the served D1 as post-0011 (risk columns present) so the check RUNS here
instead of silently self-skipping; the routes precede the general
['FROM contracts', 'COUNT(*) AS n'] one, which would otherwise swallow the
suspect-markup query.
@nikimilenkov
nikimilenkov force-pushed the feat/subject-risk-composite branch from 6a8e379 to 9e77c27 Compare August 31, 2026 11:05

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

Прегледах batch 1 (read-path, precompute агрегати, интегритетен гейт, флагове на ниво договор и субект, drill-down филтри).

Като цяло PR-ът е много добре структуриран и с изчерпателни тестове. Математиката на агрегатите е коректна и защитена: числителят на всеки value-share е строго подмножество на знаменателя (single_offer=1 ⇒ bids≥1; high_markup=1 ⇒ NOT NULL), тегленето само по положителни amount_eur държи дяловете в [0,1], а инвариантите k≤n се пазят по конструкция и се проверяват от новия гейт. Не открих зловреден код, инжекции, изтичане на данни, нови мрежови/CI стъпки или проблеми с транзакции. Drill-down филтрите (bids=1, markup=high) съвпадат с предиката, който rollup-ът материализира, и cache-poisoning класът (#138) е коректно покрит с добавянето на markup към SCALAR_FILTERS и CANONICAL_QUERY_PARAMS.

Няма блокиращи дефекти. Оставям няколко наблюдения — едно от тях е чувствителна публична промяна на текст, която заслужава изрично потвърждение преди сливане.

Забележка: refresh-slice.sql (дневният път) не е в този batch, но паритетният тест го покрива — окончателната преценка за дрейф зависи от файл в другия batch.

— сигналът е ориентир, не присъда.
СИГМА има само информативен характер: не въвежда нови данни и не оценява
процедурите. Показва неутрални индикатори, обобщени от самите договори (виж{' '}
<a href="#flagged">раздел 10</a>) — те не са обвинение и не установяват нарушение.

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-а (#229, commit 6) преформулировката е чувствителна към клевета и нарочно е обособена така, че да мине през одобрение от maintainer. Новият текст запазва духа („не са обвинение и не установяват нарушение“), но заличава конкретна публична гаранция — моля потвърдете, че sign-off от maintainer е получен преди сливане, тъй като indicator-ът се показва до името на именуван субект.

!(await tableExists(runner, 'home_totals')) ||
num(await scalar(runner, 'SELECT COUNT(*) AS n FROM home_totals', 'n')) === 0 ||
!(await columnExists(runner, 'company_totals', 'single_offer_k')) ||
!(await columnExists(runner, 'contracts', 'is_high_markup'))

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.

Гейтът проверява за съществуване само company_totals.single_offer_k и contracts.is_high_markup, но по-долу заявката чете и колони на authority_totals (single_offer_value_share, high_markup_k, single_offer_n …). При частичен дрейф, при който authority_totals няма новите колони, а company_totals ги има, проверката ще хвърли „no such column“ вместо да се самопропусне — точно поведението, което този gate твърди, че избягва (виж теста „self-skips … when a risk column is missing“). Добавете columnExists проверка и за authority_totals, за да е пълна drift-устойчивостта.

Comment thread packages/db/src/search-sql.test.ts Outdated
const migration0 = resolve(root, 'packages/db/migrations/0000_init.sql');
const migration2 = resolve(root, 'packages/db/migrations/0003_related_persons_foundation.sql');
const migration9 = resolve(root, 'packages/db/migrations/0009_interest_link_evidence.sql');
// precompute/refresh-slice write the subject-risk columns (#229); they live in 0011, not 0000_init.

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.

Дребно (maintainability): коментарът твърди „they live in 0011“, но файлът е 0014_subject_risk_columns.sql. Същият остарял коментар е копиран дословно в няколко тестови файла — подвежда при бъдещо търсене по номер на миграция. Струва си да се уеднакви на 0014.

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

Прегледах #244 при HEAD 9e77c27d — трасирано локално срещу schema + precompute.sql.

Risk-агрегация — издържана: per-contract flags в precompute.sql — is_single_offer е NULL при bids_received IS NULL (не се брои като 0); is_high_markup изисква value_flag='ok' AND signing_value_eur>0 (положителна база — negative ред не може да обърне знака на ratio-то). Rollup-ите държат k ⊆ n by construction; value-share-овете делят flagged-positive/eligible-positive amount_eur с NULLIF(...,0) → bounded [0,1]. subjectRisk.ts — mean само на reportable (n≥5) компоненти, thin се изпускат (не се зануляват), детерминистично. risk-rollups.test.ts доказва това с реален sqlite3 + миграции + adversarial fixtures (NULL-flag ред извън знаменателя; negative value_low ред asserted на 0.75). Не са cheater тестове.

Libel (физически лица) — потушено на data-layer: details.ts:264 (getCompany) — risk: isNaturalPerson ? null : subjectRiskAggregate(row), така че raw K/N изобщо не влиза в SSR/.data payload-а; getAuthority (line 416) го попълва безусловно — коректно, институции. Вторият слой е render guard-ът в company.tsx + noindex. Един консолидиран предикат isNaturalPersonSubject — не намерих разминаващ се isNaturalPersonBidder, затова потушаването не може да drift-не между повърхности.

Миграция — коректно номерирана: 0014_subject_risk_columns.sql е чист ALTER TABLE ADD COLUMN, махнат от 0000_init. Main е до 0010; няма open PR на 0014 (провери: #188→0012). Уточнение за merge-order: 0011–0013 се държат от други open PR-и — ако #188 (0012) се merge-не след #244, D1 ще приложи 0012 след като 0014 вече е applied (unapplied by name → все пак се прилага, но извън лексикалния ред). Additive-only, независими колони → безопасно, но потвърди merge-реда.

Minor:

  1. Stale migration refs в коментари — integrity.test.ts нарича колоните "0011 columns"/"post-0011", а миграцията кацна като 0014. Козметичен drift, без функционален ефект (fakeD1 route-ва SQL, не прилага миграции).
  2. (Low, pre-existing, не е finding срещу #244) contract.tsx:96 noindex-ва contract-страница само по isNaturalPersonProfileName(displayName) (name-prefix), не по пълния isNaturalPersonSubject — sole-trader идентифициран само по legal_form (без "ЕТ " префикс в името) не получава noindex там. Тази повърхност показва per-contract факти, не aggregate score.

Вердикт: clean на всичките четири критични оси (агрегация, libel, миграция, тест-конверсия). COMMENT (Triage).

…n refs

Review round on the rebased branch (@ydimitrof, @lyubomir-bozhinov).

- checkSubjectRiskBounds probed only company_totals and contracts, but the
  bounds loop reads authority_totals too. A partial drift (one rollup table
  migrated, the other not) therefore threw 'no such column' instead of
  self-skipping — the exact behaviour the gate claims to avoid. It now probes
  authority_totals as well, with a test that fails on the old guard with
  `no such column: single_offer_k`.
- The migration landed as 0014, but the comments introduced with it still
  said "they live in 0011" across 14 test files. Corrected; the coincidental
  00011 УНП fixtures are untouched.

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

Fix-ът е коректен — потвърдено при HEAD 54a08089. Двете бележки са затворени: (1) stale „0011" refs → „0014" в integrity.test.ts; (2) drift guard-ът вече probe-ва и authority_totals.single_offer_k, не само company_totals (integrity-checks.mjs:431) — понеже loop-ът чете и authority_totals, частичен drift (една таблица мигрирана, другата не) щеше да гръмне покрай company_totals-only guard-а, точно каквото check-ът обещава да не прави. Добро втвърдяване. Нямам блокери.

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

Прегледах партида 1/2 на #229 (обобщен рисков индикатор на ниво субект). Като цяло промяната е висококачествена, добре документирана и с изчерпателно тестово покритие (per-contract флагове, per-subject rollups, парити между slice и full, integrity guard за граници на дяловете, non-vacuity проверки). Няма злонамерен или подозрителен код: няма мрежови извиквания, нови зависимости, промени по workflow/CI, auth или права; тежките коментари в дифа са обяснителни, не са опит за prompt-injection. Стойностните дялове коректно тежат само положителни amount_eur (пази [0,1] при отрицателни value_low редове), знаменателите на числителя са подмножества (k ⊆ n), а миграцията 0014 е добавена като нов номериран файл, не чрез редакция на приложен 0000_init — правилно за персистентната обслужвана D1.

Сигурност: добавянето на markup към SCALAR_FILTERS/CANONICAL_QUERY_PARAMS/сигнатурата на филтъра затваря същия #138 клас cache-poisoning като bids — добро.

Няма блокиращи дефекти. Оставям две необвързващи бележки за преценка: (1) дребна възможна разлика между това какво брои rollup-ът и какво показва страницата на договора при deltaPct = null; (2) редакционен риск — най-високата категория е достижима при малка извадка (3 от 5), което при чувствителна към клевета функция заслужава по-консервативно калибриране. И двете са отбелязани в ADR-0043 като временни/за калибриране.

Verdict: COMMENT — чисто, но с два въпроса за потвърждение преди merge.

if (contract.value?.deltaPct != null && contract.value.deltaPct > 0.2) {
// isHighMarkup is the materialized flag; deltaPct is still read for the displayed %. It is NULL on the
// suspect rows where the flag is also null, so the `!= null` guard stops a stale flag rendering `NaN%`.
if (contract.isHighMarkup && contract.value.deltaPct != null) {

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.

Rollup-ът брои is_high_markup = 1 независимо от deltaPct, докато тук значката се скрива, когато deltaPct е null. На практика precompute.sql слага is_high_markup = 1 само при signing_value_eur > 0 и наличен current_value_eur, тъй че deltaPct (изчислен от същите стойности) би трябвало винаги да е ненулев в този случай — т.е. разминаване не се очаква. Само за да е ясно записано: моля потвърдете, че details.ts извежда value.deltaPct от същите EUR полета; ако някога дойде от друг източник, страницата на договора може да скрие висок markup, който субектният индикатор все пак брои. Guard-ът срещу NaN% е правилен така или иначе.

// Presentation thresholds — server-side constants, NEVER query params (ADR-0043). Band cutoffs are
// provisional, to be calibrated against the real distribution. Pure logic: keys + numbers only — the
// Bulgarian band/component labels live in the SubjectRiskIndicator component, not here.
export const MIN_ELIGIBLE = 5; // a component needs ≥ this many assessable contracts to be reportable (M3)

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.

Забележка за калибриране (не блокира): при MIN_ELIGIBLE = 5 и праг за най-високата категория ≥ 0.55, субект с 3 от 5 допустими договора попада в „Много индикатори — заслужава преглед“ до именуван субект. За функция, която самата документация определя като чувствителна към клевета, най-тежката категория е достижима при съвсем малка извадка. ADR-0043 вече отбелязва праговете като временни/за калибриране — струва си при калибрирането да се обмисли по-висок MIN_ELIGIBLE или изместване на горния праг нагоре, за да не се вдига тонът на малки извадки.

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.

4 participants