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 без родителя, и не может стать ребёнком последнего счёта.
|
контрольная сумма документа, всегда уровень 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}
|
||||||
|
|||||||
@ -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("уже в прошлом") # граница раньше «сейчас» по тем же часам
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user