Лана: флакующая проверка, которая портит доверие к полному прогону.
Мои файлы: test_search_case.py, test_case_simulator.py.
Git не трогал. Продакшн-код (assistant.py) НЕ ПРАВИЛ — ни на диске, ни на минуту:
все диверсии сделаны подменой поведения В ПАМЯТИ до запуска набора, поэтому
диверсия физически не могла остаться в боевом файле, даже если процесс умрёт.
md5 ДО любых правок:
5a2f5a71efc8bf0db5d5372f242c9b6a test_search_case.py
169a0d759e154b0116bdc9e050e0c0f4 test_case_simulator.py
16042cc80625599ea1fdf86d97f571d5 assistant.py (НЕ мой, только контроль неизменности)
Одиночный прогон test_search_case.py на тихой машине: PASSED 33 / FAILED 0.
Замер секции [7] в этом прогоне: было 29.9 мс, стало 71.0 мс -> отношение 2.37
при пороге naive*4 + 5 = 124.6 мс.
Python 3.13.14, sqlite3 3.50.4.
Каждый замер уже брался как минимум из 7 прогонов. Всё равно:
| величина | min | max | медиана | разброс max/min |
|---|---|---|---|---|
| naive (1 LIKE), мс | 29.26 | 97.96 | 31.59 | 3.35 |
| fixed (3 LIKE), мс | 61.31 | 84.47 | 74.30 | 1.38 |
| отношение fixed/naive | 0.77 | 2.79 | 2.23 | 3.64 |
Вывод числом: отношение дрожит СИЛЬНЕЕ каждого из двух замеров (3.64 против 3.35 и 1.38), потому что ошибки числителя и знаменателя независимы и умножаются. Запас до порога 4.0 по худшему наблюдённому отношению — всего 1.43x, и в полном прогоне свиты его съело: 17.7 против 77.8 мс = 4.40.
Отдельно: в упавшем прогоне сломался ЗНАМЕНАТЕЛЬ. naive показал 17.7 мс против 29.3 мс минимума на тихой машине — то есть минимум из 7 не защищает от того, что в другом процессе запрос окажется в 1.7 раза БЫСТРЕЕ (тёплые страницы, состояние турбо-частоты). Минимум устойчив только к замедлению, а отношение ломает и ускорение.
Из четырёх предложенных вариантов взяты два, оба нетайминговые.
(а) Счётчик шагов виртуальной машины SQLite — Connection.set_progress_handler(fn, 1)
вызывает fn на каждую инструкцию VDBE. Цена запроса, не зависящая от нагрузки ВООБЩЕ.
Детерминированность, 5 прогонов подряд (_measure_timing.py A):
naive (1 LIKE): [51177, 51148, 51148, 51148, 51148] <- первый прогон +29 шагов (разбор схемы)
fixed (3 LIKE): [127679, 127678, 127678, 127678, 127678]
Первый вызов дороже на 0.06% из-за холодной схемы — поэтому в тесте перед замером идёт
прогревочный прогон. После прогрева дрожание 0.0000%: _measure_timing2.py A дал
2 249 257 шагов на продакшн-пути пять раз подряд без единого расхождения.
Модель стоимости оказалась ТОЧНОЙ (_measure_timing3.py A), и это ключ к порогу:
| k форм LIKE | шагов | на строку | формула 1+3k | отношение к k=1 |
|---|---|---|---|---|
| 1 | 51148 | 4.001 | 4 | 1.000 |
| 2 | 89501 | 7.001 | 7 | 1.750 |
| 3 | 127854 | 10.001 | 10 | 2.500 |
| 4 | 166207 | 13.001 | 13 | 3.250 |
| 5 | 204560 | 16.001 | 16 | 4.000 |
| 6 | 242913 | 19.001 | 19 | 4.750 |
| 8 | 319619 | 25.001 | 25 | 6.250 |
Отношение к одиночному LIKE = (1+3k)/4 с точностью до 0.001. Величина нормирована на строку, поэтому от РАЗМЕРА базы не зависит: вика вырастет с 12 784 до 20 000 фактов — отношение останется 2.500.
Порог поставлен 3.0 — между тремя формами (2.500) и четырьмя (3.250). Проверка стала
СТРОЖЕ прежней, а не мягче: прежний порог 4.0 четвёртую форму пропускал (3.25 < 4.0),
новый её ловит. Это подтверждено диверсией four_forms ниже.
(б) Число обращений к базе на один поиск — Connection.set_trace_callback, считаем
SQL-операторы на реальном search_knowledge_corpus. Замер (_measure_timing3.py B):
ключей=1: операторов 4, SELECT 2, PRAGMA 2 -> 2.00 SELECT на ключ
ключей=2: операторов 6, SELECT 4, PRAGMA 2 -> 2.00 SELECT на ключ
ключей=4: операторов 10, SELECT 8, PRAGMA 2 -> 2.00 SELECT на ключ
Ровно одна выборка на ключ в вике и одна в архиве. Это структурный инвариант
(_CORPUS_TOTAL_ROW_BUDGET=48 < _CORPUS_CANDIDATE_CAP=60, поэтому цикл по ключам
никогда не обрывается), и он ловит класс регрессий, которого тайминг не видел вообще.
От медианы и от «больше прогонов» отказался: они уменьшают дрожание, но не убирают —
источник в самой величине. Абсолютный тайминговый потолок ОСТАВЛЕН, потому что он ловит
то, чего шаги не видят (см. п.5 про py_udf).
Замер (_measure_timing2.py C, _measure_timing4.py C), три ключа:
- по ШАГАМ снятие LIMIT стоит всего +11% (2 249 257 -> 2 485 523). Порог «не более чем втрое» этого не ловит и ловить не должен;
- по ЧИСЛУ ЗАПРОСОВ ловится начисто: SELECT-ов 6 -> 2. Первый же ключ набирает
_CORPUS_CANDIDATE_CAP=60кандидатов, срабатываетbreak, и остальные два ключа врача В БАЗУ НЕ УХОДЯТ ВООБЩЕ; - выдача при этом становится ХУЖЕ, а не «просто дороже»: справка 20 строк -> 16, архив 20 строк -> 13. Последствие для врача — не задержка, а молча проигнорированные ключи его вопроса.
То есть «убери LIMIT -> проверка падает» верно, но ловит это счётчик ЗАПРОСОВ, а не счётчик цены. Формулировка задания «станет дороже» для этого кода неточна: candidate cap съедает почти всю разницу в цене и превращает её в потерю полноты.
test_search_case.py, секция [7] — сравнение двух таймингов заменено на:
счётчик цены детерминирован: два прогона дают одно число— сам анти-флак-инвариант теперь проверяется, а не предполагается;счётчик действительно считает, а не молчит(шагов >= числа строк) — без этого при неработающем progress handler оба числа стали бы нулями,0 <= 0*3прошло бы, и проверка цены стала бы пустышкой, зелёной при любой поломке. Диверсияdead_counterпоказала ровно это: отношение напечаталось как 0.000 и прошло, поймала только проверка живости;три формы дороже одной не более чем втрое— замер 2.496 при пороге 3.0;на каждую строку зовётся только встроенный like, без питона— по опкодамEXPLAIN(p4даётlike(2)противpylower(1)). Добавлено ПОСЛЕ того, как диверсияpy_udfпрошла оба потолка цены;- абсолютный потолок по часам ОСТАВЛЕН (
fixed_ms < 500): он ловит то, чего шаги не видят — если медленным станет диск или база уйдёт в WAL под чужим писателем, число шагов не изменится, а врач будет ждать секунды. Под нагрузкой замерено max 88.4 мс, запас 5.7x; вариантов LIKE ровно три— оставлено как было.
test_search_case.py, новая секция [7a] — продакшн-путь search_knowledge_corpus под
трассировкой операторов:
справка по трём ключам не пуста— чтобы трасса не «проходила» на пустом результате;на каждый ключ по одной выборке в вике и в архиве(6 SELECT на 3 ключа);каждая выборка ограничена LIMIT;строк на ключ не больше запаса кандидатов (60)— ловит и снятый, и раздутый LIMIT.
Трассирующее соединение попутно уводит боевые базы в mode=ro: продакшн открывает их
через sqlite3.connect("stomat_wiki.db"), то есть НА ЗАПИСЬ, а на время замера эта ручка
физически не пишет.
test_case_simulator.py — ruff F841: _corpus_error = sqlite_error = RuntimeError(...)
-> _corpus_error = RuntimeError(...). Сама проверка не тронута, набор 24/0.
Заодно в test_search_case.py убраны два ruff E741 (l -> ln). По НАБОРУ ПРАВИЛ ПО
УМОЛЧАНИЮ (E4/E7/E9/F — тот, которым найден F841) в моих двух файлах остаётся только
E402 (3 шт.): репозиторный паттерн, config.DB_PATH обязан ставиться до
import database; по всему репозиторию E402 встречается 128 раз.
Если расширить до --select E,F, всплывает E501 (строка длиннее 88): в моих файлах 25
штук, из них 5 добавил я, остальные были. E501 в наборе по умолчанию НЕ проверяется, и
файл в этом стиле написан целиком, поэтому переносить не стал — вместо 5 переносов ради
правила, которое лид не включал, риск сломать рабочий код выше пользы. Если правило нужно
включить, это отдельная правка по всему репозиторию, не по двум файлам.
Цена по времени прогона (_measure_timing6.py, замер, не оценка): добавлено 1.12–1.26 с,
убрано 0.36–0.58 с (тайминг naive больше не нужен), чистый прирост +0.56…+0.86 с.
Весь набор целиком: 6.3–10.8 с.
Все диверсии — подмена поведения продакшна В ПАМЯТИ (_sabotage_timing.py), файл
assistant.py не открывался на запись ни разу. Ни одна не ломает синтаксис.
| # | диверсия | что сломано | провалов | ловит |
|---|---|---|---|---|
| 1 | four_forms |
like_any_case отдаёт 4 формы вместо 3 |
8 | цена x3.24 при пороге 3.0 (прежний порог 4.0 это ПРОПУСКАЛ) |
| 2 | no_limit |
_rows_per_keyword -> 10^9, LIMIT фактически снят |
2 | SELECT-ов 3 вместо 6; LIMIT 1000000000 против запаса 60 |
| 3 | budget_over_cap |
бюджет строк 40 на ключ > запаса кандидатов 60 | 1 | SELECT-ов 4 вместо 6 — третий ключ врача не искали |
| 4 | dead_counter |
счётчик шагов молчит (фабрика соединения) | 1 | проверка живости счётчика; отношение 0.000 прошло бы |
| 5 | single_form |
правку регистра откатили, поиск снова ASCII-only | 12 | секции [2] [3] [4]: ВНЧС снова 0 из 88 |
| 6 | py_udf |
складывание регистра через питоновскую функцию | 5 | опкоды: ['like(2)', 'pylower(1)'] |
| 7 | double_query |
повтор выборки «на случай database is locked» | 1 | SELECT-ов 12 вместо 6 |
Базовый прогон через тот же драйвер: 40/0, код 0.
Подробности по двум важным:
four_forms — печать теста: было 51148 шагов SQLite (4.001 на строку), стало 165767 (12.967 на строку), отношение 3.241. Порог 3.0 -> провал. Прежняя формулировка
(fixed_ms <= naive_ms*4 + 5) на 3.241 прошла бы: это и есть доказательство, что новая
проверка не пустышка и не ослабление.
py_udf — единственная, где НОВАЯ ПРОВЕРКА ЦЕНЫ ПРОМАХНУЛАСЬ в первой редакции:
шагов x1.252, по часам 107.9 мс при пороге 500 — прошли оба потолка. Набор был красным
только за счёт проверок формы условия (4 провала). Причина: счётчик шагов считает
КОЛИЧЕСТВО инструкций, а не их цену, а обратный вызов в питон стоит дорого за шаг
(158 мс на 17 опкодов против 85 мс на 24). Дописал проверку по опкодам EXPLAIN
(p4 = like(2) у настоящего запроса, pylower(1) у диверсии), повторил диверсию:
провалов стало 5, включая новую. Это же соответствует уже принятому в продакшне
решению — в докстринге like_any_case написано, что три формы в одном запросе выбраны
как более дешёвая альтернатива create_function.
10 прогонов подряд под нагрузкой (_flake_timing.py): 6 процессов полного скана
боевых баз плюс 2 соседних набора того же модуля, перезапускаемые по мере завершения.
# PASSED FAILED шагов было шагов стало отношение мс SELECT
1 40 0 51148 127678 2.496 63.6 6
2 40 0 51148 127678 2.496 67.1 6
3 40 0 51148 127678 2.496 88.4 6
4 40 0 51148 127678 2.496 65.9 6
5 40 0 51148 127678 2.496 74.9 6
6 40 0 51148 127678 2.496 62.3 6
7 40 0 51148 127678 2.496 71.4 6
8 40 0 51148 127678 2.496 80.6 6
9 40 0 51148 127678 2.496 58.7 6
10 40 0 51148 127678 2.496 64.7 6
зелёных прогонов: 10 из 10
отношение шагов: min=2.496 max=2.496 разброс=1.0000
пары (шагов было, шагов стало): {(51148, 127678)}
тайминг по часам под нагрузкой: min=58.7 max=88.4 мс разброс=1.51 при пороге 500
Пара чисел (51148, 127678) совпала во всех десяти прогонах ПОБИТОВО.
Контрольный опыт: старая метрика под той же нагрузкой (_flake_old_metric.py), в
одном процессе считаются обе метрики. 6 сканеров:
старая метрика (отношение мс): min=0.829 max=2.843 разброс=3.43, провалов 0 из 10
новая метрика (отношение шагов): min=2.496 max=2.496 разброс=1.0000, провалов 0 из 10
знаменатель старой метрики (naive мс): min=29.38 max=76.42 разброс=2.60
16 сканеров (тяжелее):
старая метрика: min=1.499 max=2.519 разброс=1.68, провалов 0 из 10
новая метрика: min=2.496 max=2.496 разброс=1.0000, провалов 0 из 10
Неожиданный результат, который стоит знать: под ТЯЖЁЛОЙ равномерной нагрузкой старая метрика ведёт себя СПОКОЙНЕЕ (1.68), чем под средней (3.43). Значит флак вызывает не высокая нагрузка, а НЕРАВНОМЕРНАЯ: когда все процессы тормозят одинаково, отношение сохраняется, а ломает его удачный/неудачный квант в одном из двух замеров. Отсюда же понятно, почему в упавшем прогоне свиты naive оказался 17.7 мс — быстрее, чем на тихой машине.
Итого по новой метрике: 35 наблюдений (5 тихих + 30 под нагрузкой), все ровно 2.496, разброс 1.0000. Дрожания не осталось не «мало», а структурно: величина целочисленная и детерминированная.
Диверсий на диске не было ни одной: продакшн правился только в памяти дочернего
процесса. Файлов .bak не создавал, удалять нечего (fd -e bak пусто).
md5 ПОСЛЕ всех работ:
713e5bb98233c1e57ffbd73f8dd3ad6b test_search_case.py (мой, изменён осознанно)
3a59c94052663a19df7279811fe2ab84 test_case_simulator.py (мой, ruff F841)
cc9ff38d11a5e6d9f9f59edcfe6547ad assistant.py <- ИЗМЕНИЛСЯ, но НЕ МНОЙ
Про assistant.py: на старте 16042cc80625599ea1fdf86d97f571d5, сейчас
cc9ff38d11a5e6d9f9f59edcfe6547ad, mtime 13:54:43. Мои последние записи на диск —
test_search_case.py в 13:46, после этого я в файлы не писал (в 13:54 шёл прогон
нагрузки). Правка чужая, рядом работает другой агент. Мой финальный зелёный прогон
сделан НА НОВОМ assistant.py, то есть чужая правка мои проверки не сломала.
Финальные прогоны (после чужой правки продакшна):
| набор | результат |
|---|---|
test_search_case.py |
40 / 0 (было 33 / 0) |
test_case_simulator.py |
24 / 0 |
test_rag_quality.py |
53 / 0 |
test_isolation.py |
198 / 0 (сторож mode=ro мою правку принял) |
test_import_safety.py |
448 / 0 |
run_all_tests.py не запускал — по заданию.
- Не воспроизвёл сам флак. За 30 наблюдений под нагрузкой старое отношение не перевалило 4.0 (максимум 2.843). Доказано не «старая проверка падает по команде», а что её разброс 3.43x при запасе 1.43x до порога, то есть падение — вопрос времени, и что у новой метрики разброс 1.0000.
- Порог 3.0 держится на модели
1+3kконкретной сборки SQLite (3.50.4). Формула подтверждена замером на семи значениях k с точностью 0.001, но если sqlite поменяет программу LIKE, коэффициент 3 шага на LIKE поплывёт. Провал будет громким и с числом в детали, а не молчаливым. На другой машине (бот живёт не здесь) не проверял. - Диверсии сделаны monkeypatch-ем, а не правкой файла. Семантически это то же
(подменяется поведение продакшн-функции, которое тест и проверяет), но байты
assistant.pyя не менял ни на секунду — сознательный выбор: править чужой файл запрещено, и в волне 2 два проверяющих умерли, оставив диверсию в боевом коде. _CORPUS_CANDIDATE_CAP-инвариант проверен на 1/2/3/4 ключах. Что будет при ключах больше 7 (rows_per_keywordупирается в минимум 8), не замерял.- Секция [7a] всё ещё ходит в боевые базы (через ro-соединение). Живого писателя рядом не было; поведение при конкурентной записи в вику не проверял.
- Абсолютный потолок 500 мс не срабатывал ни разу ни в одной диверсии — под
нагрузкой максимум 88.4 мс, у
py_udf107.9 мс. Он оставлен как страховка от медленного диска, но его теми я не доказал.
- Правок вне моих файлов не требуется. Продакшн не тронут.
assistant.pyизменился в 13:54:43 не мной (см. п.7) — если это неожиданно, стоит спросить владельца той полосы.- Черновики замеров (
_measure_timing*.py,_sabotage_timing.py,_load_timing.py,_flake_timing.py,_flake_old_metric.py) в diff НЕ ПОПАДУТ:.gitignoreглушит_*.py. Все числа выше воспроизводятся их запуском; сами скрипты самоограничены по времени и добивают дочерние процессы вfinally. - Ничего запущенного за собой не оставил: все сканеры нагрузки имеют дедлайн в argv и
добиваются в
finally, провереноGet-CimInstance Win32_Process— из питонов живы толькоverify_daily_cleanup.py(12:09, чужой) иh8_posctrl.py(не мой). - Если понадобится ещё строже: порог 3.0 можно опустить до 2.6 (запас 4% над замером
2.496) — тогда ловится и лишний встроенный
LOWER()(2.746). Не стал: 4% запаса на случай смены сборки sqlite мало.