Skip to content

feat(cacbg): печат за пълнота - отрязан корпус не бива да се публикува - #334

Merged
todorkolev merged 8 commits into
mainfrom
feat/corpus-completeness-sentinel
Aug 26, 2026
Merged

feat(cacbg): печат за пълнота - отрязан корпус не бива да се публикува#334
todorkolev merged 8 commits into
mainfrom
feat/corpus-completeness-sentinel

Conversation

@todorkolev

Copy link
Copy Markdown
Collaborator

Какво

Печат за пълнота до суровия корпус. fetch.mjs го пише само когато корпусът се сверява с описа на
регистъра, и го чисти в началото на всяко обхождане. extract.mjs отказва да извлича без него.

Затваря дупката, отбелязана при ревюто на #313, но неподадена като issue.

Защо

#313 направи частичния корпус очаквано състояние - краулът спира сам на срока и запазва каквото има,
което е и причината студен корпус да завършва за два хода вместо никога. Но:

  • restore-keys: cacbg-raw- връща най-скорошния запис, не най-пълния - няма как да ги различи;
  • extract.mjs изброява файловете с readdirSync и никога не ги сверява с list.xml, макар да чете
    описа за друго (контекста на лицето).

Тоест отрязан корпус даваше по-малка публикувана повърхност, без грешка никъде.

Конкретният път: ход с full_crawl=true удря срока на 29 от 37 набора и запазва частичен кеш → месечният
cron се задейства с full_crawl празно (тоест false, по замисъл) → възстановява се най-скорошният кеш,
частичният → повърхността се преизчислява без 2015-2019 и се публикува.

Нито един съществуващ гейт не го хваща. Гейтът за монотонност сравнява с предишната повърхност и вижда
нетен ръст, щом новите връзки надвишат загубените - точно каквото стана вчера (103 → 277); тихото
недопубликуване се крие вътре. Подът --min-links 250 брои, тоест отрязан корпус с 260 връзки минава. А
първо пускане в нова среда няма база за сравнение изобщо - гейтът сам казва "first run" и пропуска.

Поправката

Пишещата страна. Печатът се чисти ПРЕДИ обхождането и се пише само при сверена присъда. Чистенето в
началото значи, че всеки ненормален изход - спиране по срок, прекъсвач, необработена грешка, убит
изпълнител - оставя корпуса неподпечатан, без да му трябва собствен път за почистване.

Четящата страна. extract.mjs отказва без печат и назовава --allow-partial-corpus. Значението е
разделено на две: частичният кеш остава напълно годен за продължаване (следващият крал прескача
каквото е на диска), но негоден за публикуване.

--allow-incomplete не подпечатва. Този флаг записва, че оператор е ВИДЯЛ недостига и го е приел -
приемането е негово и не бива да пътува към следващ ненаблюдаван ход. Това го открих от тест, не по
замисъл: първата версия подпечатваше, защото просто стигаше до същия ред.

extract.mjs получава CACBG_RAW/CACBG_STAGING по модела на load.mjs, за да е тестваем; по
подразбиране сочи истинския scratch, тоест поведението не се променя.

Тестове

Шест нови, покриват двете страни: подпечатване при сверен корпус, липса на печат при спиране по срок,
липса при --allow-incomplete, изчистване на стар печат при провалено обхождане, отказ при четене, и
преминаване при подпечатан корпус.

Убиват три мутанта:

мутант резултат
печат без проверка на присъдата 1 тест пада
без чистене в началото 1 тест пада
махнат гейт в extract.mjs 1 тест пада

Целият пакет scripts/cacbg + scripts/tr: 313 минават.

Ефект върху сегашния конвейер

Никакъв при нормален ход - краулът и извличането са в едно пускане, тоест печатът е пресен. Промяната се
усеща само там, където и трябва: ход, който НЕ обхожда, върху кеш от недовършено обхождане.

#313 направи частичния корпус ОЧАКВАНО състояние: краулът вече спира сам
на срока и запазва каквото е събрал, което е и причината студен корпус
да завършва за два хода. Но `restore-keys: cacbg-raw-` връща най-СКОРОШНИЯ
запис, не най-пълния, а extract.mjs изброява файловете с readdirSync,
без изобщо да ги сверява с описа в list.xml.

