Правило выбора наблюдений: пустое не затирает непустое
РЕШЕНО владельцем 13.08.2026. Оглавление — ../LOGIKA-VYBORA-NABLYUDENIY.md.
Зачем это вообще
Владелец 13.08: «данных мы копим тысячи, а потом мы не можем логически взять что-то более адекватное. Если даже данные какие-то потерялись, почему они пропадают, я не понимаю».
Ниже — ответ на «почему пропадают» и предлагаемое правило выбора.
Сначала развести три разные беды
Их постоянно смешивают, и от этого решения получаются неверные.
| № | Беда | Природа |
|---|---|---|
| 1 | Склад сырья пухнет: прогоны идут по нескольку раз в день, эндпоинтов свыше 125, s2 собирает своё | хранение |
| 2 | При нескольких наблюдениях одного и того же за день мы не выбираем — последний пишет поверх | выбор |
| 3 | Данные пропадают | следствие пункта 2, не отдельная беда |
Этот документ — только про пункт 2. Пункт 1 требует отдельного решения и намеренно не смешивается с ним (см. хвост документа).
Почему данные пропадают — установлено на данных
Замер 13.08 по трём прогонам одного эндпоинта за одну дату:
| прогон | время | сырых ответов | из них с обогащением |
|---|---|---|---|
| 5727 | 04:24 | 369 | 226 |
| 5807 (сбой) | 16:46 | 369 | 165 |
| 5824 (перезапуск) | 17:21 | 369 | 226 |
61 артикул «потерял» 28-дневные показатели вечером. При этом они лежали в базе — в сырых ответах утреннего прогона, под тем же ключом, в той же таблице.
Причина одна и она в писателе. Разбор идёт тем же проходом, что и сбор, видит только свои ответы и пишет:
INSERT ... ON DUPLICATE KEY UPDATE `столбец` = VALUES(`столбец`) -- для ВСЕХ столбцов
Ключ [offer_id, snapshot_date], индекс uq_offer_date. Ничего не удаляется —
строка обновляется на месте, и пустое значение переписывает хорошее.
Основание правила: что означает строка
Строка за дату — это то, что мы знаем про артикул за 13 августа, а не отпечаток ответа в 16:46.
Как только это принято, правило выводится само: строка за дату есть накопитель наблюдений этой даты, а не снимок последнего ответа.
Чем рискуем, удержав значение (две породы чисел)
Правило одно, но последствия у него разные — это важно понимать, а не заводить из-за этого два правила.
| порода | пример | меняется ли в течение дня | риск удержания |
|---|---|---|---|
| «сколько лежит» | current_stock, наличие на складах | да, весь день | небольшой: цифра суточной давности вместо пустоты |
| «как продавалось» | ads_28d, idc_28d, liquidity_grade, days_without_sales | нет: площадка считает ночью один раз и весь день отдаёт одно и то же | никакого |
По остаткам риск почти умозрительный: список приходит одним запросом целиком,
наполовину пустым он не бывает — либо пришёл, либо прогон прерывается
(if not turnover: return).
Отложенный вопрос: перенос ЧЕРЕЗ сутки
Владелец 13.08: «даже если в течение следующих суток данные не прилетают… средняя продажа в день не меняется 5 дней. То есть сутки для нас это даже не предел».
Замечание по делу, но это отдельное решение, и вот почему. Внутри суток мы значение наблюдали — удерживая его, мы не сочиняем. Перенеся его на следующую дату, мы запишем в строку за 14 августа число, которого 14 августа не видели. Снаружи наблюдённое и перенесённое станут неотличимы.
Честный способ это сделать существует: переносить вместе с отметкой, когда значение замерено на самом деле (отдельная колонка происхождения). Тогда строка говорит «0,7, замерено 11 августа», и ничего не выдумано.
Это большее изменение, чем правило внутри суток, и в один заход с ним не идёт.
Ядро правила (первая редакция, ОТМЕНЕНО — оставлено как разбор)
Ниже — исходное деление на три класса. Оно не применяется: проверка показала, что правило для классов Б и В получается одно и то же, а разница лежит в риске, а не в правиле. Оставлено, чтобы не проделывать этот путь заново.
Разные поля означают разное, поэтому одного правила на всех быть не может.
Класс А — ключ
offer_id, snapshot_date. По нему опознаётся строка, не меняется.
Класс Б — мгновенное состояние
current_stock и прочие счётчики наличия. Меняется в течение дня, позднее
наблюдение вернее раннего.
Правило: побеждает последнее непустое.
Класс В — суточная сводка
ads_28d, idc_28d, days_without_sales, liquidity_grade, turnover_days,
idc_grade, turnover_grade. Это агрегаты за 28 и 60 дней, площадка
пересчитывает их раз в сутки. Внутри дня они не меняются, значит любое
наблюдение этого дня равноценно.
Правило: побеждает любое непустое; пустым не затирать.
Здесь и есть корень потерь: сейчас класс В обрабатывается как класс Б — суточную сводку переписывает мгновенный ответ, которому нечего сказать.
Почему «пустым не затирать» здесь безопасно
Обычное возражение верное: если запретить затирать пустым, замрёт последнее известное значение и будет выдавать себя за свежее. Ровно та беда, с которой мы воюем.
Но возражение верно между днями и неверно внутри дня. Ключ содержит
snapshot_date: завтра заводится новая строка, пустая с нуля. Значит устаревание
физически ограничено сутками — больше одного дня стухшее значение прожить не
может.
Это надо принять как сознательное решение, а не получить побочным эффектом.
Отдельный случай, который надо назвать вслух: если площадка отдавала ликвидность в 04:24, а к 16:46 артикул выбыл — правило удержит утреннее значение до конца суток. Это верно: мы действительно наблюдали его в этот день. Завтра строка будет пустой с самого начала.
Где считать — и почему НЕ в сырье
Соблазн: разбирать заново из всех сырых ответов за дату, выбирая лучшее. Не нужно и дорого — при нынешнем объёме склада это скан.
Строка за дату уже является накопителем. Значит сравнивать надо входящее наблюдение с тем, что уже лежит в строке: одна операция на строку, без скана.
ON DUPLICATE KEY UPDATE
current_stock = VALUES(current_stock), -- класс Б
liquidity_grade = COALESCE(VALUES(liquidity_grade), liquidity_grade), -- класс В
ads_28d = COALESCE(VALUES(ads_28d), ads_28d), -- класс В
...
Сырьё остаётся тем, чем должно быть: следом для разбирательства и источником для восстановления, а не участником ежедневного расчёта.
Место правки одно — spec_runtime/next_mapper.py, write_intake_row: сейчас там
безусловное \c`=VALUES(`c`)для всего спискаon_duplicate_update`. Класс
поля объявляется в спецификации эндпоинта рядом с самим полем.
Восстановление — отдельный инструмент, не горячий путь
Переразбор из сырья за дату нужен, но как починка по требованию, а не часть
ежедневного прогона. Колонка raw_payload.processed_at для этого и заведена.
Что правило чинит и чего не чинит
Чинит: класс потерь целиком и сразу для всех эндпоинтов. Случай 13.08 не возник бы, ручной перезапуск не понадобился бы.
Не чинит: разрастание склада сырья. Это беда №1, и её решение — отдельное.