Перейти до вмісту

Інцидент 2026-07-01 — завищений конкурсний бал: галузевий коефіцієнт ×1.02 на непідтримуваних спеціальностях

Конкурсний бал НМТ рахується за зваженою формулою (Розділ IV п.4 Порядку прийому-2026): КБ = Σ(Кᵢ·Пᵢ) / Σ(Кᵢ) + ОУ, після чого множиться на регіональний (РК) × галузевий (ГК) коефіцієнти (п.7). ГК = 1.02 лише для заяв із пріоритетністю 1/2 на спеціальності з переліку особливої підтримки (Додаток 9) — і 1.00 в усіх інших випадках.

У коді ГК реалізовано однаково на обох боках через один прапорець speciality.isSupported:

  • Фронтенд (калькулятор): calculateHelpers.tsapplyAdditionalFactors(grade) = grade × (isSupported ? 1.02 : 1.0) (IS_SUPPORTED_COEF = 1.02).
  • Бекенд (abitly-api): SpecialityScore2025Strategy.tsisSupported ? score × 1.02 : score; офери — offers.mapper.ts:72branchCoefficient: isSupported ? 1.02 : 1.0.

Прапорець живе в колонці abitly.specialities.is_supported і роздається клієнту через GET /specialities/* (кешується в Redis). Фронтенд не має захардкодженого переліку — усе тягне з API. Тобто один рядок даних живить калькулятор, бейджі «особлива підтримка», шанси, симуляцію і бота одночасно.

Спеціальності зберігаються по роках (year = 2024 числові коди / 2025–2026 літерні), а також погранульовано за підспеціальностями: один code (напр. A4) — багато рядків, що розрізняються колонкою specification.

ЧасПодіяСтан
Латентний баг: 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) — той самий робочий день.

Формула правильна — неправильні дані

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'; -- Охорона праці, помилково false

2. Застосування на проді (прод-міграції — ручні, не на бут; див. 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 → G21isSupported=false
GET …/specialities?year=2026&search=Біотех → G21isSupported=false
G20 / G22isSupported=false
J4 Охорона праціisSupported=true
Розрахунок 2026 для G21184.767 (= ЄДЕБО/Освіта) ✅
  • 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_supported vs канон. Референс-список 45 кодів Додатка-9 (по роках) у репо як source-of-truth; тест, що жоден рядок specialities не розходиться з ним. Ловив би цей клас помилки в обидва боки.
  • Джерело даних для прапорця. Значення is_supported завантажені вручну без ETL — оформити сід/імпорт із канонічного переліку, щоб наступне оновлення не відтворило помилку.
  • Показати ГК у UI калькулятора (напр. «Галузевий коефіцієнт ×1.02 — за пріоритету 1/2»): робить розбіжність із ЄДЕБО прозорою й зменшує звернення «чому в них інакше».
  • Дозакрити 2026-розбіжності поза біо-блоком: A6 має 5 підспец. true при офіційних лише А6.02–А6.05; G11 — спеціалізації false при base true. Потребує підспеціальнісного мапінгу.
  1. Звідки взялися значення is_supported? ETL немає — хтось завантажив вручну. Хто/коли, і що завадить наступному ручному завантаженню повторити помилку.
  2. Повний Додаток-9 для 2024 і 2025 (біо-блок уже виправлено). Переліки різняться щороку; лишається звірити решту кодів кожного року (не лише біотех — можливі помилки в обидва боки).
  3. 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
PRapi-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
  1. Впевнений хибний висновок гірший за «не знаю». Двічі прозвучало «Abitly рахує правильно» на основі стисненої примітки. Скепсис користувача виявився точнішим. Перевіряй першоджерело, а не переказ — особливо перед тим, як заспокоїти.
  2. Формула може бути ідеальною, а результат — неправильним: корінь у даних (прапорець), не в коді. Шукаючи баг у розрахунку, перевіряй і вхідні дані, не лише алгоритм.
  3. «Діапазон» у документації — небезпечне стиснення. Додаток 9 — це конкретні коди (G1–G16, G19), а не «G1–G22». Одна неточна примітка інфікувала і KB, і, ймовірно, спосіб завантаження даних.
  4. Один прапорець живить багато поверхонь. Виправлення даних лікує калькулятор, бейджі, шанси, симуляцію й бота одразу — але й помилка так само була всюди. Централізований прапорець = централізований ризик.
  5. Кеш переживає деплой. Прод-API віддавав старе значення після фіксу БД; без флашу Redis було б неправильно ще 12 год. Зміна даних, які кешуються, = обовʼязковий крок «флаш ключів».
  6. Dry-run проти прода — перед мутацією. Read-only «що саме зміниться» спіймав, що blanket-UPDATE завищив би A4/A6. Показуй точний diff перед тим, як писати.
  7. Рік має значення. Перелік особливої підтримки змінюється щороку, а офери вказують на минулорічні спеціальності — фікс одного року не покриває поверхні, що читають інший. Скоуп за роком треба свідомо звіряти з тим, які роки реально читаються.

Пов’язана документація

Section titled “Пов’язана документація”