Тоест отрязан корпус просто даваше по-малка публикувана повърхност, без
грешка никъде. Нито един от съществуващите гейтове не го хваща: гейтът за
монотонност вижда нетен ръст, щом новите връзки надвишат загубените;
подът --min-links само брои; а първо пускане в нова среда няма никаква
база за сравнение.

Печатът се пише САМО когато корпусът се сверява с описа, и се чисти в
началото на всяко обхождане - така всеки ненормален изход (спиране по
срок, прекъсвач, необработена грешка, убит изпълнител) оставя корпуса
неподпечатан, без да му трябва собствен път за почистване.

Гейтът е при четенето: extract.mjs отказва без печат и назовава
--allow-partial-corpus. Значението е разделено на две - частичният кеш
остава напълно годен за ПРОДЪЛЖАВАНЕ (следващият крал прескача каквото е
на диска), но негоден за ПУБЛИКУВАНЕ.

--allow-incomplete не подпечатва. Този флаг записва, че оператор е ВИДЯЛ
недостига и го е приел; приемането е негово и не пътува към следващ
ненаблюдаван ход. Открито от тест, не по замисъл - първата версия
подпечатваше, защото стигаше до същия ред.

extract.mjs получава CACBG_RAW/CACBG_STAGING по модела на load.mjs, за да
е тестваем; по подразбиране сочи истинския scratch.

Тестове: 6 нови, 313 в целия cacbg+tr пакет. Убиват три мутанта - печат
без проверка на присъдата, липсващо чистене в началото, махнат гейт при
четенето.
@github-actions

Copy link
Copy Markdown

Test coverage

Workspace Lines Δ Branches Δ Functions Statements
apps/etl 75.43% +1.43pp 63.52% +5.32pp 70.00% 74.11%
apps/web 91.50% +0.50pp 83.11% +0.71pp 92.00% 90.34%
packages/config 92.85% +0.05pp 72.22% +0.02pp 92.85% 89.18%
packages/db 94.67% +0.17pp 79.32% +0.02pp 87.50% 91.75%
packages/ingest 89.71% +3.41pp 85.52% +5.12pp 81.74% 88.14%
packages/shared 95.50% +0.00pp 80.83% +0.03pp 92.30% 89.56%
Total (informational) 91.66% 81.59% 87.71% 89.65%

✅ No workspace dropped below its baseline (tolerance 0.5pp).

📈 Coverage rose by more than 1pp — run node scripts/check-coverage.mjs --update locally and commit coverage-baseline.json to ratchet the threshold up.

Първата версия на печата имаше преходен капан от същия вид, който #332 и
#333 вече ни удари: всички съществуващи кешове са отпреди печата, тоест
ход без обхождане (понеделнишкият cron) щеше да откаже на extract - не
защото корпусът е отрязан, а защото е СТАР. И поправката щеше да зависи
от поредността "слей, после пусни full_crawl=true преди понеделник".

Вместо това ходът се лекува сам. Нова стъпка след възстановяването
проверява печата; краулът тръгва при full_crawl ИЛИ при липсващ печат.
Върху пълен кеш това е ~2 минути (файловете на диска се прескачат) и
сверява срещу живия регистър - авторитетния източник, а не евристика
върху това какво има на диска. Запазването и проверката на кеша следват
същото условие, за да не се изгуби довършеното.

Стабилно състояние с печатан кеш остава без мрежа, по замисъла на
графика: лекуването се задейства само за кеш, който никога не е бил
потвърден цял - точно случаят, в който довярването му е бъгът.

Гейтът в extract.mjs остава непроменен и вече означава друго: всички
пътища дотам гарантират печат, значи задействането му е непредвидено
състояние, не позната преходност. Fail-closed е верният отговор за това.
Ревюто на Codex намери три блокера и три сериозни в печата, всичките от
една порода: сертификатът можеше да се издаде за корпус, който никой не
е сверил целия.

- краул с --folders подмножество печаташе цялото дърво: завършено
  подмножество не е завършен корпус. Печатът вече иска пълно откриване;
  подмножеството пак чисти стар печат (мутира корпуса), тоест оставя
  възобновимо-но-непубликуемо състояние.
- индекс, който открива нула папки, минаваше за тривиално пълен
  (възпроизведено: изход 0 и печат folders: 0 върху съществуващо дърво).
  Вече е провал: регистърът публикува набори от 2015 г. без прекъсване,
  празен индекс е счупена страница, не реално състояние.
