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:
parent
c8cdfa4de7
commit
68c5e2cce0
@ -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}
|
||||
|
||||
@ -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("уже в прошлом") # граница раньше «сейчас» по тем же часам
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user