Валентин Соломко · липень 2026
Теза статті (один рядок): Методологія — це гіпотеза, доки щось не прожене її крізь реальні специфікації й не відзвітує, що зламалося; Туманомір — саме така штука: три метрики, CLI з п’ятьма командами і 25 днів, за які інструмент почав детерміновано гейтити власний туман.
Пролог: обіцянка, яку треба було виконати
Стаття “Джерело Невідомості” визначила три метрики для оцифрування туману специфікацій, які споживають ШІ-агенти — $K_{drift}$ (нетрасованість), $D_{const}$ (щільність обмежень), $D_{pair}$/$H_{spec}$ (розкид генерацій) — і закрилася чесним відкладенням:
“Деталі реалізації — тема окремого, суто інженерного матеріалу.”
Це той матеріал. За 25 днів (3–27 липня 2026) методологія перетворилася на Туманомір — CLI на Go, v0.1.0-dev, без жодної залежності, крім gopkg.in/yaml.v3 (для парсингу конфіга). 66 комітів, 64 злитих PR (діапазон #1–#114, не всі номери підряд), без жодного відкату. П’ять команд працюють наскрізь. Інструмент детерміновано гейтить власну специфікацію в CI (make dogfood = tumanomir check docs/requirements.md) — стохастичний шар (measure/gate проти Ollama) у CI не запускається, це ручна демонстрація нижче. Ми не претендуємо на реліз — тегів немає, версія відверто позначена -dev — але “стабілізувався” тут означає конкретну, перевірювану річ: feature-complete, зелений CI, жодного відкоченого PR за весь час.
Ця стаття — не продовження драматургії Актів. Рецензенти першої статті мали рацію: рефлексивна проза тримає голос маніфесту, а інженерний звіт заслуговує на спокійнішу структуру. Тому далі — розділи, не Акти.

Що побудовано
П’ять команд, кожна демонструється реальним виводом терміналу — не переказом, а копією реального прогону.
check — детермінований шар, миттєво, без мережі
$ tumanomir check docs/requirements.md
K_drift: 0.00 [ok] (threshold 0.20, 0/33 requirements untraced)
D_const: 0.03 [warn] (threshold 0.35, 101 markers / 3905 prose tokens)
D_pair: — (stochastic layer: run `tumanomir measure` with an instrument)
Це догфудинг: Туманомір вимірює власну специфікацію (docs/requirements.md, написана в його ж трасованій розмітці). Це живий прогін на день написання статті — специфікація зростає разом із проєктом, тож ці числа (33 вимоги, 101 маркер) вищі за приклад у README.md (30 вимог, 95 маркерів): README не встигає за кожним PR. Показово, що $D_{const}$ тут опиняється в [warn] — 0.03, нижче порогу 0.35. Специфікація повна rationale-прози й пояснень “чому”, а не голих схем, і це не баг — саме тому $D_{const}$ архітектурно не може блокувати гейт (REQ-CHK-06): це лексичний проксі, а не істина в останній інстанції.
measure — стохастичний шар, за фіксованим приладом
measure друкує лише стохастичний шар ($D_{pair}$/$H$/$H_{norm}$) — детерміновані $K_{drift}$/$D_{const}$ бачите з check (вище) чи з gate (нижче), що робить обидва разом.
$ tumanomir measure --instrument ollama:qwen3-coder:30b \
-n 3 --temp 1.0 --sim-threshold 0.95 \
--num-ctx 8192 --num-predict 2048 \
docs/investigation/_sanity/specs/sharp.md
Instrument config (REQ-MSR-04):
backend: ollama
model: qwen3-coder:30b
temperature: 1.00
samples (N): 3
think: false
num_ctx: 8192
num_predict: 2048
sim_threshold: 0.95
prompt: PromptV1 (276 bytes)
D_pair: 0.21 [ok] (95% CI [-0.00, 0.21]; threshold 0.30, mean sim 0.79, N=3 valid, 0 discarded)
H: 1.58 bits (ordinal signal only, not gated)
H_norm: 1.00 (ordinal signal only, not gated)
PromptV1 — не рядковий літерал, а іменована, версіонована константа коду:
const PromptV1 = "You convert software specifications into Go type definitions. " +
"Output ONLY one ```go code block containing: package declaration, " +
"type definitions (structs, named types, consts) and function " +
"signatures with empty bodies {}. No explanations, no comments, " +
"no implementation logic."
Ми запустили той самий інструмент з тими самими параметрами три рази поспіль, проти того самого sharp.md — не тому, що шукали конкретний результат, а тому, що збирали матеріал для цієї статті й повторили прогін для різних демонстрацій (measure, потім gate --format json, потім gate --explain — три різні CLI-виклики, той самий прилад і вхід). Результат — не одне число, а розкид, і це шум приладу, не помилка вимірювання:
| Прогін | Команда | D_pair | вердикт | mean_sim |
|---|---|---|---|---|
| 1 | measure |
0.21 | ok | 0.79 |
| 2 | gate --format json |
0.31 | block | 0.69 |
| 3 | gate --explain |
0.42 | block | 0.58 |
При $N=3$ і temp=1.0 (стрес-тестовий режим, не робочий — до цього ще повернемося) та сама специфікація й той самий прилад дають вердикт від “ok” до “block” залежно від того, коли саме ви натиснули Enter. $H$ і $H_{norm}$ лишаються стабільними (1.58 біт / 1.00) на всіх трьох прогонах — сатуровані на стелі $\log_2 3$, тобто не розрізняють нічого; розрізняє саме $D_{pair}$, і це аргумент на його користь як робочої метрики, а не проти вимірювання загалом. Довірчий інтервал при $N=3$ теж вартий згадки: [-0.00, 0.21] на прогоні 1 — на такій вибірці він практично марний, і це ще одна причина не читати одне число як остаточний вердикт.
Це не баг вимірювання — це саме та властивість приладу, про яку попереджала перша стаття, тепер видима наживо, а не в переказі. До неї ми повернемося в розділі “Що ще не вирішено”.
gate — обидва шари за один прохід, для CI-інтеграції (за наявності приладу)
$ tumanomir gate --instrument ollama:qwen3-coder:30b -n 3 \
--num-ctx 8192 --num-predict 2048 --format json \
docs/investigation/_sanity/specs/sharp.md | jq '.result.measure.dispersion'
{
"n": 3,
"discarded": 0,
"mean_sim": 0.6856951188405561,
"d_pair": 0.3143048811594439,
"d_pair_ci_low": 0,
"d_pair_ci_high": 0.3143048811594439,
"clusters": 3,
"sim_thresh": 0.95,
"h": 1.584962500721156,
"h_norm": 0.9999999999999999
}
--format json — для CI-парсингу. Але окрема деталь варта уваги: gate відмовляється тихо деградувати. Якщо передати --temp, а прилад не резолвиться (немає ні --instrument, ні .tumanomir.yaml), команда падає жорсткою помилкою, а не мовчки відкочується до детермінованого шару:
gate: --temp was passed but no instrument resolved (no --instrument and
no .tumanomir.yaml instrument: section) — refusing to silently downgrade
to deterministic-only (REQ-GATE-02)
--explain класифікує, який саме шар провалився і чи відтворювана ця відмова:
$ tumanomir gate --instrument ollama:qwen3-coder:30b -n 3 \
--num-ctx 8192 --num-predict 2048 --explain \
docs/investigation/_sanity/specs/sharp.md
⚠ D_pair blocked (stochastic — threshold breach under this instrument; a rerun may land differently)
K_drift: 0.00 [ok] (threshold 0.20, 0/3 requirements untraced)
D_const: 0.11 [warn] (threshold 0.35, 10 markers / 84 prose tokens)
...
exit code: 1 (gate failed)
“a rerun may land differently” — це не хеджування заради ввічливості. Ми щойно показали три прогони з різним вердиктом на тому самому вході.
calibrate — кореляція проти історичного корпусу
calibrate ніколи не торкається мережі й не викликає LLM — $D_{pair}$ читається з корпусу, а не переміряється:
$ tumanomir calibrate corpus.jsonl
Calibration over 3 valid row(s), 0 skipped
⚠ fewer than 5 valid rows — correlation coefficients below are not statistically meaningful yet
K_drift spearman=+0.00
outcome <= median: min=0.00 mean=0.00 max=0.00
outcome > median: min=0.00 mean=0.00 max=0.00
D_const spearman=-0.87
outcome <= median: min=0.00 mean=0.05 max=0.11
outcome > median: min=0.00 mean=0.00 max=0.00
D_pair spearman=+1.00
outcome <= median: min=0.19 mean=0.24 max=0.30
outcome > median: min=0.55 mean=0.55 max=0.55
No threshold is auto-selected or written to .tumanomir.yaml — use these numbers to inform your own choice (REQ-NFR-03).
Це не наш реальний корпус — реальний ще не накопичився (див. нижче). Це синтетичний приклад із трьох рядків, щоб показати, що механізм працює. Spearman, не Pearson: outcome — довільна, задана користувачем шкала, тож має сенс перевіряти лише монотонний зв’язок, а Spearman коректно деградує до бінарного випадку “чисто проти туманно” через ранговий tie-handling.
label — єдиний писар результату
label <hash-or-prefix> <score> — єдина команда, що записує outcome рядка корпусу, за префіксом spec_hash (як git резолвить короткий хеш коміту). Жодна інша команда не обчислює й не вгадує outcome.
Архітектура, що заслуговує довіри, а не просто правильна
Мережевий інваріант чотирьох детермінованих пакетів
Детерміновані пакети (internal/metrics, internal/spec, internal/config, internal/calibrate) не мають права торкатися мережі. Це не конвенція в коментарі, яку легко порушити — це перевіряється тестом:
$ go test ./internal/ -run TestNoNetworkImports -v
=== RUN TestNoNetworkImports
--- PASS: TestNoNetworkImports (0.05s)
Тест парсить транзитивні залежності саме цих чотирьох пакетів і падає, якщо десь з’явиться net/*. (Це не глобальне твердження “лише internal/instrument може торкатися мережі” — тест перевіряє конкретний список пакетів, не весь модуль.) Саме це робить check і calibrate безпечними як git hook і в air-gapped CI — не тому, що ми обіцяли, а тому, що тест (і, відповідно, CI) провалиться, якщо хтось спробує це порушити.
Переписаний сканер $K_{drift}$: 233x менше алокацій
Перша реалізація $K_{drift}$ використовувала regexp — і алокації масштабувалися майже 1:1 з кількістю вимог (3260 allocs/op на синтетичному корпусі 1МБ/~3400 вимог). PR #68 переписав його як hand-written byte scanner:
$ go test ./internal/metrics/... -bench . -benchmem -run '^$'
BenchmarkKDrift1MB-16 5004 215091 ns/op 4875.62 MB/s 354885 B/op 14 allocs/op
BenchmarkDConst1MB-16 67 16237956 ns/op 64.58 MB/s 4177923 B/op 2 allocs/op
BenchmarkCheck1MB-16 63 16723453 ns/op 62.71 MB/s 4532803 B/op 16 allocs/op
14 алокацій на операцію — незалежно від кількості вимог. Повний check на 1МБ укладається в 16.7мс, у бюджет 100мс (REQ-NFR-01). Це саме та “нотатка про реалізацію” з першої статті, доведена до конкретних чисел на реальному бенчмарку, а не обіцянка “мікросекунди на документ” без пруфа.
D_const архітектурно не може блокувати
REQ-CHK-06 фіксує це явно в коді, не лише в документації:
$D_{const}$ is a lexical proxy, not a ground-truth measure of specification precision — it must never produce
VerdictBlock(exit code 1) regardless of its value… This is advisory-only by design, not an oversight.
Це та сама чесність, що в першій статті (“метрика карає не прозу взагалі, а прозу, не прив’язану до обмежень”) — тільки тепер зафіксована як тестований інваріант, а не як застереження, яке можна забути під тиском дедлайну.
Що ми знайшли, чого не очікували
Скільки $D_{pair}$ з’їдає саме лише найменування: $0.6667$ на фіксованій структурі
PR #110 (26 липня) додав тест, який ізолює конкретне питання: скільки сигналу $D_{pair}$ насправді походить від структурної розбіжності, а скільки — просто від того, як модель називає змінні?
Три фікстури кодують одну структурну інтерпретацію маленької специфікації — структуру з двома полями (int, string), функцію, константу — варіюючи лише текст ідентифікаторів. Контрольна пара тримає фіксованим усе, включно з іменами, переставляючи лише порядок полів:
$ go test ./internal/dispersion/... -run TestNamingNoiseDPair -v
=== RUN TestNamingNoiseDPair
--- PASS: TestNamingNoiseDPair (0.00s)
Тест сам по собі друкує тільки PASS — числа він асертить, не логує. Вони зафіксовані як коментар і перевірка в коді (internal/dispersion/naming_noise_test.go), а не як вивід консолі; ми перерахували їх окремо (Analyze() на тих самих фікстурах), щоб показати, що це не переказ:
naming-noise D_pair = 0.6667 MeanSim = 0.3333 N = 3
reorder D_pair = 0.0000 MeanSim = 1.0000 N = 2
Дві третини простору схожості з’їдає вибір імені, коли структурна інтерпретація зафіксована — на цій конкретній тризразковій фікстурі, не як загальне твердження про всі можливі специфікації. На порожньотілих Go-скелетах типів — єдина ціль генерації PromptV1 у v0.1 — це означає, що значна частка сигналу $D_{pair}$ на такому вході може бути шумом стилю найменування, а не структурною розбіжністю.
Що це означає для гейта. Поточний поріг блокування — $D_{pair} > 0.30$. Якщо шум самих лише імен здатен дати 0.67 на порожньому скелеті, то “блок” на межі порогу не обов’язково сигналізує структурну неоднозначність специфікації — він може сигналізувати лише те, що дві генерації модель назвала по-різному. Це не робить метрику марною (на живих sharp/fog специфікаціях $D_{pair}$ таки розрізняє точні й туманні входи, див. sanity-check першої статті) — але означає, що абсолютне порогове значення для порожніх Go-скелетів наразі не відокремлює “модель по-різному назвала поля” від “модель по-різному зрозуміла структуру”. Тому це відкрите Фаза-2 питання, не побічна цікавинка.
Тест також документує два явно відхилені способи це полагодити — щоб їх не запропонували знову:
- Embedding-based synonym matching — повертає недетерміновану залежність від моделі в метрику, чия цінність саме в тому, що вона фіксований, відтворюваний, без-додаткового-LLM прилад.
- Levenshtein distance — не залежність від моделі, але ловить морфологію/одруки, а не синоніми:
UserID,ID,Identifierмають велику edit-відстань, попри те, що це рівно та варіативність найменувань, яку тут вимірюємо.
Число зафіксоване як вхідні дані для майбутнього рішення “Фаза 2: так/ні” про додатковий, негейтуючий діагностичний $D_{pair_shape}$ (з канонізованими іменами полів) — це рішення не приймається цим тестом. Тест тільки вимірює й фіксує базову лінію.
Що ще не вирішено
Перша стаття закінчувалася “драбиною тверджень” — розділенням доведеного й гіпотези. Тримаємо той самий стандарт тут, і ця секція важлива не менше за перелік того, що запрацювало.
Немає реального розміченого корпусу. calibrate існує й працює (демонстрація вище), але корпус — ні. session-indexer і ragivka, два реальні проєкти з першої статті, ще не почали накопичувати рядки. Порогові значення (0.20 / 0.35 / 0.30) лишаються гіпотезами з першої статті, не каліброваними числами.
$D_{pair}$ — власність приладу, не документа. Ми щойно показали це наживо: та сама специфікація, той самий прилад, три прогони — розкид 0.21–0.42. Перша стаття вимагала для цього протокол калібрувального baseline і $\Delta H$ проти нього, виміряного на робочій температурі (0.3–0.5), не на стрес-тестовій (1.0). v0.1 не робить жодного з двох:
v0.1’s default temperature is 1.0, and D_pair is gated as an absolute value against 0.30, not as a delta against a calibrated baseline measured at working temperature. This is a deliberate, temporary simplification pending the
calibrateroadmap item, not a silent methodology change.
Це задеклароване (REQ-CFG-01), не приховане розходження — але воно означає, що поточний гейт міряє щось трохи інше за те, що описувала перша стаття.
$D_{const}$ лишається лексичним проксі. Повний RFLP-граф (Neo4j) з першої статті не побудований. Поточна метрика — маркери проти прози, не структурний обхід графа.
$K_{drift}$ — лише strict-режим. Assisted-режим (LLM-парсер для специфікацій без розмітки) не реалізований — і його реалізація коштуватиме детермінізму, за визначенням: детермінований шар (REQ-CHK-01..06) вимагає нуль LLM у контурі.
Один прилад. instrument.Generator — вже плагований інтерфейс, але перевірений лише проти Ollama. OpenAI/Anthropic backends — не збудовані, не протестовані.
Лише одна проєкція. measure генерує тільки Go-визначення типів (PromptV1). SQL DDL, OpenAPI — інші проєкції з roadmap першої статті — не реалізовані; застосовність v0.1 обмежена Go-проєктами.
“Copy-floor” не універсальна. У джерельному експерименті подача моделі вже готових, однозначних Go-типів дала $H=0$ — але це властивість конкретного інструменту (qwen3-coder:30b), а не гарантована метрикою нижня межа: слабша чи інша модель може не дійти до нульової ентропії навіть на цілком однозначній специфікації.
Внесок шуму найменувань у $D_{pair}$ (0.6667 на фіксованій структурі) — виміряний, не полагоджений, і його імплікація для порогу гейта (розділ вище) поки що не вирішена архітектурно, лише зафіксована як відоме питання.
Драбина тверджень: що змінилося за 25 днів (і що ні)
Перша стаття закінчувалася розділенням доведеного й гіпотези. Ось той самий поділ для v0.1 — коротко, без повторення аргументації з розділів вище:
| Твердження | Статус |
|---|---|
| Метрики $K_{drift}$/$D_{const}$/$D_{pair}$ обчислюються наскрізно, через CLI | реалізовано |
$K_{drift}$ рахується за ~0.2мс на 1МБ (14 алокацій/op, незалежно від кількості вимог); повний check (з $D_{const}$) — 16.7мс, у бюджеті 100мс |
виміряно, тест-закріплено |
| $D_{const}$ архітектурно не може дати хибний блок | тестований інваріант |
Детерміновані пакети (metrics/spec/config/calibrate) мережі не торкаються |
тестований інваріант (саме для цих 4 пакетів) |
| Найменування саме по собі може дати $D_{pair}=0.6667$ на фіксованій структурі | виміряно один раз, зафіксовано як референс |
| Порогові значення (0.20/0.35/0.30) коректні для гейтингу | гіпотеза з першої статті, не калібрована |
| $D_{pair}$ переноситься між моделями/інструментами | не перевірено — лише Ollama |
| Виміряна дисперсія передбачає downstream-наслідки (rework, компіляційні фейли, токен-бюджет) | не перевірено — корпусу немає |
| “Copy-floor” ($H=0$ на однозначному вході) — властивість метрики | спростовано власною документацією: це властивість інструменту, не гарантія |
Цикл накопичення даних щойно почався: label і опційна корпус-акреція в measure — найновіші комміти проєкту (кінець липня). Дані для calibrate — єдиний пункт середньострокового горизонту в roadmap, що не потребує нової архітектури, лише часу й реальних вимірювань на живих проєктах.
Код: github.com/valpere/tumanomir.
Дані та відтворюваність
Кожен блок виводу в цій статті — реальний прогін проти живого бінарника (make build && bin/tumanomir ...), не переказ із документації, окрім явно позначеного винятку в розділі про шум найменувань. Специфікації для measure/gate — ті самі docs/investigation/_sanity/specs/sharp.md, що й у sanity-check першої статті. Тести відтворюються командою make ci у корені репозиторію; бенчмарки — окремо, go test ./internal/metrics/... -bench . -benchmem -run '^$' (не входять у make ci, ns/op залежить від машини — алокації стабільні).