- страница за поддръжка (HTTP 200, парсва се до нула реда) занулява
  announced и презаписва кеширания list.xml, който extract чете. При
  файлове на диска, които ѝ противоречат, папката се прескача - корпусът
  става непълен, без печат, кешираният опис оцелява.
- разархивирането на кеша не е атомарно: провалено извличане може да
  остави печата без XML-ите, при неотчетен ключ. Стъпката за печата
  вече изхвърля цялото дърво при празен cache-matched-key - състоянието
  става точно "няма кеш", което лекуващият краул поема.
- CACBG_RAW/CACBG_STAGING заобикаляха PII предпазителя: override-ът
  пренасочва истинския изход, а assertScratchIgnored проверява само
  фиксирания scratch/. Нов assertOverrideDirSafe прилага същото правило
  към override пътя: извън хранилището - свободно; вътре - само ако git
  го игнорира.

Тестове: 11 (5 нови), убиват петте мутанта по горните поправки; целият
cacbg+tr пакет 318. Позитивният extract тест вече твърди и изход 0, а
--allow-partial-corpus има собствен тест - двата оцелели мутанта от
ревюто.

Извън обхвата, отбелязано за отделни issue-та: "obtained" значи "файлът
съществува", не "валидна декларация" (заварено), и студен кеш в
понеделник пак дава червен ход по замисъл - лекуването довършва, не
гарантира зелено на всяка цена.
Втората проверка на Codex потвърди 1-3 поправени, но възпроизведе два
остатъчни пробива и четири оцелели мутанта.

Свежата папка (находка 4 докрай): страница за поддръжка върху папка БЕЗ
файлове на диска зануляваше announced и яздеше валиден съсед до печат -
възпроизведено: skippedSets 0, incomplete false, печат. Нула реда от 200
отговор вече е прескачане БЕЗУСЛОВНО: регистърът индексира папка чак
когато има декларации, тоест реално празен набор не е наблюдаван; ако
някога се появи, ходът става червен за оператор - вярната посока на
провал за сертификатор.

Предпазителят на override пътищата (находка 6 докрай): лексикалното
startsWith пропускаше корена на хранилището (равенство), /proc/self/cwd
псевдоним, чужд git worktree и главни/малки букви. Вече пита самия git:
rev-parse --show-toplevel от най-дълбокия съществуващ предшественик
(операционната система резолвва symlink-ове при chdir), после
check-ignore в НАМЕРЕНОТО хранилище, чието и да е то. Извън всякакъв
worktree - свободно; корен на worktree - отказ.

Мутантите: подмножество и празен индекс вече започват от посаден стар
печат (чистенето трябва да е безусловно и ПРЕДИ проверката за празно);
CACBG_RAW има собствен тест огледално на CACBG_STAGING; условието
announced > 0 е документирано като недостижима защита в дълбочина -
мутант, който го трие, оцелява нарочно, вместо тест да кодира
невъзможен сценарий.

Тестове: 13, трите наименувани мутанта падат; целият пакет 320.
… вяра

Третата проверка на Codex потвърди кръг 2 и намери три нови пробива.

Осакатен XML (възпроизведено): тяло, срязано по средата, чийто оцелял
цял <Declaration> се брои за цялото обявление - announced 1, сверено,
ПЕЧАТАНО. XMLValidator проверява структурата преди броене и кеширане;
невалиден документ = прескочена папка.

Свиване на списъка (възпроизведено): кеширано [a1,a2], входящо [a1] -
по-пълният кеш се презаписваше и свиването се заверяваше. Дизайнът на
продължаването стъпва на неизменимост по година, тоест свиване е
противоречие: папката се прескача, кешът оцелява, изтегляне на
декларация е човешко решение върху червен ход.

Предпазителят на override пътищата, три поправки:
- git грешка вече не значи пропуск. Само потвърдено "not a repository"
  минава; EPERM, липсващ git и повредено хранилище отказват (били са
  PASS). Тестът с PATH=/nonexistent го приковава.
- наследени GIT_DIR/GIT_WORK_TREE се изчистват - иначе подменяха
  политиката на друго хранилище (възпроизведено с exclude-всичко декой).
