Статус: ✅ сделано (2026-09-12, без изменения схемы; сьют 928 → 938).
Мотивирует: гэп cycle_rotation_nondeterminism_2026-09-12
— codemap#20, от лаборатории при подъёме пина на 0.0.17.
Бэклог: R1-C54. Пользовательские доки: ../ci.md, README («deterministic»).
Неизменные инварианты: source-only, детерминизм, два тира, resolved-or-honestly-flagged, закрытый
словарь рёбер, extras открытый, ответ называет, чего он не судил.
README обещает deterministic (canonical sorted JSON, no timestamps — diffable). CI держит это
отдельной работой: собрать одно дерево дважды и сравнить байты графа. И только их.
Между «артефакт воспроизводим» и «ответ воспроизводим» — зазор, в который и провалился дефект:
граф побайтово один и тот же, а печать цепочки цикла меняется от прогона к прогону, потому что
nx.simple_cycles входит в цикл там, где его застал обход множеств, то есть хэши строк.
Заявитель почти записал это в журнал как поведенческую правку релиза. Не записал потому, что перемерил внутри одной версии прежде, чем писать вывод, — и это самая дорогая часть находки: дефект подделывается под сдвиг поведения, а наша собственная процедура релиза как раз и сверяет вывод двух опубликованных версий на одном дереве.
Сделано: ротация приводится к началу с минимального узла в query.py, где циклы рождаются.
Замер решил вопрос «где», а не вкус: под восемью хэш-сидами разошлись три поверхности, а не
одна заявленная — check, report architecture (markdown) и build_architecture (JSON, который
отдаёт MCP-инструмент architecture). report dependencies и живые доки устойчивы, потому что
печатают счётчики, а не цепочки.
- Отвергнуто: канонизировать в
arch.py. Вылечило бы гейт — тот, про который пришла заявка, — и оставило течь отчёт и машиночитаемый ответ MCP. Чинить там, куда указал репорт, было бы ремонтом по симптому.
arch.py сортировал sorted(cycles, key=lambda c: (len(c), c)) — и это выглядело
канонизацией. Не было: ключ двигался вместе с ротацией, ["a","b"] и ["b","a"] — один цикл и два
ключа. После ротации сортировка начинает значить то, чем казалась, поэтому она переехала в тот же
помощник: сначала ротация, потом сортировка, один раз, у источника.
PYTHONHASHSEED читается один раз при старте интерпретатора, поэтому условие нельзя создать
monkeypatch'ем внутри процесса — сторож порождает процессы. Восемь сидов × семь поверхностей, и
в список включены те три, что уже были устойчивы: регрессия в них обязана ронять тест, а сторож
уже́ проверяет заявление целиком, а не тот угол, откуда пришла жалоба.
Положительный контроль двойной. Юнитом — три ротации одного цикла сводятся к одной форме, и тест краснеет, если канонизацию убрать. Интеграционно — измерено до правки: три различных вывода у каждой из трёх поверхностей на восьми сидах (числа в гэпе §2).
- ✅ До правки: 3 различных вывода на 8 сидов у
check,report architectureи JSONarchitecture; у байтов графа,report dependenciesи доков — 1. - ✅ После: 12 сидов, все шесть поверхностей по одному выводу.
- ✅ На живом дереве (
bquant, 9 настоящих ленивых циклов): пять сидов — один и тот же текст,a3f5b2a5…, и все цепочки начинаются со своего минимального узла. - ✅ Фикстура сторожа несёт и 3-цикл, и 2-цикл, поэтому проверяется и порядок самих циклов, а не только ротация внутри каждого.
- ✅ Схема не менялась;
graph.jsonне менялся — правка живёт целиком в слое ответа.
Чего это не покрывает. Сторож перебирает восемь сидов, а не все возможные; это не
доказательство, а выборка, которой достаточно, чтобы поймать зависимость от порядка хэшей (до
правки она ловилась на любых двух-трёх сидах из восьми). Другие источники порядка — greedy modularity
в communities, порядок jedi в deep-тире — этим проходом не исследовались; deep-тир вообще не
байт-стабилен по построению (R1-C42), и обещание про него сужено отдельно.
R1-C53 спрашивал у каждой операции: объявляешь ли ты своё сужение. Правильный вопрос, и не тот:
ответ может быть полон, объявлен — и разный от прогона к прогону. Воспроизводимость ответа в
матрице сужений не значилась ни как класс, ни как исключение. Добавлено восьмым классом
reproducibility с этим сторожем как его объявлением.