Рефакторинг рендера [этап 4/6]: группировка без deepcopy и мутаций, фикс naive/aware краша #31

Closed
opened 2026-07-05 20:27:46 +03:00 by agent_vscode · 0 comments
Collaborator

Часть эпика «Рефакторинг пайплайна рендера». Спека: plans/2026-07-05-render-pipeline-refactor.md (v5), раздел «Этап 4». Зависимость: этап 3 (#30). Независим от этапа 5 — можно параллельно. Line-refs действительны для f9550d8.

Цель

Убрать copy.deepcopy всех сообщений на каждый запрос фида (rss_generator.py:43) и мутацию media_group_id: тайм-кластеризация становится чистой функцией, возвращающей маппинг msg.id → effective_group_id. Попутно чинится ЖИВОЙ прод-баг: kurigram-даты naive-local, фолбэки — aware UTC → один None-date пост в фиде с реальными датами = TypeError → 500 при ЛЮБОМ time_based_merge (сорт-ключ групп, rss_generator.py:141).

Пункты реестра §3

  • §3.11 (багфикс): None-date исключаются из тайм-кластеризации (нет entry в маппинге; их собственный media_group_id продолжает действовать). Было: naive+None (прод) — краш; полностью-None и aware+None (только aware-моки тестов) — выживали и кластеризовали None-хвост по порядку вставки. Все эти поведения заменяются, новое закрепляется тестами как сознательное.
  • §3.12 (багфикс): sort-ключи timestamp-based (naive-безопасные). None-date группы: фолбэк +inf в сортировке ГРУПП — детерминированно переживают [:limit]-срез как новейшие; в ИТОГОВОМ выводе позиция не меняется — финальная сортировка постов (фолбэк 0.0, branch:319–320) ставит их в конец фида.

Новая функция

def _compute_time_based_group_ids(messages, merge_seconds) -> dict[int, str | int]:
    """Return {message.id: effective_media_group_id} WITHOUT mutating messages.

    Contract: all messages belong to ONE chat (message.id is unique only per
    chat); callers must not mix chats in a single call.

    Reproduces the old algorithm for every input the old code survived on
    PRODUCTION data (naive kurigram dates). Inputs only aware-date test mocks
    could produce (fully-None / aware+None mixes) are deliberately replaced
    — registry §3.11:
    - messages WITHOUT a date do not participate and get NO mapping entry;
    - sort by date ascending, timestamp-based key (naive-safe);
    - a message joins the current cluster if the gap to the PREVIOUS message
      is <= merge_seconds; the gap is computed by NAIVE datetime subtraction
      (msg.date - prev.date).total_seconds(), exactly as the old code — NOT
      via timestamps (they diverge during a DST fold);
    - effective id of a cluster = the FIRST TRUTHY media_group_id seen in
      cluster order (truthiness, not `is not None`; it overrides members'
      own differing ids — the old code overwrote them too);
    - if no member has a truthy id and len(cluster) >= 2:
      synthetic id f"time_{min(dates)}" (keep the exact format);
    - singleton clusters and clusters with no effective id produce NO entries;
      every member of a cluster with an effective id gets an entry.
    """

Правки

  • _create_messages_groups(messages, group_ids=None): effective = (group_ids or {}).get(message.id, message.media_group_id); sort-ключ групп — timestamp-based, фолбэк None-date групп float('inf'); финальная сортировка постов в _render_messages_groups (фолбэк 0.0) НЕ меняется.
  • _render_pipeline при time_based_merge: маппинг + пред-сортировка входа date-ASC тем же timestamp-ключом (фолбэк +inf — None-date в конце). Стабильный sorted на fetch-order входе воспроизводит порядок старого messages_sorted, включая ties (старый sorted работал на deepcopy того же списка, мутации — после сортировки). sorted() не мутирует вход — deepcopy не нужен.
  • Удалить _create_time_based_media_groups и импорт copy; поправить модульный импорт в test_stage4_eventloop.py:34–42 (API relocation).
  • Добавить None-date фикстуру ТОЛЬКО в HTML-golden (§3.11–12): в RSS None-date entry получает pubDate = datetime.now() (branch:503–507) — недетерминированно, новую нормализацию не вводим; RSS-семантика закрепляется unit-тестом (entry сгенерирован, pubDate присутствует), вне golden.

Тесты (tests/test_group_ids.py, даты NAIVE)

Time-кластер без id → синтетический time_...; усыновление truthy id с backfill и перезаписью чужого id; falsy id (0, "") игнорируется; одиночка без entry; None-date: нет entry в маппинге, но собственный media_group_id действует (медиагруппа с None-датами собирается); смешанный naive+None вход НЕ падает; полностью-None вход — новое поведение закреплено явно; None-date группы переживают [:limit]-срез, в выводе — в конце фида; вход не мутирован (сравнение объектов «кэша» до/после); ties при равных датах → порядок старого кода; кластеризация через DST-fold → как старый код (naive-вычитание).

DoD

copy.deepcopy отсутствует в rss_generator; pytest + golden зелёные (golden-дифф — только добавленная None-date фикстура в HTML-golden).

Общие правила эпика

Ветка refactor/render-pipeline от f9550d8; один этап = один коммит; комментарии — English; точечные правки.

Часть эпика «Рефакторинг пайплайна рендера». Спека: `plans/2026-07-05-render-pipeline-refactor.md` (v5), раздел «Этап 4». Зависимость: этап 3 (#30). Независим от этапа 5 — можно параллельно. Line-refs действительны для f9550d8. ## Цель Убрать `copy.deepcopy` всех сообщений на каждый запрос фида (rss_generator.py:43) и мутацию `media_group_id`: тайм-кластеризация становится чистой функцией, возвращающей маппинг `msg.id → effective_group_id`. Попутно чинится ЖИВОЙ прод-баг: kurigram-даты naive-local, фолбэки — aware UTC → один None-date пост в фиде с реальными датами = TypeError → 500 при ЛЮБОМ `time_based_merge` (сорт-ключ групп, rss_generator.py:141). ## Пункты реестра §3 - §3.11 (багфикс): None-date исключаются из тайм-кластеризации (нет entry в маппинге; их собственный `media_group_id` продолжает действовать). Было: naive+None (прод) — краш; полностью-None и aware+None (только aware-моки тестов) — выживали и кластеризовали None-хвост по порядку вставки. Все эти поведения заменяются, новое закрепляется тестами как сознательное. - §3.12 (багфикс): sort-ключи timestamp-based (naive-безопасные). None-date группы: фолбэк `+inf` в сортировке ГРУПП — детерминированно переживают `[:limit]`-срез как новейшие; в ИТОГОВОМ выводе позиция не меняется — финальная сортировка постов (фолбэк `0.0`, branch:319–320) ставит их в конец фида. ## Новая функция ```python def _compute_time_based_group_ids(messages, merge_seconds) -> dict[int, str | int]: """Return {message.id: effective_media_group_id} WITHOUT mutating messages. Contract: all messages belong to ONE chat (message.id is unique only per chat); callers must not mix chats in a single call. Reproduces the old algorithm for every input the old code survived on PRODUCTION data (naive kurigram dates). Inputs only aware-date test mocks could produce (fully-None / aware+None mixes) are deliberately replaced — registry §3.11: - messages WITHOUT a date do not participate and get NO mapping entry; - sort by date ascending, timestamp-based key (naive-safe); - a message joins the current cluster if the gap to the PREVIOUS message is <= merge_seconds; the gap is computed by NAIVE datetime subtraction (msg.date - prev.date).total_seconds(), exactly as the old code — NOT via timestamps (they diverge during a DST fold); - effective id of a cluster = the FIRST TRUTHY media_group_id seen in cluster order (truthiness, not `is not None`; it overrides members' own differing ids — the old code overwrote them too); - if no member has a truthy id and len(cluster) >= 2: synthetic id f"time_{min(dates)}" (keep the exact format); - singleton clusters and clusters with no effective id produce NO entries; every member of a cluster with an effective id gets an entry. """ ``` ## Правки - `_create_messages_groups(messages, group_ids=None)`: `effective = (group_ids or {}).get(message.id, message.media_group_id)`; sort-ключ групп — timestamp-based, фолбэк None-date групп `float('inf')`; финальная сортировка постов в `_render_messages_groups` (фолбэк `0.0`) НЕ меняется. - `_render_pipeline` при `time_based_merge`: маппинг + пред-сортировка входа date-ASC тем же timestamp-ключом (фолбэк `+inf` — None-date в конце). Стабильный sorted на fetch-order входе воспроизводит порядок старого `messages_sorted`, включая ties (старый sorted работал на deepcopy того же списка, мутации — после сортировки). `sorted()` не мутирует вход — deepcopy не нужен. - Удалить `_create_time_based_media_groups` и импорт `copy`; поправить модульный импорт в test_stage4_eventloop.py:34–42 (API relocation). - Добавить None-date фикстуру ТОЛЬКО в HTML-golden (§3.11–12): в RSS None-date entry получает `pubDate = datetime.now()` (branch:503–507) — недетерминированно, новую нормализацию не вводим; RSS-семантика закрепляется unit-тестом (entry сгенерирован, pubDate присутствует), вне golden. ## Тесты (`tests/test_group_ids.py`, даты NAIVE) Time-кластер без id → синтетический `time_...`; усыновление truthy id с backfill и перезаписью чужого id; falsy id (`0`, `""`) игнорируется; одиночка без entry; None-date: нет entry в маппинге, но собственный `media_group_id` действует (медиагруппа с None-датами собирается); смешанный naive+None вход НЕ падает; полностью-None вход — новое поведение закреплено явно; None-date группы переживают `[:limit]`-срез, в выводе — в конце фида; вход не мутирован (сравнение объектов «кэша» до/после); ties при равных датах → порядок старого кода; кластеризация через DST-fold → как старый код (naive-вычитание). ## DoD `copy.deepcopy` отсутствует в rss_generator; pytest + golden зелёные (golden-дифф — только добавленная None-date фикстура в HTML-golden). ## Общие правила эпика Ветка `refactor/render-pipeline` от f9550d8; один этап = один коммит; комментарии — English; точечные правки.
Sign in to join this conversation.