- проследеното бие игнорираното: файл, влязъл в индекса на ВЪНШНОТО
  хранилище преди вложеното да съществува, остава записваем, а
  check-ignore го покрива с имплицитния ignore на вложени хранилища.
  ls-files по всяко обхващащо хранилище отказва при съвпадение. Отделно:
  вложено хранилище БЕЗ нищо проследено отвън законно минава - git add
  отказва през границата, тоест наистина не е записваемо.

Отстрани: първата ми версия на ls-files проверката хвърляше отказа
ВЪТРЕ в try и собственият catch я преобличаше в "ls-files failed" -
хванато от твърдението за съобщението в теста, точно затова то твърди
и текста, не само кода на изход.

Тестове: 18, седемте мутанта по кръга падат; целият пакет 325.
@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

Прегледах го адверсариално при 8ac935fb — печатът за пълнота държи. Ключовото: тухлата е на ДВЕ нива, така че отрязан корпус няма как да се публикува през нито един път:

  • Код (hard fail): extract.mjs assertCorpusComplete() хвърля при липсващ .corpus-complete.json; workflow-ът вика extract.mjs без --allow-partial-corpus, така че не заобикаля кода.
  • fetch.mjs — clear-first дисциплина: сентинелът се трие ПРЕДИ да се пипне каквото и да е, значи всеки абнормален изход (deadline, circuit breaker, uncaught throw, убит runner) оставя корпуса непечатан без собствен cleanup път. Оттук: печат наличен ⟹ корпусът не е пипан след печата — затова existsSync (presence-only) е достатъчен, не трябва да се валидира съдържанието.
  • Reconciliation гардове (всичките възпроизведени в тестовете): stampable = !override && !incomplete && announced > 0, плюс — XML well-formed FIRST (отрязан list с една валидна <Declaration> на опашката вече не reconcile-ва до 1 ред), 0-folders → exit 1, 0-rows-over-existing → skip, list-shrank → keep cache + skip. Точно бай-пасите, през които труниран корпус би минал за пълен.
  • Workflow self-heal: непечатан cache → healing crawl (present != 'true'), debris от неуспешен restore (matched-key празен, но файлове има) се изхвърля, за да не сертифицира half-existing XML.

18-те теста в corpus-sentinel.test.mjs не са cheater-и — покриват deadline, subset, maintenance-page-на-свеж-folder, truncated-list, list-shrink, override-dir git-safety (вкл. GIT_DIR substitution и fail-closed). Не намерих дупка.

Едно операционно следствие за протокола (не проблем, по дизайн): ръчен --folders subset crawl ТРИЕ печата и не го пише обратно (пълен е само за подмножеството), така че следващият scheduled run ще self-heal-не с пълно обхождане (~2 мин на топъл cache). Безопасната посока — просто да се знае, за да не изненадва понеделнишкия heal след ръчен subset.

Четвъртата проверка потвърди свиването и вложените хранилища, и намери
четири нови, всичките поправени и приковани с тестове.

Многокоренов документ (блокер, възпроизведен): XMLValidator приема
<wrong/><root>...</root>, а снизходителният парсер слива корените и
parseList пак намира редовете. Валидацията вече изисква и точно един
документен елемент.

Pathspec инжекция (блокер, проверен): -- НЕ изключва магията на git, а
check-ignore отговаря 0 за директория, буквално кръстена
:(top)scratch/... - съди се игнорираният резолвнат път, а записваемото
литерално име минава. --literal-pathspecs е неизползваем (check-ignore
отхвърля literal магията, докато изпълнява :(top) - проверено), но ./
префиксът пази името име и в двете команди, в двете посоки.

Локализиран git: машината върви на bg_BG и "not a git repository"
пристига преведено - английското съвпадение четеше законен /tmp override
като необясним провал и отказваше. gitEnv() заковава LC_ALL=C.

GIT_DIR по подразбиращия се път: assertOverrideDirSafe чистеше GIT_*, но
assertScratchIgnored ги наследяваше - декой хранилище можеше да отговаря
вместо истинското и по подразбиращата се релса. Двете релси вече делят
gitEnv(). Фикстурата сочи работното дърво на декоя към самия декой -
иначе .gitignore се чете от истинското дърво и подмяната е невидима.

