synthgen: сборка набора, README, детерминированность (#38)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-13 00:37:42 +04:00
parent 94ff75fbb1
commit 004c21041c
8 changed files with 415 additions and 8 deletions

View File

@ -9,6 +9,7 @@
```
osv/{rb,schastl}/<ГГГГ-Qn | ГГГГ-FY>/ ОСВ по счетам, исходные имена файлов (разложены по шапке, не по имени)
osv/synthetic/ синтетика s01–s19 для форматов, которых нет в реальных данных (Срез 2a); генератор и описание — osv/synthetic/README.md
synthgen/ настраиваемый генератор синтетической ГСК (ОСВ, ББ, ОФР, ЕЦП) для нагрузочных и демо-прогонов; запуск `python -m synthgen`, описание — synthgen/README.md
osv-dkb/<org>/<период>/ ОСВ шести организаций из пакета ДКБ 06.10.2026 (Цимлянский, НКЗ, Новоросметалл, НПЗ, Приманыческий,
Агрокомплекс): многолистовые книги, needs_check, 50 тыс. строк — свои тесты (test_osv_dkb.py) и golden, см. osv-dkb/README.md
analysis-reports/<org>/<период>/ отчёты 1С «Анализ счета» по контрагентам группы из того же пакета (не ОСВ), см. README

View File

@ -301,3 +301,4 @@ README прогона, точные субсчета внутри перечис
| 37 | 12.11.2026 | Подгонка округления ББ: ±1 тыс. строкам с наименьшей потерей точности, каждой строке не более одного раза; подогнанные строки записаны в `Form.plugged` (по колонкам) | «в одной строке» из §5.6 п. 3 не всегда хватает: при ~20 строках расхождение округлений достигает нескольких тыс. | строка подгонки может отличаться от книги на 1 тыс. (тест допускает ±1 только для `plugged`) |
| 38 | 12.11.2026 | ОФР: 2411 = 2410 (налог текущий), 2412 = 0, 2460/2510/2520/2530/2900/2910 = «-»; 2500 = 2400 + 2510 + 2520 + 2530; подписи колонок: «За 3 месяца / 6 месяцев / 9 месяцев\n<год> года», для года — «За <год> год» (ОФР) и «На 31 декабря\n<год> года» (ББ); дата шапки — конец периода | отложенных налогов и прочих результатов модель не создаёт; подписи по образцу 1С, парсер определяет даты колонок | OPEN-QUESTION: форма подписей «За 3 месяца» для 1 квартала у реальных выгрузок не проверена |
| 39 | 12.11.2026 | В шаблонах ББ/ОФР сохранены флаги образца (`M15`/`R15` ББ, нумерация колонок ОФР); реквизиты (ОКПО, ОКВЭД по отрасли, ОКОПФ 65/47, адрес, ФИО подписантов) — детерминированные вымышленные из `stable_hash` по ИНН/компании/периоду | формы 66н требуют реквизитов; ничего из образца не должно попасть в набор | ОКПО/ОКОПФ вымышлены, не проверяются классификаторами |
| 40 | 13.11.2026 | Сборка набора: пустые по правилу 1С ОСВ тоже пишутся (шапка и «Итого» с нулями), число файлов не зависит от экономики; каталог результата по умолчанию `./out/<prefix>`; `--force` очищает содержимое `out` целиком; в `config.yaml` и README `out` всегда `null` (путь результата в файлы не попадает) | детерминированность побайтно при разных `--out`; ожидаемое число файлов предсказуемо (компании × периоды × 9 счетов) | `--force` стирает всё в `out` — указывать только каталог набора; пустой файл ОСВ может смутить загрузчик, ждущий непустых |

72
synthgen/README.md Normal file
View File

@ -0,0 +1,72 @@
# synthgen — настраиваемый генератор синтетической ГСК
Строит вымышленный набор для АРМ КА: состав группы (ЕЦП), ОСВ по счетам, ББ и ОФР по компаниям и периодам. Все числа
выведены из одной двойной записи на компанию (книга проводок), поэтому ББ, ОФР и ОСВ согласованы между собой.
Назначение — объём и демо (десятки компаний, до 150 внешних контрагентов в ОСВ); это **не оракул**: ожидаемых итогов
консолидации генератор не даёт. Проект: `docs/superpowers/specs/2026-11-12-synthgen-design.md`.
## Запуск
```
uv run --with openpyxl==3.1.5 --with pyyaml==6.0.2 python -m synthgen \
--config synthgen/examples/default.yaml --set group_size=12 --set prefix=DEMO2 --out /tmp/demo2
```
Из корня репозитория fixtures. Без `--out` результат — `./out/<prefix>`. Непустой каталог без `--force` — код 3;
`--force` очищает его содержимое целиком. Код 2 — ошибка настроек. Тесты: `python -m pytest synthgen/tests -q`
(`-m parser` — приёмка парсером, нужен `TP_WT`; `-m slow` — крупный прогон).
## Настройки
Любой ключ переопределяется `--set ключ=значение`; неизвестный ключ или значение вне диапазона — ошибка.
| Ключ | Смысл | По умолчанию | Диапазон |
|---|---|---|---|
| `group_size` | число компаний в ГК (все входят в ГСК) | 5 | 2–40 |
| `prefix` | префикс ГК: в названиях и диапазон ИНН | `SYN` | 2–12 букв/цифр |
| `periods` | число отчётных периодов | 4 | 1–12 |
| `start` | первый период | `2025-Q1` | 2020-Q1 … 2030-Q4 |
| `osv_counterparties_mean` | среднее число внешних контрагентов в одной ОСВ | 60 | 0–150 |
| `no_inn_companies` | сколько компаний выгружают ОСВ без ИНН | 1 | 0–`group_size` |
| `vgo_mismatch_share` | доля пар ВГО с расхождением | 0.1 | 0–1 |
| `seed` | зерно случайности | 1 | целое |
| `out` | каталог результата | `./out/<prefix>` | — |
Одинаковые настройки и `seed` дают побайтно одинаковые файлы (путь результата в файлы не попадает).
## Что получается
```
<out>/
README.md журнал прогона: настройки, состав группы, пары ВГО с расхождениями, контрагенты
config.yaml фактические настройки
directories/ecp-<prefix>.xlsx выгрузка ЕЦП: состав ГК
osv/<КОД>/<период>/сч NN_….xlsx ОСВ по счетам 01, 58, 60, 62, 66, 67, 76, 80, 90
statements/<КОД>/<период>/ ББ_….xlsx и ОФР_….xlsx (форма 66н, тыс. руб.)
```
`<КОД>` — `C01`…`Cnn`, головная — `C01`; периоды — нарастающим итогом с 1 января (`2025-Q1`, `2025-Q2`, `2025-Q3`,
`2025-FY`). Набор сам в git не коммитится (`out/` в `.gitignore`).
## Как загрузить на стенд
1. Импорт ЕЦП: `directories/ecp-<prefix>.xlsx` (справочник групп и юрлиц).
2. Документы по компаниям: ОСВ, затем ББ и ОФР из `osv/<КОД>/…` и `statements/<КОД>/…`. Компании без ИНН в ОСВ и
пары ВГО с расхождениями перечислены в README набора.
## Ограничения
- Не оракул; только форма 66н (ФСБУ 4/2023 не покрыта).
- Среднее число контрагентов ограничено словарём (≤ 150): при потолке README набора пишет «среднее ограничено словарём».
- ИНН компаний группы берутся из диапазона по хешу префикса; два префикса могут столкнуться (малая вероятность).
- ВГО по выручке 90.01 без аналитики; правило 6 ядра суммирует 62↔60 и 76↔76; счёт 80 головной без аналитики;
парсером проверены ОСВ 60/62/76; подпись «За 3 месяца» ОФР не сверена с реальной выгрузкой — подробности в README
каждого прогона.
## Как пересобрать словарь и шаблоны
- Словарь организаций: `python -m synthgen.dictionary.build` → `synthgen/dictionary/companies.csv` (коммитится).
- Стиль ОСВ: `python synthgen/templates/build_osv_style.py` → `osv_style.json`; шаблоны ББ/ОФР:
`python synthgen/templates/build_forms_templates.py` → `bb_1c.xlsx`, `ofr_1c.xlsx`. Образцы — из этого же
репозитория (`osv/rb/2024-Q1`, `statements-xlsx/mezheninovskaya/2025-Q3`), путь можно передать аргументом;
реквизитов образца в шаблонах нет.

View File

@ -1,6 +1,7 @@
"""Точка входа: `python -m synthgen --config synthgen/examples/default.yaml --set group_size=12 --out DIR`.
Пока печатает итоговые настройки (спека §3); генерация набора добавится в следующих задачах.
Генерирует набор (спека §7) и печатает путь и сводку. Каталог результата по умолчанию — `./out/<prefix>`;
непустой каталог без `--force` — код 3.
"""
import argparse
@ -8,7 +9,8 @@ import sys
from dataclasses import replace
from pathlib import Path
from synthgen.config import ConfigError, dump_config, load_config
from synthgen import generate
from synthgen.config import ConfigError, load_config
def _parser() -> argparse.ArgumentParser:
@ -18,8 +20,8 @@ def _parser() -> argparse.ArgumentParser:
"--set", dest="overrides", action="append", default=[], metavar="КЛЮЧ=ЗНАЧЕНИЕ",
help="переопределение настройки (можно повторять)",
)
parser.add_argument("--out", type=Path, default=None, help="каталог результата")
parser.add_argument("--force", action="store_true", help="перезаписать существующий каталог результата")
parser.add_argument("--out", type=Path, default=None, help="каталог результата (по умолчанию ./out/<prefix>)")
parser.add_argument("--force", action="store_true", help="очистить и перезаписать непустой каталог результата")
return parser
@ -32,7 +34,15 @@ def main(argv: list[str] | None = None) -> int:
return 2
if args.out is not None:
cfg = replace(cfg, out=args.out)
sys.stdout.write(dump_config(cfg))
out = generate.default_out(cfg)
try:
manifest = generate.generate(cfg, out, force=args.force)
except FileExistsError as exc:
print(f"{exc}", file=sys.stderr)
return 3
mismatches = sum(len(p.mismatches) for p in manifest.pairs)
print(f"Набор записан: {out}")
print(f"файлов: {len(manifest.files)}; пар ВГО: {len(manifest.pairs)}; расхождений: {mismatches}")
return 0

71
synthgen/generate.py Normal file
View File

@ -0,0 +1,71 @@
"""Сборка набора (спека §7): группа → периоды → книги → файлы ЕЦП, ОСВ, ББ и ОФР, config.yaml и README прогона."""
import shutil
from dataclasses import dataclass, field, replace
from pathlib import Path
from synthgen import dictionary, ecp, forms, forms_xlsx, osv, report
from synthgen.config import Config, dump_config
from synthgen.counterparties import OSV_ACCOUNTS, CounterpartyPlan
from synthgen.counterparties import plan as make_plan
from synthgen.group import build_group
from synthgen.intragroup import VgoPair, build
from synthgen.periods import build_periods
@dataclass
class Manifest:
files: list[Path] = field(default_factory=list)
pairs: list[VgoPair] = field(default_factory=list)
capped: bool = False
def default_out(cfg: Config) -> Path:
return cfg.out if cfg.out is not None else Path("out") / cfg.prefix
def _prepare(out: Path, force: bool) -> None:
if out.exists() and any(out.iterdir()):
if not force:
raise FileExistsError(f"каталог результата не пуст: {out} (--force перезапишет)")
for item in out.iterdir():
shutil.rmtree(item) if item.is_dir() and not item.is_symlink() else item.unlink()
out.mkdir(parents=True, exist_ok=True)
def generate(cfg: Config, out: Path, *, force: bool = False) -> Manifest:
"""Строит набор в каталоге `out`; непустой `out` без `force` — FileExistsError."""
_prepare(out, force)
group = build_group(cfg, dictionary.load())
periods = build_periods(cfg.start, cfg.periods)
plan: CounterpartyPlan = make_plan(cfg, group)
books, pairs = build(cfg, group, periods, plan)
manifest = Manifest(pairs=pairs, capped=plan.capped)
def keep(path: Path) -> Path:
manifest.files.append(path)
return path
ecp.write_ecp(group, keep(out / "directories" / f"ecp-{cfg.prefix}.xlsx"))
for member in group.members:
ledger = books[member.code]
with_inn = member.code not in group.no_inn
for period in periods:
for account in OSV_ACCOUNTS:
data = osv.rows(ledger, account, period, member, group)
path = out / "osv" / member.code / period.key / osv.file_name(account, period)
osv.write(path, member, account, period, data, with_inn=with_inn)
keep(path)
for form in (
forms.balance(ledger, member, period, periods, cfg.seed),
forms.income(ledger, member, period, periods, cfg.seed),
):
path = out / "statements" / member.code / period.key / forms_xlsx.file_name(form)
forms_xlsx.write(form, path)
keep(path)
(out / "config.yaml").write_text(dump_config(replace(cfg, out=None)), encoding="utf-8")
keep(out / "config.yaml")
readme = out / "README.md"
manifest.files.append(readme)
readme.write_text(report.render(cfg, group, pairs, plan, manifest), encoding="utf-8")
return manifest

115
synthgen/report.py Normal file
View File

@ -0,0 +1,115 @@
"""README прогона (спека §7): журнал генератора — настройки, состав группы, пары ВГО с расхождениями, число контрагентов.
Это не оракул: ожидаемых итогов консолидации здесь нет, только то, что генератор заложил в данные.
"""
from dataclasses import replace
from decimal import Decimal
from typing import TYPE_CHECKING
from synthgen.config import Config, dump_config
from synthgen.counterparties import WEIGHTS, CounterpartyPlan
from synthgen.group import Group, variant_spelling
from synthgen.intragroup import VgoPair
if TYPE_CHECKING:
from synthgen.generate import Manifest
KIND = {"trade": "торговля", "loan": "заём"}
def _money(x: Decimal) -> str:
return f"{x:,.2f}".replace(",", " ")
def _settings(cfg: Config) -> list[str]:
return ["## Настройки прогона", "", "```yaml", dump_config(replace(cfg, out=None)).rstrip(), "```", ""]
def _members(group: Group) -> list[str]:
lines = [
"## Состав группы", "", f"{group.name} (iD ГК {group.gk_id}); все компании входят в ГСК.", "",
"| Код | Название | ИНН | Доля головной | Миноритарий |", "|---|---|---|---|---|",
] # fmt: skip
for m in group.members:
share = "головная" if m.head_share is None else f"{m.head_share * 100:.0f} %"
minority = f"{m.minority.name} (ИНН {m.minority.inn})" if m.minority is not None else "—"
lines.append(f"| {m.code} | {m.name} | {m.inn} | {share} | {minority} |")
return [*lines, ""]
def _no_inn(group: Group) -> list[str]:
lines = ["## Компании без ИНН и вариантное имя", ""]
if not group.no_inn:
return [*lines, "Все ОСВ выгружены с ИНН контрагентов; вариантных имён нет.", ""]
by_code = {m.code: m for m in group.members}
for code in sorted(group.no_inn):
other = by_code[group.variant_name[code]]
lines.append(
f"- {code}: ОСВ без колонки ИНН; контрагент {other.code} ({other.name}) записан в ней вариантным "
f"написанием «{variant_spelling(other)}» (корень, префикс и форма без кавычек). Сопоставит ли ядро "
"такое имя с компанией группы автоматически, не проверено (OPEN-QUESTION O-2)."
)
return [*lines, ""]
def _pairs(group: Group, pairs: list[VgoPair]) -> list[str]:
lines = ["## Пары ВГО", ""]
if not pairs:
return [*lines, "Пар ВГО нет: расхождений сторон тоже нет.", ""]
lines.append(f"Пар: {len(pairs)} (торговля: {sum(p.kind == 'trade' for p in pairs)}, займы: {sum(p.kind == 'loan' for p in pairs)}).")
mism = [(p, m) for p in pairs for m in p.mismatches]
if not mism:
return [*lines, "", "Расхождений сторон в этом прогоне нет.", ""]
lines += [
"", "Расхождения: сумма — разница сторон, % — от меньшей стороны сальдо пары на конец периода; порог 5 %.", "",
"| Пара (продавец/займодавец → покупатель/заёмщик) | Вид | Период | Проводка у | Сумма, руб. | % | Порог |",
"|---|---|---|---|---|---|---|",
] # fmt: skip
for p, m in mism:
verdict = "выше порога 5 %" if m.above_threshold else "ниже порога 5 %"
lines.append(f"| {p.a} → {p.b} | {KIND[p.kind]} | {m.period_key} | {m.side} | {_money(m.amount)} | {m.pct} % | {verdict} |")
return [*lines, ""]
def _counts(plan: CounterpartyPlan, group: Group) -> list[str]:
lines = [
"## Внешние контрагенты по файлам ОСВ", "",
"| Счёт | Мин | Среднее | Макс |", "|---|---|---|---|",
] # fmt: skip
for acct in WEIGHTS:
counts = [len(plan.by_file[(m.code, acct)]) for m in group.members]
lines.append(f"| {acct} | {min(counts)} | {sum(counts) / len(counts):.1f} | {max(counts)} |")
lines.append("")
if plan.capped:
lines += ["Среднее ограничено словарём: потолок (размер словаря без банков и компаний группы) срезал выборку.", ""]
return lines
NOTES = """\
## Что нужно знать при чтении результатов
- Набор не оракул: ожидаемых итогов консолидации здесь нет, README — журнал того, что заложил генератор. Только форма 66н.
- ВГО по выручке (90.01) в ОСВ идут без аналитики по контрагентам (M-4, T4.6): взаимные продажи внутри группы
видны только по расчётным счетам, и при элиминации выручка может попасть в строку 2110 дважды.
- Правило 6 ядра суммирует обороты пар 62 ↔ 60 и 76 ↔ 76 (M-5, T4.7): расхождение одной пары по расчётам и по
займам/прочим расчётам складывается, а не сравнивается раздельно.
- Счёт 80 у головной компании без аналитики (T4.10): строка 80 не раскрывается по участникам.
- Парсером tabular-parser проверены только ОСВ по счетам 60, 62 и 76 (O-5); остальные счета (01, 58, 66, 67, 80, 90)
покрыты юнит-тестами формы.
- Подпись колонки ОФР «За 3 месяца» (и «6 месяцев», «9 месяцев») сделана по образцу 1С, с реальной выгрузкой не
сверена (O-6); строки 2340 и 2350 в ОФР всегда нулевые.
- Счёт 58 с внешними контрагентами (займы 58.03) и 76.05 только по Кт — OPEN-QUESTION O-3: нужна проверка, как ядро
трактует такие внешние стороны.
- Вариантное имя контрагента в ОСВ без ИНН: см. раздел выше (O-2).
"""
def render(cfg: Config, group: Group, pairs: list[VgoPair], plan: CounterpartyPlan, manifest: "Manifest") -> str:
head = [
f"# Синтетический набор {group.name}", "",
f"Сгенерирован `python -m synthgen` (seed {cfg.seed}). Файлов: {len(manifest.files)}. Данные вымышленные; "
"это журнал генератора, а не оракул.", "",
] # fmt: skip
parts = [head, _settings(cfg), _members(group), _no_inn(group), _pairs(group, pairs), _counts(plan, group), NOTES.splitlines()]
return "\n".join(line for part in parts for line in part).rstrip() + "\n"

View File

@ -74,9 +74,10 @@ def test_cli_bad_config_exit_2(capsys):
assert capsys.readouterr().err != ""
def test_cli_prints_config(capsys):
assert main(["--set", "group_size=7"]) == 0
assert "group_size: 7" in capsys.readouterr().out
def test_cli_writes_actual_config(tmp_path):
out = tmp_path / "res"
assert main(["--set", "group_size=7", "--set", "periods=1", "--set", "osv_counterparties_mean=2", "--out", str(out)]) == 0
assert "group_size: 7" in (out / "config.yaml").read_text(encoding="utf-8")
def test_rng_for_stable():

View File

@ -0,0 +1,136 @@
"""Сборка набора (спека §7): структура каталога, детерминированность, README прогона, граничные настройки."""
import hashlib
import time
from dataclasses import replace
from pathlib import Path
import pytest
from synthgen import generate as gen
from synthgen.__main__ import main
from synthgen.config import Config, load_config
SMALL = Config(group_size=3, periods=2, osv_counterparties_mean=5, no_inn_companies=1)
def _digest(root: Path) -> dict[str, str]:
return {str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(root.rglob("*")) if p.is_file()}
@pytest.fixture(scope="module")
def default_run(tmp_path_factory):
out = tmp_path_factory.mktemp("default")
manifest = gen.generate(Config(), out)
return out, manifest
def test_layout(default_run):
out, manifest = default_run
assert (out / "README.md").stat().st_size > 0
assert (out / "config.yaml").is_file()
assert (out / "directories" / "ecp-SYN.xlsx").is_file()
codes = [f"C{i:02d}" for i in range(1, 6)]
assert sorted(p.name for p in (out / "osv").iterdir()) == codes
assert sorted(p.name for p in (out / "statements").iterdir()) == codes
assert len(list(out.glob("osv/*/*/*.xlsx"))) == 5 * 4 * 9
assert len(list(out.glob("statements/*/*/ББ_*.xlsx"))) == 5 * 4
assert len(list(out.glob("statements/*/*/ОФР_*.xlsx"))) == 5 * 4
assert sorted(p.name for p in (out / "osv" / "C01").iterdir()) == ["2025-FY", "2025-Q1", "2025-Q2", "2025-Q3"]
# манифест перечисляет все файлы набора
on_disk = {p for p in out.rglob("*") if p.is_file()}
assert set(manifest.files) == on_disk
def test_determinism(tmp_path):
a, b, c = tmp_path / "a", tmp_path / "b", tmp_path / "c"
gen.generate(SMALL, a)
gen.generate(SMALL, b)
gen.generate(replace(SMALL, seed=2), c)
assert _digest(a) == _digest(b)
assert str(a) not in (a / "config.yaml").read_text(encoding="utf-8")
d = tmp_path / "d"
gen.generate(replace(SMALL, out=d), d)
assert _digest(a) == _digest(d) # путь результата не попадает в файлы
assert _digest(a) != _digest(c)
def test_out_not_empty(tmp_path, capsys):
out = tmp_path / "res"
gen.generate(SMALL, out)
with pytest.raises(FileExistsError):
gen.generate(SMALL, out)
junk = out / "old.txt"
junk.write_text("старое", encoding="utf-8")
gen.generate(SMALL, out, force=True)
assert not junk.exists()
assert (out / "README.md").is_file()
args = ["--set", "group_size=3", "--set", "periods=1", "--set", "osv_counterparties_mean=3", "--out", str(out)]
assert main(args) == 3
assert "не пуст" in capsys.readouterr().err
assert main([*args, "--force"]) == 0
assert str(out) in capsys.readouterr().out
def test_default_out_is_prefix_dir(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
assert main(["--set", "group_size=2", "--set", "periods=1", "--set", "osv_counterparties_mean=2", "--set", "prefix=ZZ"]) == 0
assert (tmp_path / "out" / "ZZ" / "README.md").is_file()
@pytest.mark.parametrize(
"overrides",
[
{"group_size": 2},
{"group_size": 40, "periods": 1, "osv_counterparties_mean": 10},
{"no_inn_companies": 5},
{"vgo_mismatch_share": 1},
{"start": "2025-Q4", "periods": 2},
],
ids=["size2", "size40", "all-no-inn", "all-mismatch", "from-q4"],
)
def test_edge_configs(tmp_path, overrides):
base = {"osv_counterparties_mean": 4, "periods": 1, **overrides}
cfg = load_config(None, [f"{k}={v}" for k, v in base.items()])
manifest = gen.generate(cfg, tmp_path / "x")
readme = (tmp_path / "x" / "README.md").read_text(encoding="utf-8")
assert readme.strip()
if not manifest.pairs:
assert "Пар ВГО нет" in readme
def _dec(v):
from decimal import Decimal
return Decimal(str(v))
def test_readme_lists_mismatches(tmp_path):
cfg = replace(SMALL, group_size=5, vgo_mismatch_share=_dec(1))
manifest = gen.generate(cfg, tmp_path / "x")
readme = (tmp_path / "x" / "README.md").read_text(encoding="utf-8")
mismatches = [(p, m) for p in manifest.pairs for m in p.mismatches]
assert mismatches
for p, m in mismatches:
line = next((ln for ln in readme.splitlines() if p.a in ln and "→" in ln and m.period_key in ln and f"{m.pct}" in ln), None)
assert line is not None, (p.a, p.b, m)
assert "выше порога" in line or "ниже порога" in line
def test_readme_notes(default_run):
out, _ = default_run
readme = (out / "README.md").read_text(encoding="utf-8")
for needle in ("90.01", "62", "76", "80", "58", "За 3 месяца", "вариантн"):
assert needle in readme
assert "не оракул" in readme
@pytest.mark.slow
def test_large_run_time(tmp_path):
cfg = Config(group_size=15, periods=6, osv_counterparties_mean=150)
t0 = time.monotonic()
manifest = gen.generate(cfg, tmp_path / "big")
elapsed = time.monotonic() - t0
print(f"large run: {elapsed:.1f} s, files={len(manifest.files)}")
assert elapsed < 300