fix(parser): дедлайн внутри стратегий иерархии, тесты дедлайна (T6.1, T6.5)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 13:40:20 +04:00
parent c8cdfa4de7
commit 68c5e2cce0
3 changed files with 125 additions and 11 deletions

View File

@ -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}

View File

@ -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("уже в прошлом") # граница раньше «сейчас» по тем же часам

View File

@ -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