Land the bank read-out pack: the signing stop reaches the reader through a new resource, contract 0.16.0, and the register literals follow it
This commit is contained in:
parent
dc1b99e71d
commit
ffd3751631
25 changed files with 2750 additions and 45 deletions
File diff suppressed because one or more lines are too long
|
|
@ -10,7 +10,7 @@
|
|||
- [product-requirements.md](product-requirements.md) — реестр «что продукт обязан уметь»: производное живого брифа владельца и его находок, статусы сверены кодом.
|
||||
- `architecture/` — синтез и контракты:
|
||||
- [05-decisions-log.md](architecture/05-decisions-log.md) — **источник истины по решениям**: ратифицированный контракт, при конфликте побеждает он. Дисциплина чтения (грепать номер, не читать целиком, баннер прежде тела) — в каноне `../CLAUDE.md`, здесь не дублируется. Статус и тело ЛЮБОГО номера одним хопом — реестр [05-decisions-index.md](architecture/05-decisions-index.md) (все ноты, колонка тем для грепа «какой закон по X»).
|
||||
- [09-target-architecture.md](architecture/09-target-architecture.md) — целевая 7-слойная архитектура (статус стройки — шапка-таблица; §3 — карта находок H/L) · [10-prompt-architecture.md](architecture/10-prompt-architecture.md) — промпт-слой · [12-go-style-notes.md](architecture/12-go-style-notes.md) — норматив общности §0 · [13-tech-debt-anchors.md](architecture/13-tech-debt-anchors.md) — якоря техдолга + археология выселенных строк бэклога §Б-* (справочник к бэклогу, НЕ трекер) · [14-api-contract/](architecture/14-api-contract/) — **контракт API v0 фронт↔платформа (D39.99)**: нормативная спека OpenAPI 3.1 (типы фронта генерятся из неё) + компаньон-README с провенансом каждой строки (легенда — в нём самом; зонная копия `frontend/docs/api-contract/` ОТСТАЛА на `0.2.3` при каноне `0.15.0` (испр. оркестратором 17.09, прежде стояло `0.10.0` — разошлось на пять миноров; ⚠ версию канона брать ФАЙЛОМ — `grep '^ version:' architecture/14-api-contract/openapi.yaml`, число здесь стареет) и зеркалом сегодня НЕ является — зона заморожена, синк при разморозке; читать ТОЛЬКО канон).
|
||||
- [09-target-architecture.md](architecture/09-target-architecture.md) — целевая 7-слойная архитектура (статус стройки — шапка-таблица; §3 — карта находок H/L) · [10-prompt-architecture.md](architecture/10-prompt-architecture.md) — промпт-слой · [12-go-style-notes.md](architecture/12-go-style-notes.md) — норматив общности §0 · [13-tech-debt-anchors.md](architecture/13-tech-debt-anchors.md) — якоря техдолга + археология выселенных строк бэклога §Б-* (справочник к бэклогу, НЕ трекер) · [14-api-contract/](architecture/14-api-contract/) — **контракт API v0 фронт↔платформа (D39.99)**: нормативная спека OpenAPI 3.1 (типы фронта генерятся из неё) + компаньон-README с провенансом каждой строки (легенда — в нём самом; зонная копия `frontend/docs/api-contract/` ОТСТАЛА на `0.2.3` при каноне `0.16.0` (испр. оркестратором 17.09 дважды: прежде стояло `0.10.0` — разошлось на пять миноров, затем `0.15.0` — бамп `D39.262`; ⚠ версию канона брать ФАЙЛОМ — `grep '^ version:' architecture/14-api-contract/openapi.yaml`, число здесь стареет) и зеркалом сегодня НЕ является — зона заморожена, синк при разморозке; читать ТОЛЬКО канон).
|
||||
- Топикальные входы (D39.126; НЕ источники истины, при конфликте побеждает D-лог): [15-money-path.md](architecture/15-money-path.md) — деньги от гранта до settle одним маршрутом · [16-events-emitter.md](architecture/16-events-emitter.md) — сборка-норматив эмиттера шва — обязательное пре-чтение перед кодом эмиттера · **[17-seam-inbound-law.md](architecture/17-seam-inbound-law.md) — закон ВХОДНОЙ двери шва движок↔платформа — РАТИФИЦИРОВАН D39.156: семь пунктов дисциплины, двери встают паками без нового решения владельца** · **[18-bank-ontology.md](architecture/18-bank-ontology.md) — онтология банка памяти (РАТИФИЦИРОВАНА D39.158): три роли носителей, единственные писатели, дисциплина проекции — обязательное пре-чтение перед кодом, трогающим банк или его артефакты** · [STACK.md](STACK.md) — карта «роль → модель → конфиг → квирки».
|
||||
- Исторические, читать через ⚠-баннеры: [01-decisions.md](architecture/01-decisions.md) (Р1–Р10) · [02-mvp-plan.md](architecture/02-mvp-plan.md) · [03-implementation-notes.md](architecture/03-implementation-notes.md) · [04-unhappy-paths.md](architecture/04-unhappy-paths.md) · [06-memory-risk-registry.md](architecture/06-memory-risk-registry.md).
|
||||
- `experiments/` — эмпирика полигона: [00-provider-quirks.md](experiments/00-provider-quirks.md) — **читать перед любым вызовом провайдера**; [08-cost-model-v2.md](experiments/08-cost-model-v2.md) — денежная модель; [09-pilot-protocol.md](experiments/09-pilot-protocol.md) — пилот Ф2.5; остальные — отчёты экспериментов. ⚠ **У каждого отчёта статус в его ревью-шапке: читай шапку прежде тела** — она первична и говорит, ратифицированы выводы или заморожены.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Реестр D-нот — карта актуальности v2 (D1–D39.261; титул — носитель головы, бампать при каждом аппенде)
|
||||
# Реестр D-нот — карта актуальности v2 (D1–D39.262; титул — носитель головы, бампать при каждом аппенде)
|
||||
|
||||
> ⚠ **Колонку «тело» `counts.py --check` НЕ сторожит по устройству:** он сверяет полноту НОМЕРОВ, а не
|
||||
> место тела, поэтому колонка держится дисциплиной лендинга. Не нашёл тело по колонке — иди в слайсы,
|
||||
|
|
@ -320,3 +320,4 @@
|
|||
| D39.259 | 17.09 | **РЕШЕНИЕ ОРКЕСТРАТОРА: ре-спрос банк-ролей ВКЛЮЧЁН в боевых конфигах** — денежная ручка сверх заказа пака, зона назвала это сама. С нулём пак инертен: движок вычисляет вердикт о негодном ответе и выбрасывает его, а способ отказа — не счёт, а БАНК (эвристический тип форсирует передачу и уезжает в каждую главу). Цена по хранилищу, сверена с леджером до цента: ступень $0.005134, худшая $0.0232, потолок на книгу ≈$0.162 при суб-бюджете $1.00; трата РАЗОВАЯ на книгу. Пин по форме соседа, значение ровно 1, печатает знаменатель 3 из 4 | ЖИВОЕ: вторая ступень НЕ измерена и потому не разрешена | жив | деньги банк |
|
||||
| D39.260 | 17.09 | **РЕШЕНИЯ ВЛАДЕЛЬЦА, вторая порция:** лестница исходов при отказе роли — ре-спрос, затем фоллбек на ДРУГУЮ фронтир-модель, и лишь при отказе обоих один черновой вариант (ряд 330 и В2 закрыты; ⚠ фоллбек лечит класс с замеренной популяцией НОЛЬ, а не текущую болезнь) · род: редактору идёт КОНТЕКСТ «пол скрыт», а не директива «бери мужские» (Д-1 снят в пользу D5 п.1) · **гейт БЛОКИРУЕТ**, редакция оркестратора №15 отменена · docs/experiments/ — общая проектная зона оркестратора, ряд 359 закрыт | ЖИВОЕ: стройка фоллбека (ряды 435 · 451), правка строки языковых данных | жив | банк деньги процесс |
|
||||
| D39.261 | 17.09 | **РЕШЕНИЯ ВЛАДЕЛЬЦА, третья порция:** зависимость банка от истории покупок ПРИНЯТА как цена — книга, купленная одной покупкой и десятью, законно получает разные банки (ряд Д-11 закрыт; цель воспроизводимости уточнена: тот же прогон при ТОЙ ЖЕ истории) · вынос закрытых эр журнала РАЗРЕШЁН, вычеркивание 02.09 отменено. ⛔ Условие владельца становится нормой ВСЕЙ уборки: перед переносом проверять, не лежит ли в носителе ЗАМЫСЕЛ, ещё не построенный в коде — такой кусок уезжает строкой трекера прежде, чем файл уедет в архив | ЖИВОЕ: сам вынос эр и проверка носителей на нереализованное | жив | процесс банк |
|
||||
| D39.262 | 17.09 | **ПРИЁМКА ПЛАТФОРМЕННОГО ПАКА «пустой экран подписи»** — 21 путь, ряды 224 и 253 закрыты со стороны платформы (было 0, стало 69 и 66 на купленных прогонах), контракт **0.16.0** ратифицирован. ⛔ Мажор приёмки: канон объявлял `confidence` неотрицательным, а тракт согласованно слал `-1` — и лечение оказалось КЛАССОМ: гейт, читающий диапазоны ИЗ канона, покраснел на ЧЕТЫРЁХ полях; правило «число вне объявленного диапазона читается как не названо» поставлено У ШВА. Оба варианта оркестратора отклонены зоной с доводом. ⚠ Мой полный гейт красен `internal/books` (это `PD-469`, ряд 484); предъявлена пара: изолированно пакет зелен на трёх деревьях | ЖИВОЕ: ряды 479 · 353 · 484 · 486, строки регистра `PD-467`/`PD-468`/`PD-469` | жив | контракт банк платформа процесс |
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Журнал решений оркестратора — контракт D1–D39.261 (живой файл: карта · эрраты · живые тела · голова D39.124+ (подрезка D39.139); тела закрытых эр — в слайсах `docs/archive/architecture/`, указатель ниже; реестр всех нот — `05-decisions-index.md`)
|
||||
# Журнал решений оркестратора — контракт D1–D39.262 (живой файл: карта · эрраты · живые тела · голова D39.124+ (подрезка D39.139); тела закрытых эр — в слайсах `docs/archive/architecture/`, указатель ниже; реестр всех нот — `05-decisions-index.md`)
|
||||
|
||||
> **⟶ КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Работая с контрактом (греп номера: живой файл → слайсы, целиком НЕ читать — D39.125), держи под рукой, что чем перекрыто:
|
||||
> ⚠ **Эррата 09.08 (D39.125):** D39.111 п.1 предписывал промту S3 «максимум = баланс МИНУС открытые холды» — формула ОШИБОЧНА (вычитание дважды), исправлена D39.115 п.2(а): максимум = Balance КАК ЕСТЬ; тело D39.111 — в слайсе `../archive/architecture/05-decisions-D39-106-123.md` (испр. 05.09: прежнее «живёт ниже в этом файле» протухло подрезкой D39.139) (голова D39.106+).
|
||||
|
|
@ -4147,3 +4147,97 @@ bought NOTHING». ⇒ **`tmctl manifest` есть НИЖНЯЯ граница,
|
|||
СТРОКОЙ ТРЕКЕРА прежде, чем файл уедет в архив. ⚠ Ровно это назвал слабым местом своей работы ревизор документации 17.09: вердикт «в архив» он ставил по шапке
|
||||
и графу ссылок, а «файл с шапкой закрыт может быть единственным носителем живого факта» его прибор не видит.
|
||||
|
||||
## D39.262 — ПРИЁМКА ПЛАТФОРМЕННОГО ПАКА «ЧЕЛОВЕКУ ПЕРЕСТАЛИ ПОКАЗЫВАТЬ ПУСТОЙ ЭКРАН»: ряды 224 и 253 закрыты со стороны платформы, контракт 0.16.0 ратифицирован, мажор приёмки вылечен КЛАССОМ (17.09, оркестратор №23)
|
||||
|
||||
**1. Что принято.** Пак `PLATFORM_BANK_READOUT` (`180c41e`), сессия зоны `textmachine-main-94`, **21 путь**
|
||||
(20 заявленных плюс `platform/docs/STACK_DECISIONS.md`, дописанный после гейта с доказательством инертности).
|
||||
Предмет: на единственном узле продукта, где у человека спрашивают решение, платформа отдавала **ноль** при 69 и 66
|
||||
предложениях, лежащих в том же файле. Теперь отдаёт их вместе с границей, на которой они сняты. Числа ДО и ПОСЛЕ
|
||||
предъявлены командой на обоих купленных прогонах: `0 → 69` (A) и `0 → 66` (B).
|
||||
**Ряды 224 и 253 закрыты со стороны платформы**; остатки названы и не спрятаны: ряд **479** (части ярлыка `variants`
|
||||
не опубликованы, движковый долг) и ряд **353** (у `SettledByBank` нет свидетеля — движковая зона).
|
||||
|
||||
**2. Чем принято — исполнением, а не чтением.** Гейт зоны на замороженной копии сданного дерева, прогон оркестратора:
|
||||
`MAKE_EXIT=0`, линтер `0 issues.`, `sqlc diff` чист, **ok 20 · FAIL 0 · скипов 9**. Полнота сверена СПИСКОМ:
|
||||
`go list` 20 = уникальных вердиктов 20, `comm -23` пуст, контроль с настоящей жертвой даёт 1 строку, обратный
|
||||
`comm -13` — 0. Сводка условий хоста печатает `TM_PLATFORM_TEST_BANK_READOUT set` ⇒ оракул холодного прогона на
|
||||
настоящем купленном документе бежал ВНУТРИ батареи, а не рядом. Мутационная кампания зоны: 17 посадок — 17 RED,
|
||||
выживших 0, неизмеренных 0, каждая названа своим пином.
|
||||
|
||||
**3. ⛔ МАЖОР ПРИЁМКИ: канон обещал клиенту диапазон, которого код не держал, — и лечение оказалось КЛАССОМ, а не
|
||||
полем.** Найдено осью приёмки, пере-снято оркестратором: канон объявляет `confidence` `minimum: 0` и говорит, что
|
||||
«роль не назвала» приезжает как `null`, а движок (`bankstopparse.go:64`), ридер, хранилище и проекция согласованно
|
||||
шлют **`-1`** — преобразования нет ни в одном звене (`< 0` → 0 хитов при контроле `Confidence` → 3·1·1), и состояние
|
||||
запинено С ОБЕИХ сторон. Компаньон обещал читателю обратное.
|
||||
⛔ **Оба варианта лечения, предложенные оркестратором, ОТКЛОНЕНЫ зоной с доводом, и оба отклонения верны.**
|
||||
(а) «дать канону третье состояние» — посылка «состояний три» опровергнута замером: «поля нет» для `conf`
|
||||
НЕДОСТИЖИМО, поле вошло в проекцию тем же коммитом, что и секция, 0 строк из 135 без него ⇒ канон получил бы ветку,
|
||||
по которой клиент никогда не пойдёт. (б) «свернуть в проекции» — отклонено ПО АДРЕСУ: шапка ридера ратифицирует
|
||||
перевод каждого пересечения У ШВА, «чтобы никакой поздний путь не мог забыть перевести», и именно поздний путь забыл.
|
||||
⭐ **Принято и построено:** гейт, читающий `minimum`/`maximum` ИЗ канона, покраснел на ЧЕТЫРЁХ полях — `freq`,
|
||||
`spread`, `conventions`, `confidence`. У трёх из них сентинела нет вовсе: отрицательное там просто документ, которого
|
||||
сборка не понимает, и публиковать его было бы той же ложью в более тихом месте. ⇒ правило: **число вне объявленного
|
||||
каноном диапазона читается как «не названо»**, у шва, той же дисциплиной, что закрытые словари. **Канон не тронут — он
|
||||
говорил правду, врал код.**
|
||||
⚠ **Почему старый гейт этого не видел — по построению, а не по небрежности:** он читает из канона только `required`,
|
||||
то есть доказывает ЧЛЕНСТВО и не спрашивает про ДИАПАЗОН. У нового напечатан собственный знаменатель и он падает,
|
||||
когда канон даёт диапазон, а гейт не знает питающего поля. ⚠ Вторая его гарантия — не скипаться при отсутствии канона —
|
||||
ПЕРЕНЕСЕНА у соседнего гейта, а не изобретена; зона поправила оркестратора в свою невыгоду, и это записано так,
|
||||
потому что перенос воспроизводим по образцу, а изобретение — нет.
|
||||
|
||||
**4. Остальные находки приёмки — восемь, все вылечены в дофикс-круге, ни одна не останавливала лендинг.**
|
||||
`omitempty` делал канонный `null` у полноты недостижимым, а комментарий строкой выше его обещал → `json.RawMessage`,
|
||||
потому что у члена ДВЕ разные пустоты · носителей версии в компаньоне оказалось ЧЕТЫРЕ при предупреждении «их два»,
|
||||
подняты были два · литерал «19 операций из 21» при замере 20 из 22 · нового ресурса не было в таблице зависимостей,
|
||||
объявляющей себя единственным местом, где это ведётся · удалён доккомментарий `DecodeBank`, несший довод гарда против
|
||||
замены банка книги пустотой, при §8 «больше ничего» → восстановлен дословно · «9 вызовов» при замере 8 · семь чисел
|
||||
консолидации остались простыми `int` без второй половины вывода → условие конца названо · адрес довода о выборе имени
|
||||
исправлен ТРИЖДЫ.
|
||||
⭐ **И одна находка приёмки была ОПРОВЕРГНУТА замером зоны, что ценнее её подтверждения:** «гард канала дублирует
|
||||
`nullif`» неверно — `nullif` превращает ПУСТОЕ в null, а гард превращает НЕИЗВЕСТНОЕ непустое в пустое; без гарда
|
||||
движковое слово не «ложится как неназванное», а **роняет CHECK и всю запись банка**. Это был живой отказ на том самом
|
||||
рубеже, где стоит человек, а не латентный перекос. Гард добавлен и на `kind`, оба запинены.
|
||||
|
||||
**5. `PD-469` — конфликт ДВУХ ратифицированных вещей, а не дефект пакета. Общий ряд 484.**
|
||||
Рецепт `D39.159` §2 требует сажать мутации в копию дерева ⇒ всякая приёмка гонит две батареи разом; батарея зоны
|
||||
содержит фикстуры, кладущие настоящую операцию Postgres внутрь суб-секундного бюджета. Измерено с двух сторон: зоной —
|
||||
двумя полными батареями, запущенными в одну секунду (красны ОБА дерева, на РАЗНЫХ тестах); оркестратором — двумя
|
||||
изолированными прогонами пакета в двух деревьях одновременно (`ok 76.816s` и `ok 75.761s`, оба `GOTEST_EXIT=0`).
|
||||
Механизм пере-снимался ТРИЖДЫ, и все три редакции оставлены видимыми: «фикстуры сжимают 220 с до 700 мс» (верно про
|
||||
другой тест, падение не объясняет) → «фикстура называет посылку `on an idle host` четырежды и не проверяет ни разу»
|
||||
(верно по классу, сжимает не она) → решающая: `uploadSettle = 0` ВОЗВРАЩАЕТ продуктовые 210 с, а режет невозвращённый
|
||||
`writeBudget = 20 мс` первой половины теста. ⛔ **Четвёртый красный снёс довод о нагрузке:** пакеты шли почти вровень с
|
||||
зелёным прогоном ⇒ грубого замедления не требуется, хватает мгновенной заминки. **Семейство посчитано: 6 тестов из 73,
|
||||
и все четыре красневших — внутри этих шести.**
|
||||
|
||||
**6. Класс ошибки, названный этим кругом и стоящий отдельной строки: ИСКАТЬ ВЕЩЬ ПО УГАДАННОМУ ИМЕНИ ИЛИ МЕСТУ, А
|
||||
ПОТОМ ПРИНЯТЬ МОЛЧАНИЕ ПРИБОРА ЗА ЕЁ ОТСУТСТВИЕ.** Форма коварна тем, что норма «отрицательный замер обязан доказать,
|
||||
что спросил существующее» ВЫПОЛНЕНА: знаменатель напечатан, предмет существует — а спрошен другой. Оркестратор дал три
|
||||
таких за один заход (гейт искал не в том каталоге · свёртку по угаданным именам · греп поймал заголовок находки вместо
|
||||
кода), зона — два своих (тавтологичный контроль, сравнивавший выражение само с собой; повторно применённый контроль
|
||||
инертности, не спрошенный про НОВЫЙ предмет). ⇒ **лечение: спрашивать у отрицательного результата не «сколько строк я
|
||||
прочёл», а «то ли я открыл» — и предъявлять не знаменатель, а НАЙДЕННОЕ СОСЕДНЕЕ, доказывающее, что смотрел в нужный
|
||||
файл.** ⚠ Родственный случай той же смены: оркестратор обрезал вывод `cut -c1-200`, прочёл видимую часть длинной
|
||||
строки лога и отправил зоне «0 хитов» как ФАКТ — при том что хит был. Ложный замер, посланный как факт, дороже
|
||||
неточного адреса, который он поправлял.
|
||||
⛔ **И ВТОРОЕ ЛИЦО КЛАССА, найденное зоной на себе через минуту после того, как класс был сформулирован: «спросил не на
|
||||
том ЯЗЫКЕ, и прибор промолчал буквально».** Замер: `grep -cE 'НЕИЗМЕРЕН\|неизмерен'` → **0**, `grep -cE` без
|
||||
экранирования → **1**, `grep -c` с экранированием → **1**. Под `-E` экранированная палка означает ЛИТЕРАЛЬНУЮ палку,
|
||||
и прибор честно искал строку, которой никто не писал; смешаны два диалекта одной командой. ⚠ Здесь знаменатель не
|
||||
спасает ВОВСЕ: файл прочитан, строк много, ноль настоящий. Спасает только соседнее — глазами открытый файл, где слово
|
||||
стоит на строке 15. ⇒ **ноль от грепа проверяется КОНТРОЛЬНЫМ ТОКЕНОМ, который обязан найтись в этом же файле**
|
||||
(у зоны это были `restore` → 4 и `mutate` → 6). Та же смена дала и третий повод: два предыдущих нуля она получила,
|
||||
грепая английскими токенами по русскому докстрингу.
|
||||
|
||||
**7. Что сделано тем же лендингом оркестратором:** литералы регистра платформы в журнале прогресса 108 → 111 и
|
||||
466 → 469 (счёт сошёлся двумя разными приборами, дельта +3) · голова CURRENT-STATE → `D39.262` · строка РАТИФИКАЦИИ
|
||||
компаньона поднята на 0.16.0 (её поднимает оркестратор, потому что она про ратификацию, а не про номер) ·
|
||||
`docs/README.md` — носитель отставания зеркала.
|
||||
|
||||
**8. Засчитано зоне отдельно — работа над собственным ПРИБОРОМ, а не над предметом:** состав полей в промте был
|
||||
неверен, и зона доказала это ПОСТРОЕНИЕМ, а не подогнала под промт (обязательность определяется `omitempty`; отсюда же
|
||||
вывод, что прогон A написан другой сборкой движка) · копия под мутации отставала на три файла, и зона нашла это раньше
|
||||
приёмки · таблица исходов пере-проверена прибором после находки приёмки — пропусков оказалось три, а не один ·
|
||||
стенды пере-синхронизированы под сданное дерево, приборы вынесены туда, куда не дотянется чистка скретчпада.
|
||||
|
||||
**9. Приёмочный прогон оркестратора — ЧЕСТНАЯ ПАРА, а не зелёный целиком, и это названо.** Своего `MAKE_EXIT=0` по дереву после дофикс-круга у меня НЕТ: оба моих полных прогона вышли красными, каждый раз единственным пакетом `internal/books` и каждый раз НА ДРУГОМ тесте (`TestAnUploadThatRunsOutOfBudgetWaitingForASlot…`, затем `TestTheCutOfAnUploadIsBoundedByTheWalk…`) — оба из тех шести, что зона посчитала. Предъявляю пару: полный гейт красен ТОЛЬКО этим пакетом (`go list` 20 = вердиктов 20, `comm -23` пуст, контроль с настоящей жертвой даёт 1 строку, линтер `0 issues.`, ok 19) **плюс** тот же пакет на том же дереве ИЗОЛИРОВАННО зелен: `ok 70.563s`, `GOTEST_EXIT=0`, при нагрузке 6.4 — то есть даже не в тишине. Всего изолированных зелёных три, на трёх деревьях: до дофиксов 76.816s, чистый `HEAD` 75.761s, после дофиксов 70.563s. Зелёный ЦЕЛИКОМ снят зоной на своём прогоне и мною не воспроизведён. ⚠ И числа скипов у меня НЕТ, а не ноль: сводку условий хоста печатает цель гейта ПОСЛЕ тестов, а оба прогона до неё не дожили.
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
батч 0.3.0 — D39.138, 16.08.2026, оркестратор №17;
|
||||
синк с платформой 0.4.0 — 20.08.2026, контрактная сессия; РАТИФИЦИРОВАН D39.152, 20.08.2026, оркестратор №18)
|
||||
|
||||
СТАТУС: РАТИФИЦИРОВАН как контракт API v0 по **0.15.0** включительно (0.15.0 — `D39.246` п.6: `resumeRun` отвечает ПРОГОНОМ о прогоне, который уже идёт, — провенанс §2.26; 0.14.0 — `D39.244`: `Blocked.code` получает `run_in_flight`, форма выведена платформенной сессией и ратифицирована ДО стройки — провенанс §2.25; 0.13.1 — `D39.235` п.6: корректирующий минор, `Run.required` требовал отставленный член) (0.11.0 — D39.208 · 0.12.0 — D39.211 · **0.13.0 — D39.221: четыре исхода `POST /v0/books` после синхронного разреза на приёме**; перечень миноров и их
|
||||
СТАТУС: РАТИФИЦИРОВАН как контракт API v0 по **0.16.0** включительно (0.16.0 — `D39.262`: экран подписи перестаёт быть пустым — ридер секций предложений и консолидации, форма выведена платформенной сессией из ДВУХ купленных боевых прогонов; число вне объявленного каноном диапазона читается как «не названо» У ШВА, канон не тронут; 0.15.0 — `D39.246` п.6: `resumeRun` отвечает ПРОГОНОМ о прогоне, который уже идёт, — провенанс §2.26; 0.14.0 — `D39.244`: `Blocked.code` получает `run_in_flight`, форма выведена платформенной сессией и ратифицирована ДО стройки — провенанс §2.25; 0.13.1 — `D39.235` п.6: корректирующий минор, `Run.required` требовал отставленный член) (0.11.0 — D39.208 · 0.12.0 — D39.211 · **0.13.0 — D39.221: четыре исхода `POST /v0/books` после синхронного разреза на приёме**; перечень миноров и их
|
||||
провенанс — ниже по файлу, здесь НЕ дублируется). Нормативная поверхность — openapi.yaml РЯДОМ.
|
||||
⚠ Зонная копия frontend/docs/api-contract/ ВРЕМЕННО ОТСТАЁТ (0.2.3 при каноне
|
||||
0.15.0 — ТРИНАДЦАТЬ миноров; ⚠ испр. 11.09: здесь стояло «0.13.0 — ОДИННАДЦАТЬ», отставшее на два бампа — 0.14.0 `D39.244` и 0.15.0 `D39.246` п.6) — ратифицировано D39.142 п.5 на время фриза фронта; синк байт-в-байт + перегенерация типов =
|
||||
0.16.0 — ЧЕТЫРНАДЦАТЬ миноров; ⚠ испр. 17.09 приёмкой: здесь стояло «0.15.0 — ТРИНАДЦАТЬ» — носителей числа в шапке не ДВА, как утверждает предупреждение ниже, а ЧЕТЫРЕ, и этот бамп поднял сперва только два; ⚠ испр. 11.09: здесь стояло «0.13.0 — ОДИННАДЦАТЬ», отставшее на два бампа — 0.14.0 `D39.244` и 0.15.0 `D39.246` п.6) — ратифицировано D39.142 п.5 на время фриза фронта; синк байт-в-байт + перегенерация типов =
|
||||
первое касание зоны при разморозке; после него правило прежнее: расхождение = дефект лендинга.
|
||||
Генерация типов фронта после этой ратификации идёт из ЭТОЙ копии.
|
||||
|
||||
|
|
@ -66,9 +66,13 @@ cmp-сверка обязательна (D39.138 п.3).
|
|||
> **0.15.0 (`D39.246` п.6, 11.09) — `resumeRun` о прогоне, который УЖЕ ИДЁТ, отвечает `202` с этим
|
||||
> прогоном вместо `409`, с одним вырезом: прогон, который уже попросили остановить, сохраняет отказ и
|
||||
> получает причину `stop_requested`. Ломающее — меняется смысл строки собственной таблицы операции, а
|
||||
> в `0.x` это минор по построению. Провенанс — §2.26**.
|
||||
> в `0.x` это минор по построению. Провенанс — §2.26** ·
|
||||
> **0.16.0 (17.09, форма выведена платформенной сессией из ДВУХ купленных боевых прогонов) — экран
|
||||
> подписи перестаёт быть пустым: ресурс `GET /books/{bookId}/bank/signing-stop` (что спросил последний
|
||||
> стоп) и агрегат `BankPage.consolidation` (насколько полон подписываемый банк). Ряды 224 и 253
|
||||
> бэклога, движковые половины закрыты 07.09 и 08.09. Провенанс — §2.27**.
|
||||
> Дом канона — этот каталог;
|
||||
> `frontend/docs/api-contract/openapi.yaml` — байт-зеркало ⚠ НА ФРИЗЕ ЗОНЫ РАВЕНСТВО ПРИОСТАНОВЛЕНО (D39.142 п.5): зеркало 0.2.3, канон **0.15.0** (⚠ число испр. 11.09 — здесь стояло 0.13.0, отставшее на два минора: 0.14.0 — `D39.244`, 0.15.0 — `D39.246` п.6; прежде, 10.09, оно же стояло 0.10.0, отставшее на два. Тот же промах третий раз подряд: число живёт в ДВУХ местах шапки, и бампающий пак правит `openapi.yaml`, а про них забывает. Шапка файла и `openapi.yaml` всё это время несли верное).
|
||||
> `frontend/docs/api-contract/openapi.yaml` — байт-зеркало ⚠ НА ФРИЗЕ ЗОНЫ РАВЕНСТВО ПРИОСТАНОВЛЕНО (D39.142 п.5): зеркало 0.2.3, канон **0.16.0** (⚠ число испр. 11.09 — здесь стояло 0.13.0, отставшее на два минора: 0.14.0 — `D39.244`, 0.15.0 — `D39.246` п.6; прежде, 10.09, оно же стояло 0.10.0, отставшее на два. Тот же промах третий раз подряд: число живёт в ДВУХ местах шапки, и бампающий пак правит `openapi.yaml`, а про них забывает. Шапка файла и `openapi.yaml` всё это время несли верное).
|
||||
>
|
||||
> ⚠ **0.5.0 ломающий по построению (мажор `0`): снесены путь `POST …/bank/decisions` и три его
|
||||
> схемы, из `BankPage` и `EventBank` сняты `pending_decisions`/`complete` (§2.19-бис), в
|
||||
|
|
@ -1069,6 +1073,75 @@ $1.6, спишем по факту», и закрытие после прого
|
|||
`platform/docs/ENGINEERING_STANDARDS.md` и **`platform/README.md`** (зонные — чинит зона).
|
||||
⚠ **Прозаические указатели на отзыв проставлены 05.09 везде, где носитель мой**; ПОЛЯ приезжают паком.
|
||||
|
||||
### 2.26-бис. Две идиомы «может быть null» — различие МЕХАНИЧЕСКОЕ, смысла в нём нет
|
||||
|
||||
Записано 17.09 по наблюдению сплошного аудита контракта: в спеке сосуществуют `oneOf: [$ref, null]` и
|
||||
`type: [x, 'null']`, примерно поровну, и читатель ищет в различии смысл, которого там не было. Смысла
|
||||
нет, а правило есть, и оно одно: **`$ref` не может нести соседний `type`** (JSON Schema разрешает
|
||||
рядом с `$ref` только аннотации), поэтому значение ИМЕНОВАННОЙ схемы обнуляется через `oneOf`, а
|
||||
ВСТРОЕННОГО типа — списком в `type`. Обе формы означают ровно одно и то же — «значение может
|
||||
отсутствовать, и `null` значит „не известно“» (§Absence of a value спеки). Выбирать надо ту, которую
|
||||
требует механика, и ничего этим выбором не сообщать. Носитель наблюдения — ряд бэклога **480**.
|
||||
|
||||
### 2.27. Экран подписи перестаёт быть пустым (0.16.0) — ✓ форма выведена из ДВУХ купленных боевых прогонов
|
||||
|
||||
**Что было.** Движок один раз за прогон останавливается, чтобы человек подписал термины книги, и в этот
|
||||
момент кладёт рядом с базой книги проекцию банка. Платформа читала из неё ТОЛЬКО секцию `terms`, а эта
|
||||
секция на стопе пуста ПО ПОСТРОЕНИЮ: майненные строки попадают в глоссарий в ветке авто-продолжения, а
|
||||
остановленный прогон до неё не доходит. Замер на двух купленных прогонах: `terms` — **0** и **0** при
|
||||
`proposed` — **69** и **66**, и секция консолидации в обоих. То есть в единственной точке продукта, где
|
||||
у человека спрашивают решение, ему отдавали ноль — а вопрос лежал в том же файле непрочитанным. Ряды
|
||||
бэклога **224** (движковая половина закрыта 07.09) и **253** (08.09).
|
||||
|
||||
**Почему ДВА разных места, а не одно.** Первая редакция формы клала обе секции на `BankPage` — довод был
|
||||
«обе описывают один момент стопа». Довод **опровергнут кодом движка**: его результат терминологии
|
||||
ставится ДО развилки «стоп / продолжаем» и переживает её, поэтому секция полноты едет на ТРЁХ границах
|
||||
прогона из пяти, а секция вопросов — ровно на одной. Оси свежести разные, и один ответ не может быть
|
||||
честен про обе: список появляется и исчезает вместе с ПРОГОНОМ, пока ревизия банка не двигается.
|
||||
⇒ полнота — агрегат `BankPage` (буквой ряда 253: «поле на `BankPage`»), вопросы стопа — свой ресурс.
|
||||
⚠ Третьим членом «только первой страницы» шапка спеки НЕ правится: её исключение названо КЛАССОМ
|
||||
(«the aggregates of `BankPage`»), а не перечнем.
|
||||
|
||||
**Имя: почему не `proposed` и не `suggestions`.** Слово `proposed` в каноне уже значит СТАТУС строки
|
||||
(`TermStatus`), а в проекции движка — имя секции; третий смысл не заводится, существующий не
|
||||
переименовывается, наружу слово не идёт вовсе. `suggestions` отвергнуто по существу: «suggestion» —
|
||||
то, что можно проигнорировать, а здесь **игнор есть принятие** (движок: «whatever you leave undecided is
|
||||
used on the wire like any signed row»). Взято слово, которым канон УЖЕ называет этот предмет:
|
||||
`BankSignatureCount` описан как «the count against the surfaces the LAST signing stop **offered**».
|
||||
|
||||
**Идентичности у предложения НЕТ, и она не выдумана.** Ни в одном из 135 объектов двух купленных
|
||||
прогонов у строки нет `id`; выдумать его на стороне платформы значило бы пере-реализовать закон движка.
|
||||
Следствия названы прозой операции, а не подразумеваются: ресурс отдаётся ЦЕЛИКОМ, без курсора и без
|
||||
дельта-чтения (дельте нечего называть), а ДЕЙСТВИЕ адресуется уже ратифицированной кортежной формой
|
||||
`BankCorrection` — «for a surface the bank does not list, send `sense: ""` and `null` windows».
|
||||
|
||||
**`unreadable` — не новая форма, а перенос ратифицированной.** Флаг взят у `BankSignatureCount` вместе с
|
||||
его обоснованием: без него «не смог прочесть» байт-идентично «стоп не спросил ничего» — то единственное,
|
||||
чего эта поверхность не должна сказать случайно. Он истинен по трём причинам сразу (материализации ещё
|
||||
не было · документ не несёт секции · документ снят на границе, которая её нести не может), и они не
|
||||
разделяются, потому что лекарство одно. ⚠ Третья причина — не формальность: секция, стоящая на границе,
|
||||
которая её не несёт, означает документ, который эта сборка не понимает, и отдать её строки значило бы
|
||||
нарисовать стоп, которого не держит ни один прогон.
|
||||
|
||||
**Числа могут быть `null`, и это НЕ ноль — замерено.** У четырёх чисел предложения (`freq` · `spread` ·
|
||||
`conventions` · `confidence`) ноль есть законный ответ движка, а отсутствие означает «документ писала
|
||||
сборка без этого поля». Улика: `conventions` отсутствует у ВСЕХ 69 строк прогона A и стоит у ВСЕХ 66
|
||||
строк прогона B — поле без `omitempty` сериализатор опустить не может, значит A написан сборкой до
|
||||
`0997e41`. Сборка, читающая это нулём, показала бы человеку 69 терминов, чьи варианты «единогласны», на
|
||||
листе, весь смысл которого — разногласие. Отдельно `confidence`: движок пишет ОТРИЦАТЕЛЬНОЕ для «роль не
|
||||
назвала уверенности», и наружу это идёт как `null`, потому что названный `0 %` — первая строка для
|
||||
просмотра, а молчание — не строка вовсе.
|
||||
|
||||
**Что НЕ обещано намеренно.** (1) Длина списка не равна `BankSignatureCount.surfaces`: замер на обоих
|
||||
прогонах дал совпадение (69/69 и 66/66, разность множеств 0 в обе стороны), но механизм расхождения
|
||||
реален — реверс-секция капается и при выключенной роли отсутствует, — поэтому равенство в контракте не
|
||||
обещано и «N из M» между ними запрещено прозой. (2) Пустой `dst` не значит «служба не смогла перевести»:
|
||||
на печатном листе движка этот же символ несёт четыре разных факта, и один из них («банк уже отдаёт эту
|
||||
поверхность, решать нечего») движок различает у себя и в проекцию намеренно не кладёт. (3) `variants` —
|
||||
ДИСПЛЕЙНЫЙ текст, а не данные: части ярлыка движок не публикует, разбирать его на своей стороне нельзя
|
||||
(парсер живёт рядом с писателем не случайно), и из-за этого клиент не может отрисовать его на языке
|
||||
читателя — движковый долг, заведён рядом **479**.
|
||||
|
||||
## 3. Зависимости: чтение → источник → строка бэклога
|
||||
|
||||
**Правило, введённое 0.3.0 (Б-21): предупреждение о недостроенном ОБЯЗАНО нести номер строки
|
||||
|
|
@ -1084,11 +1157,13 @@ $1.6, спишем по факту», и закрытие после прого
|
|||
| `POST /runs/{id}/resume` | реконсилятор | ПОСТРОЕНО, но **0.3.0 сменил поведение на паузе по потолку, а 0.4.0 — на ИСЧЕРПАННОМ прогоне**. Пауза: платформа уже отвечает `409` `ceiling_reached` (`runs/reconcile.go`, ветка `case "paused"` в `Resume`) — совпало. Исчерпанность: ⚠ **РАСХОЖДЕНИЕ ЗАКРЫТО (испр. 02.09)** — `Resume` больше не отвечает `202` на исчерпанный прогон. Реконсилятор различает ДВА вердикта, и оба доезжают до клиента кодом 409 `run_not_resumable`: `ceilingSpent` → `cause.code: ceiling_reached` и `creditUnavailable` → `cause.code: credit_unavailable` (`platform/internal/runs/reconcile.go`, греп `ceilingSpent`; `platform/internal/httpapi/v0.go`, греп `run_not_resumable`). Почему молчаливый `202` был дефектом — §6в A. Полная таблица по статусам — в описании `resumeRun` | **вход P7** ⚠ ИЗРАСХОДОВАН (D39.153) (правка построенного пути, не только читающей поверхности) |
|
||||
| `GET /usage` | кредиты | ПОСТРОЕНО, **не читается ни одним экраном** | зона фронта |
|
||||
| `GET /capabilities` | конфигурация деплоя | **ПОСТРОЕНО P7** — маршрут смонтирован безусловно (`platform/internal/httpapi/v0.go`, таблица `contractSurface`), хендлер `capabilities.go`; версия контракта запинена ГЕЙТОМ против ЭТОГО канона (`platform/internal/gates/contract_test.go`) | закрыто D39.153 |
|
||||
| `DELETE /books/{id}`, `GET /runs/{id}` | колонки есть | НЕ ПОСТРОЕНО (заведено 0.3.0) — роутер монтирует **19 операций из 21** ⚠ (испр. 05.09: `PATCH /books/{id}` СМОНТИРОВАН платформенным паком `7e2226a`, акт D39.201; счёт 18→19) (испр. 05.09: дверь выдачи построена `adf5e53`, прежнее «15 из 20» протухло) | ⚠ носитель «вход P7» ИЗРАСХОДОВАН (пак P7 принят D39.153); живой носитель — `platform/BACKLOG.md` П-17 и очередь CURRENT-STATE |
|
||||
| `DELETE /books/{id}`, `GET /runs/{id}` | колонки есть | НЕ ПОСТРОЕНО (заведено 0.3.0) — роутер монтирует **20 операций из 22** (⚠ испр. 17.09: было «19 из 21»; минор 0.16.0 добавил и операцию, и её маршрут, так что двинулись ОБА числа. Пере-снято: операций в каноне 22, строк в `contractSurface` 20, без маршрута остаются ровно те две, что названы слева) ⚠ (испр. 05.09: `PATCH /books/{id}` СМОНТИРОВАН платформенным паком `7e2226a`, акт D39.201; счёт 18→19) (испр. 05.09: дверь выдачи построена `adf5e53`, прежнее «15 из 20» протухло) | ⚠ носитель «вход P7» ИЗРАСХОДОВАН (пак P7 принят D39.153); живой носитель — `platform/BACKLOG.md` П-17 и очередь CURRENT-STATE |
|
||||
| `GET /books/{id}/chapters`, `/units` | материализация манифеста | **ПОСТРОЕНО P7** — `httpapi/reading.go`, материализатор `internal/readmodel` | закрыто D39.153 |
|
||||
| `GET /books/{id}/notes` | `unit_done` несёт флаг и причину (`runevents.go:126-135`), платформа хранит (`sink.go:227-233`), колонка `notes.reason` заведена под это | канал ЕСТЬ; не хватает карты «причина → код → фраза» (приложение А) и проекции | приложение А + ⚠ носитель «вход P7» ИЗРАСХОДОВАН (D39.153) |
|
||||
| `GET /books/{id}/bank` | движок пишет сайдкар всего банка (`pipeline/bankexport.go:16-33,72`=`the bank lives in the engine`, D39.122) | ⚠ пере-снято 0.5.0: проекция платформы ПОСТРОЕНА (P7 — маршрут в `contractSurface`, `wireBankPage` в `httpapi/reading.go`, `SaveBank` в `pgstore`); форма синхронна канону с лендингом P9: счётчики `pending_decisions`/`complete` сняты с проекции (`PD-399`) | закрыто D39.162 |
|
||||
| ~~`POST /bank/decisions`~~ | стоп-механика майнера | ⚠ **НЕ «не построено», а ОТМЕНЕНО**: было построено P7 и СНЯТО 22.08 вместе с пер-термной моделью подписи (D39.144, слово владельца). **0.5.0 снёс и канон-половину — `PD-370` закрыт этим минором**; преемник — строка `POST …/bank/corrections` ниже | отменено D39.144; снесено 0.5.0 |
|
||||
| `GET /books/{bookId}/bank/signing-stop` | движок публикует секцию `proposed` того же сайдкара на ГРАНИЦЕ подписи и ни на какой другой (`pipeline/bankexport.go`, греп `Empty at every boundary that is not a stop`); движковая половина закрыта 07.09 | **ПОСТРОЕНО 0.16.0** — ридер секции (`platform/internal/ingest/bank.go`), хранилище (`migrations/00036_bank_read_out.sql` + `bank_offered_terms`), ручка `readBankSigningStop` (`httpapi/reading.go`). Отдаётся ЦЕЛИКОМ, без курсора и дельта-чтения: у предложения нет идентичности ни в одном из 135 замеренных объектов, и дельте нечего назвать. Решение по строке — существующая кортежная форма `BankCorrection` | строка бэклога **224**, платформенная половина |
|
||||
| `BankPage.consolidation` | движок публикует полноту консолидации секцией того же сайдкара, и ОТСУТСТВИЕ её, а не ноль, означает «никто не мерил» | **ПОСТРОЕНО 0.16.0** — агрегат первой страницы; бит `complete` берётся у движка ГОТОВЫМ и на этой стороне не выводится (ратифицированное требование ряда) | строка бэклога **253**, половина (б) |
|
||||
| `POST /books/{bookId}/bank/corrections` | `tmctl bank-apply` — движковая половина ПОСТРОЕНА (D39.158, лендинг `d1eb8a9`) | **ПОСТРОЕНО пакетом P9**: перевод словаря (`platform/internal/ingest/bankdecisions.go`), спавн глагола (`runner/bankapply.go`), раскладка отказов и пер-книжная сериализация (`runs/bank.go`), дверь синхронная. Флаг `bank_corrections_enabled` следует включённости прогонов (`cmd/tmplatformd/runner.go:195`) — дверь спавнит тот же глагол | закрыто D39.162 |
|
||||
| `GET /books/{id}/events` (SSE) | эмиттер шва построен (D39.131) | **ПОСТРОЕНО P7** — `httpapi/stream.go`, поток регистрируется ВНЕ слоя сжатия (сжатие буферизует поток — единственное, что канон запрещает этому маршруту) | закрыто D39.153 |
|
||||
| `POST`/`GET /exports` | движок: `tmctl build [--format epub,txt] [--out path] [--partial]` — EPUB 3 + чистый txt (`backend/internal/bookfile`), конверт `tm-build-v1`, пути в `StatusArtifacts.book_files`, отказ exit 16 `book_incomplete` | ⚠ **ПЕРЕ-СНЯТО 05.09: ОБЕ ПОЛОВИНЫ ПОСТРОЕНЫ.** Движковая — D39.175 (`8adcb86`); ПЛАТФОРМЕННАЯ дверь заленджена `adf5e53` (D39.194) и предъявлена живьём платным EPUB через API. Прежнее «открыта только дверь платформы» протухло на сутки. Дверь СТРОИТ через `tmctl build` (не читать файл у БД: там копия прежней сборки) и сверять `BuildReport` (`config_drift`/`stale_unknown`). ⚠ Это про ФАЙЛ КНИГИ; редакторский `tmctl export`/annot-v1 (D29.1а) — ОТДЕЛЬНАЯ, не закрытая работа | движковая половина — D39.175; дверь — пинг зоне платформы (её журнал, греп `Движок отдаёт книгу файлом`); annot-v1 — строка 49 / D29.1 |
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ openapi: 3.1.0
|
|||
|
||||
info:
|
||||
title: TextMachine API
|
||||
version: 0.15.0
|
||||
version: 0.16.0
|
||||
summary: Ratified contract between the frontend and the TextMachine platform.
|
||||
description: |
|
||||
**RATIFIED contract.** Canonical copy: `docs/architecture/14-api-contract/`;
|
||||
|
|
@ -507,6 +507,52 @@ paths:
|
|||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
|
||||
/books/{bookId}/bank/signing-stop:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/BookId'
|
||||
get:
|
||||
tags: [bank]
|
||||
operationId: readBankSigningStop
|
||||
summary: What the last signing stop asked about.
|
||||
description: |
|
||||
The terms the LAST signing stop offered — surfaces the service found and rendered that are
|
||||
NOT in the bank yet, in the order the stop ranked them. This is the one screen in the product
|
||||
where the user is asked for a decision, and this operation is what fills it.
|
||||
|
||||
**It does NOT say whether a stop is standing.** That is `Run.status` — `awaiting_bank` — and
|
||||
the past tense here is deliberate: this is the same population the correction receipt counts
|
||||
against ("the surfaces the LAST signing stop offered", `BankSignatureCount`). A client reads
|
||||
both and shows the terms when a stop is standing.
|
||||
|
||||
**Read whole: no cursor, no delta.** A term offered here has no id, and that is not an
|
||||
omission this contract will fill later — the service publishes none, and the ORDER is the
|
||||
information (which row to read first). With no identity there is nothing for a delta to name,
|
||||
so the list is answered entire and a client replaces what it holds.
|
||||
|
||||
**Deciding one of these terms is a correction** (`POST /books/{bookId}/bank/corrections`) in
|
||||
its TUPLE form: a term the bank does not list is named by `src` with `sense: ""` and `null`
|
||||
windows, and approving it ADDS it. Nothing else is needed and nothing else works — there is
|
||||
no id to send.
|
||||
|
||||
**Silence is not refusal.** A term left undecided is used in the translation exactly as a
|
||||
signed one; signing is ONE act over the whole bank (`resumeRun`). A client MUST NOT present
|
||||
this list as optional extras: what is not corrected here is what the book will say.
|
||||
|
||||
**The count on the correction receipt is not the length of this list.** The two are measured
|
||||
from different documents of the same moment and can legitimately differ, so a client MUST NOT
|
||||
derive one from the other or show them against each other as "N of M".
|
||||
responses:
|
||||
'200':
|
||||
description: What the last signing stop asked about.
|
||||
headers:
|
||||
ETag: { $ref: '#/components/headers/ETag' }
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/BankSigningStop' }
|
||||
'304': { $ref: '#/components/responses/NotModified' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
|
||||
/books/{bookId}/bank/corrections:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/BookId'
|
||||
|
|
@ -2341,10 +2387,209 @@ components:
|
|||
corrected and NOT signed, because declining is also a correction. How many surfaces
|
||||
still await a word is not derivable from these rows at all — see the note on the
|
||||
operation.
|
||||
consolidation:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/BankConsolidation'
|
||||
- type: 'null'
|
||||
description: |
|
||||
How complete the bank in this response is — a whole-bank aggregate, so it rides the
|
||||
first page with the counts above. **`null` means nothing has measured it, which is
|
||||
NOT "it is complete"**: the pass that measures completeness runs at one point of a
|
||||
translation, and a bank read before or apart from it has no such measurement. A
|
||||
client MUST NOT read `null` as a whole bank.
|
||||
terms:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/BankTerm' }
|
||||
|
||||
OfferedTermChannel:
|
||||
type: string
|
||||
description: |
|
||||
How the service came to know the surface — the axis a person judges corroboration by.
|
||||
|
||||
- `source_text` — it was found in the book's own text;
|
||||
- `translated_text` — a translated passage named it and the source text did not yield it;
|
||||
- `both` — both, the strongest case a row can carry;
|
||||
- `alias` — it names something the service already tracks under another term.
|
||||
enum: [source_text, translated_text, both, alias]
|
||||
|
||||
OfferedTerm:
|
||||
type: object
|
||||
description: |
|
||||
One term the signing stop is asking about. It is NOT a `BankTerm`: it is not in the bank, it
|
||||
has no id, and it is addressed for correction by its tuple — see the operation.
|
||||
|
||||
⚠ **Every number here can be `null`, and `null` is never zero.** The service publishes these
|
||||
figures only in the form the build that produced the read-out knew, so a deployment reading
|
||||
an older read-out gets fewer of them — measured on real runs, not hypothetical. Zero is a
|
||||
real answer for each, so a client MUST render "not stated" and MUST NOT substitute 0 or drop
|
||||
the row.
|
||||
required:
|
||||
[src, dst, kind, channel, freq, spread, conventions, confidence, invented, contradicts,
|
||||
bank_holds, variants]
|
||||
properties:
|
||||
src:
|
||||
type: string
|
||||
description: The surface as it stands in the book's own language.
|
||||
dst:
|
||||
type: string
|
||||
description: |
|
||||
The rendering the service arrived at; **the empty string means this read-out carries
|
||||
none**, and it does not say why. A client MUST NOT render it as "the service could not
|
||||
translate this": several different facts share that emptiness and the read-out does not
|
||||
separate them.
|
||||
kind:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/TermKind'
|
||||
- type: 'null'
|
||||
description: |
|
||||
`null` when the kind was not decided — the same state and the same obligation as
|
||||
`BankTerm.kind`: show it as "kind not decided", never drop the row, never invent one.
|
||||
channel:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/OfferedTermChannel'
|
||||
- type: 'null'
|
||||
description: |
|
||||
`null` when this deployment cannot name the channel — a read-out naming one this contract
|
||||
does not carry. Never guessed: invented corroboration is the error reading further cannot
|
||||
undo.
|
||||
freq:
|
||||
type: [integer, 'null']
|
||||
minimum: 0
|
||||
description: |
|
||||
How often the surface occurs in the book's own text. **Zero is a real answer** — the
|
||||
surface was named only by a translated passage — and therefore not the same as `null`.
|
||||
spread:
|
||||
type: [integer, 'null']
|
||||
minimum: 0
|
||||
description: |
|
||||
How many DIFFERENT renderings the book's translated passages produced for this surface.
|
||||
The disagreement signal: high means the book is calling one thing several things.
|
||||
conventions:
|
||||
type: [integer, 'null']
|
||||
minimum: 0
|
||||
description: |
|
||||
How many of those are genuinely different DECISIONS, once renderings differing only in
|
||||
form fold together. Published beside `spread` rather than left to be derived, because the
|
||||
two answer different questions and comparing one against the length of `variants` has
|
||||
already been mistaken for a defect.
|
||||
confidence:
|
||||
type: [integer, 'null']
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
description: |
|
||||
The service's own stated confidence in `dst`, as a percentage. **`null` means it stated
|
||||
none, which is not zero**: a stated 0 is the first row a person should look at, and no
|
||||
statement at all is not a row about confidence. It orders a review list and decides
|
||||
nothing.
|
||||
invented:
|
||||
type: boolean
|
||||
description: |
|
||||
`true` — the rendering in `dst` is NOT one the book's translated passages proposed; the
|
||||
service arrived at it from the whole book. Legitimate, and **the class to read first**: a
|
||||
name the service composed is what a person most needs to see before it becomes canon.
|
||||
contradicts:
|
||||
type: array
|
||||
items: { type: string }
|
||||
description: |
|
||||
Other renderings decided in the same pass that this one breaks, each named by the surface
|
||||
and the rendering it carries. Empty is the ordinary case.
|
||||
bank_holds:
|
||||
type: array
|
||||
items: { type: string }
|
||||
description: |
|
||||
Rows the bank ALREADY holds for this surface with a different rendering. Separate from
|
||||
`contradicts` because "the service disagreed with itself" and "the book already calls it
|
||||
something else" are different decisions for the person signing.
|
||||
variants:
|
||||
type: array
|
||||
items: { type: string }
|
||||
description: |
|
||||
The renderings the book's translated passages produced, best first — each one line the
|
||||
service composed: the rendering, how many passages used it, and the surface it was
|
||||
proposed for where that differs. **Display text, not data**: the parts are not published
|
||||
separately, so a client shows the line and does not take it apart.
|
||||
|
||||
BankSigningStop:
|
||||
type: object
|
||||
description: |
|
||||
What the last signing stop asked about — see the operation for how it is read and acted on.
|
||||
required: [unreadable, offered]
|
||||
properties:
|
||||
unreadable:
|
||||
type: boolean
|
||||
description: |
|
||||
`true` — `offered` means NOTHING for this book: what the stop is asking cannot be told
|
||||
from what this deployment has read. Without this flag "could not read it" would be
|
||||
byte-identical to "the stop asked nothing", and a client would tell a person there is
|
||||
nothing to decide at the one moment the product stops to ask them.
|
||||
|
||||
One flag for several causes on purpose, because the remedy is one: look again later, and
|
||||
if a stop is standing while this stays `true`, this deployment cannot serve the screen. A
|
||||
client MUST NOT present an empty list as an answer while this is `true`.
|
||||
offered:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/OfferedTerm' }
|
||||
description: |
|
||||
The terms, in the stop's own ranking — the order IS information and a client MUST NOT
|
||||
re-sort it as its primary order. Empty with `unreadable: false` means the stop asked
|
||||
nothing; empty with `unreadable: true` means nothing is known.
|
||||
|
||||
BankConsolidation:
|
||||
type: object
|
||||
description: |
|
||||
How complete the bank being signed actually IS. It exists because a person can otherwise sign
|
||||
as whole a bank the service itself calls partial: the pass that renders terms runs on a
|
||||
budget, and where the budget ran out the bank is missing renderings nobody was told about.
|
||||
|
||||
⚠ **Six numbers and not one, because three facts look alike and are not.** A cut RENDER pass
|
||||
leaves the BANK partial. A cut CLASSIFY pass leaves the bank whole and the term KINDS
|
||||
unrefined — a different remedy, and reading it as an incomplete bank is a false alarm. And
|
||||
terms nobody was asked about, because the book already renders them, are a saving and not a
|
||||
gap.
|
||||
required:
|
||||
[complete, render_batches_dropped, classify_batches_dropped, consolidated, declined,
|
||||
unanswered, never_asked]
|
||||
properties:
|
||||
complete:
|
||||
type: boolean
|
||||
description: |
|
||||
Whether every batch of renderings the pass planned was actually bought. `false` means the
|
||||
bank being signed is missing renderings. It is the SERVICE's own answer, reported as
|
||||
given: a client MUST NOT compute it from the numbers below, which do not determine it.
|
||||
render_batches_dropped:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: What makes `complete` false — batches of renderings the budget did not reach.
|
||||
classify_batches_dropped:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: |
|
||||
The OTHER pass's cut, on its own budget: the one that refines term kinds. Never added to
|
||||
the number above — different budgets, different remedies.
|
||||
consolidated:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: Terms that came back with a rendering.
|
||||
declined:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: Terms the service explicitly could not render.
|
||||
unanswered:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: |
|
||||
⚠ **Two facts in one number, and the service says so.** Where `complete` is `true` this is
|
||||
silence — terms the pass was asked about and did not answer. Where `complete` is `false`,
|
||||
part of it is terms never asked because the budget stopped first. A client MUST NOT
|
||||
present it as "the service had nothing to say" without reading `complete`.
|
||||
never_asked:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: |
|
||||
The opposite of a gap: terms nobody had to render, because the bank already carries the
|
||||
surface and every translated passage agreed with it. A client MUST NOT add this to the
|
||||
numbers above.
|
||||
|
||||
BankCorrection:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -772,6 +772,15 @@ export TM_PLATFORM_TEST_DSN='postgres://postgres@/postgres?host=/tmp&port=55432&
|
|||
/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/tm-runs.slice/cgroup.subtree_control`.
|
||||
- **Демон нельзя убивать `pkill -f <путь к бинарю>`** — шаблон совпадает с собственной командной
|
||||
строкой оболочки, и она убивает сама себя (exit 144). Убивать по PID.
|
||||
- ⛔ **TCP-ПРОБА СТЕНДА ДАЁТ `Connection refused` НА ЖИВОМ СЕРВЕРЕ, И ЭТО НЕ ПОЛОМКА, А ЕГО УСТРОЙСТВО.**
|
||||
Рецепт выше поднимает Postgres с `-c listen_addresses=''`, то есть он слушает ТОЛЬКО Unix-сокет.
|
||||
Проба `127.0.0.1:55433` отвечает отказом всегда, и «порт закрыт» неотличимо в её выводе от
|
||||
«сервер намеренно не слушает TCP» — вопрос задан не тому предмету. Замер 17.09, обе стороны в один
|
||||
момент: `/dev/tcp/127.0.0.1/55433` → `Connection refused`, при этом `/tmp/.s.PGSQL.55433` на месте и
|
||||
`psql 'postgres://postgres@/postgres?host=/tmp&port=55433&sslmode=disable' -c 'select 1'` → `1`.
|
||||
⇒ **живость стенда проверяется тем же DSN, которым его читают тесты**, либо `pg_ctl … status`, а
|
||||
не портом. Цена ошибки названа тем, кто её совершил: объявив стенд недоступным, приёмка пере-сняла бы
|
||||
4 PASS как 4 SKIP и сравнила бы две стороны РАЗНЫМИ приборами.
|
||||
- **Postgres стенда переживает не всё:** сокет живёт в `/tmp`, и уборка `/tmp` (или smart shutdown)
|
||||
роняет соединение — `pg_ctl … status` скажет «no server running». Поднимать заново по разделу выше.
|
||||
- **Замер страницы библиотеки** (когда трогаете проекции книги): `go test ./internal/pgstore/
|
||||
|
|
|
|||
|
|
@ -3,6 +3,580 @@
|
|||
> **Что это.** Состояние зоны и её живые остатки. Обратно-хронологический: свежее выше.
|
||||
> Отработавшие эры вынесены срезами в [`archive/`](archive/) — читать только по конкретной ссылке.
|
||||
|
||||
## ОТЧЁТ 17.09 — ЧЕЛОВЕКУ ПЕРЕСТАЛИ ПОКАЗЫВАТЬ ПУСТОЙ ЭКРАН (ряды 224 · 253, контрактный минор 0.16.0)
|
||||
|
||||
Пак `docs/PLATFORM_BANK_READOUT_SESSION_PROMPT.md` (180c41e), сессия `textmachine-main-94`. НЕ коммичу,
|
||||
дерево передаю оркестратору. Записка-план — ниже этим же блоком, она писалась ДО первой правки кода и
|
||||
намеренно не переписана задним числом: два её решения я потом ПЕРЕМЕНИЛА, и это видно.
|
||||
|
||||
### 1. Числа ДО и ПОСЛЕ — командой, на обоих купленных файлах
|
||||
|
||||
Собственный контроль ряда **224** (он в теле ряда: «`json:"proposed` в `platform/` — 0 хитов»):
|
||||
|
||||
```
|
||||
git grep -l 'json:"proposed' HEAD -- 'platform/**/*.go' → 0 файлов
|
||||
grep -rn 'json:"proposed' platform/ --include=*.go → 1 (internal/ingest/bank.go:184)
|
||||
```
|
||||
|
||||
Собственный контроль ряда **253** («`grep -rn 'consolidat\|unanswered\|batches_dropped' platform/internal/
|
||||
--include=*.go` → 0»). ⚠ **Пере-снят и на HEAD — там НЕ 0, а 3**, и это первая мелкая находка отчёта:
|
||||
|
||||
```
|
||||
git grep -l '…' HEAD -- 'platform/internal/**/*.go' → 3 файла (manifest.go · pricing.go · sweep_test.go)
|
||||
grep -rln '…' platform/internal/ --include=*.go → 10 файлов, из них 7 мои
|
||||
контроль, что прибор читал населённое дерево: 194 файла при HEAD, 198 сейчас
|
||||
```
|
||||
|
||||
Три хита на HEAD — прозаические упоминания в комментариях, читателей среди них нет, так что СУТЬ ряда
|
||||
верна. Неверно ЧИСЛО, напечатанное рядом с нулём: команда ряда даёт 3, а не 0. Класс знакомый — контроль
|
||||
у нуля обязан быть воспроизводимым той командой, которая рядом написана.
|
||||
|
||||
Исполнением, на самих купленных проекциях (оракул `TestARealReadOutIsReadTheWayThisBuildClaims`, копии
|
||||
на чтение, `sha256` совпал, `mtime` оригиналов не тронут):
|
||||
|
||||
```
|
||||
прогон A: boundary=signature_requested offered=69 terms=0 consolidation=true
|
||||
прогон B: boundary=signature_requested offered=66 terms=0 consolidation=true
|
||||
```
|
||||
|
||||
**Было 0, стало 69 и 66.** `terms=0` осталось нулём — и это правда движка на стопе, а не дефект.
|
||||
|
||||
### 2. Что построено
|
||||
|
||||
| Слой | Что | Файл |
|
||||
|---|---|---|
|
||||
| Ридер | две секции + якорь свежести (`as_of`, `run_id`), которого ридер не читал вовсе | `internal/ingest/bank.go` |
|
||||
| Хранилище | миграция `00036`: `bank_read_out` (граница · идентичность прогона · ДВА флага наличия) + `bank_offered_terms` | `internal/pgstore/migrations/00036_bank_read_out.sql` |
|
||||
| Шов | `SaveBank` берёт ВЕСЬ развёрток, а не его строки; одна транзакция | `internal/pgstore/readmodel.go`, `internal/readmodel/readmodel.go` |
|
||||
| Провод | ресурс `GET /books/{bookId}/bank/signing-stop` + агрегат `BankPage.consolidation` | `internal/httpapi/{v0,reading,project,capabilities}.go` |
|
||||
| Контракт | минор **0.16.0** + компаньон: §2.27 (провенанс минора) и §2.26-бис (две идиомы «может быть null», по наблюдению сплошного аудита оркестратора, ряд 480). ⚠ испр. 17.09: здесь стояла ОДНА секция вместо двух | `docs/architecture/14-api-contract/{openapi.yaml,README.md}` |
|
||||
|
||||
### 3. Исход КАЖДОГО пункта заказа
|
||||
|
||||
⚠ Сверено механически, а не глазами: заголовки пака (`^## N.` и `^### N.N.`) против строк этой таблицы.
|
||||
Заказов с §4 и дальше — **14**, строк здесь **15** (у §4.1 и §4.3 по две: у каждого две разные обязанности).
|
||||
⚠ Первую редакцию таблицы поймала приёмка: в ней не было строки под §9, и тем же прибором нашлись ещё
|
||||
две — §11 и §12. Довод «пункт исполнен, не хватает лишь исхода в таблице» верен, но таблица, объявляющая
|
||||
себя полной по КАЖДОМУ пункту, и есть носитель этой полноты: три пропуска в ней — это три места, где
|
||||
читатель не смог бы отличить «сделано» от «не рассматривалось».
|
||||
|
||||
| Пункт пака | Исход |
|
||||
|---|---|
|
||||
| §4.1 прочитать `proposed` и `consolidation`, поля снять ЗАМЕРОМ | **сделано**, и замер дал находку — см. §4 |
|
||||
| §4.1 не потерять `invented` | **сделано**; пин `TestTheClassAPersonReadsFirstSurvivesTheReadOut`, и поле доезжает до провода |
|
||||
| §4.2 перенести КЛАСС гарантий ридера | **сделано**: «секции нет» ≠ «секция пуста» (указатели на всех трёх ярусах), «поля нет» ≠ «ноль» (четыре числа — указатели), граница читается ВМЕСТЕ с секцией и является УСЛОВИЕМ выдачи |
|
||||
| §4.3 контрактный минор, форму решаю сама | **сделано**, форма ПЕРЕМЕНЕНА относительно записки — довод в §5 |
|
||||
| §4.3 развилка идентичности предложения | **решено без пинга**: контракт уже отвечает — см. §5 |
|
||||
| §4.4 хранилище назвать явно | **в БД**, миграция часть пака; довод и доказательство, что дельта и ревизия не ломаются — §6 |
|
||||
| §4.5 не строить UI, не трогать движок, не подпирать заплаткой | **соблюдено**; рефакторинг — §8 |
|
||||
| §5 самопроверка исполнением + адверсариальный проход | **сделано**: гейт зоны целиком, мутационная кампания, один старший советчик, чьи посылки я проверила и одну опровергла |
|
||||
| §6 три оси ревью | **§9** |
|
||||
| §7 купленное сырьё только на чтение | **соблюдено**: работала с копиями, база рядом с B не открывалась вовсе |
|
||||
| §8 записка-план до кода | **сделано**, лежит ниже |
|
||||
| §9 эхо-протокол старта: десять строк своими словами ДО работы | **сделано первым действием** — блок в `/tmp/textmachine-channel` и письмо оркестратору до первой правки: скоуп, инварианты, чего не делаю |
|
||||
| §10 что не удалось и где прибор слеп | **§10** |
|
||||
| §11 канал вопросов и право отказа | **использован**: восемь писем оркестратору по ходу пака, два из них — улики, менявшие работу (состав полей, смена формы). Правом отказа не пользовалась: предмета для «этого делать не надо» не нашлось |
|
||||
| §12 критерий завершённости | **§12**, по пунктам |
|
||||
|
||||
### 4. ⚠ ГЛАВНАЯ НАХОДКА ЗАМЕРА: состав полей в промте был неверен, и это не мелочь
|
||||
|
||||
Промт давал контроль «восемь обязательных (`src dst kind channel freq spread conf variants`) и четыре
|
||||
опциональных (`conventions invented contradicts bank_holds`)». Снято с писателя
|
||||
(`backend/internal/pipeline/bankexport.go:125-160`) и с обоих файлов: **счёт 8+4 верен, состав — нет.**
|
||||
Обязательность определяется наличием `omitempty` у писателя, и по нему обязательны
|
||||
`src dst kind channel freq spread` **`conventions`** `conf`, а опциональны
|
||||
`invented contradicts bank_holds` **`variants`**.
|
||||
|
||||
**И объяснение «в A поля нет ни у одной строки» через опциональность — ЛОЖНОЕ.** У `conventions` нет
|
||||
`omitempty`, значит сериализатор опустить его не мог ⇒ **прогон A написан сборкой движка до `0997e41`
|
||||
(11.09), добавившего поле.** Доказательство построением, а не совпадением двух снимков.
|
||||
|
||||
Следствие несущее и оно в коде: **у обязательного числового поля «поля нет» ≠ «ноль»**, потому что ноль
|
||||
у каждого из четырёх — собственный ответ движка. `freq: 0` — «поверхность видела только черновая
|
||||
сторона» (`terminology.Candidate.Freq`); `conf: 0` — «роль сказала 0 %», по её же комментарию самая
|
||||
важная строка листа; отрицательный `conf` — «роль не сказала ничего». Сборка, читающая отсутствие нулём,
|
||||
показала бы человеку 69 терминов, чьи варианты «единогласны», на листе, весь смысл которого —
|
||||
разногласие.
|
||||
|
||||
### 5. Две развилки, где я ПЕРЕДУМАЛА после записки — с доводами
|
||||
|
||||
**(а) Форма. Записка обещала обе секции на `BankPage`. Довод записки оказался ложным по коду.**
|
||||
Я писала «обе описывают ОДИН момент стопа»; это опроверг мой советчик, и я проверила деревом:
|
||||
`r.lastTerminology` ставится в `backend/internal/pipeline/mining.go:171` — ДО развилки «стоп/не стоп» —
|
||||
и живёт в Runner до конца процесса ⇒ секция полноты едет на ТРЁХ границах из пяти, а секция вопросов —
|
||||
ровно на одной («Empty at every boundary that is not a stop»). Оси свежести разные уже у движка.
|
||||
⇒ **полнота — агрегат `BankPage`** (буква ряда 253: «поле на `BankPage`», и по сути это свойство банка),
|
||||
**вопросы стопа — свой ресурс**, потому что они появляются и исчезают вместе с ПРОГОНОМ, пока ревизия
|
||||
банка не двигается.
|
||||
⚠ Проверка советчика поймала его перегиб: он утверждал, что третий член «только первой страницы» требует
|
||||
правки шапки спеки про «Two exceptions». Шапка называет КЛАСС («the aggregates of `BankPage`»), а не
|
||||
перечень ⇒ новый агрегат входит в существующее исключение, шапка не тронута.
|
||||
|
||||
**(б) Имя. Записка обещала `suggestions`. Отвергла по существу.** «Suggestion» — то, что можно
|
||||
проигнорировать, а здесь **игнор есть ПРИНЯТИЕ**. ⚠ Адрес довода испр. 17.09 приёмкой, и дважды: я
|
||||
писала `mining.go:281`, настоящая строка — `:280` (внутри многострочного `WarnContext`), а **сильнейший
|
||||
носитель фразы вообще не там** — `backend/cmd/tmctl/render.go:225-227`: «whatever you leave undecided
|
||||
still goes to the model, as part of the same law block as the terms you signed (D39.104: the bank on the
|
||||
wire is law for every row)». Это операторский ВЫВОД — текст, который человек читает в момент подписи, —
|
||||
и он ссылается на ратифицированное `D39.104`, то есть довод от поправки стал крепче, а не слабее.
|
||||
⚠ И поправка к поправке: приёмка заявила «`leave undecided` в `mining.go` — 0 хитов»; пере-снято мной —
|
||||
**1 хит** (`:280`; контроль: `bank` в том же файле — 114). Носителей фразы в движке три. Взято слово, которым канон УЖЕ называет этот предмет: `BankSignatureCount`
|
||||
описан как «the count against the surfaces the LAST signing stop **offered**» ⇒ `offered[]` / `OfferedTerm`
|
||||
/ `readBankSigningStop`. Слово `proposed` наружу не идёт ВОВСЕ, третьего смысла нет, существующий
|
||||
`TermStatus.proposed` не тронут.
|
||||
|
||||
**⭐ И там же нашлась ратифицированная ФОРМА моей гарантии — я её не изобретала.** У
|
||||
`BankSignatureCount` есть `unreadable` с ровно моим обоснованием: «`true` — the two numbers mean NOTHING…
|
||||
Without this flag, „could not count“ would be byte-identical to „nothing left undecided“ — the one thing
|
||||
this schema must never say by accident». Взяла буквой. `unreadable: true` истинен по трём причинам сразу
|
||||
(материализации ещё не было · документ не несёт секции · документ снят на границе, которая её нести не
|
||||
может) — они не разделяются, потому что лекарство одно.
|
||||
|
||||
**Вот где граница у меня НЕСУЩАЯ, а не прочитанная и выброшенная:** секция, стоящая на границе, которая
|
||||
её не несёт, — документ, который эта сборка не понимает, и отдать его строки значило бы нарисовать стоп,
|
||||
которого не держит ни один прогон. Это условие выдачи, и его стережёт посадка.
|
||||
|
||||
### 6. Идентичность предложения — закрыто контрактом, пинга не потребовалось
|
||||
|
||||
`id` нет ни в одном из **135** объектов; выдумывать запрещено. Развилка разбивается надвое, и обе
|
||||
половины закрываются без выдумки:
|
||||
- **чтение:** дельта над списком не выразима ПО ПОСТРОЕНИЮ (нечего называть), ресурс отдаётся целиком,
|
||||
без курсора. Написано прозой операции, а не подразумевается;
|
||||
- **действие:** контракт УЖЕ говорит, чем адресуется поверхность, которой в банке нет — кортежная форма
|
||||
`BankCorrection`: «for a surface the bank does not list, send `sense: ""` and `null` windows».
|
||||
|
||||
**Дельта и ревизия не ломаются, и вот чем это предъявлено.** Строки стопа лежат ОТДЕЛЬНОЙ таблицей,
|
||||
заменяемой целиком, и в машинерию `bank_reset_revision` не входят. Втянуть их туда было бы дефектом: без
|
||||
идентичности КАЖДАЯ материализация читалась бы как «строки исчезли» и держала бы книгу в вечном сбросе
|
||||
ревизии. Пин `TestTheStopsRankingIsTheOrderThatComesBackAndTheListIsReplacedWhole`; вся прежняя батарея
|
||||
`internal/pgstore` (включая тесты ревизии и дельта-чтения) зелёная.
|
||||
|
||||
### 7. Гейт зоны — и КРАСНЫЙ ПАКЕТ, который оказался не моим
|
||||
|
||||
`make check` = `build vet fmt lint sqlc-check` + батарея. Прогонов было пять, и стоит назвать все,
|
||||
потому что три из них учат разному.
|
||||
|
||||
**Прогон 1 — `MAKE_EXIT=2`, а уведомление харнесса сказало «exit code 0».** Гейт умер на `tools-check`:
|
||||
`sqlc` лежал в `~/go/bin` вне `PATH`. Первая цель, упавшая первой, отменила все последующие; ни одного
|
||||
теста прогнано не было. Код обёртки — не код работы, и вердикт я читаю строкой `MAKE_EXIT` из лога.
|
||||
|
||||
**Прогон 2 — ЗЕЛЁНЫЙ ЦЕЛИКОМ, и полнота сверена СПИСКОМ, а не отсутствием слова FAIL:**
|
||||
|
||||
```
|
||||
MAKE_EXIT=0 · линтер 0 issues · sqlc diff чист · FAIL 0
|
||||
go list ./... → 20 · вердиктов → 20 · comm -23 список вердикты → 0 строк
|
||||
контроль, что сравнение умеет говорить: убрать один вердикт → 1 строка
|
||||
```
|
||||
|
||||
**Прогоны 3 и 4 — красные, и оба единственным красным дали `internal/books`, пакет, которого мой диф не
|
||||
касается.** Прогон 3: `pgstore: commit: timeout: context deadline exceeded` за 145 с. Прогон 4: паника
|
||||
таймаута 600 с, тест висел 7 м 26 с. Разные тесты, один пакет.
|
||||
|
||||
⛔ **Флейком по цвету я это не назвала — проверила цепь ЗВЕНЬЯМИ.** (1) Текст падения сам называет
|
||||
звено: коммит перерос свой срез. (2) Фикстуры `internal/books` НАМЕРЕННО сжимают 220-секундный
|
||||
продуктовый бюджет до 700 мс и 300 мс и делают внутри настоящий коммит в Postgres — иначе разрез не
|
||||
удержал бы ни одного среза. (3) Звено, через которое могла бы войти МОЯ правка, — длительность установки
|
||||
схемы; пере-снято: лишняя миграция стоит ≈0.12 с на прогон, и она лежит ВНЕ измеряемого окна.
|
||||
(4) В том же логе все пакеты шли примерно в 2.4 раза дольше обычного — на машине работала чужая
|
||||
мутационная кампания.
|
||||
|
||||
⭐ **РЕШАЮЩИЙ ЗАМЕР снят правильным прибором — ДВУМЯ полными батареями ОДНОВРЕМЕННО, а не по очереди.**
|
||||
Развёрнут `git archive HEAD` (35 миграций, ни строки пака) и запущен в одну секунду с батареей пака,
|
||||
под одной и той же чужой нагрузкой:
|
||||
|
||||
| дерево | вердикт | что упало |
|
||||
|---|---|---|
|
||||
| чистый `HEAD` | FAIL `internal/books` 201 с | `TestAnIntakeStoppedByTheCap…` (4.48 с) · `TestTheCutOfAnUploadIsBoundedByTheWalk…` (2.81 с) |
|
||||
| дерево пака | FAIL `internal/books` 600 с | таймаут на `TestAnUploadThatRunsOutOfBudgetWaitingForASlot…` |
|
||||
|
||||
**Красны ОБА, в одном пакете, на РАЗНЫХ тестах** ⇒ краснота принадлежит ПАКЕТУ, а не правке, и разные
|
||||
тесты при каждом падении — подпись голодания планировщика, а не логического дефекта. ⚠ Последовательным
|
||||
прогоном этот вывод не снимался бы: условия между двумя прогонами не совпадают, и сравнение вышло бы о
|
||||
разной нагрузке. Изолированно пакет целиком зелен в ОБОИХ деревьях даже при load average 11 — губит его
|
||||
не нагрузка сама по себе, а нагрузка ПЛЮС собственная батарея зоны под `-race`.
|
||||
Заведено **PD-469**, родня — **PD-420** того же класса. Не чиню: не мой предмет и не мой заказ.
|
||||
|
||||
**Прогон 5 — ФИНАЛЬНЫЙ, на дереве, которое я передаю, с поднятой переменной оракула: ЗЕЛЁНЫЙ ЦЕЛИКОМ.**
|
||||
Гейт, снятый ДО двух последних правок (пина на владение и строк регистра), я за финальный не выдавала —
|
||||
именно за это канон и бьёт, — и дождалась окна, когда чужая кампания отпустила процессор (load 3.18).
|
||||
|
||||
```
|
||||
MAKE_EXIT=0 · линтер 0 issues · sqlc diff чист · FAIL 0 · скипов 9
|
||||
go list ./... → 20 · вердиктов → 20 · comm -23 список вердикты → 0 строк
|
||||
контроль перевёрнутого сравнения: убрать один вердикт → 1 строка
|
||||
```
|
||||
|
||||
⚠ **Что оракул РЕАЛЬНО бежал внутри батареи, доказано разностью, а не словом:** скипов было 10 в
|
||||
прогоне 2 и 9 в прогоне 5, и единственная разница — `TestARealReadOutIsReadTheWayThisBuildClaims`;
|
||||
ничего нового скипаться не начало (`comm` в обратную сторону — 0 строк).
|
||||
|
||||
**Условия гейта на этом хосте, названные вместе с числом скипов (стандарт зоны §3.1).** Скипов **10** в
|
||||
зелёном прогоне 2. `TM_PLATFORM_TEST_DSN` был UNSET — **подняла** по рецепту `STACK_DECISIONS.md`
|
||||
(Postgres без root, порт 55433); контроль, что гейт РЕАЛЬНО открыт, а не объявлен открытым: тот же
|
||||
селектор `-run Bank` в `internal/pgstore` даёт **4 SKIP** без переменной и **4 PASS** с ней. НЕ подняты
|
||||
`TM_PLATFORM_TEST_ENGINE_BIN` + `TM_PLATFORM_TEST_BOOK_TEMPLATE` (пара): шаблон на машине есть, но его
|
||||
`pipeline:`/`models:` указывают на пути ДРУГОГО хоста (`/home/ubuntu-26/…`). Они открывают 9 из 10
|
||||
скипов — живые движковые пробы, ни одна не про читатель проекции; `artifacts_test.go`, где живёт мой
|
||||
предмет, этой парой НЕ гейтится. Десятый скип — МОЙ оракул, он гейтится новой переменной
|
||||
`TM_PLATFORM_TEST_BANK_READOUT`, которая выводится в `make conditions` автоматически (перечень условий
|
||||
там ПАРСИТСЯ из исходников, руками его никто не ведёт).
|
||||
|
||||
### 8. Что отрефакторено и почему
|
||||
|
||||
1. **`SaveBank` принимает `ingest.Bank`, а не `[]ingest.BankTerm`.** Не косметика: развёрток — одна
|
||||
проекция одного момента, и два сохранения дали бы экран, где вопросы этого стопа стоят рядом с банком
|
||||
другого момента. Одна транзакция, одна книжная блокировка.
|
||||
⚠ **Правка тестов ОБЪЯВЛЯЕТСЯ** (D39.183): **8** вызовов в `internal/pgstore/{readmodel,events}_test.go`
|
||||
(⚠ испр. 17.09 приёмкой: стояло «9» — я посчитала изменённые СТРОКИ диффа, а один вызов занимает две) и
|
||||
фейк в `internal/readmodel/readmodel_test.go` переписаны под новую сигнатуру. Поведение не ослаблено —
|
||||
каждый передаёт тот же список термов внутри `ingest.Bank{Terms: …}`; гарантия никуда не уехала.
|
||||
2. **`ingest.BoundaryOrUnknown` / `ingest.ChannelOrNone` — гарды закрытых словарей у ШВА записи.**
|
||||
Заведены не для красоты: приёмочный прогон показал, что нулевое значение `ingest.Bank`, которое любой
|
||||
вызывающий вправе построить, нарушает CHECK новой таблицы. Лечение по существу — значение, «заявляющее
|
||||
о себе меньше всего», а не отказ: падать всем сохранением банка из-за ярлыка границы было бы худшей
|
||||
сделкой.
|
||||
3. **Больше ничего.** Существующий ридер, `ReadBank`, ListBank-курсор и ревизионная машинерия не тронуты:
|
||||
заказ их не менял, а трогать работающее ради стройности — то, что канон запрещает.
|
||||
|
||||
### 9. Три оси ревью
|
||||
|
||||
**Контрактная.** Минор законен (мажор `0`, только добавление: новый путь, четыре схемы, один
|
||||
необязательный агрегат — ни одно существующее поле не сужено и не переименовано). Полнота предъявлена
|
||||
ГЕЙТОМ, а не глазами: `TestEveryMemberTheCanonRequiresIsOnTheWire` читает `required` ИЗ канона и сверяет
|
||||
с реальными байтами ответа в ОБЕ стороны — объявленное и не отданное красит так же, как отданное и не
|
||||
объявленное. Константу версии подняла до `0.16.0`, и её стережёт чужой гейт против самого канона.
|
||||
Компаньон: §2.27 плюс ОБА носителя номера в шапке (список ратификаций и строка про отставание зеркала) —
|
||||
именно на втором предыдущие три бампа спотыкались три раза подряд.
|
||||
|
||||
**Дисциплина ридера.** Гарантии перенесены, а не процитированы, и каждую стережёт посадка (§11). Одну
|
||||
вещь я перенесла ШИРЕ образца и называю это явно: образец различает «нет» и «пусто» на уровне ДОКУМЕНТА,
|
||||
а у меня то же различение стоит и на уровне ПОЛЯ — потому что замер показал, что именно там оно и
|
||||
ломается.
|
||||
|
||||
**Человеческий узел.** Чего человеку для решения НЕ хватает, хотя я этого не строю:
|
||||
1. **`variants` — склеенные ярлыки** (`"третий оборот ×1"`, с алиасом `"… (proposed for X)"`). Части не
|
||||
опубликованы, разбирать нельзя (парсер живёт рядом с писателем не случайно) ⇒ клиент не может
|
||||
отрисовать это на языке читателя. Движковый долг, оркестратор завёл рядом **479**.
|
||||
2. **Пустой `dst` неотличим по причине.** На печатном листе движка тот же символ несёт четыре факта, и
|
||||
один из них — `SettledByBank`, «решать нечего», — движок намеренно в проекцию не кладёт (ряд **353**).
|
||||
Контроль, который у меня это ловит: пустых `dst` — **0 и 0**, `never_asked` — **0 и 0**, сходится;
|
||||
класс реален, но на купленном сырье не проявился. ⚠ **Гейт ряда 353 сработал: читатель у секции
|
||||
появился**, и там же висит долг «у `SettledByBank` нет свидетеля в списке полей, которых сайдкар
|
||||
намеренно НЕ несёт». Это движковая зона — пинг, не правка у себя.
|
||||
3. **Ни одной подсказки, СКОЛЬКО осталось.** Счёт живёт только на квитанции правок, и канон сам называет
|
||||
это «named narrowness». Мой ресурс его не дублирует намеренно: популяции совпали на обоих прогонах
|
||||
(69/69 и 66/66, разность множеств 0 в обе стороны), но механизм расхождения реален — реверс-секция
|
||||
капается (`mining.go:143-147`), а при выключенной роли её нет вовсе ⇒ равенство в контракте не обещано
|
||||
и «N из M» между ними запрещено прозой.
|
||||
|
||||
### 10. Что не удалось — и где прибор слеп
|
||||
|
||||
- ⛔ **`contradicts` и `bank_holds` — 0 из 135 объектов.** Поля объявлены и доезжают, но проверены только
|
||||
синтетикой ЭТОЙ ЖЕ сборки, то есть пин спрашивает «декодер согласен сам с собой», а не «согласен с
|
||||
движком». Не чинила: лечение — не код, а УЛИКА (прогон, где конфликт случился). Строка **PD-468**.
|
||||
- ⛔ **`dst: ""` и отрицательный `conf` не встретились ни разу** (0 из 135). Ветки построены и запинены
|
||||
синтетикой; на живых данных не наблюдались. Закрывается той же уликой.
|
||||
- ⛔ **Якорь свежести хранится целиком и не сверяется.** Ресурс обещает ПОСЛЕДНИЙ стоп, а не текущий, и
|
||||
прозой это сказано — но клиент, не прочитавший `Run.status`, покажет вопросы ушедшего прогона.
|
||||
Сравнение построить можно и дёшево, но материализация идёт по долгу КНИГИ и прогона в руках не держит.
|
||||
Не строила намеренно: расширять обещание без механизма — взять форму якоря без гарантии. Строка **PD-467**.
|
||||
- ⛔ **Два гейта зоны на этом хосте не подняты** (движковый бинарь + шаблон книги): 9 скипов из 10.
|
||||
Ни один не про мой предмет, но сказать «батарея без скипов» нельзя, и я этого не говорю.
|
||||
- ⛔ **`unreadable` схлопывает три причины.** Оператор по нему не отличит «старая сборка движка» от «ещё
|
||||
не материализовали». Сделано сознательно (лекарство одно, и контракт служит клиенту), но диагностика
|
||||
беднее, чем могла бы быть.
|
||||
- ⛔ **Пятая граница, `redrive/re-seeded`, живого носителя в моих уликах не имеет** — обе проекции сняты
|
||||
на стопе. Перевод всех пяти проверен синтетикой против ГРЕПА по вызовам движка, а не против пяти живых
|
||||
документов.
|
||||
|
||||
### 11. Мутационная кампания — посадки и их вердикты
|
||||
|
||||
Копия дерева ВМЕСТЕ с каноном контракта (`cp -a --parents platform docs/architecture/14-api-contract`,
|
||||
норма `D39.113`/`PD-395`), один мутатор на копию, копия защищена ПОСТРОЕНИЕМ: харнесс отказывается
|
||||
работать, если по корню нет `platform/go.mod` и канона, или если корень внутри рабочего дерева.
|
||||
Базовые прогоны всех четырёх пакетов ЗЕЛЁНЫЕ до первой посадки — без этого вердикт был бы о них.
|
||||
Засчитывается по ТЕКСТУ падения: харнесс требует, чтобы упал ИМЕННО тот пин, который обязан назвать
|
||||
посадку, и выдаёт отдельный исход «не измерена» на посадке, которая не собралась.
|
||||
|
||||
| Посадка | Что ломает | Вердикт |
|
||||
|---|---|---|
|
||||
| A | отсутствующая секция читается как пустая | RED · `…SectionTheDocumentDoesNotCarryIsNotASectionThatIsEmpty` |
|
||||
| B | отсутствующее `conventions` читается нулём | RED · `…MandatoryNumberTheReadOutDoesNotCarryIsNotZero` |
|
||||
| C | неназываемая граница читается как СТОП | RED · `…BoundaryIsTranslatedAndAnUnnameableOneIsNeverTheStop` |
|
||||
| D | неназываемый детектор читается как «подтверждено обоими» | RED · `…DetectorVocabularyDoesNotReachAClient` |
|
||||
| E | отсутствующая полнота читается как обнулённая | RED · `…SectionTheDocumentDoesNotCarryIsNotASectionThatIsEmpty` |
|
||||
| F | `invented` теряется | RED · `…ClassAPersonReadsFirstSurvivesTheReadOut` |
|
||||
| G | граница перестаёт быть УСЛОВИЕМ выдачи | RED · `…TermsStandingAtABoundaryThatCannotCarryThemAreNotServed` |
|
||||
| H | «развёртка нет» читается как читаемый стоп | RED · `…ThreeWaysAStopCanHaveNoQuestionsAreNotOneAnswer` |
|
||||
| I | наличие секции выводится из числа строк | RED · `…ThreeWaysAStopCanHaveNoQuestionsAreNotOneAnswer` |
|
||||
| J | неизмеренная полнота отдаётся | RED · `…CompletenessIsAbsentUntilSomethingMeasuresIt…` |
|
||||
| K | ранжирование заменено сортировкой | RED · `…StopsRankingIsTheOrderThatComesBackAndTheListIsReplacedWhole` |
|
||||
| L | прежний список не очищается | RED · тот же пин (+1) |
|
||||
| M | `null`-число исчезает с провода (`omitempty`) | RED · `…EveryMemberTheCanonRequiresIsOnTheWire` |
|
||||
| N | неизмеренная полнота проецируется обнулённой | RED · `…CompletenessRidesTheFirstPageAndItsAbsenceIsNotWholeness` |
|
||||
| O | через шов едут только строки | RED · `…WholeReadOutCrossesTheSeamAndNotOnlyItsRows` |
|
||||
| P | пустой список уходит как `null` | RED · `…MeasurementNobodyTookReachesTheClientAsNullAndNotAsZero` |
|
||||
| Q | проверка владения снята | RED · `TestAnotherUsersStopIsNotReadable` |
|
||||
|
||||
**17 посадок — 17 RED, выживших 0, неизмеренных 0.**
|
||||
|
||||
⭐ **И ОТДЕЛЬНО — про сам ОРАКУЛ, потому что «скип превратился в PASS» доказывает, что тест ИДЁТ, а не
|
||||
что он ДЕРЖИТ.** В кампании оракул скипался (переменной у харнесса не было), то есть все посадки поймала
|
||||
СИНТЕТИКА. Спросила его посадкой отдельно: посадка **B** (отсутствующее `conventions` читается нулём) при
|
||||
поднятой переменной даёт `--- FAIL: TestARealReadOutIsReadTheWayThisBuildClaims` с топичным текстом
|
||||
«row 0: the document does not state `conventions` and the reader reported it stated», а при снятой —
|
||||
`--- SKIP`. ⇒ **эта посадка поймана ДВУМЯ независимыми приборами** — синтетическим пином и оракулом
|
||||
холодного прогона на настоящем купленном документе, — значит сломать чтение отсутствующего поля и
|
||||
получить зелень нельзя даже мимо синтетики. ⚠ Посадка Q прогнана ОТДЕЛЬНО и на пере-снятой
|
||||
копии: пин владения написан после первой кампании, и копия его не несла. Контроль чистоты после
|
||||
кампании — не `diff -rq` (он говорит о дереве, а не о числах), а вопрос «на месте ли ЦЕЛЬ каждой
|
||||
правки»: **16 целей проверено, пропавших 0.**
|
||||
|
||||
### 10-бис. КРУГ ДОФИКСА ПО ПРИЁМКЕ 17.09 — находка → что сделано → чем предъявлено
|
||||
|
||||
| Находка приёмки | Что сделано | Чем предъявлено |
|
||||
|---|---|---|
|
||||
| **F1 (мажор, денежный экран).** Канон объявляет `confidence` как `0..100`, движок шлёт `-1` для «роль не назвала», и вся цепь несла минус на провод — при том что компаньон обещал `null`. Класс: вместо ложного НУЛЯ — ложное ЧИСЛО | **⛔ И это оказался КЛАСС, а не поле — нашёл мой же новый гейт.** Свернула у ШВА (`ingest`), где файл велит переводить все пересечения, и потом гейт показал, что канон бьёт диапазоном ЧЕТЫРЕ числа, а проходили насквозь все четыре | `statedCount`/`statedConfidence`/`inRange` в `ingest/bank.go`; `TestTheEnginesNoConfidenceSentinelNeverReachesAClient` |
|
||||
| **Почему гейт этого не поймал** — `TestEveryMemberTheCanonRequiresIsOnTheWire` читает из канона только `required` и по построению не видит `minimum`/`maximum`: «взять форму образца и не взять гарантию», но у ГЕЙТА | Заведён гейт, читающий из канона ДИАПАЗОНЫ и падающий на любом ранжированном члене, которого ридер не ограничивает — то есть закрыт класс, а не случай | `TestEveryRangeTheCanonDeclaresIsOneThisReaderEnforces`: «ranges read from the canon and exercised: 4»; на момент заведения краснел по ЧЕТЫРЁМ полям |
|
||||
| **F2.** `omitempty` на nil-указателе ОПУСКАЕТ ключ, поэтому обещанный каноном `null` («никто не мерил») недостижим, а комментарий строкой выше утверждал обратное | У члена ДВЕ разных пустоты, и тег умеет одну: на поздней странице — отсутствие, на первой — `null`. Перешла на `json.RawMessage` | `TestCompletenessRidesTheFirstPageAndItsAbsenceIsNotWholeness` |
|
||||
| **F3.** Носителей номера версии в шапке компаньона **четыре**, подняты два — и предупреждение рядом само утверждает, что их два | Поднят четвёртый (счёт отставания зеркала: тринадцать → четырнадцать). `README.md:6` не тронут намеренно: он про РАТИФИКАЦИЮ, её делает акт оркестратора | греп `ЧЕТЫРНАДЦАТЬ`; в тексте правки названо, что носителей четыре, а не два |
|
||||
| **F4.** «Роутер монтирует 19 операций из 21» — минор двинул оба числа | Пере-снято: операций в каноне **22**, маршрутов **20**, без маршрута те же две, что названы в строке | `yaml`-разбор канона против таблицы `contractSurface` |
|
||||
| **F5.** Нового ресурса нет в таблице зависимостей компаньона, которая объявляет себя единственным местом, где это ведётся | Заведены ДВЕ строки — ресурс стопа и агрегат полноты — с носителями 224 и 253 | греп `bank/signing-stop` в README |
|
||||
| **A.** Удалён доккомментарий `DecodeBank`, несший довод гарда («пустой банк и документ, который эта сборка не умеет читать, декодируются одинаково») | Восстановлен дословно. §8 говорил «больше ничего» — удаление под «ничего» не подходило | греп `replace a book's whole bank with nothing` |
|
||||
| **B/C.** Гард закрытого словаря стоит на канале и не стоит на `kind`; и сам гард якобы дублирует `nullif` | ⛔ **Замер опроверг вторую половину и усилил первую:** `nullif` делает ДРУГУЮ работу, и без гарда движковое слово не «ложится как неназванное», а **роняет CHECK и всю запись банка**. Гард добавлен на `kind`, оба запинены | `TestAnEngineWordThatLeakedThisFarIsStoredAsNotNamedRatherThanTakingTheBankDown` — до правки красный именно на `kind` |
|
||||
| **D.** §8 говорит «9 вызовов», их 8 | Испр.: я посчитала изменённые СТРОКИ диффа, а один вызов занимает две | `grep -c 'SaveBank('` → 7 + 1 |
|
||||
| **Семь чисел консолидации** не получили второй половины вывода — условия, при котором он перестанет держаться | Условие названо: семь полей вошли в писателя ОДНИМ коммитом, поэтому «present» сегодня всегда значит «все семь»; в день восьмого числа `unanswered: 0` станет ложным нулём, и они станут указателями | комментарий у `decodeConsolidation` |
|
||||
| **Адрес довода §5(б)** неверен | Испр. трижды: `:281` → `:280` → настоящий носитель `cmd/tmctl/render.go:225-227`. ⚠ И поправка к поправке приёмки: «0 хитов в `mining.go`» — неверно, там **1** | §5(б), контроль напечатан рядом |
|
||||
| **§2 отчёта** называл одну новую секцию компаньона вместо двух | Испр., §2.26-бис назван | таблица §2 |
|
||||
|
||||
### 11-бис. Опись дерева — снята ПОСЛЕ последней правки
|
||||
|
||||
**Двадцать один путь, и все внутри разрешённых паком зон** (`platform/` + `docs/architecture/14-api-contract/`):
|
||||
|
||||
```
|
||||
docs/architecture/14-api-contract/README.md platform/internal/ingest/bank.go
|
||||
docs/architecture/14-api-contract/openapi.yaml platform/internal/pgstore/readmodel.go
|
||||
platform/docs/DEFECT_REGISTER.md platform/internal/pgstore/readmodel_test.go
|
||||
platform/docs/platform-PROGRESS.md platform/internal/pgstore/events_test.go
|
||||
platform/internal/httpapi/capabilities.go platform/internal/pgstore/migrations.sha256
|
||||
platform/internal/httpapi/project.go platform/internal/readmodel/readmodel.go
|
||||
platform/internal/httpapi/reading.go platform/internal/readmodel/readmodel_test.go
|
||||
platform/internal/httpapi/v0.go
|
||||
platform/internal/httpapi/v0_test.go
|
||||
новые: platform/internal/pgstore/migrations/00036_bank_read_out.sql
|
||||
platform/internal/ingest/bankreadout_test.go
|
||||
platform/internal/pgstore/bankreadout_test.go
|
||||
platform/internal/httpapi/signingstop_test.go
|
||||
```
|
||||
|
||||
⚠ **В дереве лежит ЧУЖОЕ, и оно не моё — коммитить его нельзя:** 16 путей под `backend/` (пак лестницы
|
||||
попытки, сессия `textmachine-main-12`) плюс `docs/BACKLOG.md` и `docs/experiments/00-provider-quirks.md`.
|
||||
Я их не трогала ни разу. Коммит — только pathspec-формой по списку выше.
|
||||
|
||||
⚠ **Двадцать первый путь появился ПОСЛЕ зелёного гейта и назван отдельно** — `platform/docs/STACK_DECISIONS.md`,
|
||||
одна грабля в раздел «Грабли стенда, каждая стоила времени»: TCP-проба стенда отвечает
|
||||
`Connection refused` на ЖИВОМ сервере, потому что рецепт поднимает его с `listen_addresses=''` и слушает
|
||||
он только Unix-сокет. Правка инертна к гейту, и это ЗАМЕРЕНО, а не объявлено: файл цитируется в прозе
|
||||
комментариев 12 раз и не открывается кодом НИ РАЗУ (`grep` по `platform/**/*.go` вне комментариев — 0);
|
||||
исполняемых путей (`.go`/`.sql`) новее лога зелёного прогона — **0**, при контроле «новее предыдущего
|
||||
лога — 1», то есть сравнение умеет говорить. Записано потому, что цена этой грабли уже уплачена
|
||||
приёмкой, а знание, оставшееся в переписке, следующей смене не достаётся.
|
||||
|
||||
⚠ **Один побочный эффект, который надо знать при лендинге:** две мои строки в `DEFECT_REGISTER.md`
|
||||
(а теперь три — `PD-467`, `PD-468`, `PD-469`) двигают числа, которые стережёт `docs/scripts/counts.py`,
|
||||
а живут они в `docs/PROGRESS.md` — зона оркестратора, куда платформа не пишет. Оркестратор знает и
|
||||
правит тем же коммитом.
|
||||
|
||||
**Стенды, оставленные для приёмки** (вне репозитория, git не видит): `/home/ubuntu/tm-mut-94-1709` —
|
||||
копия под мутации вместе с каноном; `/home/ubuntu/tm-head-94-1709` — развёрнутый чистый `HEAD` для
|
||||
сравнения двух батарей. Postgres стенда поднят мной на порту 55433 и оставлен работать.
|
||||
|
||||
### 12. Критерий завершённости — по пунктам пака
|
||||
|
||||
- у каждого пункта заказа исход — **§3**;
|
||||
- круги сошлись, находки закрыты таблицей — **§13**;
|
||||
- числа ДО и ПОСЛЕ на обоих купленных файлах предъявлены командой — **§1**;
|
||||
- ряды 224 и 253 — **§14**;
|
||||
- список отрефакторенного — **§8**;
|
||||
- гейт зоны зелёный целиком — **§7**;
|
||||
- **работа завершена, править не планирую.**
|
||||
|
||||
### 13. Находки круга самопроверки: находка → что сделано → чем предъявлено
|
||||
|
||||
| Находка | Кто нашёл | Что сделано | Чем предъявлено |
|
||||
|---|---|---|---|
|
||||
| Состав «8 обязательных / 4 опциональных» в промте неверен; `conventions` обязательно, `variants` опционально | я, замером писателя | замер принят, промт не подгонялся | §4; оркестратор подтвердил и правит промт |
|
||||
| «В A нет `conventions`» — не опциональность, а ДРУГАЯ СБОРКА движка | я | четыре числа стали указателями | посадка **B**, пин `…MandatoryNumber…` |
|
||||
| «Обе секции — один момент» ЛОЖНО: полнота едет на трёх границах, вопросы на одной | советчик, проверено мной по `mining.go:171` | форма переиграна: агрегат + отдельный ресурс | §5(а), эррата над запиской |
|
||||
| `suggestions` врёт читателю: игнор здесь есть принятие | советчик; адрес трижды уточнён (`:281`→`:280`→`render.go:225`) | имя `offered` из слов самого канона | §5(б) |
|
||||
| Гарантию «не читается» изобретать не надо — она ратифицирована у `BankSignatureCount` | я, при проверке имени | `unreadable` взят буквой вместе с обоснованием | посадка **H**, контракт §`BankSigningStop` |
|
||||
| Советчик перегнул: третий член первой страницы якобы правит шапку спеки | я, чтением шапки | шапка НЕ тронута — там КЛАСС, а не перечень | §5(а) |
|
||||
| Советчик: популяции списка и счётчика подписи расходятся | я, замером карты подписи | **опровергнуто на сырье** (69/69, 66/66, разность 0), но механизм реален ⇒ равенство не обещано | §9, проза операции |
|
||||
| Нулевое значение `ingest.Bank` нарушает CHECK новой таблицы | приёмочный прогон | гарды `BoundaryOrUnknown`/`ChannelOrNone` у шва записи | §8 п.2; вся батарея `pgstore` зелёная |
|
||||
| Уведомление харнесса сказало «exit 0» на упавшем гейте | я, чтением лога | вердикт читается строкой `MAKE_EXIT` из лога, не уведомлением | §7 |
|
||||
| Контроль ряда 253 даёт 3, а не заявленный 0 | я, пере-снятием на HEAD | суть ряда верна, число — нет; названо оркестратору | §1 |
|
||||
| `contradicts`/`bank_holds` — 0 из 135, проекция исполнением не проверена | я | не закрыто: нужна улика, а не код | **PD-468** |
|
||||
| Якорь свежести хранится и не сверяется | советчик, принято | не строила; обещание ресурса сужено прозой до «ПОСЛЕДНИЙ стоп» | **PD-467** |
|
||||
| `variants` — склеенные ярлыки с английским фрагментом | я, по пункту 4 аддендума | пропущено как непрозрачный текст, разбирать нельзя | ряд **479** (завёл оркестратор) |
|
||||
| Гейт ряда 353 сработал: у секции появился читатель | я | пинг движковой зоне через оркестратора | §9 п.2 |
|
||||
|
||||
### 14. Ряды 224 и 253 — что закрыто и что осталось
|
||||
|
||||
**224** — платформенная половина **ЗАКРЫТА**: у секции есть читатель, хранилище и поверхность; её
|
||||
собственный контроль ушёл с 0 на 1. Осталось за пределами ряда — ряд **479** (части ярлыка `variants`).
|
||||
|
||||
**253** — платформенная половина **ЗАКРЫТА**: поле на `BankPage` есть, бит `complete` берётся у движка
|
||||
ГОТОВЫМ и не выводится у меня (ратифицированное требование ряда соблюдено буквой). ⚠ Осталась
|
||||
ЧУЖАЯ работа, которую этот пак только разбудил: ряд **353** обещал вернуться к исчерпывающему пину
|
||||
контракта секции «когда у неё появится ЧИТАТЕЛЬ», и читатель появился — вместе с висящим там долгом
|
||||
про отсутствие свидетеля у `SettledByBank`. Это движковая зона.
|
||||
|
||||
### 15. Аддендум владельца 17.09 — отдельным пунктом, как просил оркестратор
|
||||
|
||||
1. **«Тщательно проектируй решение».** Три развилки предъявлены запиской ДО кода; две из трёх я потом
|
||||
переиграла, и обе — не по вкусу, а по опровержению довода кодом (§5). Эррата над запиской оставляет
|
||||
видимым и прежнее решение, и причину отмены.
|
||||
2. **«Комментарии — только нужные, без странных гарантий».** Аддендум пришёл, когда ридер уже был
|
||||
написан, и **изменил написанное**: я прошла по своим комментариям и сократила их, оставив «почему» и
|
||||
выбросив пересказ строки. Где комментарий несёт ВЫВОД, названо условие его конца — прямо у
|
||||
`Invented`: «плоский bool, потому что движок опускает поле при `false`; в день, когда `omitempty`
|
||||
уйдёт, отсутствие перестанет значить `false` и поле обязано стать указателем — и НИЧТО здесь в этот
|
||||
день не покраснеет, гарда — эта фраза». Гарантий, которых не стережёт пин, я не писала: каждое
|
||||
утверждение §4.2 имеет посадку в §11.
|
||||
3. **«Прозу в код не писать»** — и плоскости не спутаны: в Go минимум, в спецификации описание, потому
|
||||
что там проза НОРМАТИВНА и компилируется в исходник генерируемого клиента.
|
||||
4. **«Решение общее для книг и языков».** Ревью-вопрос «заработает ли пара, которой в репо нет, без
|
||||
правки Go?» — **да**: ни одно поле секций и ни одно значение их словарей не несёт пара-специфики,
|
||||
ветвлений по паре/книге в добавленном Go нет, а закрытые словари (`kind`, `channel`, граница) — про
|
||||
устройство пайплайна, не про язык. ⚠ И ровно этот вопрос дал находку: **`variants` несут английский
|
||||
фрагмент фразы внутри данных** («proposed for X»), то есть клиент не отрисует их на языке читателя.
|
||||
Не зашила и не пере-вывела — назвала; ряд **479**.
|
||||
5. **«Советчиков до двух»** — использован ОДИН, с постоянным контекстом, на проектировании (развилка
|
||||
формы и имени). Его посылки проверены деревом: одна опровергнута (шапка спеки), одна опровергнута
|
||||
замером (популяции), остальные приняты и изменили работу.
|
||||
6. **«Право грепать доки и полигон»** — воспользовалась при проверке доводов советчика.
|
||||
|
||||
|
||||
## ЗАПИСКА-ПЛАН (17.09) — ЧИТАТЕЛЬ СЕКЦИЙ ПРЕДЛОЖЕНИЙ И КОНСОЛИДАЦИИ (ряды 224 · 253)
|
||||
|
||||
Пак `docs/PLATFORM_BANK_READOUT_SESSION_PROMPT.md` (180c41e), сессия `textmachine-main-94`.
|
||||
Записка написана ДО первой правки кода, как требует §8 пака: по ней приёмка судит работу против
|
||||
замысла, а не против домысла. Решения ниже названы явно; передумаю с доводом — напишу это здесь же.
|
||||
|
||||
> ⚠⚠ **ЭРРАТА 17.09 — ДВА РЕШЕНИЯ ЭТОЙ ЗАПИСКИ ОТМЕНЕНЫ, ЧИТАТЬ ВМЕСТЕ С §5 ОТЧЁТА ВЫШЕ.**
|
||||
> Тело записки НЕ переписано задним числом намеренно: смена замысла и её довод — сама по себе улика,
|
||||
> и затирать её значило бы предъявить приёмке замысел, которого у меня в тот момент не было.
|
||||
> **(1) Имя.** Записка обещала `suggestions` — **отменено**, действует **`offered`** (ресурс
|
||||
> `.../bank/signing-stop`, схема `OfferedTerm`). Довод записки был «ноль хитов в спеке» — он
|
||||
> доказывает, что имя СВОБОДНО, а не что оно ВЕРНО. Отменяющий довод: игнор здесь есть ПРИНЯТИЕ
|
||||
> (сильнейший носитель — `backend/cmd/tmctl/render.go:225-227`, операторский вывод, который человек
|
||||
> читает в момент подписи; ⚠ адрес испр. 17.09 — в первой редакции стоял `mining.go:281`),
|
||||
> а «suggestion» обещает необязательность; и `offered` контракт уже употребляет
|
||||
> об этом же предмете («the surfaces the LAST signing stop offered»).
|
||||
> **(2) Форма.** Записка обещала обе секции на `BankPage` — **отменено**: полнота осталась агрегатом
|
||||
> `BankPage`, а вопросы стопа уехали в свой ресурс. Довод записки («обе описывают ОДИН момент»)
|
||||
> опровергнут кодом движка: `r.lastTerminology` ставится ДО развилки «стоп/не стоп», поэтому полнота
|
||||
> едет на трёх границах из пяти, а вопросы — на одной.
|
||||
> Остальное записки — состав полей, три состояния уверенности, перевод словаря границ, отказ выдумывать
|
||||
> идентичность, хранилище в БД — **действует без изменений**.
|
||||
|
||||
### Что меряю и чем (замер, не пересказ)
|
||||
|
||||
Обе купленные проекции прочитаны копиями в скретчпаде (`sha256` копий совпал с оригиналами, `mtime`
|
||||
оригиналов не тронут). Числа сошлись с промтом по КОЛИЧЕСТВУ и **разошлись по СОСТАВУ** — это находка,
|
||||
и она ушла пингом оркестратору, а не выровнялась молча:
|
||||
|
||||
| | A | B |
|
||||
|---|---|---|
|
||||
| `terms` | 0 | 0 |
|
||||
| `proposed` | 69 | 66 |
|
||||
| `consolidation` | объект из 7 полей | объект из 7 полей |
|
||||
| `as_of` | `bank-mining/signature-stop` | `bank-mining/signature-stop` |
|
||||
| различных `src` | 69 из 69 | 66 из 66 |
|
||||
|
||||
Восемь полей, которые движок пишет ВСЕГДА (у них нет `omitempty` в `backend/internal/pipeline/bankexport.go`):
|
||||
`src dst kind channel freq spread` **`conventions`** `conf`. Четыре с `omitempty`: `invented contradicts
|
||||
bank_holds` **`variants`**. ⚠ Промт называл `variants` обязательным, а `conventions` опциональным —
|
||||
наоборот; счёт 8+4 верен, состав нет.
|
||||
|
||||
### Находка, ради которой состав и переснимался
|
||||
|
||||
`conventions` отсутствует у **всех 69** строк прогона A и стоит у **всех 66** строк прогона B. У поля нет
|
||||
`omitempty`, значит `json.Marshal` его не мог опустить ⇒ **A написан сборкой движка ДО `0997e41` (11.09),
|
||||
добавившего поле**. Это доказательство построением, а не догадка. Следствие несущее: у ОБЯЗАТЕЛЬНОГО поля
|
||||
«поля нет» значит «другая сборка», и прочесть его нулём — напечатать измерение, которого никто не делал.
|
||||
Ровно тот ложный ноль, ради которого пак заказан, только на ярус ниже — не у секции, а у поля.
|
||||
|
||||
`contradicts` и `bank_holds` не встретились НИ РАЗУ (0 из 135 объектов). Читатель их несёт, но живым
|
||||
сырьём они не проверены — назову это в секции «где прибор слеп».
|
||||
|
||||
### Что строю
|
||||
|
||||
**1. Ридер (`platform/internal/ingest/bank.go`).** Две новые секции плюс якорь свежести (`as_of`, `run_id`),
|
||||
которого ридер сегодня не читает вовсе. Наследую КЛАСС гарантий существующего ридера, а не цитирую его:
|
||||
|
||||
- «секции нет» ≠ «секция есть и пуста» — обе секции указателями; `nil` ⇔ член документа отсутствовал;
|
||||
- у ОБЯЗАТЕЛЬНЫХ числовых полей «поля нет» ≠ «ноль»: `conventions` и `conf` — указатели. У `conf` три
|
||||
состояния, а не два: нет поля · отрицательное («роль не назвала уверенности», закон движка) · `≥0`
|
||||
(названная уверенность, включая осмысленный ноль);
|
||||
- у полей с `omitempty` отсутствие есть нулевое значение ПО ЗАКОНУ ПИСАТЕЛЯ, и комментарий назовёт
|
||||
условие, при котором вывод перестанет держаться (движок снимет `omitempty` — и тогда `invented`
|
||||
обязан стать указателем);
|
||||
- **граница читается вместе с секцией.** «Граница не та» обязано отличаться от «предложений нет»:
|
||||
документ с `as_of: run-finished` и без секции — это не «нечего подписывать», это «read-out снят не в
|
||||
том месте».
|
||||
|
||||
**2. Словарь границ ПЕРЕВОДИТСЯ, а не проходит насквозь.** Шапка `bank.go` ратифицирует: каждое
|
||||
пересечение словаря переводится, движковые слова наружу не идут. `bank-mining/signature-stop` — имя
|
||||
СТАДИИ конвейера, тот самый класс. Пять значений сняты грепом `exportBank(` по `backend/` (пять живых
|
||||
вызовов, не из комментария), неизвестное шестое читается как то, что заявляет о себе меньше всего, и
|
||||
НИКОГДА как стоп.
|
||||
|
||||
**3. Наружу — контрактный минор `0.16.0`** (канон `0.15.0`, полоса мажора `0`, добавление = минор),
|
||||
вместе с ратифицированным компаньоном `README.md`. ⚠ Номер живёт в ДВУХ местах шапки компаньона, и
|
||||
предыдущие три бампа правили только `openapi.yaml` — правлю оба.
|
||||
|
||||
**Коллизия имени — решение.** `proposed` в контракте уже значит СТАТУС строки (`TermStatus`), в проекции —
|
||||
ИМЯ СЕКЦИИ. Третьего смысла не завожу, существующий не переименовываю, наружу слово `proposed` не беру
|
||||
вовсе. Внешнее имя — **`suggestions`** (контроль: `suggestion`/`Suggestion` в `openapi.yaml` — 0 хитов,
|
||||
в компаньоне — 0). Не `candidate`: это движковый тип (`terminology.Candidate`), то есть словарь, который
|
||||
шапка `bank.go` велит переводить, а не заимствовать.
|
||||
|
||||
**Идентичность предложения — решение, а не пинг.** `id` у предложения нет ни в одном из 135 объектов, и
|
||||
выдумывать его у себя запрещено. Развилка разбивается надвое, и обе половины закрываются БЕЗ выдумки:
|
||||
- **чтение:** предложения в дельта-чтении не участвуют ВОВСЕ и едут всегда целиком. Дельта над ними не
|
||||
выразима по построению (нет идентичности), а порядок — ранжирование стопа, которое между прогонами
|
||||
меняется. Это пишется в контракт прозой, а не подразумевается;
|
||||
- **действие:** контракт УЖЕ говорит, чем адресуется поверхность, которой в банке нет —
|
||||
`BankCorrection`, кортежная форма: «for a surface the bank does not list, send `sense: ""` and `null`
|
||||
windows». То есть дверь для решения по предложению построена и ратифицирована; своего ключа ей не надо.
|
||||
|
||||
**4. Хранилище — в БД, миграцией `00036`, не на лету.** Довод — исполнением, а не вкусом: реплика API
|
||||
может не иметь движка вовсе (`platform/internal/readmodel/readmodel.go`: «A replica with no engine serves
|
||||
what was materialized before»), а путь артефакта берётся из манифеста, чтение которого стоит вызова движка
|
||||
с ре-чанком исходника (`PD-248`). Чтение на лету в HTTP-пути перенесло бы пустой экран с одной причины на
|
||||
другую — и именно на том развёртывании, где его труднее всего увидеть.
|
||||
Дельта-чтение и ревизию это не ломает, и вот почему: предложения ложатся ОТДЕЛЬНОЙ таблицей, заменяемой
|
||||
целиком, и в машинерию `bank_reset_revision` не входят. Втянуть их туда было бы дефектом: у них нет
|
||||
идентичности, значит КАЖДАЯ материализация читалась бы как «строки исчезли» и держала бы книгу в вечном
|
||||
сбросе ревизии.
|
||||
|
||||
**5. Чего НЕ отдаю наружу.** `run_id` проекции хранится, но на провод не идёт: это движковая идентичность
|
||||
(`tm-stream-<runID>-<attempt>`, `pgstore.EngineStreamID`), а шов движковых идентичностей не экспортирует.
|
||||
Хранится потому, что у движка якорь свежести из ДВУХ полей, и взять форму без её гарантии — известный
|
||||
класс ошибки.
|
||||
|
||||
### Гейт и его условия на этом хосте (называю ВМЕСТЕ с числом, как требует §3.1 стандарта зоны)
|
||||
|
||||
`TM_PLATFORM_TEST_DSN` был UNSET — **поднят мной** по рецепту `STACK_DECISIONS.md` («Postgres на стенде без
|
||||
root», порт 55433). Контроль, что гейт действительно открыт, а не объявлен открытым: тот же селектор
|
||||
`-run Bank` в `internal/pgstore` даёт **4 SKIP** без переменной и **4 PASS** с ней.
|
||||
Не подняты: `TM_PLATFORM_TEST_ENGINE_BIN` + `TM_PLATFORM_TEST_BOOK_TEMPLATE` (пара). Они открывают живые
|
||||
движковые пробы `internal/runner` (`backup_live` · `bankapply_live` · `build_live` · `priceprojection_live` ·
|
||||
`translate_resnapshot_live`) и `internal/books` — ни одна из них не про читатель проекции; `artifacts_test.go`,
|
||||
где живёт мой предмет, этой парой НЕ гейтится (контроль: греп `TM_PLATFORM_TEST_ENGINE_BIN` по
|
||||
`platform/internal/runner/*_test.go` даёт 5 файлов, и `artifacts_test.go` среди них нет).
|
||||
|
||||
## ⚠ ПИНГ ОРКЕСТРАТОРА №23 (10.09) — ЧЕТЫРЕ АДРЕСА В ВАШЕЙ ЗОНЕ ПОСЛЕ ДВИЖКОВОГО ПАКА И РАТИФИКАЦИИ ДВУХ ОСТАНОВОК
|
||||
|
||||
Зона ваша, рукой не трогаю. Всё ниже пере-снято моим прибором сегодня; каждый адрес — от корня репозитория.
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ import "net/http"
|
|||
// it buys is that two clicks on «continue» answer the same whichever reached the database first
|
||||
// (D39.246 п.6, PD-448) — the refusal was not a rare race but the ordinary answer to every resume of
|
||||
// a live run.
|
||||
const ContractVersion = "0.15.0"
|
||||
const ContractVersion = "0.16.0"
|
||||
|
||||
// Capabilities is what this deployment can do: one flat document, the same for every account.
|
||||
type Capabilities struct {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"textmachine/platform/internal/ingest"
|
||||
"textmachine/platform/internal/pgstore"
|
||||
)
|
||||
|
|
@ -118,6 +119,54 @@ func projectNote(n pgstore.Note) wireNote {
|
|||
return out
|
||||
}
|
||||
|
||||
// projectOfferedTerm carries the read model's absences out unchanged. Every `null` it emits is one the
|
||||
// store held, and none is manufactured here: the numbers are the service's own measurements, and a
|
||||
// zero substituted for a missing one would be a figure nobody took.
|
||||
func projectOfferedTerm(t pgstore.BankOfferedTerm) wireOffered {
|
||||
return wireOffered{
|
||||
Src: t.Src, Dst: t.Dst, Kind: nilIfEmpty(t.Kind), Channel: nilIfEmpty(t.Channel),
|
||||
Freq: t.Freq, Spread: t.Spread, Conventions: t.Conventions, Confidence: t.Confidence,
|
||||
Invented: t.Invented,
|
||||
// The three lists are `required` and an empty collection is an empty array, never null.
|
||||
Contradicts: emptyIfNil(t.Contradicts), BankHolds: emptyIfNil(t.BankHolds),
|
||||
Variants: emptyIfNil(t.Variants),
|
||||
}
|
||||
}
|
||||
|
||||
// projectConsolidation renders the member the first page always carries: the section, or the literal
|
||||
// `null` that says nothing measured it. Never an empty object — "consolidated 0, unanswered 0" reads
|
||||
// as "nothing is missing", which is the one thing this member must not say by accident.
|
||||
func projectConsolidation(c *pgstore.BankConsolidation) json.RawMessage {
|
||||
if c == nil {
|
||||
return json.RawMessage("null")
|
||||
}
|
||||
body, err := json.Marshal(wireConsolidation{
|
||||
Complete: c.Complete, RenderBatchesDropped: c.RenderBatchesDropped,
|
||||
ClassifyBatchesDropped: c.ClassifyBatchesDropped, Consolidated: c.Consolidated,
|
||||
Declined: c.Declined, Unanswered: c.Unanswered, NeverAsked: c.NeverAsked,
|
||||
})
|
||||
if err != nil {
|
||||
// Seven integers and a bool cannot fail to marshal; `null` is still the safe answer, because
|
||||
// the alternative is a page that says the bank is whole when nobody asked.
|
||||
return json.RawMessage("null")
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func nilIfEmpty(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func emptyIfNil(v []string) []string {
|
||||
if v == nil {
|
||||
return []string{}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func projectTerm(t pgstore.BankTerm) wireTerm {
|
||||
out := wireTerm{
|
||||
ID: t.ID, Src: t.Src, Dst: t.Dst, Status: t.Status, Origin: t.Origin, Sense: t.Sense,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
|
|
@ -90,12 +91,54 @@ type wireTerm struct {
|
|||
// still undecided" is the engine's answer and rides the correction receipt (`signature`), not a
|
||||
// read of this table.
|
||||
type wireBankPage struct {
|
||||
Revision int64 `json:"revision"`
|
||||
NextCursor *string `json:"next_cursor"`
|
||||
StructureVersion int `json:"structure_version"`
|
||||
Total *int `json:"total,omitempty"`
|
||||
Signed *int `json:"signed,omitempty"`
|
||||
Terms []wireTerm `json:"terms"`
|
||||
Revision int64 `json:"revision"`
|
||||
NextCursor *string `json:"next_cursor"`
|
||||
StructureVersion int `json:"structure_version"`
|
||||
Total *int `json:"total,omitempty"`
|
||||
Signed *int `json:"signed,omitempty"`
|
||||
// ⛔ RAW, and not a `*wireConsolidation` with `omitempty`, because this member has TWO different
|
||||
// absences and that tag can only spell one. On a later page it is ABSENT, like the counts above —
|
||||
// the aggregates ride the first page only. On the FIRST page it is `null` when nothing has
|
||||
// measured completeness, which the canon promises and a client must be able to tell from a bank
|
||||
// that is whole. A nil pointer under `omitempty` collapses both into "no key", so the state the
|
||||
// canon describes was unreachable and the comment that claimed otherwise was false by the tag one
|
||||
// line below it.
|
||||
Consolidation json.RawMessage `json:"consolidation,omitempty"`
|
||||
Terms []wireTerm `json:"terms"`
|
||||
}
|
||||
|
||||
type wireConsolidation struct {
|
||||
Complete bool `json:"complete"`
|
||||
RenderBatchesDropped int `json:"render_batches_dropped"`
|
||||
ClassifyBatchesDropped int `json:"classify_batches_dropped"`
|
||||
Consolidated int `json:"consolidated"`
|
||||
Declined int `json:"declined"`
|
||||
Unanswered int `json:"unanswered"`
|
||||
NeverAsked int `json:"never_asked"`
|
||||
}
|
||||
|
||||
// wireSigningStop is what the last signing stop asked about. `Unreadable` carries the fact an empty
|
||||
// list cannot: without it "this deployment could not read the question" and "the stop asked nothing"
|
||||
// are the same bytes, and a client would tell a person there is nothing to decide at the one moment
|
||||
// the product stops to ask them.
|
||||
type wireSigningStop struct {
|
||||
Unreadable bool `json:"unreadable"`
|
||||
Offered []wireOffered `json:"offered"`
|
||||
}
|
||||
|
||||
type wireOffered struct {
|
||||
Src string `json:"src"`
|
||||
Dst string `json:"dst"`
|
||||
Kind *string `json:"kind"`
|
||||
Channel *string `json:"channel"`
|
||||
Freq *int `json:"freq"`
|
||||
Spread *int `json:"spread"`
|
||||
Conventions *int `json:"conventions"`
|
||||
Confidence *int `json:"confidence"`
|
||||
Invented bool `json:"invented"`
|
||||
Contradicts []string `json:"contradicts"`
|
||||
BankHolds []string `json:"bank_holds"`
|
||||
Variants []string `json:"variants"`
|
||||
}
|
||||
|
||||
func (h *v0) listChapters(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -218,6 +261,9 @@ func (h *v0) listBank(w http.ResponseWriter, r *http.Request) {
|
|||
if page.First {
|
||||
c := page.Counts
|
||||
out.Total, out.Signed = &c.Total, &c.Signed
|
||||
// Set on the first page WHATEVER the answer is, so that `null` reaches the client; the raw
|
||||
// member stays nil on later pages and is then omitted.
|
||||
out.Consolidation = projectConsolidation(page.Consolidation)
|
||||
}
|
||||
for _, t := range page.Terms {
|
||||
out.Terms = append(out.Terms, projectTerm(t))
|
||||
|
|
@ -225,6 +271,29 @@ func (h *v0) listBank(w http.ResponseWriter, r *http.Request) {
|
|||
h.writeJSON(w, r, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// readBankSigningStop answers what the LAST signing stop asked about — the one screen in the product
|
||||
// where a person is asked for a decision (engine backlog row 224).
|
||||
//
|
||||
// It does not say whether a stop is standing: that is the run's status, and the past tense is the same
|
||||
// one the correction receipt already uses about this population. Uncursored and undeltaed, because a
|
||||
// term offered here has no id to name in a delta and the stop's ranking is the information.
|
||||
func (h *v0) readBankSigningStop(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := principal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
stop, err := h.lib.BankSigningStop(r.Context(), user, r.PathValue("bookId"))
|
||||
if err != nil {
|
||||
h.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
out := wireSigningStop{Unreadable: stop.Unreadable, Offered: make([]wireOffered, 0, len(stop.Offered))}
|
||||
for _, t := range stop.Offered {
|
||||
out.Offered = append(out.Offered, projectOfferedTerm(t))
|
||||
}
|
||||
h.writeJSON(w, r, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// The write half of the bank lives at `POST /books/{bookId}/bank/corrections` (bank.go): the
|
||||
// per-term act is a CORRECTION applied by the engine's own verb, never a signature — signing stayed
|
||||
// ONE act over the whole bank, `resume` (D39.144, D39.156). What stood here before it — the
|
||||
|
|
|
|||
164
platform/internal/httpapi/signingstop_test.go
Normal file
164
platform/internal/httpapi/signingstop_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"textmachine/platform/internal/pgstore"
|
||||
)
|
||||
|
||||
// canon is the ratified contract, read from this package's directory. The gates package reads it the
|
||||
// same way and for the same reason: a second copy of a fact is one more place to forget.
|
||||
const canon = "../../../docs/architecture/14-api-contract/openapi.yaml"
|
||||
|
||||
// ⛔ EVERY MEMBER THE CANON DECLARES REQUIRED IS ON THE WIRE, checked against the canon and not
|
||||
// against a copy of its list. A Go tag proves a field can be marshalled, not that anything fills it:
|
||||
// a projection that forgets one member serves a document a generated client refuses, and nothing in
|
||||
// this build would have said so.
|
||||
//
|
||||
// Mutations this must catch: dropping a field from the projection; giving one `omitempty` so that its
|
||||
// zero value vanishes; adding a member to the canon and not to the wire.
|
||||
func TestEveryMemberTheCanonRequiresIsOnTheWire(t *testing.T) {
|
||||
required := requiredOf(t, "OfferedTerm")
|
||||
if len(required) != 12 {
|
||||
t.Fatalf("the canon declares %d required members of OfferedTerm, expected 12 — this gate is "+
|
||||
"reading the wrong schema or the schema changed and nobody read this test", len(required))
|
||||
}
|
||||
lib := &fakeLibrary{signingStop: pgstore.BankSigningStop{
|
||||
Offered: []pgstore.BankOfferedTerm{{Src: "方源"}},
|
||||
}}
|
||||
w := call(t, readingServer(t, lib), "GET", "/v0/books/bk_1/bank/signing-stop", "")
|
||||
var body struct {
|
||||
Unreadable bool `json:"unreadable"`
|
||||
Offered []map[string]json.RawMessage `json:"offered"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("the response is not a document: %v\n%s", err, w.Body.String())
|
||||
}
|
||||
if len(body.Offered) != 1 {
|
||||
t.Fatalf("one term was offered and %d came back: %s", len(body.Offered), w.Body.String())
|
||||
}
|
||||
// The term above carries NOTHING but its surface, on purpose: a member that only appears when the
|
||||
// store has a value for it is exactly the omission this gate exists to catch.
|
||||
for _, member := range required {
|
||||
if _, ok := body.Offered[0][member]; !ok {
|
||||
t.Errorf("the canon requires `%s` on an offered term and the wire does not carry it: %s",
|
||||
member, w.Body.String())
|
||||
}
|
||||
}
|
||||
for member := range body.Offered[0] {
|
||||
if !slices.Contains(required, member) {
|
||||
t.Errorf("the wire carries `%s` on an offered term and the canon does not declare it", member)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ⛔ An absent measurement reaches the client as `null` and NEVER as zero. Measured on bought runs:
|
||||
// `conventions` is absent from every row of one stop read-out and present on every row of another,
|
||||
// and `freq: 0` is the service's own "only a translated passage saw it". A client shown 0 for either
|
||||
// is shown a figure nobody took.
|
||||
//
|
||||
// Mutation this must catch: an `omitempty` on any of the four numbers, or a non-pointer type.
|
||||
func TestAMeasurementNobodyTookReachesTheClientAsNullAndNotAsZero(t *testing.T) {
|
||||
lib := &fakeLibrary{signingStop: pgstore.BankSigningStop{
|
||||
Offered: []pgstore.BankOfferedTerm{{Src: "silent"}},
|
||||
}}
|
||||
w := call(t, readingServer(t, lib), "GET", "/v0/books/bk_1/bank/signing-stop", "")
|
||||
var body struct {
|
||||
Offered []map[string]any `json:"offered"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, member := range []string{"freq", "spread", "conventions", "confidence", "kind", "channel"} {
|
||||
if got := body.Offered[0][member]; got != nil {
|
||||
t.Errorf("`%s` was never measured and reached the client as %v", member, got)
|
||||
}
|
||||
}
|
||||
// The three lists are collections, and an empty collection is an empty array — never null, which
|
||||
// would ask a client to tell "no conflicts" from "not known" where only one of the two exists.
|
||||
for _, member := range []string{"contradicts", "bank_holds", "variants"} {
|
||||
got, ok := body.Offered[0][member].([]any)
|
||||
if !ok || got == nil {
|
||||
t.Errorf("`%s` reached the client as %v, want an empty array", member, body.Offered[0][member])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ⛔ "Could not read the question" must not be byte-identical to "the stop asked nothing" — the one
|
||||
// thing this surface may never say by accident, at the one moment the product stops to ask a person.
|
||||
func TestAnUnreadableStopIsNotAStopThatAskedNothing(t *testing.T) {
|
||||
unreadable := decode(t, call(t, readingServer(t, &fakeLibrary{
|
||||
signingStop: pgstore.BankSigningStop{Unreadable: true},
|
||||
}), "GET", "/v0/books/bk_1/bank/signing-stop", ""))
|
||||
asked := decode(t, call(t, readingServer(t, &fakeLibrary{
|
||||
signingStop: pgstore.BankSigningStop{Offered: []pgstore.BankOfferedTerm{}},
|
||||
}), "GET", "/v0/books/bk_1/bank/signing-stop", ""))
|
||||
if unreadable["unreadable"] != true {
|
||||
t.Errorf("an unreadable stop answered %v", unreadable)
|
||||
}
|
||||
if asked["unreadable"] != false {
|
||||
t.Errorf("a stop that asked nothing answered %v", asked)
|
||||
}
|
||||
if len(unreadable["offered"].([]any)) != 0 || len(asked["offered"].([]any)) != 0 {
|
||||
t.Errorf("both answers should carry an empty list:\n %v\n %v", unreadable, asked)
|
||||
}
|
||||
}
|
||||
|
||||
// Completeness rides the whole-bank aggregates: the first page carries it, later pages do not, and
|
||||
// `null` there means nothing measured it — never that the bank is whole.
|
||||
func TestCompletenessRidesTheFirstPageAndItsAbsenceIsNotWholeness(t *testing.T) {
|
||||
lib := &fakeLibrary{bank: pgstore.BankPage{First: true,
|
||||
Consolidation: &pgstore.BankConsolidation{RenderBatchesDropped: 5, Unanswered: 47}}}
|
||||
first := decode(t, call(t, readingServer(t, lib), "GET", "/v0/books/bk_1/bank", ""))
|
||||
c, ok := first["consolidation"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("the first page carries no completeness: %v", first)
|
||||
}
|
||||
for _, member := range requiredOf(t, "BankConsolidation") {
|
||||
if _, ok := c[member]; !ok {
|
||||
t.Errorf("the canon requires `%s` on completeness and the wire does not carry it: %v", member, c)
|
||||
}
|
||||
}
|
||||
if c["complete"] != false || c["render_batches_dropped"] != float64(5) || c["unanswered"] != float64(47) {
|
||||
t.Errorf("the numbers were rewritten on the way out: %v", c)
|
||||
}
|
||||
nothing := decode(t, call(t, readingServer(t, &fakeLibrary{bank: pgstore.BankPage{First: true}}),
|
||||
"GET", "/v0/books/bk_1/bank", ""))
|
||||
if got, present := nothing["consolidation"]; present && got != nil {
|
||||
t.Errorf("a bank nothing measured reported completeness as %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// requiredOf reads the `required` list of one schema from the ratified canon.
|
||||
func requiredOf(t *testing.T, schema string) []string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(canon)
|
||||
if err != nil {
|
||||
// Not skipped: a missing canon leaves these members with no check at all except a human
|
||||
// noticing, which is the thing this gate replaces.
|
||||
t.Fatalf("the ratified canon could not be read: %v", err)
|
||||
}
|
||||
var doc struct {
|
||||
Components struct {
|
||||
Schemas map[string]struct {
|
||||
Required []string `yaml:"required"`
|
||||
} `yaml:"schemas"`
|
||||
} `yaml:"components"`
|
||||
}
|
||||
if err := yaml.Unmarshal(raw, &doc); err != nil {
|
||||
t.Fatalf("the canon does not parse: %v", err)
|
||||
}
|
||||
s, ok := doc.Components.Schemas[schema]
|
||||
if !ok {
|
||||
t.Fatalf("the canon carries no schema %q: this gate reads the wrong document", schema)
|
||||
}
|
||||
if len(s.Required) == 0 {
|
||||
t.Fatalf("the canon's %s declares nothing required: a gate over an empty list is a coin", schema)
|
||||
}
|
||||
return s.Required
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ type Library interface {
|
|||
ListUnits(ctx context.Context, userID, bookID, chapterID string, limit int, cursor string) (pgstore.UnitPage, error)
|
||||
ListNotes(ctx context.Context, userID, bookID string, limit int, cursor string, after *int64) (pgstore.NotePage, error)
|
||||
ListBank(ctx context.Context, userID, bookID string, limit int, cursor string, after *int64) (pgstore.BankPage, error)
|
||||
BankSigningStop(ctx context.Context, userID, bookID string) (pgstore.BankSigningStop, error)
|
||||
ReadStream(ctx context.Context, userID, bookID string) (pgstore.StreamState, error)
|
||||
ReadFrames(ctx context.Context, bookID string, after int64, limit int) ([]pgstore.Frame, error)
|
||||
}
|
||||
|
|
@ -90,6 +91,10 @@ var contractSurface = []struct {
|
|||
{method: "GET", path: "/books/{bookId}/chapters/{chapterId}/units", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listUnits }},
|
||||
{method: "GET", path: "/books/{bookId}/notes", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listNotes }},
|
||||
{method: "GET", path: "/books/{bookId}/bank", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listBank }},
|
||||
// What the last signing stop asked about (canon 0.16.0, engine backlog row 224). Under the library
|
||||
// mount and not the correction one: reading what a stop asks is not the same capability as being
|
||||
// able to answer it, and a deployment that cannot take corrections still has to show the question.
|
||||
{method: "GET", path: "/books/{bookId}/bank/signing-stop", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.readBankSigningStop }},
|
||||
// The correction door (canon 0.5.0). Unmounted it answers the guarded 404 the canon promises for
|
||||
// `Capabilities.bank_corrections_enabled: false` — the same fact decides both. Its body cap IS
|
||||
// the canon's 1 MiB document ceiling, so it rides the default deliberately.
|
||||
|
|
|
|||
|
|
@ -23,16 +23,17 @@ import (
|
|||
// a projection is exactly the kind of code that drifts from it silently: nothing in Go fails when a
|
||||
// json tag is misspelled, and the client that breaks is in another repository.
|
||||
type fakeLibrary struct {
|
||||
lib pgstore.Library
|
||||
book pgstore.Book
|
||||
run *pgstore.Run
|
||||
usage pgstore.Usage
|
||||
chapters pgstore.ChapterPage
|
||||
units pgstore.UnitPage
|
||||
notes pgstore.NotePage
|
||||
bank pgstore.BankPage
|
||||
stream pgstore.StreamState
|
||||
frames []pgstore.Frame
|
||||
lib pgstore.Library
|
||||
book pgstore.Book
|
||||
run *pgstore.Run
|
||||
usage pgstore.Usage
|
||||
chapters pgstore.ChapterPage
|
||||
units pgstore.UnitPage
|
||||
notes pgstore.NotePage
|
||||
bank pgstore.BankPage
|
||||
signingStop pgstore.BankSigningStop
|
||||
stream pgstore.StreamState
|
||||
frames []pgstore.Frame
|
||||
// onSecondRead mutates the state the SECOND time a stream asks for it, which is how a test says
|
||||
// "this changed while the client was connected".
|
||||
onSecondRead func(*pgstore.StreamState)
|
||||
|
|
@ -94,6 +95,10 @@ func (f *fakeLibrary) ListBank(context.Context, string, string, int, string, *in
|
|||
return f.bank, f.err
|
||||
}
|
||||
|
||||
func (f *fakeLibrary) BankSigningStop(context.Context, string, string) (pgstore.BankSigningStop, error) {
|
||||
return f.signingStop, f.err
|
||||
}
|
||||
|
||||
func (f *fakeLibrary) ReadStream(context.Context, string, string) (pgstore.StreamState, error) {
|
||||
f.streamReads++
|
||||
if f.streamReads == 2 && f.onSecondRead != nil {
|
||||
|
|
|
|||
|
|
@ -41,7 +41,96 @@ type BankTerm struct {
|
|||
|
||||
// Bank is the whole read-out.
|
||||
type Bank struct {
|
||||
// Boundary is which of the run's five publishing moments wrote this document, in the contract's
|
||||
// words. It is read WITH the sections and not instead of them: the engine publishes `offered` at
|
||||
// the signing stop and nowhere else, so a section standing at any other boundary is a document
|
||||
// this build does not understand, and serving its rows would invent a stop that is not there.
|
||||
Boundary string
|
||||
// RunID is the anchor's other half — the id the engine announces its event stream under, which is
|
||||
// the id this platform minted for the attempt (pgstore.EngineStreamID). Empty means the write had
|
||||
// no run identity: unknown, never "current". Read and stored, never served: it is an engine
|
||||
// identity, and the seam does not export those.
|
||||
RunID string
|
||||
Terms []BankTerm
|
||||
// Offered is what the signing stop is asking about — mined rows NOT in the bank yet, in the stop's
|
||||
// own ranking. A pointer to the slice because nil ("the document carried no section") and an empty
|
||||
// slice ("the stop asked nothing") are different answers to a person looking at an empty screen,
|
||||
// and a plain slice decodes both to nil.
|
||||
Offered *[]OfferedTerm
|
||||
// Consolidation is how complete the bank being signed is, nil when nothing measured it. The engine
|
||||
// makes the section absent rather than zeroed where the paid pass never ran, because a zeroed one
|
||||
// answers "consolidated 0, unanswered 0" — which reads as "nothing is missing".
|
||||
Consolidation *Consolidation
|
||||
}
|
||||
|
||||
// OfferedTerm is one row of a signing stop's table, in the contract's own words.
|
||||
//
|
||||
// ⛔ The numbers are pointers and the strings are not. For a field the engine writes ALWAYS, absence
|
||||
// means a build without that field wrote the document — and for these four the zero value is itself a
|
||||
// measurement (`freq: 0` is the engine's "only the draft side saw it"; a stated `conf: 0` is the row a
|
||||
// signer must look at), so decoding absence to zero publishes a figure nobody took. Measured, not
|
||||
// hypothetical: `conventions` is absent from all 69 rows of one bought stop read-out and present on
|
||||
// all 66 of the other, because the field was added between the two runs. The empty string carries no
|
||||
// such second meaning, so the strings lose nothing by letting absence land on them.
|
||||
type OfferedTerm struct {
|
||||
Src string
|
||||
// Dst is the consolidated rendering, "" when this read-out carries none — and it does NOT say why.
|
||||
// The engine's own sheet spends four facts on that glyph, one of which ("the bank already renders
|
||||
// this surface, nothing to decide") it distinguishes as BankStopRow.SettledByBank and deliberately
|
||||
// does not project here. Nothing downstream may read "" as "the service could not translate it".
|
||||
Dst string
|
||||
// Kind and Channel are "" for what this build cannot name — a kind the engine did not decide, a
|
||||
// detector outside the closed set. Never a guess: Channel is how a person judges how well
|
||||
// corroborated a row is, and invented corroboration is what reading further cannot undo.
|
||||
Kind, Channel string
|
||||
// Conventions is NOT re-derived from len(Variants) when absent, although the engine's projection
|
||||
// makes them equal today: deriving is this side re-implementing engine law, and the engine's own
|
||||
// comment records that reading one of these numbers as the other already cost two sessions a
|
||||
// misfiled defect.
|
||||
Freq, Spread, Conventions *int
|
||||
// Confidence is nil when the service stated none — INCLUDING the engine's own spelling for it, a
|
||||
// negative number. Folded here at the seam rather than at projection time, like every other
|
||||
// crossing in this file and for the reason the note at the top gives: a translation left to a
|
||||
// later path is one a later path can forget, and this one WAS forgotten — the canon promises
|
||||
// `0..100` or null and a build that carried the minus through would have shown a person "-1 %".
|
||||
//
|
||||
// ⚠ It folds "the read-out carried no `conf` at all" into the same nil, and that is measured
|
||||
// rather than assumed: `conf` entered the projection in the SAME commit as the section it lives
|
||||
// in, so no build emitting the section omits it (0 of 135 rows across two bought read-outs).
|
||||
// THE CONDITION THAT ENDS THAT: a build that starts omitting it — then absence stops being
|
||||
// unreachable and the two causes need telling apart again.
|
||||
Confidence *int
|
||||
// Invented — the rendering is not one the drafts proposed, the class the engine names as the one to
|
||||
// read first. A plain bool because the engine omits the field when false. ⚠ THE CONDITION THAT ENDS
|
||||
// THAT: the day `omitempty` leaves the engine's field, absence stops meaning false and this has to
|
||||
// become a pointer. Nothing here goes red on that day.
|
||||
Invented bool
|
||||
// Two lists and never one: "this run disagreed with itself" and "the book already calls it
|
||||
// something else" are different decisions.
|
||||
Contradicts, BankHolds []string
|
||||
// The drafts' renderings, best-ranked first, in the engine's own labelling — opaque here and never
|
||||
// parsed. It keeps a reader beside that writer precisely because a re-derived parser "would keep
|
||||
// working until the day the label gains a field, and then it would report numbers rather than an
|
||||
// error".
|
||||
Variants []string
|
||||
}
|
||||
|
||||
// Consolidation is what the paid terminology pass MANAGED. Six numbers and not one, because a cut
|
||||
// RENDER pass leaves the bank partial while a cut CLASSIFY pass leaves it whole with the types
|
||||
// unrefined — a different budget and a different remedy — and rows the role was never asked about are
|
||||
// a saving rather than a gap.
|
||||
type Consolidation struct {
|
||||
// Complete comes from the engine ready-made: it is computed off the render pass alone, and a
|
||||
// second mechanism answering that here is how the two come to disagree.
|
||||
Complete bool
|
||||
// Two cuts and never one sum: an operator raises one budget or the other, never "the" budget.
|
||||
RenderBatchesDropped, ClassifyBatchesDropped int
|
||||
Consolidated, Declined int
|
||||
// Unanswered conflates the role's silence with the budget's cut — it means "the role stayed
|
||||
// silent" only where Complete is true.
|
||||
Unanswered int
|
||||
// NeverAsked is the opposite of a gap: the bank already renders the surface and every draft agreed.
|
||||
NeverAsked int
|
||||
}
|
||||
|
||||
// The contract's TermStatus and TermOrigin values.
|
||||
|
|
@ -55,10 +144,41 @@ const (
|
|||
OriginFound = "found"
|
||||
)
|
||||
|
||||
// The contract's ReadOutBoundary values, stored but never served. The engine's own names are pipeline
|
||||
// vocabulary (`bank-mining` is a stage, `redrive` an operator's verb), which the note at the top of
|
||||
// this file refuses to pass through.
|
||||
//
|
||||
// ⛔ BoundaryUnknown covers both a name this build does not know and the empty one a document from
|
||||
// before the anchor carries. Safe to collapse because the consequence is identical — nothing says this
|
||||
// is a stop — and the one thing neither may become is BoundarySignatureRequested.
|
||||
const (
|
||||
BoundaryRunStarted = "run_started"
|
||||
BoundaryTermsTakenUnsigned = "terms_taken_unsigned"
|
||||
BoundarySignatureRequested = "signature_requested"
|
||||
BoundaryRunFinished = "run_finished"
|
||||
BoundaryBookReseeded = "book_reseeded"
|
||||
BoundaryUnknown = "unknown"
|
||||
)
|
||||
|
||||
// The contract's OfferedTermChannel values. The engine's four — `mined`, `banknote`, `both`, `alias` —
|
||||
// are pipeline vocabulary, one of them the very word the contract renamed in 0.3.0 so it could not
|
||||
// reach a client.
|
||||
const (
|
||||
ChannelSourceText = "source_text"
|
||||
ChannelTranslatedText = "translated_text"
|
||||
ChannelBoth = "both"
|
||||
ChannelAlias = "alias"
|
||||
)
|
||||
|
||||
type wireBank struct {
|
||||
Version string `json:"bank_version"`
|
||||
BookID string `json:"book_id"`
|
||||
Terms []struct {
|
||||
// Additive on tm-bank-v1 and absent in documents written before the anchor existed, so an absent
|
||||
// AsOf lands on BoundaryUnknown rather than refusing the document: such a read-out is old, not
|
||||
// malformed.
|
||||
AsOf string `json:"as_of"`
|
||||
RunID string `json:"run_id"`
|
||||
Terms []struct {
|
||||
ID string `json:"id"`
|
||||
Src string `json:"src"`
|
||||
Dst string `json:"dst"`
|
||||
|
|
@ -69,6 +189,34 @@ type wireBank struct {
|
|||
SinceChapter int `json:"since_chapter"`
|
||||
UntilChapter int `json:"until_chapter"`
|
||||
} `json:"terms"`
|
||||
// Pointers, so that an absent member stays distinguishable from an empty one — see Bank.Offered.
|
||||
Proposed *[]wireOffered `json:"proposed"`
|
||||
Consolidation *wireConsolidation `json:"consolidation"`
|
||||
}
|
||||
|
||||
type wireOffered struct {
|
||||
Src string `json:"src"`
|
||||
Dst string `json:"dst"`
|
||||
Kind string `json:"kind"`
|
||||
Channel string `json:"channel"`
|
||||
Freq *int `json:"freq"`
|
||||
Spread *int `json:"spread"`
|
||||
Conventions *int `json:"conventions"`
|
||||
Conf *int `json:"conf"`
|
||||
Invented bool `json:"invented"`
|
||||
Contradicts []string `json:"contradicts"`
|
||||
BankHolds []string `json:"bank_holds"`
|
||||
Variants []string `json:"variants"`
|
||||
}
|
||||
|
||||
type wireConsolidation struct {
|
||||
Complete bool `json:"complete"`
|
||||
RenderBatchesDropped int `json:"render_batches_dropped"`
|
||||
ClassifyBatchesDropped int `json:"classify_batches_dropped"`
|
||||
Consolidated int `json:"consolidated"`
|
||||
Declined int `json:"declined"`
|
||||
Unanswered int `json:"unanswered"`
|
||||
NeverAsked int `json:"never_asked"`
|
||||
}
|
||||
|
||||
// DecodeBank parses a bank read-out, and refuses anything that does not identify itself as one — the
|
||||
|
|
@ -82,7 +230,10 @@ func DecodeBank(b []byte) (Bank, error) {
|
|||
if doc.Version == "" {
|
||||
return Bank{}, fmt.Errorf("ingest: decode bank: the document carries no bank_version, so it is not a bank")
|
||||
}
|
||||
out := Bank{Terms: make([]BankTerm, 0, len(doc.Terms))}
|
||||
out := Bank{
|
||||
Boundary: readOutBoundary(doc.AsOf), RunID: doc.RunID,
|
||||
Terms: make([]BankTerm, 0, len(doc.Terms)),
|
||||
}
|
||||
for _, t := range doc.Terms {
|
||||
status, ok := termStatus(t.Status)
|
||||
if !ok {
|
||||
|
|
@ -105,9 +256,146 @@ func DecodeBank(b []byte) (Bank, error) {
|
|||
SinceChapter: chapterBound(t.SinceChapter), UntilChapter: chapterBound(t.UntilChapter),
|
||||
})
|
||||
}
|
||||
out.Offered = decodeOffered(doc.Proposed)
|
||||
out.Consolidation = decodeConsolidation(doc.Consolidation)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// decodeOffered reads the stop's section. The nil check IS the section's contract: no member means no
|
||||
// section, a section with no rows means a stop that asked nothing.
|
||||
func decodeOffered(rows *[]wireOffered) *[]OfferedTerm {
|
||||
if rows == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]OfferedTerm, 0, len(*rows))
|
||||
for _, r := range *rows {
|
||||
out = append(out, OfferedTerm{
|
||||
Src: r.Src, Dst: r.Dst, Kind: termKind(r.Kind), Channel: offeredChannel(r.Channel),
|
||||
Freq: statedCount(r.Freq), Spread: statedCount(r.Spread),
|
||||
Conventions: statedCount(r.Conventions),
|
||||
Confidence: statedConfidence(r.Conf), Invented: r.Invented,
|
||||
Contradicts: r.Contradicts, BankHolds: r.BankHolds, Variants: r.Variants,
|
||||
})
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// decodeConsolidation copies the numbers and derives none of them.
|
||||
//
|
||||
// ⚠ A section that is PRESENT and empty decodes to "not complete, nothing consolidated" — the
|
||||
// cautious direction, and the opposite of the rule for the numbers above, where a zero would have
|
||||
// invented a measurement. The two look contradictory until you ask which way each zero errs.
|
||||
//
|
||||
// ⛔ AND THE CONDITION UNDER WHICH THAT STOPS HOLDING, because it holds only by an accident of how
|
||||
// the writer grew: the seven fields entered the engine's projection in ONE commit, so no build emits
|
||||
// a partial section and "present" always means all seven. The day the engine adds an eighth number,
|
||||
// a document written by the older build decodes it as zero — and `unanswered: 0` says "the role
|
||||
// stayed silent about nothing", which is the false zero this whole reader exists to refuse. It is
|
||||
// cautious for `complete` and a lie for the counters, and the difference appears the moment the
|
||||
// field set moves. Then these become pointers, like the four above.
|
||||
func decodeConsolidation(c *wireConsolidation) *Consolidation {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return &Consolidation{
|
||||
Complete: c.Complete,
|
||||
Consolidated: c.Consolidated, Declined: c.Declined,
|
||||
Unanswered: c.Unanswered, NeverAsked: c.NeverAsked,
|
||||
RenderBatchesDropped: c.RenderBatchesDropped,
|
||||
ClassifyBatchesDropped: c.ClassifyBatchesDropped,
|
||||
}
|
||||
}
|
||||
|
||||
// BoundaryOrUnknown is the closed set as a GUARD, for a caller that did not come through DecodeBank.
|
||||
// The zero value of a Bank carries no boundary, and a read-out whose moment nobody named is exactly
|
||||
// what BoundaryUnknown is for — refusing instead would fail a whole bank save over a label.
|
||||
func BoundaryOrUnknown(s string) string {
|
||||
switch s {
|
||||
case BoundaryRunStarted, BoundaryTermsTakenUnsigned, BoundarySignatureRequested,
|
||||
BoundaryRunFinished, BoundaryBookReseeded:
|
||||
return s
|
||||
}
|
||||
return BoundaryUnknown
|
||||
}
|
||||
|
||||
// KindOrNone and ChannelOrNone are the same guard for the two closed vocabularies an offered term
|
||||
// carries, whose "cannot name it" is "" rather than a value.
|
||||
//
|
||||
// ⛔ They are NOT what `nullif($, ”)` does in the statement, and the difference is a live failure
|
||||
// rather than a nicety: `nullif` turns the EMPTY value into null, while these turn an UNKNOWN
|
||||
// non-empty one into the empty value first. Measured — an engine word that leaked this far without a
|
||||
// guard does not land as "not named", it violates the column's CHECK and takes the WHOLE bank save
|
||||
// down, at the one boundary a person is standing at.
|
||||
func KindOrNone(s string) string { return termKind(s) }
|
||||
|
||||
func ChannelOrNone(s string) string {
|
||||
switch s {
|
||||
case ChannelSourceText, ChannelTranslatedText, ChannelBoth, ChannelAlias:
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ⛔ A NUMBER OUTSIDE THE RANGE THE CONTRACT DECLARES IS READ AS «NOT STATED» — the same discipline
|
||||
// the vocabularies above follow, one floor down: a value this build cannot name becomes the one that
|
||||
// claims the least, never a guess and never a pass-through.
|
||||
//
|
||||
// It is a CLASS and not one field, which is how it was found: the engine spells "the reply carried no
|
||||
// confidence" as a negative, and a build that let that through would show a person "-1 %" on the one
|
||||
// screen that asks them to decide. But the canon bounds `freq`, `spread` and `conventions` too, and
|
||||
// nothing there is a sentinel — a negative count is simply a document this build does not understand,
|
||||
// and publishing it would be the same lie in a quieter place.
|
||||
//
|
||||
// ⚠ The bounds are mirrored from the canon rather than read from it at runtime, like the closed
|
||||
// vocabularies above, and like them they are gated against it:
|
||||
// TestEveryRangeTheCanonDeclaresIsOneThisReaderEnforces reads `minimum`/`maximum` out of the canon and
|
||||
// fails on any ranged member this file does not bound. THE CONDITION THAT ENDS THE MIRRORING: a canon
|
||||
// that gives a member a range this file has no branch for — which is exactly what that gate says.
|
||||
func statedCount(v *int) *int { return inRange(v, 0, -1) }
|
||||
|
||||
func statedConfidence(v *int) *int { return inRange(v, 0, 100) }
|
||||
|
||||
// inRange keeps a value the contract would accept and drops one it would not. A negative max means
|
||||
// "no ceiling", which is what the canon says by declaring `minimum` alone.
|
||||
func inRange(v *int, min, max int) *int {
|
||||
if v == nil || *v < min || (max >= 0 && *v > max) {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func readOutBoundary(engine string) string {
|
||||
switch engine {
|
||||
case "run-start/seeded":
|
||||
return BoundaryRunStarted
|
||||
case "bank-mining/auto-continue":
|
||||
return BoundaryTermsTakenUnsigned
|
||||
case "bank-mining/signature-stop":
|
||||
return BoundarySignatureRequested
|
||||
case "run-finished":
|
||||
return BoundaryRunFinished
|
||||
case "redrive/re-seeded":
|
||||
return BoundaryBookReseeded
|
||||
}
|
||||
return BoundaryUnknown
|
||||
}
|
||||
|
||||
// offeredChannel returns "" for a detector outside the closed set — the same spelling termKind uses
|
||||
// for a value this build cannot name, so one convention carries "not nameable" all the way out.
|
||||
func offeredChannel(engine string) string {
|
||||
switch engine {
|
||||
case "mined":
|
||||
return ChannelSourceText
|
||||
case "banknote":
|
||||
return ChannelTranslatedText
|
||||
case "both":
|
||||
return ChannelBoth
|
||||
case "alias":
|
||||
return ChannelAlias
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func termStatus(engine string) (string, bool) {
|
||||
switch engine {
|
||||
case "auto":
|
||||
|
|
|
|||
392
platform/internal/ingest/bankreadout_test.go
Normal file
392
platform/internal/ingest/bankreadout_test.go
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
package ingest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// bankreadout_test.go: the two sections a signing stop publishes beside the bank — engine backlog
|
||||
// rows 224 and 253. Until this build the reader took `terms` alone, which at a stop is EMPTY by
|
||||
// construction, so the one screen in the product that asks a person for a decision was served a zero
|
||||
// that meant "the reader does not look here", not "there is nothing to decide".
|
||||
|
||||
const bankHead = `"bank_version":"tm-bank-v1","book_id":"bk_1","terms":[]`
|
||||
|
||||
// ⛔ THE PIN THIS PACK EXISTS FOR, one floor down from the screen: a section that is not in the
|
||||
// document and a section that is there and holds nothing are different answers, and a reader that
|
||||
// returns an empty slice for both has rebuilt the false zero it was written to remove. At a stop the
|
||||
// first means "this read-out cannot tell you"; the second means "the stop asked nothing".
|
||||
//
|
||||
// Mutation this must catch: decoding `proposed` into a plain []Suggestion — json writes nil for both
|
||||
// cases and every caller downstream then reports "no suggestions" for a document that never carried
|
||||
// the section.
|
||||
func TestASectionTheDocumentDoesNotCarryIsNotASectionThatIsEmpty(t *testing.T) {
|
||||
absent, err := DecodeBank([]byte(`{` + bankHead + `}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if absent.Offered != nil {
|
||||
t.Errorf("a document with no `proposed` member reported a section: %+v", absent.Offered)
|
||||
}
|
||||
if absent.Consolidation != nil {
|
||||
t.Errorf("a document with no `consolidation` member reported one: %+v", absent.Consolidation)
|
||||
}
|
||||
|
||||
empty, err := DecodeBank([]byte(`{` + bankHead + `,"proposed":[],"consolidation":{}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if empty.Offered == nil {
|
||||
t.Fatal("a `proposed` section that is present and empty was reported as absent")
|
||||
}
|
||||
if len(*empty.Offered) != 0 {
|
||||
t.Errorf("an empty section decoded %d rows", len(*empty.Offered))
|
||||
}
|
||||
if empty.Consolidation == nil {
|
||||
t.Fatal("a `consolidation` section that is present and empty was reported as absent")
|
||||
}
|
||||
// The premise the assertion above stands on, asserted so the fixture cannot lose its power to
|
||||
// tell the two apart: an empty consolidation errs toward warning the signer, the opposite
|
||||
// direction from the numbers below, and if that ever flips the test is measuring nothing.
|
||||
if empty.Consolidation.Complete {
|
||||
t.Error("an empty consolidation section claimed the bank is complete")
|
||||
}
|
||||
}
|
||||
|
||||
// ⛔ The measured case, not a hypothetical. `conventions` has no `omitempty` on the engine's side, so
|
||||
// it is written ALWAYS — and it is absent from all 69 rows of one bought stop read-out and present on
|
||||
// all 66 of the other, because the field was added to the projection between the two runs. An absent
|
||||
// mandatory number therefore means "an engine build without this field wrote the document", and zero
|
||||
// is a measurement: `freq: 0` is the engine's own "only the draft side saw it".
|
||||
//
|
||||
// Mutation this must catch: decoding any of the four numbers into a plain int.
|
||||
func TestAMandatoryNumberTheReadOutDoesNotCarryIsNotZero(t *testing.T) {
|
||||
doc := `{` + bankHead + `,"proposed":[{"src":"丙等","dst":"третий разряд","kind":"title","channel":"banknote","variants":["третий ранг ×1"]}]}`
|
||||
bank, err := DecodeBank([]byte(doc))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := (*bank.Offered)[0]
|
||||
for name, p := range map[string]*int{
|
||||
"freq": got.Freq, "spread": got.Spread, "conventions": got.Conventions, "confidence": got.Confidence,
|
||||
} {
|
||||
if p != nil {
|
||||
t.Errorf("%s was absent from the read-out and came back as %d, which is a figure nobody measured", name, *p)
|
||||
}
|
||||
}
|
||||
|
||||
stated := `{` + bankHead + `,"proposed":[{"src":"丙等","freq":0,"spread":0,"conventions":0,"conf":0}]}`
|
||||
bank, err = DecodeBank([]byte(stated))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = (*bank.Offered)[0]
|
||||
for name, p := range map[string]*int{
|
||||
"freq": got.Freq, "spread": got.Spread, "conventions": got.Conventions, "confidence": got.Confidence,
|
||||
} {
|
||||
if p == nil {
|
||||
t.Errorf("%s was stated as 0 in the read-out and came back as «not stated»", name)
|
||||
} else if *p != 0 {
|
||||
t.Errorf("%s was stated as 0 and came back as %d", name, *p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ⛔ THE ENGINE'S SENTINEL NEVER REACHES A CLIENT. It spells "the reply carried no confidence" as a
|
||||
// NEGATIVE number, which is a sentinel and not a measurement; the canon promises `0..100` or null, so
|
||||
// a build that carried the minus through would show a person "-1 %" on the one screen that asks them
|
||||
// to decide. Folded at the SEAM, where every other crossing in this file is folded, because a
|
||||
// translation left to a later path is one a later path can forget — and this one WAS forgotten once,
|
||||
// between a canon that said `minimum: 0` and a projection that passed the value straight out.
|
||||
//
|
||||
// ⚠ A stated ZERO must survive: "the service said it was 0 % sure" is the first row a person should
|
||||
// look at, and "the service said nothing" is not a row about confidence at all. The two are one byte
|
||||
// apart in the engine's encoding and opposite in meaning.
|
||||
//
|
||||
// Mutation this must catch: returning `conf` unchanged from statedConfidence, or testing `<= 0`.
|
||||
func TestTheEnginesNoConfidenceSentinelNeverReachesAClient(t *testing.T) {
|
||||
bank, err := DecodeBank([]byte(`{` + bankHead + `,"proposed":[{"src":"a","conf":-1},{"src":"b","conf":0},{"src":"c"},{"src":"d","conf":95}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := *bank.Offered
|
||||
if rows[0].Confidence != nil {
|
||||
t.Errorf("the engine's «no confidence stated» (-1) reached the wire as %d", *rows[0].Confidence)
|
||||
}
|
||||
if rows[1].Confidence == nil || *rows[1].Confidence != 0 {
|
||||
t.Errorf("a stated confidence of 0 was folded into «not stated»: %v", rows[1].Confidence)
|
||||
}
|
||||
if rows[2].Confidence != nil {
|
||||
t.Errorf("a row whose read-out carries no `conf` at all came back as %d", *rows[2].Confidence)
|
||||
}
|
||||
if rows[3].Confidence == nil || *rows[3].Confidence != 95 {
|
||||
t.Errorf("a stated confidence of 95 came back as %v", rows[3].Confidence)
|
||||
}
|
||||
// The canon's own range, asserted over every row rather than over the one that motivated this:
|
||||
// a value outside it is a document a generated client refuses.
|
||||
for i, r := range rows {
|
||||
if r.Confidence != nil && (*r.Confidence < 0 || *r.Confidence > 100) {
|
||||
t.Errorf("row %d carries a confidence of %d, outside the canon's 0..100", i, *r.Confidence)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `invented` is the class the engine names as the one to read FIRST — the consolidated rendering is
|
||||
// not one any draft proposed. A reader that loses it shows a person a list in which nothing says the
|
||||
// service made the name up. Measured: 11 of 69 rows in one bought read-out, 17 of 66 in the other.
|
||||
func TestTheClassAPersonReadsFirstSurvivesTheReadOut(t *testing.T) {
|
||||
bank, err := DecodeBank([]byte(`{` + bankHead + `,"proposed":[{"src":"a","invented":true},{"src":"b"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !(*bank.Offered)[0].Invented {
|
||||
t.Error("the engine said it invented this rendering and the reader dropped it")
|
||||
}
|
||||
if (*bank.Offered)[1].Invented {
|
||||
t.Error("a row the engine did not mark came back marked")
|
||||
}
|
||||
}
|
||||
|
||||
// ⛔ A section is only ever read TOGETHER with the boundary that wrote it: the read-out is published
|
||||
// at five of them and its own export warns three times over that the file may be a previous one's.
|
||||
// "This document is not from a signing stop" and "the stop had nothing to ask" are different answers
|
||||
// to a person staring at an empty screen.
|
||||
//
|
||||
// The engine's words are pipeline vocabulary and none passes through. Anything this build cannot name
|
||||
// — including the empty string a document from before the freshness anchor carries — is the value
|
||||
// that claims the LEAST, and the one thing it may never become is the stop.
|
||||
func TestTheBoundaryIsTranslatedAndAnUnnameableOneIsNeverTheStop(t *testing.T) {
|
||||
for engine, want := range map[string]string{
|
||||
"run-start/seeded": BoundaryRunStarted,
|
||||
"bank-mining/auto-continue": BoundaryTermsTakenUnsigned,
|
||||
"bank-mining/signature-stop": BoundarySignatureRequested,
|
||||
"run-finished": BoundaryRunFinished,
|
||||
"redrive/re-seeded": BoundaryBookReseeded,
|
||||
"bank-mining/some-boundary-added-later": BoundaryUnknown,
|
||||
"": BoundaryUnknown,
|
||||
} {
|
||||
bank, err := DecodeBank([]byte(`{` + bankHead + `,"as_of":` + quote(engine) + `}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bank.Boundary != want {
|
||||
t.Errorf("boundary %q read as %q, want %q", engine, bank.Boundary, want)
|
||||
}
|
||||
if engine != "bank-mining/signature-stop" && bank.Boundary == BoundarySignatureRequested {
|
||||
t.Errorf("boundary %q was read as the signing stop", engine)
|
||||
}
|
||||
}
|
||||
// The run identity is the anchor's other half and is read, not dropped: half an anchor is a
|
||||
// freshness claim nothing supports.
|
||||
bank, err := DecodeBank([]byte(`{` + bankHead + `,"run_id":"tm-stream-run_X-1"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bank.RunID != "tm-stream-run_X-1" {
|
||||
t.Errorf("the read-out's run identity came back as %q", bank.RunID)
|
||||
}
|
||||
}
|
||||
|
||||
// The detector's name is pipeline vocabulary — `mined` is the very word the contract renamed in 0.3.0
|
||||
// so that it could not reach a client — so all four are translated and an unrecognised one becomes
|
||||
// `unknown` rather than a guess. This axis is how a person judges how well corroborated a suggestion
|
||||
// is, and inventing corroboration is the error reading further cannot undo.
|
||||
func TestTheDetectorVocabularyDoesNotReachAClient(t *testing.T) {
|
||||
for engine, want := range map[string]string{
|
||||
"mined": ChannelSourceText, "banknote": ChannelTranslatedText,
|
||||
"both": ChannelBoth, "alias": ChannelAlias, "surveyor": "", "": "",
|
||||
} {
|
||||
bank, err := DecodeBank([]byte(`{` + bankHead + `,"proposed":[{"src":"a","channel":` + quote(engine) + `}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := (*bank.Offered)[0].Channel; got != want {
|
||||
t.Errorf("channel %q read as %q, want %q", engine, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The completeness numbers are COPIED and never re-derived: the engine computes `complete` off the
|
||||
// render pass alone, and a classifier cut does not mean an incomplete bank. A second mechanism
|
||||
// answering the same question on this side is how the two come to disagree — which is why the whole
|
||||
// section is taken ready-made.
|
||||
func TestTheCompletenessNumbersAreTakenReadyMade(t *testing.T) {
|
||||
raw := `{"complete":false,"render_batches_dropped":5,"classify_batches_dropped":2,` +
|
||||
`"consolidated":38,"declined":1,"unanswered":47,"never_asked":7}`
|
||||
bank, err := DecodeBank([]byte(`{` + bankHead + `,"consolidation":` + raw + `}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var want wireConsolidation
|
||||
if err := json.Unmarshal([]byte(raw), &want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := *bank.Consolidation
|
||||
if got.Complete != want.Complete || got.RenderBatchesDropped != want.RenderBatchesDropped ||
|
||||
got.ClassifyBatchesDropped != want.ClassifyBatchesDropped || got.Consolidated != want.Consolidated ||
|
||||
got.Declined != want.Declined || got.Unanswered != want.Unanswered || got.NeverAsked != want.NeverAsked {
|
||||
t.Errorf("the section was rewritten on the way in:\n got %+v\nwant %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The ORACLE: a read-out a real run actually published, handed in by the operator. The fixtures above
|
||||
// are this build's own idea of the document; this one is the engine's, and the two bought stop
|
||||
// read-outs it was written against differ from each other in exactly the way that matters — one
|
||||
// carries `conventions` on every row and the other on none.
|
||||
//
|
||||
// Gated like every test that needs something this host may not have, and it SAYS what is missing.
|
||||
func TestARealReadOutIsReadTheWayThisBuildClaims(t *testing.T) {
|
||||
path := os.Getenv("TM_PLATFORM_TEST_BANK_READOUT")
|
||||
if path == "" {
|
||||
t.Skip("TM_PLATFORM_TEST_BANK_READOUT not set: the reader is not checked against a read-out a real run published")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bank, err := DecodeBank(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("a read-out a real run published was refused: %v", err)
|
||||
}
|
||||
// What the document itself says, read independently of the type under test — otherwise the
|
||||
// assertion is the decoder agreeing with itself.
|
||||
var doc map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
section, present := doc["proposed"]
|
||||
if present != (bank.Offered != nil) {
|
||||
t.Fatalf("the document %s a `proposed` member and the reader reported the section %s",
|
||||
ifElse(present, "carries", "does not carry"), ifElse(bank.Offered != nil, "present", "absent"))
|
||||
}
|
||||
if present {
|
||||
var rows []map[string]any
|
||||
if err := json.Unmarshal(section, &rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != len(*bank.Offered) {
|
||||
t.Fatalf("the document carries %d suggestions and the reader returned %d", len(rows), len(*bank.Offered))
|
||||
}
|
||||
for i, row := range rows {
|
||||
_, stated := row["conventions"]
|
||||
if got := (*bank.Offered)[i].Conventions; stated != (got != nil) {
|
||||
t.Fatalf("row %d: the document %s `conventions` and the reader reported it %s",
|
||||
i, ifElse(stated, "states", "does not state"), ifElse(got != nil, "stated", "absent"))
|
||||
}
|
||||
if got := (*bank.Offered)[i].Channel; got == "" {
|
||||
t.Errorf("row %d: this build cannot name the detector %q the engine wrote", i, row["channel"])
|
||||
}
|
||||
}
|
||||
t.Logf("read-out %s: boundary=%s offered=%d terms=%d consolidation=%v",
|
||||
path, bank.Boundary, len(*bank.Offered), len(bank.Terms), bank.Consolidation != nil)
|
||||
}
|
||||
if _, present := doc["consolidation"]; present != (bank.Consolidation != nil) {
|
||||
t.Errorf("the document %s a `consolidation` member and the reader reported it %s",
|
||||
ifElse(present, "carries", "does not carry"), ifElse(bank.Consolidation != nil, "present", "absent"))
|
||||
}
|
||||
if bank.Boundary == BoundaryUnknown {
|
||||
t.Errorf("this build cannot name the boundary the engine wrote; the document's `as_of` is %s", doc["as_of"])
|
||||
}
|
||||
}
|
||||
|
||||
func quote(s string) string {
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func ifElse(cond bool, yes, no string) string {
|
||||
if cond {
|
||||
return yes
|
||||
}
|
||||
return no
|
||||
}
|
||||
|
||||
// ⛔ EVERY RANGE THE CANON DECLARES IS ONE THIS READER ENFORCES — and the gate reads the canon rather
|
||||
// than a copy of its numbers, so a field given a range tomorrow is covered without anybody
|
||||
// remembering this test.
|
||||
//
|
||||
// It exists because the neighbouring gate proves MEMBERSHIP and stops there: it reads `required` and
|
||||
// never `minimum`, which is the shape "take the form and not the guarantee" — in a gate. That gap let
|
||||
// a canon promising `0..100` stand beside a chain that carried the engine's `-1` all the way out,
|
||||
// pinned on both sides, for a whole delivery.
|
||||
//
|
||||
// Mutation this must catch: any numeric field whose canon range is not honoured by the decoder.
|
||||
func TestEveryRangeTheCanonDeclaresIsOneThisReaderEnforces(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../../docs/architecture/14-api-contract/openapi.yaml")
|
||||
if err != nil {
|
||||
// Not skipped: a missing canon would leave these ranges with nothing to be checked against.
|
||||
t.Fatalf("the ratified canon could not be read: %v", err)
|
||||
}
|
||||
var doc struct {
|
||||
Components struct {
|
||||
Schemas map[string]struct {
|
||||
Properties map[string]struct {
|
||||
Minimum *int `yaml:"minimum"`
|
||||
Maximum *int `yaml:"maximum"`
|
||||
} `yaml:"properties"`
|
||||
} `yaml:"schemas"`
|
||||
} `yaml:"components"`
|
||||
}
|
||||
if err := yaml.Unmarshal(raw, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The engine's field names, against the contract's — the reader is the thing that maps between
|
||||
// them, so a gate over both has to carry the pair.
|
||||
engineName := map[string]string{"freq": "freq", "spread": "spread",
|
||||
"conventions": "conventions", "confidence": "conf"}
|
||||
ranged := 0
|
||||
for member, spec := range doc.Components.Schemas["OfferedTerm"].Properties {
|
||||
if spec.Minimum == nil && spec.Maximum == nil {
|
||||
continue
|
||||
}
|
||||
ranged++
|
||||
engine, ok := engineName[member]
|
||||
if !ok {
|
||||
t.Errorf("the canon gives `%s` a range and this gate does not know which engine field feeds it", member)
|
||||
continue
|
||||
}
|
||||
for _, outside := range outsideOf(spec.Minimum, spec.Maximum) {
|
||||
bank, err := DecodeBank([]byte(fmt.Sprintf(`{%s,"proposed":[{"src":"x","%s":%d}]}`,
|
||||
bankHead, engine, outside)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]*int{
|
||||
"freq": (*bank.Offered)[0].Freq, "spread": (*bank.Offered)[0].Spread,
|
||||
"conventions": (*bank.Offered)[0].Conventions, "confidence": (*bank.Offered)[0].Confidence,
|
||||
}[member]
|
||||
if got == nil {
|
||||
continue // read as "not stated", which is inside the contract
|
||||
}
|
||||
if (spec.Minimum != nil && *got < *spec.Minimum) || (spec.Maximum != nil && *got > *spec.Maximum) {
|
||||
t.Errorf("the canon bounds `%s` and the reader passed %d straight through from the engine's %d",
|
||||
member, *got, outside)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The gate's own denominator: a canon whose ranges this gate silently found none of would pass
|
||||
// while proving nothing.
|
||||
if ranged == 0 {
|
||||
t.Fatal("the canon's OfferedTerm declares no ranges at all: this gate is reading the wrong schema")
|
||||
}
|
||||
t.Logf("ranges read from the canon and exercised: %d", ranged)
|
||||
}
|
||||
|
||||
// outsideOf returns values just outside a declared range — one below the floor, one above the ceiling.
|
||||
func outsideOf(min, max *int) []int {
|
||||
var out []int
|
||||
if min != nil {
|
||||
out = append(out, *min-1)
|
||||
}
|
||||
if max != nil {
|
||||
out = append(out, *max+1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
313
platform/internal/pgstore/bankreadout_test.go
Normal file
313
platform/internal/pgstore/bankreadout_test.go
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
package pgstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"textmachine/platform/internal/ingest"
|
||||
)
|
||||
|
||||
// bankreadout_test.go: the storage half of engine backlog rows 224 and 253. Before it the read model
|
||||
// took the read-out's `terms` alone, which at a signing stop is empty by construction — so the one
|
||||
// screen that asks a person for a decision answered zero while 69 and 66 questions lay unread in the
|
||||
// same document.
|
||||
|
||||
func stopReadOut(offered []ingest.OfferedTerm) ingest.Bank {
|
||||
return ingest.Bank{
|
||||
Boundary: ingest.BoundarySignatureRequested, RunID: "tm-stream-run_X-1",
|
||||
Offered: &offered,
|
||||
}
|
||||
}
|
||||
|
||||
func intp(n int) *int { return &n }
|
||||
|
||||
// ⛔ THE THREE ABSENCES, and the pin exists because they look identical on a screen and are not: a book
|
||||
// whose runs never published a read-out · a read-out carrying no such section · a section that is there
|
||||
// and holds nothing. Only the last means "nothing to sign".
|
||||
//
|
||||
// Mutations this must catch: answering Unreadable false when no read-out exists; storing "the section
|
||||
// was there" as a row count; dropping the `offered_present` column and deriving presence from rows.
|
||||
func TestTheThreeWaysAStopCanHaveNoQuestionsAreNotOneAnswer(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
book := readingBook(t, s, ctx, "u1")
|
||||
|
||||
never, err := s.BankSigningStop(ctx, "u1", book)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !never.Unreadable {
|
||||
t.Error("a book whose runs never published a read-out reported the stop as readable")
|
||||
}
|
||||
|
||||
if err := s.SaveBank(ctx, book, ingest.Bank{Boundary: ingest.BoundarySignatureRequested}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
noSection, err := s.BankSigningStop(ctx, "u1", book)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !noSection.Unreadable {
|
||||
t.Error("a read-out that carries no section reported the stop as readable — an engine build older " +
|
||||
"than the section is then indistinguishable from a stop that asked nothing")
|
||||
}
|
||||
|
||||
if err := s.SaveBank(ctx, book, stopReadOut(nil)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
empty, err := s.BankSigningStop(ctx, "u1", book)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if empty.Unreadable {
|
||||
t.Error("a section that is present and holds nothing was reported as unreadable")
|
||||
}
|
||||
if len(empty.Offered) != 0 {
|
||||
t.Errorf("an empty section answered %d terms", len(empty.Offered))
|
||||
}
|
||||
}
|
||||
|
||||
// ⛔ The boundary is a CONDITION, not a label. The engine publishes this section at the signing stop and
|
||||
// nowhere else, so rows standing at another boundary mean the document is not what this build takes it
|
||||
// for — and serving them puts a stop on the screen that no run is holding.
|
||||
//
|
||||
// Mutation this must catch: reading `offered_present` alone and ignoring the boundary.
|
||||
func TestTermsStandingAtABoundaryThatCannotCarryThemAreNotServed(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
book := readingBook(t, s, ctx, "u1")
|
||||
offered := []ingest.OfferedTerm{{Src: "方源", Dst: "Фан Юань", Channel: ingest.ChannelBoth}}
|
||||
// The premise the assertion stands on: the SAME rows at the stop's own boundary are served. Without
|
||||
// it the test could pass on a build that serves nothing at all.
|
||||
if err := s.SaveBank(ctx, book, stopReadOut(offered)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
atStop, err := s.BankSigningStop(ctx, "u1", book)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if atStop.Unreadable || len(atStop.Offered) != 1 {
|
||||
t.Fatalf("the stop's own boundary did not serve its rows: %+v", atStop)
|
||||
}
|
||||
for _, boundary := range []string{
|
||||
ingest.BoundaryRunStarted, ingest.BoundaryTermsTakenUnsigned,
|
||||
ingest.BoundaryRunFinished, ingest.BoundaryBookReseeded, ingest.BoundaryUnknown,
|
||||
} {
|
||||
bank := stopReadOut(offered)
|
||||
bank.Boundary = boundary
|
||||
if err := s.SaveBank(ctx, book, bank); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.BankSigningStop(ctx, "u1", book)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Unreadable || len(got.Offered) != 0 {
|
||||
t.Errorf("boundary %s served %d terms as a stop's questions: %+v", boundary, len(got.Offered), got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ⛔ An absent number is not a zero, and the read model must not flatten what the reader kept apart.
|
||||
// Measured: `conventions` is absent from every row of one bought stop read-out and present on every row
|
||||
// of the other. `freq = 0` is the engine's own "only the draft side saw it"; a stated `confidence = 0`
|
||||
// is the row a signer must look at, and the engine spells "the reply stated none" as a negative.
|
||||
//
|
||||
// Mutation this must catch: a `coalesce(freq, 0)` anywhere on the way out, or non-null columns.
|
||||
func TestANumberTheReadOutNeverCarriedStaysAbsentThroughTheStore(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
book := readingBook(t, s, ctx, "u1")
|
||||
if err := s.SaveBank(ctx, book, stopReadOut([]ingest.OfferedTerm{
|
||||
{Src: "silent"},
|
||||
{Src: "stated", Freq: intp(0), Spread: intp(0), Conventions: intp(0), Confidence: intp(0)},
|
||||
{Src: "high-confidence", Confidence: intp(95)},
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.BankSigningStop(ctx, "u1", book)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Offered) != 3 {
|
||||
t.Fatalf("stored 3 terms and read back %d", len(got.Offered))
|
||||
}
|
||||
silent := got.Offered[0]
|
||||
for name, p := range map[string]*int{"freq": silent.Freq, "spread": silent.Spread,
|
||||
"conventions": silent.Conventions, "confidence": silent.Confidence} {
|
||||
if p != nil {
|
||||
t.Errorf("%s was never carried and came back as %d", name, *p)
|
||||
}
|
||||
}
|
||||
stated := got.Offered[1]
|
||||
for name, p := range map[string]*int{"freq": stated.Freq, "spread": stated.Spread,
|
||||
"conventions": stated.Conventions, "confidence": stated.Confidence} {
|
||||
if p == nil || *p != 0 {
|
||||
t.Errorf("%s was stated as 0 and came back as %v", name, p)
|
||||
}
|
||||
}
|
||||
// ⚠ The engine's own «the reply stated none» is a NEGATIVE, and it no longer reaches this layer:
|
||||
// the reader folds it to "not stated" at the seam, where every other crossing is folded. So what
|
||||
// this store must carry is a STATED number, unrounded and unclamped — pinning the negative here
|
||||
// would teach a path that was abolished.
|
||||
if c := got.Offered[2].Confidence; c == nil || *c != 95 {
|
||||
t.Errorf("a stated confidence came back as %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// The ranking IS the information — which row to read first — and it is the only address a term has, so
|
||||
// the order the read-out published is the order that comes back. The list is replaced whole for the
|
||||
// same reason: without an identity, no delta over it is expressible.
|
||||
//
|
||||
// Mutation this must catch: `order by src` or no order at all; writing the new rows before deleting the
|
||||
// old, which leaves the tail of a longer previous read-out standing.
|
||||
func TestTheStopsRankingIsTheOrderThatComesBackAndTheListIsReplacedWhole(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
book := readingBook(t, s, ctx, "u1")
|
||||
if err := s.SaveBank(ctx, book, stopReadOut([]ingest.OfferedTerm{
|
||||
{Src: "zeta"}, {Src: "alpha"}, {Src: "mu"},
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.BankSigningStop(ctx, "u1", book)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"zeta", "alpha", "mu"}
|
||||
for i, src := range want {
|
||||
if got.Offered[i].Src != src {
|
||||
t.Fatalf("the stop's ranking came back re-sorted: %v", srcsOf(got.Offered))
|
||||
}
|
||||
}
|
||||
if err := s.SaveBank(ctx, book, stopReadOut([]ingest.OfferedTerm{{Src: "only"}})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shorter, err := s.BankSigningStop(ctx, "u1", book)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(shorter.Offered) != 1 || shorter.Offered[0].Src != "only" {
|
||||
t.Errorf("a shorter read-out left the previous tail standing: %v", srcsOf(shorter.Offered))
|
||||
}
|
||||
}
|
||||
|
||||
// ⛔ Completeness is ABSENT until something measures it, and absence is not "the bank is complete". The
|
||||
// engine makes the section absent rather than zeroed for exactly this reason: a zeroed one answers
|
||||
// "consolidated 0, unanswered 0", which reads as "nothing is missing".
|
||||
//
|
||||
// Mutation this must catch: storing a zeroed section when the read-out carried none, or dropping the
|
||||
// `consolidation_measured` column and testing the numbers instead.
|
||||
func TestCompletenessIsAbsentUntilSomethingMeasuresItAndAbsenceIsNotCompleteness(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
book := readingBook(t, s, ctx, "u1")
|
||||
if err := s.SaveBank(ctx, book, ingest.Bank{Boundary: ingest.BoundaryRunStarted}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := s.ListBank(ctx, "u1", book, 0, "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Consolidation != nil {
|
||||
t.Errorf("a read-out that measured nothing reported completeness: %+v", page.Consolidation)
|
||||
}
|
||||
measured := ingest.Bank{Boundary: ingest.BoundarySignatureRequested,
|
||||
Consolidation: &ingest.Consolidation{RenderBatchesDropped: 5, ClassifyBatchesDropped: 2,
|
||||
Consolidated: 38, Declined: 1, Unanswered: 47, NeverAsked: 7}}
|
||||
if err := s.SaveBank(ctx, book, measured); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err = s.ListBank(ctx, "u1", book, 0, "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Consolidation == nil {
|
||||
t.Fatal("a measured section came back absent — the signer would read a partial bank as whole")
|
||||
}
|
||||
got := *page.Consolidation
|
||||
want := BankConsolidation{RenderBatchesDropped: 5, ClassifyBatchesDropped: 2,
|
||||
Consolidated: 38, Declined: 1, Unanswered: 47, NeverAsked: 7}
|
||||
if got != want {
|
||||
t.Errorf("the section was rewritten on the way through:\n got %+v\nwant %+v", got, want)
|
||||
}
|
||||
// It is a whole-bank aggregate and rides where they do: with the counts, on the first page only.
|
||||
later, err := s.ListBank(ctx, "u1", book, 1, "cursor-that-is-not-the-first-page", nil)
|
||||
if err == nil && later.Consolidation != nil {
|
||||
t.Error("completeness rode a page that is not the first")
|
||||
}
|
||||
}
|
||||
|
||||
func srcsOf(ts []BankOfferedTerm) []string {
|
||||
out := make([]string, 0, len(ts))
|
||||
for _, t := range ts {
|
||||
out = append(out, t.Src)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ⛔ A new `/v0` route is a new place to read somebody else's book from (API1 BOLA, the zone's base
|
||||
// line). The ownership check is the FIRST thing this read does and the only thing between one user's
|
||||
// stop and another's; a reader that scoped by book alone would serve it happily.
|
||||
//
|
||||
// Mutation this must catch: dropping the `bookScope` call, or scoping the query by book alone.
|
||||
func TestAnotherUsersStopIsNotReadable(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
book := readingBook(t, s, ctx, "u1")
|
||||
if err := s.SaveBank(ctx, book, stopReadOut([]ingest.OfferedTerm{{Src: "方源"}})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The premise: the owner DOES see it. Without this the test could pass on a build that serves
|
||||
// nobody, and the fixture would have lost the power to tell the two apart.
|
||||
mine, err := s.BankSigningStop(ctx, "u1", book)
|
||||
if err != nil || len(mine.Offered) != 1 {
|
||||
t.Fatalf("the owner could not read their own stop: %v %+v", err, mine)
|
||||
}
|
||||
if _, err := s.BankSigningStop(ctx, "u2", book); !errors.Is(err, ErrNoBook) {
|
||||
t.Errorf("a stranger's read of this book's stop answered %v, want ErrNoBook", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ⛔ THE CLOSED-VOCABULARY GUARDS AT THE WRITE SEAM, pinned because an unpinned data decision is one a
|
||||
// later edit removes silently — and here "silently" means a whole bank save failing on a CHECK.
|
||||
//
|
||||
// ⚠ `nullif($, ”)` in the statement does NOT do this work: it turns the EMPTY value into null, and
|
||||
// the guard turns an UNKNOWN non-empty one into the empty value first. Without the guard an engine
|
||||
// word that leaked this far — `banknote`, the very kind the contract renamed so it could not reach a
|
||||
// client — hits the constraint and takes the bank down with it, at the one boundary a person is
|
||||
// waiting at. Measured here rather than argued: the same value with the guard lands as "not named",
|
||||
// and the row survives.
|
||||
//
|
||||
// Mutation this must catch: dropping either guard from the insert, or widening the CHECK.
|
||||
func TestAnEngineWordThatLeakedThisFarIsStoredAsNotNamedRatherThanTakingTheBankDown(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
book := readingBook(t, s, ctx, "u1")
|
||||
// Both engine vocabularies, both outside the contract's closed sets.
|
||||
if err := s.SaveBank(ctx, book, stopReadOut([]ingest.OfferedTerm{
|
||||
{Src: "leaked", Channel: "banknote", Kind: "ruby"},
|
||||
})); err != nil {
|
||||
t.Fatalf("an unknown vocabulary took the whole bank save down: %v", err)
|
||||
}
|
||||
got, err := s.BankSigningStop(ctx, "u1", book)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Offered) != 1 {
|
||||
t.Fatalf("the row did not survive: %+v", got)
|
||||
}
|
||||
if got.Offered[0].Channel != "" {
|
||||
t.Errorf("an engine word reached the read side as %q", got.Offered[0].Channel)
|
||||
}
|
||||
if got.Offered[0].Kind != "" {
|
||||
t.Errorf("an engine kind reached the read side as %q", got.Offered[0].Kind)
|
||||
}
|
||||
// The premise the assertions stand on: a value INSIDE the vocabulary is still carried, or the
|
||||
// fixture would pass on a build that stores nothing at all.
|
||||
if err := s.SaveBank(ctx, book, stopReadOut([]ingest.OfferedTerm{
|
||||
{Src: "named", Channel: ingest.ChannelBoth, Kind: "name"},
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
named, err := s.BankSigningStop(ctx, "u1", book)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if named.Offered[0].Channel != ingest.ChannelBoth || named.Offered[0].Kind != "name" {
|
||||
t.Errorf("a value inside the vocabulary was dropped too: %+v", named.Offered[0])
|
||||
}
|
||||
}
|
||||
|
|
@ -43,9 +43,9 @@ func TestEveryFrameCarriesTheBooksRevisionAndStructureVersion(t *testing.T) {
|
|||
if err := apply(t, sink, 4, ingest.TypeBankStop, ingest.BankStop{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SaveBank(ctx, "bk1", []ingest.BankTerm{
|
||||
if err := s.SaveBank(ctx, "bk1", ingest.Bank{Terms: []ingest.BankTerm{
|
||||
{ID: "t1", Src: "s", Dst: "d", Status: "proposed", Origin: "found", Sense: "one"},
|
||||
}); err != nil {
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@
|
|||
# tailer does not do. A migration whose comment lies is worse than one whose hash moved before it was
|
||||
# ever released; the day either of them ships, this exception closes with it.
|
||||
#
|
||||
# ⚠ 00036 is listed for the FIRST time by the pack that writes it and was re-fingerprinted inside that
|
||||
# same pack, when the acceptance run showed its boundary constraint refused the zero value a caller can
|
||||
# legally build. Same narrowness as 00016 below: nothing has run it outside scratch databases, so no
|
||||
# deployment can hold the earlier version. The day it ships, this exception closes with it.
|
||||
#
|
||||
# ⚠ 00016 is listed for the FIRST time by the pack that writes it and has been re-fingerprinted
|
||||
# inside that same pack (an index of 00015 it made redundant, a version claim that named a pin the
|
||||
# stack table does not carry). Same narrowness: nothing has run it outside scratch databases.
|
||||
|
|
@ -51,3 +56,4 @@ c21877113b5966bc8a200ba69ce752d4ac295bdfd34e681afbd887523edb6ceb 00019_read_mod
|
|||
d56496573d793ce1b825664e2e28bdb0f8335fc4155a578cd3d23747363b8424 00033_order_and_price.sql
|
||||
ee5ea50e815479db3550eb28f1f039e417d36fcbf7799a3ac5b9f01913938eef 00034_spawn_proof_and_park.sql
|
||||
463589447fed990aa1e53d3ad835cf27115c9ebf750f1724894aa11764204551 00035_settled_repair_index.sql
|
||||
a30abdebab801145b9914a0fc67f87fc1c862a2a1a790d30f47473970ae525da 00036_bank_read_out.sql
|
||||
|
|
|
|||
133
platform/internal/pgstore/migrations/00036_bank_read_out.sql
Normal file
133
platform/internal/pgstore/migrations/00036_bank_read_out.sql
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
-- +goose Up
|
||||
|
||||
-- The signing stop's two sections, which until now had nowhere to land: the bank table held the
|
||||
-- `terms` section alone, and at a signature stop that section is EMPTY by construction — the mined
|
||||
-- rows are folded into the glossary in the engine's auto-continue branch and a stopped run returns
|
||||
-- before it. So the one screen in the product that asks a person for a decision was served a zero
|
||||
-- that meant "nothing here reads the answer" (engine backlog rows 224 and 253; measured on two
|
||||
-- bought stop read-outs: terms 0 against 69 and 66 offered terms).
|
||||
|
||||
-- ── The read-out itself: WHICH moment of a run published the document ───────────────────────────
|
||||
--
|
||||
-- One row per book, or none for a book whose runs have never published a read-out — a third state,
|
||||
-- and distinct from the two below: "no read-out at all", "a read-out carrying no section", "a
|
||||
-- section carrying no rows". Collapsing any pair of them puts a zero on the screen that says
|
||||
-- something nobody measured.
|
||||
create table bank_read_out (
|
||||
book_id text primary key references books (id) on delete cascade,
|
||||
-- The contract's ReadOutBoundary. `unknown` is a VALUE and not a null: the engine publishes at
|
||||
-- five boundaries and warns three times over that the file on disk may be a previous one's, so
|
||||
-- a read-out whose boundary this build cannot name still has to be storable — as the value that
|
||||
-- claims the least, never as the stop.
|
||||
boundary text not null check (boundary in (
|
||||
'run_started', 'terms_taken_unsigned', 'signature_requested',
|
||||
'run_finished', 'book_reseeded', 'unknown')),
|
||||
-- The other half of the engine's freshness anchor: the id it announces its event stream under,
|
||||
-- which is the id THIS platform minted for the attempt (pgstore.EngineStreamID). Stored and NOT
|
||||
-- served: it is an engine identity and the seam does not export those. Kept because the engine
|
||||
-- calls the two fields ONE anchor, and a build that took the boundary and dropped this one would
|
||||
-- have taken the form of the anchor without its guarantee.
|
||||
engine_run_id text not null default '',
|
||||
-- Whether the document carried a `proposed` member AT ALL. Not derivable from the row count in
|
||||
-- bank_offered_terms: zero rows is what BOTH "no section" and "a section that asked nothing" look
|
||||
-- like there, and they are different answers to a person staring at an empty screen.
|
||||
offered_present boolean not null,
|
||||
-- Whether anything measured how complete the bank being signed is. The engine makes the section
|
||||
-- ABSENT rather than zeroed at the four boundaries where the paid pass never ran, because a
|
||||
-- zeroed one answers "consolidated 0, unanswered 0" — which reads as "nothing is missing". The
|
||||
-- flag plus nullable columns is that distinction in the schema instead of in a convention.
|
||||
consolidation_measured boolean not null,
|
||||
-- Taken from the engine READY-MADE and never re-derived here: it computes `complete` off the
|
||||
-- RENDER pass alone, and a classifier cut leaves the bank whole with the term TYPES unrefined —
|
||||
-- a different budget and a different remedy. A second mechanism answering one question is how
|
||||
-- the two come to disagree.
|
||||
complete boolean,
|
||||
-- render_batches_dropped is what makes `complete` false; classify_batches_dropped is the OTHER
|
||||
-- pass's cut on its OWN budget. Two columns and never one sum, because an operator raises one
|
||||
-- budget or the other, never "the" budget.
|
||||
render_batches_dropped integer,
|
||||
classify_batches_dropped integer,
|
||||
-- consolidated came back with a rendering; declined the role explicitly could not render;
|
||||
-- unanswered is the role's silence AND the budget's cut together (the engine's counter conflates
|
||||
-- them, and it means "the role stayed silent" only where `complete` is true); never_asked is the
|
||||
-- opposite of a gap — the bank already renders the surface and every draft agreed with it.
|
||||
consolidated integer,
|
||||
declined integer,
|
||||
unanswered integer,
|
||||
never_asked integer,
|
||||
-- All seven or none. Without this a half-written section is storable, and a half-written section
|
||||
-- is exactly the zeroed one the engine refuses to publish.
|
||||
constraint bank_read_out_consolidation_is_whole_or_absent check (
|
||||
num_nonnulls(complete, render_batches_dropped, classify_batches_dropped,
|
||||
consolidated, declined, unanswered, never_asked)
|
||||
= case when consolidation_measured then 7 else 0 end)
|
||||
);
|
||||
|
||||
-- ── The terms the stop offered ─────────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- ⛔ KEYED BY POSITION, and that is not an identity for an offered term — it is the document's own
|
||||
-- ORDER. An offered term has no id in any of the 135 objects of the two bought read-outs, and minting
|
||||
-- one here would be this side re-implementing engine law. What the engine does publish is a RANKING:
|
||||
-- "which row to read first is the information here". So the key is (book, position in that ranking),
|
||||
-- the table is replaced whole on every read, and no delta is expressible over it — which is the
|
||||
-- honest consequence of there being no identity, not a limitation to work around.
|
||||
--
|
||||
-- It therefore stays OUT of the bank's revision machinery on purpose. bank_terms sets
|
||||
-- books.bank_reset_revision when a row disappears, "because a disappeared row cannot be expressed as
|
||||
-- a delta"; folding these rows into that would set it on EVERY materialization and leave the book in
|
||||
-- a permanent revision reset.
|
||||
create table bank_offered_terms (
|
||||
book_id text not null references books (id) on delete cascade,
|
||||
ordinal integer not null check (ordinal >= 0),
|
||||
src text not null,
|
||||
-- The consolidated rendering, '' when the read-out carries none. ⚠ It does NOT say WHY: on the
|
||||
-- engine's printed sheet an empty rendering covers four different facts, one of which ("the bank
|
||||
-- already renders this surface, there was nothing to decide") the engine distinguishes and
|
||||
-- deliberately does not project here. This column must not be read as "the service could not
|
||||
-- translate it".
|
||||
dst text not null default '',
|
||||
-- Null means "the kind was not decided" — legal, and the row still needs signing. The same
|
||||
-- vocabulary and the same null as bank_terms.kind.
|
||||
kind text check (kind in ('name', 'place', 'title', 'term', 'nickname')),
|
||||
-- The contract's OfferedTermChannel: which detector found the surface. Deliberately not called
|
||||
-- `origin` — on a bank row that word is the PROVENANCE, and one word meaning two things inside
|
||||
-- one document is the confusion the engine's own field comment refuses.
|
||||
-- Null for a detector outside the closed set — the same spelling as `kind` above, so one
|
||||
-- convention carries "this build cannot name it" all the way to the wire. Never a guess: this is
|
||||
-- how a person judges how well corroborated a row is.
|
||||
channel text check (channel in ('source_text', 'translated_text', 'both', 'alias')),
|
||||
-- ⛔ NULLABLE ON PURPOSE, AND MEASURED: the engine writes all four of these ALWAYS, so an absent
|
||||
-- one means a build without the field wrote the document — and for these the zero value is
|
||||
-- itself a measurement. `conventions` is absent from all 69 rows of one bought read-out and
|
||||
-- present on all 66 of the other, because it was added to the projection between the two runs.
|
||||
-- A build storing 0 there would have told a person that 69 terms' renderings all agree, on a
|
||||
-- sheet whose whole purpose is disagreement. `freq = 0` is the engine's own "only the draft side
|
||||
-- saw it"; a stated `confidence = 0` is the most important row on a signing sheet.
|
||||
freq integer,
|
||||
spread integer,
|
||||
conventions integer,
|
||||
-- Three states and not two: null for "the read-out does not carry it", NEGATIVE for the engine's
|
||||
-- own "the reply carried none", and >= 0 for a stated one.
|
||||
confidence integer,
|
||||
-- The rendering is not one the drafts proposed — legitimate, and the class the engine names as
|
||||
-- the one to read FIRST. Not null because the engine omits the field when false, so absence IS
|
||||
-- false by the writer's own encoding; the day that stops being true this column becomes nullable
|
||||
-- along with the Go field that feeds it.
|
||||
invented boolean not null default false,
|
||||
-- contradicts: this run's OTHER consolidations the rendering breaks. bank_holds: rows the bank
|
||||
-- already carries for the same surface with a different rendering. Two columns, because "the run
|
||||
-- disagreed with itself" and "the book already calls it something else" are different decisions.
|
||||
contradicts text[] not null default '{}',
|
||||
bank_holds text[] not null default '{}',
|
||||
-- The drafts' renderings, best-ranked first, in the engine's OWN labelling — stored as opaque
|
||||
-- text and never parsed. The engine keeps a reader for that format beside its writer precisely
|
||||
-- because a second, re-derived parser "would keep working until the day the label gains a field,
|
||||
-- and then it would report numbers rather than an error".
|
||||
variants text[] not null default '{}',
|
||||
primary key (book_id, ordinal)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
|
||||
drop table bank_offered_terms;
|
||||
drop table bank_read_out;
|
||||
|
|
@ -344,7 +344,13 @@ func writeUnits(ctx context.Context, tx pgx.Tx, bookID, chapterID string, units
|
|||
// abolished (D39.144) and removed — but a rebuild must still leave it alone: what replaces it, an
|
||||
// EDIT of a term, has to survive the rebuild for the same reason, and the note left where the write
|
||||
// path stood promises that the storage is ready for it.
|
||||
func (s *Store) SaveBank(ctx context.Context, bookID string, terms []ingest.BankTerm) error {
|
||||
//
|
||||
// ⚠ It takes the WHOLE read-out rather than its rows, and one transaction writes all of it. The
|
||||
// document is one projection of one moment — the bank, what a stop is asking about, and how complete
|
||||
// the pass that produced it managed to be — and two saves would let half of it land: a screen showing
|
||||
// this run's suggestions against the previous run's bank is a worse answer than either half alone.
|
||||
func (s *Store) SaveBank(ctx context.Context, bookID string, bank ingest.Bank) error {
|
||||
terms := bank.Terms
|
||||
return s.inTx(ctx, func(tx pgx.Tx) error {
|
||||
if err := lockBook(ctx, tx, bookID); err != nil {
|
||||
return err
|
||||
|
|
@ -393,6 +399,9 @@ func (s *Store) SaveBank(ctx context.Context, bookID string, terms []ingest.Bank
|
|||
return fmt.Errorf("pgstore: record the bank reset: %w", err)
|
||||
}
|
||||
}
|
||||
if err := saveBankReadOut(ctx, tx, bookID, bank); err != nil {
|
||||
return err
|
||||
}
|
||||
counts, err := bankCountsTx(ctx, tx, bookID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -401,6 +410,81 @@ func (s *Store) SaveBank(ctx context.Context, bookID string, terms []ingest.Bank
|
|||
})
|
||||
}
|
||||
|
||||
// saveBankReadOut stores the two sections a signing stop publishes beside the bank, and the boundary
|
||||
// that published them (engine backlog rows 224 and 253).
|
||||
//
|
||||
// ⚠ The offered terms are deleted BEFORE they are written, the opposite of the order the bank rows
|
||||
// above use, and each order is load-bearing for its own reason: a bank row goes first because
|
||||
// `bank_decisions` cascades on it, while an offered term has no dependants and its key is a POSITION
|
||||
// in a ranking that moves — writing first would leave the tail of a longer previous read-out standing.
|
||||
func saveBankReadOut(ctx context.Context, tx pgx.Tx, bookID string, bank ingest.Bank) error {
|
||||
c := bank.Consolidation
|
||||
// Seven values or none: the schema refuses a half-written section, because a half-written one is
|
||||
// exactly the zeroed section the engine refuses to publish — it would answer "consolidated 0,
|
||||
// unanswered 0", which reads as "nothing is missing".
|
||||
var complete *bool
|
||||
var renderDropped, classifyDropped, consolidated, declined, unanswered, neverAsked *int
|
||||
if c != nil {
|
||||
complete = &c.Complete
|
||||
renderDropped, classifyDropped = &c.RenderBatchesDropped, &c.ClassifyBatchesDropped
|
||||
consolidated, declined = &c.Consolidated, &c.Declined
|
||||
unanswered, neverAsked = &c.Unanswered, &c.NeverAsked
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
insert into bank_read_out (book_id, boundary, engine_run_id, offered_present,
|
||||
consolidation_measured, complete, render_batches_dropped,
|
||||
classify_batches_dropped, consolidated, declined, unanswered,
|
||||
never_asked)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
on conflict (book_id) do update set boundary = excluded.boundary,
|
||||
engine_run_id = excluded.engine_run_id,
|
||||
offered_present = excluded.offered_present,
|
||||
consolidation_measured = excluded.consolidation_measured,
|
||||
complete = excluded.complete,
|
||||
render_batches_dropped = excluded.render_batches_dropped,
|
||||
classify_batches_dropped = excluded.classify_batches_dropped,
|
||||
consolidated = excluded.consolidated,
|
||||
declined = excluded.declined,
|
||||
unanswered = excluded.unanswered,
|
||||
never_asked = excluded.never_asked`,
|
||||
bookID, ingest.BoundaryOrUnknown(bank.Boundary), bank.RunID, bank.Offered != nil, c != nil,
|
||||
complete, renderDropped, classifyDropped, consolidated, declined, unanswered, neverAsked,
|
||||
); err != nil {
|
||||
return fmt.Errorf("pgstore: write the bank read-out: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `delete from bank_offered_terms where book_id = $1`, bookID); err != nil {
|
||||
return fmt.Errorf("pgstore: drop the previously offered terms: %w", err)
|
||||
}
|
||||
if bank.Offered == nil {
|
||||
return nil
|
||||
}
|
||||
for i, t := range *bank.Offered {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
insert into bank_offered_terms (book_id, ordinal, src, dst, kind, channel, freq, spread,
|
||||
conventions, confidence, invented, contradicts, bank_holds,
|
||||
variants)
|
||||
values ($1, $2, $3, $4, nullif($5, ''), nullif($6, ''), $7, $8, $9, $10, $11, $12, $13, $14)`,
|
||||
bookID, i, t.Src, t.Dst, ingest.KindOrNone(t.Kind), ingest.ChannelOrNone(t.Channel),
|
||||
t.Freq, t.Spread,
|
||||
t.Conventions, t.Confidence, t.Invented,
|
||||
stringList(t.Contradicts), stringList(t.BankHolds), stringList(t.Variants),
|
||||
); err != nil {
|
||||
return fmt.Errorf("pgstore: write an offered term: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// stringList is the empty ARRAY for an absent list. The columns are `not null default '{}'` and nil
|
||||
// would be a null; "the read-out named nothing here" and "the read-out does not have this field" are
|
||||
// the same fact for these three, because the engine omits all of them when empty.
|
||||
func stringList(v []string) []string {
|
||||
if v == nil {
|
||||
return []string{}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// BankCounts are the whole-bank aggregates: the header of a signing screen, and INFORMATIONAL —
|
||||
// never a gate. Signing is one act over the whole bank and `resume` lifts the stop with the
|
||||
// decisions as they stand (D39.144), so nothing here decides whether continuing may be offered.
|
||||
|
|
@ -1102,6 +1186,52 @@ type BankTerm struct {
|
|||
Until *int
|
||||
}
|
||||
|
||||
// BankOfferedTerm is one row of a signing stop's table as the client reads it — a surface the service
|
||||
// mined and consolidated that is NOT in the bank yet.
|
||||
//
|
||||
// It has no id, here or anywhere: the engine publishes none and minting one on this side would be
|
||||
// re-implementing engine law. What it has is a POSITION in the stop's ranking, which the list's order
|
||||
// carries — so no delta is expressible over this list and it is always read whole.
|
||||
type BankOfferedTerm struct {
|
||||
Src string
|
||||
Dst string // "" when the read-out carries no rendering — it does not say why
|
||||
// "" for what this build cannot name: a kind the engine did not decide, a detector outside the
|
||||
// closed set.
|
||||
Kind, Channel string
|
||||
// Nil is NOT zero: `freq = 0` is the engine's "only the draft side saw it", and `conventions` is
|
||||
// absent from every row of a read-out bought before that field existed.
|
||||
Freq, Spread, Conventions *int
|
||||
// Nil "not carried", negative for the engine's "the reply stated none", >= 0 for a stated one.
|
||||
Confidence *int
|
||||
Invented bool
|
||||
Contradicts, BankHolds []string
|
||||
Variants []string
|
||||
}
|
||||
|
||||
// BankConsolidation is how complete the bank being signed is, taken from the engine ready-made.
|
||||
type BankConsolidation struct {
|
||||
Complete bool
|
||||
RenderBatchesDropped, ClassifyBatchesDropped int
|
||||
Consolidated, Declined int
|
||||
Unanswered, NeverAsked int
|
||||
}
|
||||
|
||||
// BankSigningStop is what the last signing stop asked about.
|
||||
//
|
||||
// ⛔ Unreadable is the whole reason this is a struct and not a slice: without it "this deployment
|
||||
// cannot tell you what the stop is asking" is byte-identical to "the stop asked nothing" — the one
|
||||
// thing this type must never say by accident. It is true for THREE different causes, and they need no
|
||||
// telling apart because the remedy is one: no read-out has been materialized yet, the read-out carries
|
||||
// no such section (an engine build older than it), or the read-out was taken at a boundary that cannot
|
||||
// carry one, where rows would be a stop this build invented.
|
||||
//
|
||||
// It does NOT answer whether a stop is standing — that is the run's status. This is the LAST stop's
|
||||
// question, in the past tense the correction receipt already uses for the same population.
|
||||
type BankSigningStop struct {
|
||||
Unreadable bool
|
||||
Offered []BankOfferedTerm
|
||||
}
|
||||
|
||||
// BankPage is one page of the bank. The aggregates ride on the FIRST page only — any response to a
|
||||
// request with no cursor — which puts them at the same moment as the oldest rows of the walk.
|
||||
type BankPage struct {
|
||||
|
|
@ -1109,6 +1239,14 @@ type BankPage struct {
|
|||
Terms []BankTerm
|
||||
First bool
|
||||
Counts BankCounts
|
||||
// Consolidation is another whole-bank aggregate and rides where they do — the first page only.
|
||||
// Nil means nothing has measured this bank's completeness, which is NOT "it is complete".
|
||||
//
|
||||
// ⚠ It belongs with the bank rather than with the stop's questions, and the engine's own code is
|
||||
// why: its terminology result is set BEFORE the stop/continue fork and outlives it, so this
|
||||
// section rides three of the five boundaries while the questions ride one. Two different axes of
|
||||
// freshness, and one read cannot be honest about both.
|
||||
Consolidation *BankConsolidation
|
||||
}
|
||||
|
||||
// ListBank returns one page of the bank, ordered by source surface then by the term's window so
|
||||
|
|
@ -1168,6 +1306,9 @@ func (s *Store) ListBank(ctx context.Context, userID, bookID string, limit int,
|
|||
if out.Counts, err = bankCountsTx(ctx, tx, bookID); err != nil {
|
||||
return err
|
||||
}
|
||||
if out.Consolidation, err = bankConsolidationTx(ctx, tx, bookID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
|
@ -1177,6 +1318,95 @@ func (s *Store) ListBank(ctx context.Context, userID, bookID string, limit int,
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// bankConsolidationTx reads how complete the bank being signed is. No row, or a row from a boundary
|
||||
// that never measured it, both answer nil — "nothing measured this", never "it is complete".
|
||||
func bankConsolidationTx(ctx context.Context, tx pgx.Tx, bookID string) (*BankConsolidation, error) {
|
||||
var measured bool
|
||||
var c BankConsolidation
|
||||
err := tx.QueryRow(ctx, `
|
||||
select consolidation_measured, coalesce(complete, false),
|
||||
coalesce(render_batches_dropped, 0), coalesce(classify_batches_dropped, 0),
|
||||
coalesce(consolidated, 0), coalesce(declined, 0), coalesce(unanswered, 0),
|
||||
coalesce(never_asked, 0)
|
||||
from bank_read_out where book_id = $1`, bookID).
|
||||
Scan(&measured, &c.Complete, &c.RenderBatchesDropped, &c.ClassifyBatchesDropped,
|
||||
&c.Consolidated, &c.Declined, &c.Unanswered, &c.NeverAsked)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pgstore: read the bank consolidation: %w", err)
|
||||
}
|
||||
// The coalesces keep the scan targets plain and are safe ONLY because the schema refuses a
|
||||
// partly-written section: the zeroes they produce are read exclusively when `measured` is false,
|
||||
// and nothing then sees them.
|
||||
if !measured {
|
||||
return nil, nil
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// BankSigningStop answers what the book's last signing stop asked about.
|
||||
//
|
||||
// Its own read and its own resource, because it moves on a different axis from the bank: these rows
|
||||
// appear and vanish with a RUN while the bank's revision does not move, and there is no identity to
|
||||
// express a delta over. So it is served whole, uncursored, and stamped by nothing.
|
||||
func (s *Store) BankSigningStop(ctx context.Context, userID, bookID string) (BankSigningStop, error) {
|
||||
var out BankSigningStop
|
||||
err := s.inReadTx(ctx, func(tx pgx.Tx) error {
|
||||
if _, err := bookScope(ctx, tx, userID, bookID); err != nil {
|
||||
return err
|
||||
}
|
||||
var boundary string
|
||||
var present bool
|
||||
err := tx.QueryRow(ctx, `select boundary, offered_present from bank_read_out where book_id = $1`,
|
||||
bookID).Scan(&boundary, &present)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
out.Unreadable = true
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("pgstore: read the bank read-out: %w", err)
|
||||
}
|
||||
// ⛔ The boundary is a condition and not a label. The engine publishes this section at the
|
||||
// signing stop and nowhere else, so rows standing at any other boundary mean the document is
|
||||
// not what this build takes it for — and serving them would put a stop on the screen that no
|
||||
// run is holding.
|
||||
if !present || boundary != ingest.BoundarySignatureRequested {
|
||||
out.Unreadable = true
|
||||
return nil
|
||||
}
|
||||
rows, err := tx.Query(ctx, `
|
||||
select src, dst, coalesce(kind, ''), coalesce(channel, ''), freq, spread, conventions,
|
||||
confidence, invented, contradicts, bank_holds, variants
|
||||
from bank_offered_terms where book_id = $1 order by ordinal`, bookID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pgstore: list the offered terms: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
// Non-nil before the loop: a stop that asked nothing must read as an empty list, not as a
|
||||
// document that carried no section — `Unreadable` is the only carrier of the second fact.
|
||||
out.Offered = []BankOfferedTerm{}
|
||||
for rows.Next() {
|
||||
var t BankOfferedTerm
|
||||
if err := rows.Scan(&t.Src, &t.Dst, &t.Kind, &t.Channel, &t.Freq, &t.Spread,
|
||||
&t.Conventions, &t.Confidence, &t.Invented, &t.Contradicts, &t.BankHolds,
|
||||
&t.Variants); err != nil {
|
||||
return fmt.Errorf("pgstore: scan an offered term: %w", err)
|
||||
}
|
||||
out.Offered = append(out.Offered, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("pgstore: list the offered terms: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return BankSigningStop{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// scope is what every book-scoped read needs before it reads anything: that the caller may see the
|
||||
// book at all, and the two numbers the page is stamped with.
|
||||
type scope struct {
|
||||
|
|
|
|||
|
|
@ -264,7 +264,7 @@ func TestTheBankIsReplacedFromTheEnginesReadOut(t *testing.T) {
|
|||
{ID: "e1", Src: "方源", Dst: "Фан Юань", Kind: "name", Status: "proposed", Origin: "found"},
|
||||
{ID: "e2", Src: "蛊", Status: "proposed", Origin: "given", SinceChapter: &since},
|
||||
}
|
||||
if err := s.SaveBank(ctx, book, terms); err != nil {
|
||||
if err := s.SaveBank(ctx, book, ingest.Bank{Terms: terms}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := s.ListBank(ctx, "u1", book, 0, "", nil)
|
||||
|
|
@ -276,7 +276,7 @@ func TestTheBankIsReplacedFromTheEnginesReadOut(t *testing.T) {
|
|||
}
|
||||
// A rebuild of the same read-out answers the same bank: the identities are derived from the term
|
||||
// itself, so nothing is duplicated and nothing is lost.
|
||||
if err := s.SaveBank(ctx, book, terms); err != nil {
|
||||
if err := s.SaveBank(ctx, book, ingest.Bank{Terms: terms}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := s.ListBank(ctx, "u1", book, 0, "", nil)
|
||||
|
|
@ -287,7 +287,7 @@ func TestTheBankIsReplacedFromTheEnginesReadOut(t *testing.T) {
|
|||
t.Errorf("the rebuild changed the bank: %+v", after.Counts)
|
||||
}
|
||||
// A term dropped from the read-out is GONE from the bank — the whole point of a replacement.
|
||||
if err := s.SaveBank(ctx, book, terms[:1]); err != nil {
|
||||
if err := s.SaveBank(ctx, book, ingest.Bank{Terms: terms[:1]}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shrunk, err := s.ListBank(ctx, "u1", book, 0, "", nil)
|
||||
|
|
@ -365,7 +365,7 @@ func TestARebuildOfTheBankLeavesTheDecisionsTableAlone(t *testing.T) {
|
|||
terms := []ingest.BankTerm{
|
||||
{ID: "e1", Src: "方源", Dst: "Фан Юань", Kind: "name", Status: "proposed", Origin: "found"},
|
||||
}
|
||||
if err := s.SaveBank(ctx, book, terms); err != nil {
|
||||
if err := s.SaveBank(ctx, book, ingest.Bank{Terms: terms}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := s.ListBank(ctx, "u1", book, 0, "", nil)
|
||||
|
|
@ -380,7 +380,7 @@ func TestARebuildOfTheBankLeavesTheDecisionsTableAlone(t *testing.T) {
|
|||
values ($1, $2, 'approve', 'Фан Юань', 'u1')`, book, page.Terms[0].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SaveBank(ctx, book, terms); err != nil {
|
||||
if err := s.SaveBank(ctx, book, ingest.Bank{Terms: terms}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var left int
|
||||
|
|
@ -398,10 +398,10 @@ func TestARebuildOfTheBankLeavesTheDecisionsTableAlone(t *testing.T) {
|
|||
func TestTheBankAggregatesAreAnsweredOnTheFirstPageOnly(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
book := readingBook(t, s, ctx, "u1")
|
||||
if err := s.SaveBank(ctx, book, []ingest.BankTerm{
|
||||
if err := s.SaveBank(ctx, book, ingest.Bank{Terms: []ingest.BankTerm{
|
||||
{ID: "e1", Src: "a", Status: "proposed", Origin: "found"},
|
||||
{ID: "e2", Src: "b", Status: "approved", Origin: "given"},
|
||||
}); err != nil {
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := s.ListBank(ctx, "u1", book, 1, "", nil)
|
||||
|
|
@ -500,8 +500,8 @@ func TestFramesAreMintedPerBookAndPruned(t *testing.T) {
|
|||
s, ctx := testDB(t)
|
||||
book := readingBook(t, s, ctx, "u1")
|
||||
for i := range bufferFrames + 10 {
|
||||
if err := s.SaveBank(ctx, book, []ingest.BankTerm{
|
||||
{ID: fmt.Sprintf("e%d", i), Src: fmt.Sprintf("src%d", i), Status: "proposed", Origin: "found"}}); err != nil {
|
||||
if err := s.SaveBank(ctx, book, ingest.Bank{Terms: []ingest.BankTerm{
|
||||
{ID: fmt.Sprintf("e%d", i), Src: fmt.Sprintf("src%d", i), Status: "proposed", Origin: "found"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ type Engine interface {
|
|||
// Store is the write side of the read model, plus the ledger of what it still owes.
|
||||
type Store interface {
|
||||
SaveStructure(ctx context.Context, bookID string, in pgstore.Structure) error
|
||||
SaveBank(ctx context.Context, bookID string, terms []ingest.BankTerm) error
|
||||
SaveBank(ctx context.Context, bookID string, bank ingest.Bank) error
|
||||
BooksOwedReadModel(ctx context.Context, limit int) ([]pgstore.OwedBook, error)
|
||||
ClaimReadModelDebt(ctx context.Context, bookID string, owedAt time.Time, window time.Duration) (time.Time, error)
|
||||
ClearReadModelDebt(ctx context.Context, bookID string, owedAt time.Time) error
|
||||
|
|
@ -385,7 +385,11 @@ func (s *Service) refreshBank(ctx context.Context, bookID, bankExport string) er
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Store.SaveBank(ctx, bookID, bank.Terms)
|
||||
// The WHOLE read-out, not its rows: the bank, what a signing stop is asking about and how complete
|
||||
// the pass behind it managed to be are one projection of one moment (engine backlog rows 224, 253).
|
||||
// Handing over the terms alone is what made the signing screen answer zero — the section it needed
|
||||
// was in the document the whole time, and nothing on this side reached for it.
|
||||
return s.Store.SaveBank(ctx, bookID, bank)
|
||||
}
|
||||
|
||||
// manifestSourceChars is the book's ingested text in runes as the engine counted it, or 0.
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ type fakeStore struct {
|
|||
structure pgstore.Structure
|
||||
saved bool
|
||||
bank []ingest.BankTerm
|
||||
readOut ingest.Bank
|
||||
bankSaved bool
|
||||
owed []pgstore.OwedBook
|
||||
cleared []pgstore.OwedBook
|
||||
|
|
@ -69,8 +70,8 @@ func (f *fakeStore) SaveStructure(_ context.Context, _ string, in pgstore.Struct
|
|||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SaveBank(_ context.Context, _ string, terms []ingest.BankTerm) error {
|
||||
f.bank, f.bankSaved = terms, true
|
||||
func (f *fakeStore) SaveBank(_ context.Context, _ string, bank ingest.Bank) error {
|
||||
f.bank, f.readOut, f.bankSaved = bank.Terms, bank, true
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -719,3 +720,48 @@ func pricedTree(m *ingest.Manifest) {
|
|||
m.Chapters[i].Price = &ingest.UnitPrice{ExpectedUSD: total, SourceChars: int64(len(m.Chapters[i].Units)) * 1000}
|
||||
}
|
||||
}
|
||||
|
||||
// ⛔ THE WHOLE read-out crosses the seam, not its rows. The bank, what a signing stop is asking about
|
||||
// and how complete the pass behind it managed to be are one projection of one moment, and handing the
|
||||
// terms alone across is exactly what made the signing screen answer zero: the questions were in the
|
||||
// document the whole time and nothing on this side reached for them (engine backlog rows 224, 253).
|
||||
//
|
||||
// Mutation this must catch: passing `bank.Terms` to the store again — every assertion below goes red
|
||||
// while the terms themselves keep arriving, which is what made the defect invisible for so long.
|
||||
func TestTheWholeReadOutCrossesTheSeamAndNotOnlyItsRows(t *testing.T) {
|
||||
workdir := t.TempDir()
|
||||
doc := `{"bank_version":"tm-bank-v1","book_id":"bk_1","as_of":"bank-mining/signature-stop",` +
|
||||
`"run_id":"tm-stream-run_X-1","terms":[],` +
|
||||
`"proposed":[{"src":"方源","dst":"Фан Юань","kind":"name","channel":"both","invented":true}],` +
|
||||
`"consolidation":{"complete":false,"render_batches_dropped":5,"unanswered":47}}`
|
||||
if err := os.WriteFile(filepath.Join(workdir, "bk_1.db.bank.json"), []byte(doc), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := &fakeStore{}
|
||||
svc := &Service{Store: store, Binary: "tmctl", Engine: &fakeEngine{manifest: tree()}}
|
||||
if err := svc.Refresh(t.Context(), owedBook(workdir)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := store.readOut
|
||||
// The terms section is EMPTY here on purpose: that is what a real stop publishes, and it is the
|
||||
// state in which every assertion below is the only thing standing between a person and a blank
|
||||
// screen.
|
||||
if len(got.Terms) != 0 {
|
||||
t.Fatalf("the fixture's bank is empty and %d terms arrived", len(got.Terms))
|
||||
}
|
||||
if got.Boundary != ingest.BoundarySignatureRequested {
|
||||
t.Errorf("the boundary that published the read-out arrived as %q", got.Boundary)
|
||||
}
|
||||
if got.RunID != "tm-stream-run_X-1" {
|
||||
t.Errorf("the read-out's run identity arrived as %q", got.RunID)
|
||||
}
|
||||
if got.Offered == nil || len(*got.Offered) != 1 {
|
||||
t.Fatalf("the stop's question did not cross the seam: %v", got.Offered)
|
||||
}
|
||||
if !(*got.Offered)[0].Invented {
|
||||
t.Error("the service composed this rendering itself and the mark did not cross")
|
||||
}
|
||||
if got.Consolidation == nil || got.Consolidation.Complete || got.Consolidation.Unanswered != 47 {
|
||||
t.Errorf("the completeness of what is being signed did not cross: %+v", got.Consolidation)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue