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 без родителя, и не может стать ребёнком последнего счёта. контрольная сумма документа, всегда уровень 0 без родителя, и не может стать ребёнком последнего счёта.
""" """
from collections.abc import Callable, Mapping, Sequence from collections.abc import Iterator, Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from decimal import Decimal from decimal import Decimal
from typing import Protocol
from src.parser.deadline import Deadline from src.parser.deadline import Deadline
from src.parser.errors import InvalidFormatError from src.parser.errors import InvalidFormatError
@ -40,6 +41,8 @@ SUM_TOLERANCE = Decimal("0.01")
# из корней (пустая ОСВ: только «Итого», в том числе «Итого» без сумм, не распознанное как TOTAL) # из корней (пустая ОСВ: только «Итого», в том числе «Итого» без сумм, не распознанное как TOTAL)
MIN_ROWS_FOR_CASCADE = 2 MIN_ROWS_FOR_CASCADE = 2
TRIVIAL_STRATEGY = "trivial" TRIVIAL_STRATEGY = "trivial"
# Кооперативный дедлайн внутри стратегии (design D-8): проверка каждые N строк или N шагов перебора поддеревьев
DEADLINE_CHECK_EVERY = 500
_ZERO = Decimal(0) _ZERO = Decimal(0)
@ -66,7 +69,34 @@ class Levels:
strategy: str 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: 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)} 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. """Outline level Excel (группировки строк), сдвинутый так, чтобы минимальный уровень стал 0.
Сдвиг нужен, когда вся таблица целиком вложена в группировку (уровни 1, 2, … без 0); пропуски уровней Сдвиг нужен, когда вся таблица целиком вложена в группировку (уровни 1, 2, … без 0); пропуски уровней
сохраняются как в файле. `None`, если различных уровней меньше двух. сохраняются как в файле. `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: if len(set(outlines)) < MIN_DISTINCT_LEVELS:
return None return None
base = min(outlines) base = min(outlines)
return {item.row: item.outline - base for item in rows} 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), поэтому шаг отступа `indent` приходит либо как `alignment.indent`, либо как число ведущих пробелов (T2.4), поэтому шаг отступа
не предполагается: (0, 2, 4), (0, 1, 2) и (0, 3, 7) дают одинаковые уровни 0, 1, 2. `None`, если различных не предполагается: (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: if ranks is None:
return None return None
return {item.row: ranks[item.indent] for item in rows} 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, Строка-счёт: уровень = ранг кода (число сегментов) − минимальный ранг среди счетов листа (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] = {} by_row: dict[int, int] = {}
account_level: int | None = None account_level: int | None = None
group_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: if item.is_account and item.account_rank is not None:
level = item.account_rank - base level = item.account_rank - base
account_level, group_level = level, None 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)) 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` Жадно и без рекурсии: поддеревья строятся с конца листа (`end[i]` — граница поддерева строки `i`), строка `i`
@ -160,19 +190,26 @@ def strategy_sums(rows: Sequence[BodyRow]) -> dict[int, int] | None:
(± `SUM_TOLERANCE`; пусто = 0). Строка без сумм (все пусто/0) родителем не бывает. Соседи с равными суммами (± `SUM_TOLERANCE`; пусто = 0). Строка без сумм (все пусто/0) родителем не бывает. Соседи с равными суммами
могут быть приняты за родителя и ребёнка — это ограничение жадного правила; результат всё равно проходит могут быть приняты за родителя и ребёнка — это ограничение жадного правила; результат всё равно проходит
контрольные суммы §1.4. `None`, если ни одна строка не стала родителем. контрольные суммы §1.4. `None`, если ни одна строка не стала родителем.
Худший случай (ни одна строка не сходится) — O(n²) шагов перебора: каждая строка проходит все следующие
поддеревья до конца листа. Поэтому дедлайн проверяется во внутреннем цикле каждые `DEADLINE_CHECK_EVERY` шагов,
и такой файл завершается `TIMEOUT`, а не висит.
""" """
count = len(rows) count = len(rows)
vectors = [_vector(item) for item in rows] vectors = [_vector(item) for item in rows]
zero = tuple(_ZERO for _ in vectors[0]) if vectors else () zero = tuple(_ZERO for _ in vectors[0]) if vectors else ()
end = [index + 1 for index in range(count)] # граница (исключительно) поддерева строки end = [index + 1 for index in range(count)] # граница (исключительно) поддерева строки
has_children = False has_children = False
pacer = _Pacer(deadline, "иерархия (sums): перебор поддеревьев")
for index in reversed(range(count)): for index in reversed(range(count)):
pacer.tick()
target = vectors[index] target = vectors[index]
if all(value == _ZERO for value in target): if all(value == _ZERO for value in target):
continue continue
accumulated = zero accumulated = zero
position = index + 1 position = index + 1
while position < count: while position < count:
pacer.tick()
accumulated = _add(accumulated, vectors[position]) accumulated = _add(accumulated, vectors[position])
position = end[position] position = end[position]
if _matches(accumulated, target): 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) return Levels(by_row=by_row, parent=parents(rows, by_row), strategy=TRIVIAL_STRATEGY)
for name, strategy in STRATEGIES: for name, strategy in STRATEGIES:
deadline.check(f"иерархия ({name})") deadline.check(f"иерархия ({name})")
found = strategy(body) found = strategy(body, deadline)
if found is None: if found is None:
continue continue
by_row = {**found, **pinned} by_row = {**found, **pinned}

View File

@ -1,5 +1,8 @@
"""Кооперативный дедлайн разбора (design D-8, ruling R-3): проверка между шагами, часы подменяются в тестах.""" """Кооперативный дедлайн разбора (design D-8, ruling R-3): проверка между шагами, часы подменяются в тестах."""
import inspect
import time
import pytest import pytest
from src.parser.deadline import Deadline 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: 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.deadline import Deadline
from src.parser.errors import InvalidFormatError, ParseTimeoutError from src.parser.errors import InvalidFormatError, ParseTimeoutError
from src.parser.hierarchy import ( from src.parser.hierarchy import (
DEADLINE_CHECK_EVERY,
STRATEGIES, STRATEGIES,
TRIVIAL_STRATEGY, TRIVIAL_STRATEGY,
BodyRow, BodyRow,
Levels, Levels,
Strategy,
is_consistent, is_consistent,
parents, parents,
resolve_levels, resolve_levels,
@ -391,3 +393,66 @@ def test_expired_deadline_raises_timeout() -> None:
with pytest.raises(ParseTimeoutError, match="иерархия"): with pytest.raises(ParseTimeoutError, match="иерархия"):
resolve_levels(rows, None, deadline) 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