From 68c5e2cce0add79cbbbdbdd9a5c3108a896371f5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 13:40:20 +0400 Subject: [PATCH] =?UTF-8?q?fix(parser):=20=D0=B4=D0=B5=D0=B4=D0=BB=D0=B0?= =?UTF-8?q?=D0=B9=D0=BD=20=D0=B2=D0=BD=D1=83=D1=82=D1=80=D0=B8=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D1=82=D0=B5=D0=B3=D0=B8=D0=B9=20=D0=B8=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D1=80=D1=85=D0=B8=D0=B8,=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B4=D0=B5=D0=B4=D0=BB=D0=B0=D0=B9=D0=BD=D0=B0?= =?UTF-8?q?=20(T6.1,=20T6.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit strategy_sums проверяет Deadline каждые DEADLINE_CHECK_EVERY = 500 шагов внутреннего перебора поддеревьев (худший случай O(n²) завершается TIMEOUT, а не висит); линейные стратегии — каждые 500 строк (D-8). Strategy — Protocol (rows, deadline=None); resolve_levels передаёт дедлайн. Тесты: истечение внутри sums, проверка перед каждой стратегией, пакинг линейных стратегий, часы по умолчанию — time.monotonic. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/parser/hierarchy.py | 57 ++++++++++++++++++---- api/tests/unit/parser/test_deadline.py | 14 +++++- api/tests/unit/parser/test_hierarchy.py | 65 +++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 11 deletions(-) diff --git a/api/src/parser/hierarchy.py b/api/src/parser/hierarchy.py index 34e8c7c..ab8310f 100644 --- a/api/src/parser/hierarchy.py +++ b/api/src/parser/hierarchy.py @@ -9,9 +9,10 @@ контрольная сумма документа, всегда уровень 0 без родителя, и не может стать ребёнком последнего счёта. """ -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Iterator, Mapping, Sequence from dataclasses import dataclass from decimal import Decimal +from typing import Protocol from src.parser.deadline import Deadline from src.parser.errors import InvalidFormatError @@ -40,6 +41,8 @@ SUM_TOLERANCE = Decimal("0.01") # из корней (пустая ОСВ: только «Итого», в том числе «Итого» без сумм, не распознанное как TOTAL) MIN_ROWS_FOR_CASCADE = 2 TRIVIAL_STRATEGY = "trivial" +# Кооперативный дедлайн внутри стратегии (design D-8): проверка каждые N строк или N шагов перебора поддеревьев +DEADLINE_CHECK_EVERY = 500 _ZERO = Decimal(0) @@ -66,7 +69,34 @@ class Levels: strategy: str -Strategy = Callable[[Sequence[BodyRow]], dict[int, int] | None] +class Strategy(Protocol): + """Стратегия каскада: уровни `{row: level}` или `None`, если неприменима; `deadline` проверяется изнутри + (каждые `DEADLINE_CHECK_EVERY` строк/шагов), без него — не проверяется (чистая функция для тестов). + """ + + def __call__(self, rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None: ... + + +class _Pacer: + """Счётчик шагов: каждые `DEADLINE_CHECK_EVERY` вызовов `tick()` — `deadline.check(where)`.""" + + def __init__(self, deadline: Deadline | None, where: str) -> None: + self._deadline = deadline + self._where = where + self._steps = 0 + + def tick(self) -> None: + self._steps += 1 + if self._deadline is not None and self._steps % DEADLINE_CHECK_EVERY == 0: + self._deadline.check(self._where) + + +def _paced(rows: Sequence[BodyRow], deadline: Deadline | None, where: str) -> Iterator[BodyRow]: + """Строки по порядку с проверкой дедлайна каждые `DEADLINE_CHECK_EVERY` строк.""" + pacer = _Pacer(deadline, where) + for item in rows: + pacer.tick() + yield item def _ranks(values: Sequence[int]) -> dict[int, int] | None: @@ -77,33 +107,33 @@ def _ranks(values: Sequence[int]) -> dict[int, int] | None: return {value: rank for rank, value in enumerate(distinct)} -def strategy_outline(rows: Sequence[BodyRow]) -> dict[int, int] | None: +def strategy_outline(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None: """Outline level Excel (группировки строк), сдвинутый так, чтобы минимальный уровень стал 0. Сдвиг нужен, когда вся таблица целиком вложена в группировку (уровни 1, 2, … без 0); пропуски уровней сохраняются как в файле. `None`, если различных уровней меньше двух. """ - outlines = [item.outline for item in rows] + outlines = [item.outline for item in _paced(rows, deadline, "иерархия (outline)")] if len(set(outlines)) < MIN_DISTINCT_LEVELS: return None base = min(outlines) return {item.row: item.outline - base for item in rows} -def strategy_indent(rows: Sequence[BodyRow]) -> dict[int, int] | None: +def strategy_indent(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None: """Отступ ячейки текста: уровень — ранг отступа среди различных отступов листа. `indent` приходит либо как `alignment.indent`, либо как число ведущих пробелов (T2.4), поэтому шаг отступа не предполагается: (0, 2, 4), (0, 1, 2) и (0, 3, 7) дают одинаковые уровни 0, 1, 2. `None`, если различных отступов меньше двух. """ - ranks = _ranks([item.indent for item in rows]) + ranks = _ranks([item.indent for item in _paced(rows, deadline, "иерархия (indent)")]) if ranks is None: return None return {item.row: ranks[item.indent] for item in rows} -def strategy_account_pattern(rows: Sequence[BodyRow]) -> dict[int, int] | None: +def strategy_account_pattern(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None: """Шаблон кода счёта и жирность. Строка-счёт: уровень = ранг кода (число сегментов) − минимальный ранг среди счетов листа (60 → 0, 60.01 → 1, @@ -123,7 +153,7 @@ def strategy_account_pattern(rows: Sequence[BodyRow]) -> dict[int, int] | None: by_row: dict[int, int] = {} account_level: int | None = None group_level: int | None = None # уровень последней жирной не-счётной строки под текущим счётом - for item in rows: + for item in _paced(rows, deadline, "иерархия (account_pattern)"): if item.is_account and item.account_rank is not None: level = item.account_rank - base account_level, group_level = level, None @@ -152,7 +182,7 @@ def _matches(left: tuple[Decimal, ...], right: tuple[Decimal, ...]) -> bool: return all(abs(a - b) <= SUM_TOLERANCE for a, b in zip(left, right, strict=True)) -def strategy_sums(rows: Sequence[BodyRow]) -> dict[int, int] | None: +def strategy_sums(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None: """Последнее средство: строка — родитель следующих поддеревьев, пока их сумма не совпадёт с её суммами. Жадно и без рекурсии: поддеревья строятся с конца листа (`end[i]` — граница поддерева строки `i`), строка `i` @@ -160,19 +190,26 @@ def strategy_sums(rows: Sequence[BodyRow]) -> dict[int, int] | None: (± `SUM_TOLERANCE`; пусто = 0). Строка без сумм (все пусто/0) родителем не бывает. Соседи с равными суммами могут быть приняты за родителя и ребёнка — это ограничение жадного правила; результат всё равно проходит контрольные суммы §1.4. `None`, если ни одна строка не стала родителем. + + Худший случай (ни одна строка не сходится) — O(n²) шагов перебора: каждая строка проходит все следующие + поддеревья до конца листа. Поэтому дедлайн проверяется во внутреннем цикле каждые `DEADLINE_CHECK_EVERY` шагов, + и такой файл завершается `TIMEOUT`, а не висит. """ count = len(rows) vectors = [_vector(item) for item in rows] zero = tuple(_ZERO for _ in vectors[0]) if vectors else () end = [index + 1 for index in range(count)] # граница (исключительно) поддерева строки has_children = False + pacer = _Pacer(deadline, "иерархия (sums): перебор поддеревьев") for index in reversed(range(count)): + pacer.tick() target = vectors[index] if all(value == _ZERO for value in target): continue accumulated = zero position = index + 1 while position < count: + pacer.tick() accumulated = _add(accumulated, vectors[position]) position = end[position] if _matches(accumulated, target): @@ -241,7 +278,7 @@ def resolve_levels(rows: Sequence[BodyRow], total_row: int | None, deadline: Dea return Levels(by_row=by_row, parent=parents(rows, by_row), strategy=TRIVIAL_STRATEGY) for name, strategy in STRATEGIES: deadline.check(f"иерархия ({name})") - found = strategy(body) + found = strategy(body, deadline) if found is None: continue by_row = {**found, **pinned} diff --git a/api/tests/unit/parser/test_deadline.py b/api/tests/unit/parser/test_deadline.py index 03638a6..bd57506 100644 --- a/api/tests/unit/parser/test_deadline.py +++ b/api/tests/unit/parser/test_deadline.py @@ -1,5 +1,8 @@ """Кооперативный дедлайн разбора (design D-8, ruling R-3): проверка между шагами, часы подменяются в тестах.""" +import inspect +import time + import pytest from src.parser.deadline import Deadline @@ -46,4 +49,13 @@ def test_limit_is_counted_from_creation() -> None: def test_default_clock_is_monotonic() -> None: - Deadline(60).check("сразу") + default = inspect.signature(Deadline).parameters["clock"].default + + assert default is time.monotonic + + +def test_default_clock_measures_real_time() -> None: + Deadline(60).check("сразу") # монотонные часы: свежий лимит не превышен + + with pytest.raises(ParseTimeoutError): + Deadline(-1).check("уже в прошлом") # граница раньше «сейчас» по тем же часам diff --git a/api/tests/unit/parser/test_hierarchy.py b/api/tests/unit/parser/test_hierarchy.py index cfd454a..7a15156 100644 --- a/api/tests/unit/parser/test_hierarchy.py +++ b/api/tests/unit/parser/test_hierarchy.py @@ -13,10 +13,12 @@ import pytest from src.parser.deadline import Deadline from src.parser.errors import InvalidFormatError, ParseTimeoutError from src.parser.hierarchy import ( + DEADLINE_CHECK_EVERY, STRATEGIES, TRIVIAL_STRATEGY, BodyRow, Levels, + Strategy, is_consistent, parents, resolve_levels, @@ -391,3 +393,66 @@ def test_expired_deadline_raises_timeout() -> None: with pytest.raises(ParseTimeoutError, match="иерархия"): resolve_levels(rows, None, deadline) + + +CLOCK_READS_CREATE_AND_ONE_CHECK = 2 # чтение часов при создании Deadline + одна проверка на DEADLINE_CHECK_EVERY строк + + +class TickingClock: + """Часы, которые сдвигаются на 1 с при каждом обращении: дедлайн истекает на заданной по счёту проверке.""" + + def __init__(self) -> None: + self.calls = 0 + + def __call__(self) -> float: + value = float(self.calls) + self.calls += 1 + return value + + +@pytest.mark.parametrize("expired_at", range(len(STRATEGIES)), ids=[name for name, _ in STRATEGIES]) +def test_deadline_is_checked_before_each_strategy(expired_at: int) -> None: + # ни одна стратегия не подходит, каскад проходит все четыре; строк меньше DEADLINE_CHECK_EVERY — проверки + # только между стратегиями: создание — t=0, перед k-й стратегией — t=k + rows = numbered(("Строка", {}), ("Строка 2", {})) + name = STRATEGIES[expired_at][0] + + with pytest.raises(ParseTimeoutError) as raised: + resolve_levels(rows, None, Deadline(expired_at, clock=TickingClock())) + + assert raised.value.message == f"превышен лимит {expired_at} с на шаге иерархия ({name})" + + +def _never_matching_sums(count: int) -> list[BodyRow]: + """Строки без outline/отступов/счетов с растущими суммами: ни одна не равна сумме следующих — худший случай sums.""" + return [ + body(FIRST + offset, f"Строка {offset}", sums=money(str(offset + 1), None, None, None, None, None)) + for offset in range(count) + ] + + +def test_deadline_expires_inside_sums_scan() -> None: + rows = _never_matching_sums(60) # ≈ 1800 шагов перебора > DEADLINE_CHECK_EVERY, строк < DEADLINE_CHECK_EVERY + # четыре проверки между стратегиями (t=1…4) проходят; следующая — уже внутри перебора sums + deadline = Deadline(len(STRATEGIES), clock=TickingClock()) + + with pytest.raises(ParseTimeoutError, match=r"иерархия \(sums\): перебор поддеревьев"): + resolve_levels(rows, None, deadline) + + +def test_sums_without_deadline_finishes_worst_case() -> None: + assert strategy_sums(_never_matching_sums(60)) is None + + +@pytest.mark.parametrize( + ("strategy", "name"), + [(strategy_outline, "outline"), (strategy_indent, "indent"), (strategy_account_pattern, "account_pattern")], +) +def test_linear_strategies_check_deadline_every_n_rows(strategy: Strategy, name: str) -> None: + rows = [body(FIRST + offset, "60") for offset in range(DEADLINE_CHECK_EVERY)] + clock = TickingClock() + deadline = Deadline(0, clock=clock) # истекает на первой же проверке + + with pytest.raises(ParseTimeoutError, match=rf"иерархия \({name}\)"): + strategy(rows, deadline) + assert clock.calls == CLOCK_READS_CREATE_AND_ONE_CHECK