Тестове: 22, четирите мутанта по кръга падат; целият пакет 329.
…ден кеш

Финалното съобщение на четвъртия кръг беше срязано от филтъра на самия
Codex; от лога възстанових още два блокера и една дребна, всичките
поправени.

Symlink целта не се канонизираше: scratch/ е игнориран, тоест symlink
scratch/cacbg/leak -> scripts/ минаваше лексикалната проверка, докато
извличането пише ПРЕЗ него в проследени файлове. Предпазителят вече
резолвва най-дълбокия съществуващ предшественик преди да съди -
резолвнатото място е scripts/, проследено, отказ.

Границите на файлови системи: изчистването на GIT_* маха и
GIT_DISCOVERY_ACROSS_FILESYSTEM, при което mount точка ВЪТРЕ във външно
работно дърво отговаря "not a repository", макар дървото да може да
проследява пътя. Променливата се връща изрично след изчистването.
Нетестваемо без root (bind mount) - редът носи обяснението вместо
фикстура.

Повреден кеширан списък (DOCTYPE наследство, срязан запис) сриваше
shrink проверката, вместо валидният входящ списък да го излекува.
Нечетимият кеш се брои за нула редове: без възражение за свиване, а
atomicWrite го заменя със здравия.

Тестове: 24, двата тестваеми мутанта падат; целият пакет 331.
Петата проверка потвърди седемте поправки от кръг 4 и остави два
"блокера". Единият е реален, другият - не.

Реалният (блокер 2): само override пътищата минаваха през
assertOverrideDirSafe, който канонизира и пита git. Подразбиращите се
raw/staging разчитаха на assertScratchIgnored, който проверява само
фиксирания .probe - symlink НА самата подразбираща се позиция, сочещ
към проследена територия, му е невидим, докато I/O го следва. Вече и
двете подразбиращи се позиции минават канонизиращия предпазител
безусловно, и в extract.mjs, и в краула в fetch.mjs.

Отхвърленият (блокер 1): твърдеше, че ./ префиксът спира двоеточие-
магията, но оставя обикновените глоб-метазнаци активни, тоест буквална
дир, чието име е глоб-образец, скрива проследени descendants от
ls-files. Проверено чрез изпълнение, не по документация: дир, буквално
кръстена 'a*b', 'a?b' и 'a[x]b', с проследен файл вътре - ls-files --
'./name' намери файла и в трите. Метазнаците се третират буквално,
защото пътят сочи съществуваща директория с точно това име.
Рецензентът не можа да пусне тестове (read-only монтиране) и разсъждава
теоретично; изпълнението го опровергава.

Follow-ups от кръга (не блокери, за отделно issue): броене на
top-level елементите директно вместо по разчетени ключове; структурна
валидация на кеширания списък преди да му се вярва броят; bind-mount
регресия, когато CI я поддържа.

Тестове: 25, мутантът по блокер 2 пада; целият пакет 332.
@todorkolev
todorkolev merged commit d1bd6b3 into main Aug 26, 2026
5 checks passed
lyubomir-bozhinov added a commit to lyubomir-bozhinov/sigma that referenced this pull request Aug 26, 2026
Adopts midt-bg#331's shared fake-D1 double and its check:fake-d1 gate across every
suite this branch adds, and midt-bg#334's CACBG corpus work.

- migrate all 71 hand-rolled D1 doubles (18 files) to @sigma/test-support:
  fakeD1 for query tests, recordingD1 for the two ingest wrappers whose SQL is
  generated rather than routed, throwingD1 for the run_sql error path. An
  unmatched query now throws instead of answering with no rows, which surfaced
  two silent gaps: getCompany/getAuthority never declared the listContracts
  panel reads, and eop/etl passed `{}` as a binding that could never fail.
- keep the doubles that must record batch GROUPING (ingest staging/refresh) by
  wrapping batch() over the shared double rather than re-rolling one.
- coverage-baseline.json: union of this branch's raised floors and upstream's
  new packages/test-support entry; apps/etl branches ratcheted 97 -> 97.6.
- vitest.shared.ts: drop the now-stale src/test/d1-sqlite.ts exclusion (midt-bg#331
  moved that shim into packages/test-support, which carries its own entry).

All seven workspaces stay at or above their floors; check-fake-d1, check-docs
and check-coverage are green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants