Туманомір: Що Насправді Побудовано Після "Джерела Невідомості"

Валентин Соломко · липень 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 calibrate roadmap 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 залежить від машини — алокації стабільні).