Інцидент 2026-07-01 — завищений конкурсний бал: галузевий коефіцієнт ×1.02 на непідтримуваних спеціальностях
Контекст
Section titled “Контекст”Конкурсний бал НМТ рахується за зваженою формулою (Розділ IV п.4 Порядку прийому-2026): КБ = Σ(Кᵢ·Пᵢ) / Σ(Кᵢ) + ОУ, після чого множиться на регіональний (РК) × галузевий (ГК) коефіцієнти (п.7). ГК = 1.02 лише для заяв із пріоритетністю 1/2 на спеціальності з переліку особливої підтримки (Додаток 9) — і 1.00 в усіх інших випадках.
У коді ГК реалізовано однаково на обох боках через один прапорець speciality.isSupported:
- Фронтенд (калькулятор):
calculateHelpers.ts→applyAdditionalFactors(grade) = grade × (isSupported ? 1.02 : 1.0)(IS_SUPPORTED_COEF = 1.02). - Бекенд (abitly-api):
SpecialityScore2025Strategy.ts→isSupported ? score × 1.02 : score; офери —offers.mapper.ts:72→branchCoefficient: isSupported ? 1.02 : 1.0.
Прапорець живе в колонці abitly.specialities.is_supported і роздається клієнту через GET /specialities/* (кешується в Redis). Фронтенд не має захардкодженого переліку — усе тягне з API. Тобто один рядок даних живить калькулятор, бейджі «особлива підтримка», шанси, симуляцію і бота одночасно.
Спеціальності зберігаються по роках (year = 2024 числові коди / 2025–2026 літерні), а також погранульовано за підспеціальностями: один code (напр. A4) — багато рядків, що розрізняються колонкою specification.
Хронологія (2026-07-01, UTC)
Section titled “Хронологія (2026-07-01, UTC)”| Час | Подія | Стан |
|---|---|---|
| — | Латентний баг: is_supported=true для G20/G21/G22 у прод-даних (походження — див. «Відкриті питання»). Жоден алерт/тест не ловив | 🟡 бомба в даних |
| вдень | Користувач повідомляє: на Abitly бал для Біотехнології (188.5) вищий, ніж на Освіті (184.767) | 🔴 репорт |
| — | Діагностика №1: формулу звірено (коректна); розбіжність = ×1.02; спираючись на KB-примітку «G1–G22» зроблено хибний висновок «G21 ∈ Додаток 9 → Abitly правий» | 🔴 хибний висновок |
| — | Користувач сумнівається, просить показати перелік Додатка 9. Витягнуто дослівний Додаток 9 (osvita-PDF + ЄДЕБО + ZAXID + RBC): галузь G = G1–G16, G19; G20/G21/G22 відсутні | 🟡 root cause знайдено |
| — | Підтверджено в прод-даних: G21 is_supported=true — помилка. Локалізовано джерело: міграція лише додає колонку, ETL немає → значення завантажені вручну | 🟡 |
| ~18:41 | Змерджено PR api-v2 #533 (міграція) + hub #32 (правка KB) | 🟡 |
| після 18:41 | Прод-БД оновлено ідемпотентною транзакцією (UPDATE 3 + UPDATE 1 + запис міграції в abitly.migrations) | 🟡 |
| — | Прод-API все ще віддає isSupported=true — стейл Redis (TTL 12 год). Флаш 36 ключів SpecialitiesController-* через SSM-тунель до ElastiCache | 🟡 |
| — | LIVE-перевірка: /specialities/calculator → G21 isSupported=false → бал 184.767 | 🟢 2026 resolved |
| — | Виявлено ширший скоуп: офери вказують на спеціальності 2024/2025, де біо-блок досі true → офер-сторінки накручують. ЄДЕБО-2025 підтверджує: біотех і в 2025 не підтримуваний | 🟠 |
| пізніше | Верифіковано 2024/2025 (дослівні списки + рейтинги ЄДЕБО, контроль Металургії) → міграція 1782500000000 (2025 G20/G21/G22; 2024 162/163 → false), застосовано на проді + флаш; офери → branchCoefficient=1.0 | 🟢 біо-блок resolved (усі роки) |
Вікно впливу: латентно, тривало (усі абітурієнти, що рахували G20/G21/G22, бачили бал, завищений на ~2 %). MTTD — не застосовно у класичному сенсі: баг не породжував алертів/помилок і був виявлений репортом користувача, а не системою. MTTR (для 2026, від root cause до live-verified) — той самий робочий день.
Першопричина (deep-dive)
Section titled “Першопричина (deep-dive)”Формула правильна — неправильні дані
Section titled “Формула правильна — неправильні дані”Розбіжність — чиста арифметика: 188.5 / 184.767 = 1.0202, тобто рівно галузевий коефіцієнт. База (зважена сума ÷ сума коефіцієнтів) в Abitly і в ЄДЕБО ідентична (184.767). Єдина відмінність — Abitly застосовував ×1.02 там, де не мав. Отже, ані calcGrade.ts, ані SpecialityScore2025Strategy.ts не винні — винен рядок даних is_supported.
Що насправді в Додатку 9 (доказ)
Section titled “Що насправді в Додатку 9 (доказ)”Дослівний Додаток 9 Порядку прийому-2026 (наказ №373, реєстр. z0374-26), звірений із чотирьох незалежних джерел (osvita-PDF репродукція, стаття Освіти, ЄДЕБО, item-level переліки ZAXID/RBC), містить 45 спеціальностей у 6 галузях. Галузь G:
G1–G16, G19 ← і все. НЕМАЄ G17, G18, G20, G21, G22.G21 Біотехнології та біоінженерія і G22 Біомедична інженерія до переліку не входять → ГК для них = 1.00. Прапорець is_supported=true для них — фактична помилка щодо першоджерела.
Чому діагностика №1 дала хибний висновок
Section titled “Чому діагностика №1 дала хибний висновок”KB-примітка (dodatok-9.md) стискала перелік як «галузі … G1–G22 …». Це неправдиве стиснення діапазону: Додаток 9 — це конкретні коди, а не суцільний ряд. Спираючись на «G1–G22», перша діагностика вирішила, що G21 підтримується, і двічі впевнено відповіла «Abitly рахує правильно». Хибний висновок розвернув лише скепсис користувача + витяг дослівного переліку.
Чому виправлення мусило бути точковим, а не blanket
Section titled “Чому виправлення мусило бути точковим, а не blanket”Наївний декларативний UPDATE … SET is_supported = (code = ANY(канон)) WHERE year=2026 здавався елегантним, але dry-run проти прода показав, що він також перекинув би 14 рядків A4 і 5 рядків G11 з false→true. Причина — підспеціальнісна гранульованість: Додаток 9 підтримує A4 лише для окремих предметних спеціальностей (А4.04–А4.10, А4.15–А4.16), і поточний split A4 у даних уже коректний (STEM-предмети true, мови/мистецтво/фізкультура false). Blanket по code завищив би їх — тобто проміняв би один баг на інший. Тому фікс звужено до цілісних спеціальностей біо-блоку (G20/G21/G22), де підспеціальностей немає, плюс однозначний недооблік J4.
Джерело істини = рядки БД, не код
Section titled “Джерело істини = рядки БД, не код”Міграція 1738138282123-IsSupportedSpeciality лише додає колонку з DEFAULT false — значень не виставляє. Пошук по всіх репо (abitly-api, abitly-parse, abitly-mcp, abitly-data): жоден ETL/сід/CLI не пише abitly.specialities.is_supported (сідер abitly-parse працює з іншою таблицею catalog.specialities, де такої колонки немає). Тобто ~48 значень true завантажені позасистемно (вручну). Наслідок: ризик відкату міграції ≈ нульовий, але й немає джерела, яке б валідувало значення проти Додатка 9 при наступному оновленні.
Чому це не зловили раніше
Section titled “Чому це не зловили раніше”| Гарантія | Чи була | Чому не спрацювала |
|---|---|---|
| Автоматична звірка КБ проти ЄДЕБО/Освіти | ❌ ні | Розбіжність у 2 % не породжує помилок/алертів — виявлено лише репортом користувача |
Тест: is_supported відповідає канонічному Додатку 9 | ❌ ні | Немає списку-еталона в коді, ні перевірки в CI |
| Валідація сіду/імпорту прапорця | ❌ ні (немає ETL) | Значення завантажені вручну, без будь-якої перевірки проти джерела |
| Оновлення даних при зміні року | ❌ процесу немає | Перелік-2026 звузили (біотех прибрали), але прод-дані на 2026 не оновили |
| Одне джерело правди для правил | ⚠️ частково | KB-примітка сама містила помилку «G1–G22» — і код, і документація повторили її |
Виправлення (що реально спрацювало)
Section titled “Виправлення (що реально спрацювало)”1. Міграція 1782400000000-CorrectSpecialitySupported2026.ts (ідемпотентна, year=2026):
UPDATE "specialities" SET is_supported = false WHERE year = 2026 AND code IN ('G20','G21','G22');UPDATE "specialities" SET is_supported = true WHERE year = 2026 AND code = 'J4'; -- Охорона праці, помилково false2. Застосування на проді (прод-міграції — ручні, не на бут; див. db-issues) — одна ідемпотентна транзакція = дані + запис у abitly.migrations:
BEGIN; UPDATE abitly.specialities SET is_supported=false WHERE year=2026 AND code IN ('G20','G21','G22'); -- 3 рядки UPDATE abitly.specialities SET is_supported=true WHERE year=2026 AND code='J4'; -- 1 рядок INSERT INTO abitly.migrations ("timestamp", name) SELECT 1782400000000, 'CorrectSpecialitySupported20261782400000000' WHERE NOT EXISTS (SELECT 1 FROM abitly.migrations WHERE name='CorrectSpecialitySupported20261782400000000');COMMIT;3. Флаш стейл-кешу. Прод-API продовжував віддавати старе значення: відповіді /specialities/* кешуються в Redis (RedisCacheInterceptor, CACHE_TTL=43200 = 12 год). Через SSM-тунель до ElastiCache видалено 36 ключів SpecialitiesController-* (ключ = ${Class}-${method}:GET:url:query:params:locale).
Фінальна перевірка (LIVE):
| Перевірка | Результат |
|---|---|
GET api.abitly.org/specialities/calculator → G21 | isSupported=false ✅ |
GET …/specialities?year=2026&search=Біотех → G21 | isSupported=false ✅ |
| G20 / G22 | isSupported=false ✅ |
| J4 Охорона праці | isSupported=true ✅ |
| Розрахунок 2026 для G21 | 184.767 (= ЄДЕБО/Освіта) ✅ |
Превентивні заходи
Section titled “Превентивні заходи”- 2026 виправлено (міграція
1782400000000+ прод-транзакція + флаш Redis) — PR api-v2 #533, live-verified. - KB виправлено —
dodatok-9.mdтепер містить точний перелік (G1–G16, G19) замість «G1–G22» (PR hub #32). - Біо-блок 2024/2025 виправлено — міграція
1782500000000(2025 G20/G21/G22; 2024 162/163 → false) застосована на проді + флаш; live-verified (офери →branchCoefficient=1.0). Верифіковано дослівними списками 2024 Додаток 6 / 2025 Додаток 9 + рейтингами ЄДЕБО. - Повний per-year аудит 2024/2025 — усіх кодів Додатка-9 (не лише біо-блоку; перелік щороку різний), щоб зловити можливі розбіжності в обидва боки.
- CI-тест
is_supportedvs канон. Референс-список 45 кодів Додатка-9 (по роках) у репо як source-of-truth; тест, що жоден рядокspecialitiesне розходиться з ним. Ловив би цей клас помилки в обидва боки. - Джерело даних для прапорця. Значення
is_supportedзавантажені вручну без ETL — оформити сід/імпорт із канонічного переліку, щоб наступне оновлення не відтворило помилку. - Показати ГК у UI калькулятора (напр. «Галузевий коефіцієнт ×1.02 — за пріоритету 1/2»): робить розбіжність із ЄДЕБО прозорою й зменшує звернення «чому в них інакше».
- Дозакрити 2026-розбіжності поза біо-блоком: A6 має 5 підспец.
trueпри офіційних лише А6.02–А6.05; G11 — спеціалізаціїfalseпри basetrue. Потребує підспеціальнісного мапінгу.
Відкриті питання
Section titled “Відкриті питання”- Звідки взялися значення
is_supported? ETL немає — хтось завантажив вручну. Хто/коли, і що завадить наступному ручному завантаженню повторити помилку. - Повний Додаток-9 для 2024 і 2025 (біо-блок уже виправлено). Переліки різняться щороку; лишається звірити решту кодів кожного року (не лише біотех — можливі помилки в обидва боки).
- A6/G11 у 2026. Підспеціальнісні розбіжності (див. останній пункт превентивів) — доброякісні чи баги; потребують точного мапінгу
specification→офіційний код.
Ключові ідентифікатори
Section titled “Ключові ідентифікатори”| Поле | Значення |
|---|---|
| Таблиця/колонка | abitly.specialities.is_supported (bool) |
| Хибні рядки (2026) | G20, G21 (Біотехнології), G22 — були true, стало false; J4 — було false, стало true |
| Виправлено (історичні) | 2025 G20/G21/G22, 2024 162/163 → false (міграція 1782500000000); офери → branchCoefficient=1.0 |
| Міграції | 1782400000000-CorrectSpecialitySupported2026 (2026 + J4) · 1782500000000-CorrectHistoricalBiotechSupported (2024/2025 біо-блок) · колонку додала 1738138282123-IsSupportedSpeciality |
| PR (історичний фікс) | api-v2 #535 |
| PR | api-v2 #533 · hub #32 |
| Код | FE calculateHelpers.ts (IS_SUPPORTED_COEF=1.02) · BE SpecialityScore2025Strategy.ts · offers.mapper.ts:72 |
| Кеш | ElastiCache abitly-prod-cache…:6379 (без auth/TLS), CACHE_TTL=43200; флашнуто 36 ключів SpecialitiesController-* |
| Першоджерело | наказ №373 (z0374-26), Розділ IV п.7 + Додаток 9 — G-блок = G1–G16, G19 |
- Впевнений хибний висновок гірший за «не знаю». Двічі прозвучало «Abitly рахує правильно» на основі стисненої примітки. Скепсис користувача виявився точнішим. Перевіряй першоджерело, а не переказ — особливо перед тим, як заспокоїти.
- Формула може бути ідеальною, а результат — неправильним: корінь у даних (прапорець), не в коді. Шукаючи баг у розрахунку, перевіряй і вхідні дані, не лише алгоритм.
- «Діапазон» у документації — небезпечне стиснення. Додаток 9 — це конкретні коди (G1–G16, G19), а не «G1–G22». Одна неточна примітка інфікувала і KB, і, ймовірно, спосіб завантаження даних.
- Один прапорець живить багато поверхонь. Виправлення даних лікує калькулятор, бейджі, шанси, симуляцію й бота одразу — але й помилка так само була всюди. Централізований прапорець = централізований ризик.
- Кеш переживає деплой. Прод-API віддавав старе значення після фіксу БД; без флашу Redis було б неправильно ще 12 год. Зміна даних, які кешуються, = обовʼязковий крок «флаш ключів».
- Dry-run проти прода — перед мутацією. Read-only «що саме зміниться» спіймав, що blanket-UPDATE завищив би A4/A6. Показуй точний diff перед тим, як писати.
- Рік має значення. Перелік особливої підтримки змінюється щороку, а офери вказують на минулорічні спеціальності — фікс одного року не покриває поверхні, що читають інший. Скоуп за роком треба свідомо звіряти з тим, які роки реально читаються.
Пов’язана документація
Section titled “Пов’язана документація”- Калькулятор конкурсного бала — фіча, яку зачепив баг
- Додаток 9 · Розділ IV п.7 — норма ГК (першоджерело)
- abitly-api — де живе прапорець і формула
- Проблеми з БД — доступ до прод-Postgres, ручні міграції
- MCP-реєстр — які MCP підключати для роботи з даними