#!/usr/bin/env python3 """ВТОРАЯ ЯЗЫКОВАЯ ПАРА — en→ru на Kristoff, «Empire of the Dawn». Зачем. Промт эксп-21 заказывал одну пару (zh→ru), и первая редакция мерила топологию только на ней. Но цель проекта №1 канона — ОДИН движок на все пары, и вывод о топологии, снятый на одной паре, этот вопрос не закрывает: ревью-вопрос по умолчанию «заработает ли пара, которой в репо ещё НЕТ, без правки Go?» здесь звучит как «переживёт ли вывод смену пары». Расширение — по слову владельца 07.08. Почему именно эта книга. Собственная проба контаминации пака: `kristoff-en` узнаётся моделью 1/5 и даёт 0/5 по клоуз-тесту, тогда как `gu-zhenren` — 4/5 узнавания при пороге «≥3/5 = книга модели ИЗВЕСТНА». То есть материал второй пары ЧИЩЕ основного, и часть оговорки «эмпирика на претрейн-классике предварительна» (строка 55 бэклога) закрывается именно здесь. Что тут особенного — ЭДЖ-КЕЙС, названный владельцем. Английский текст несёт французский культурный слой тремя разными классами, и переводчик обязан развести их по-разному: * имена и топонимы (`François`, `Fabién`, `Château`, `Lumière`) — передаются по ФРАНЦУЗСКИМ правилам чтения, а не по английским, и одинаково на всю книгу; * вставные французские реплики (`Merci, saint-père`, `mon dieu`) — чужая речь внутри чужой, её сохранение/перевод есть решение, а не мелочь; * офранцуженная английская лексика (`façade`) — обычное русское слово. Отбор единиц СТРАТИФИЦИРОВАН по этому признаку: половина с французским слоем, половина без. Стратификация объявлена ДО покупок и позволяет читать эффект отдельно на трудном материале. Банкноты у армов этой пары НЕТ — объявленная девиация: подписанного глоссария для книги не существует, а выдумывать канон полигон не вправе (CLAUDE.md). Следствие: контрасты пары мерят ЧИСТУЮ топологию, без фактора банка; фактор банка мерится на zh контрастом A_law против A. Запуск: --units инвентарь и стратификация ($0) --arms черновики и армы --judge судейство контрастов --score свод ($0) """ from __future__ import annotations import html import json import re import sys import time import zipfile from pathlib import Path REPO = Path("/home/ubuntu/projects/textmachine") for sub in ("editor_contract", "bank_arbitration", "editor_harness", "role_topology"): sys.path.insert(0, str(REPO / "eval" / sub)) import battery as B # noqa: E402 import buy as BUY # noqa: E402 import editor_wire_probe as P # noqa: E402 import judges as J # noqa: E402 from bakeoff import boot_ci, client, uid_of # noqa: E402 import statistics # noqa: E402 EPUB = Path.home() / "books" / "Kristoff_Jay_-_Empire_of_the_Vampire_3_-_Empire_of_the_Dawn.epub" OUT = Path.home() / "books" / "role-topology" N_PER_STRATUM = 6 UNIT_CHARS = 1600 REPS_PER_ORDER = 1 CEIL_ARMS = 0.55 CEIL_JUDGE = 1.45 LED_ARMS = BUY.Ledger("армы en→ru", CEIL_ARMS, ("en2-*.json",), default_expect=0.03) LED_JUDGE = BUY.Ledger("судейство en→ru", CEIL_JUDGE, ("jve-*.json",), default_expect=0.008) DRAFT_MODEL = "deepseek-v4-flash" ARMS = { "A": dict(model="deepseek-v4-pro", contract="full", think=None), "B": dict(model="glm-5", contract="full", think="off"), "D": dict(model="deepseek-v4-pro", contract="direct-reflow", think=None), "D_": dict(model="deepseek-v4-pro", contract="direct", think=None), } CONTRASTS = [("A", "F"), ("B", "F"), ("D", "A"), ("D", "D_")] # Французский слой опознаётся по диакритике — признак механический и не требует словаря. _RE_FR = re.compile(r"\b\w*[éèêëàâäçôöûùüïî]\w*\b") _RE_LAT = re.compile(r"[A-Za-zÀ-ɏ]") def raw_text() -> str: z = zipfile.ZipFile(EPUB) parts = [] for n in sorted(x for x in z.namelist() if x.endswith((".xhtml", ".html", ".htm"))): t = re.sub(r"<[^>]+>", " ", z.read(n).decode("utf-8", "ignore")) parts.append(re.sub(r"[ \t]+", " ", html.unescape(t))) return "\n".join(parts) def chunks() -> list[str]: """Единицы боевого размера, нарезанные ПО ГРАНИЦАМ ПРЕДЛОЖЕНИЙ. Рвать посреди фразы нельзя: обрубок меняет задачу переводчика и портит сравнение армов. """ text = raw_text() sents = re.split(r"(?<=[.!?”\"])\s+", text) out, buf = [], "" for s in sents: buf += s + " " if len(buf) >= UNIT_CHARS: out.append(buf.strip()) buf = "" return [c for c in out if len(c) <= UNIT_CHARS * 1.4] _SERVICE = ("also by ", "about the author", "copyright", "all rights reserved", "isbn", "newsletter", "st. martin", "thank you for buying", "table of contents", "for email updates", "click here", "acknowledgments") def is_prose(c: str) -> bool: """Проза ли это, или служебная страница книги. ⚠ Заведено 07.08 по адверсариальному ревью: единица `129b73ad5a` страты `plain` оказалась выходными данными и библиографией («ALSO BY JAY KRISTOFF», 18 названий, копирайт, рассылка). Армы её ПЕРЕВОДИЛИ, и она ушла в судейство и в слепое чтение. Фильтр страты был «нет токенов с диакритикой» — служебные страницы он притягивает по построению, потому что диакритики там нет никогда. Два сигнала, оба механические: маркеры издательского аппарата и ПЛОТНОСТЬ ЗАВЕРШЁННЫХ ПРЕДЛОЖЕНИЙ. У прозы точек/восклицаний/вопросов на 1000 знаков много; у списка названий и выходных данных — мало, там строки без терминальной пунктуации. """ low = c.lower() if any(m in low for m in _SERVICE): return False return len(re.findall(r"[.!?]", c)) / max(1, len(c)) * 1000 >= 8.0 MANIFEST = OUT / "manifest-en.json" def units(n_per_stratum: int | None = None, freeze: bool = False) -> list[dict]: """Единицы английского среза — СТРОГО из приколотого манифеста. ⚠ Прошлая редакция этого прикола (14.08) была декоративной и вредной, и оба дефекта нашла вычитка. Первый: подпись считалась как sha1 от `source`, а `uid` — как sha1 от `source.strip()` при уже обрезанных текстах, то есть подпись выводилась ИЗ ТОГО ЖЕ, что и ключ, и расхождение было непредставимо в принципе. Второй, дороже: манифест перезаписывался при ЛЮБОМ вызове, включая чтение, — дефолтный `units()` из `material.units_en()` ужимал приколотые 16 единиц оси Д3 до своих 12 и молча ронял прикол четырёх. Теперь: чтение НИКОГДА не пишет, заморозка — явный акт (`freeze=True`), а расхождением считается отсутствие приколотого uid среди пересчитанных кандидатов. Это проверяет то, ради чего прикол заводился: смена `UNIT_CHARS`, правка `is_prose` или до-качка epub меняют uid, приколотый исчезает из пула — и покупка останавливается вместо тихой смены текстов. Наборы разного размера живут порознь: ось эксп-21 заморожена на 12 единиц, ось Д3 на 16. """ n = n_per_stratum or N_PER_STRATUM picked = _units_raw(n) man = json.loads(MANIFEST.read_text(encoding="utf-8")) if MANIFEST.exists() else {} sets = man.get("sets", {}) if freeze: sets[str(n)] = [u["uid"] for u in picked] MANIFEST.parent.mkdir(parents=True, exist_ok=True) MANIFEST.write_text(json.dumps({"sets": sets}, ensure_ascii=False, indent=1), encoding="utf-8") return picked want = sets.get(str(n)) if want is None: raise SystemExit(f"⛔ набор n_per_stratum={n} не заморожен в {MANIFEST}: " f"прогнать eval/role_topology/pair_en.py --freeze {n} до покупок") have = {u["uid"]: u for u in picked} miss = [u for u in want if u not in have] if miss: raise SystemExit(f"⛔ ЕДИНИЦЫ РАЗЪЕХАЛИСЬ С МАНИФЕСТОМ en: нет {miss[:3]} — " "источник или отбор изменились под купленными клетками, СТОП") return [have[u] for u in want] def _units_raw(n_per_stratum: int | None = None) -> list[dict]: """12 единиц: 6 с французским слоем, 6 без. Отбор детерминированный — по длине внутри страты. Порог страты `fr`: ≥4 токена с диакритикой, из них ≥2 РАЗНЫХ. Одиночное `façade` французским слоем не является, и страту им засорять нельзя. """ fr, plain = [], [] for c in chunks(): if not is_prose(c): continue toks = _RE_FR.findall(c) if len(toks) >= 4 and len(set(toks)) >= 2: fr.append(c) elif not toks: plain.append(c) out = [] n_take = n_per_stratum or N_PER_STRATUM if n_take > min(len(fr), len(plain)): raise SystemExit(f"⛔ страта меньше запроса: fr={len(fr)} plain={len(plain)} < {n_take}") for name, pool in (("fr", fr), ("plain", plain)): # ⚠ Ключ сортировки — (длина, uid), а НЕ одна длина. Поймано исполнением 07.08: два # вызова подряд вернули разные наборы единиц. Причина — `sorted(set(...), key=len)`: # при равной длине порядок задаётся обходом МНОЖЕСТВА строк, а он рандомизирован по # процессам (хеш-сид). Отбор при этом выглядел детерминированным. Последствие было бы # тихим и дорогим: армы покупаются на один набор, судейство идёт по другому. pool = sorted(set(pool), key=lambda c: (len(c), uid_of(c))) # Окно ЦЕНТРИРОВАНО, поэтому расширение страты вкладывает прежний набор в новый: # lo(n+2) = lo(n) − 1 при любой чётности длины пула, значит купленные единицы остаются # в наборе и не переназначаются. Проверяется вложением ниже по манифесту. lo = (len(pool) - n_take) // 2 for c in pool[lo:lo + n_take]: out.append(dict(source=c, uid=uid_of(c), stratum=name, fr_tokens=sorted(set(_RE_FR.findall(c))))) return out PAIR = Path(__file__).parent / "prompts" / "en-ru" BRIEF = {"source_lang": "en", "target_lang": "ru", "genre": "тёмное фэнтези", "audience": "взрослые читатели фэнтези", "title": "Empire of the Dawn", "venuti": "0.60", "honorifics": "keep", "transcription": "practical", "footnotes": "minimal"} def render_pair(path: Path, text: str, draft: str) -> tuple[str, str]: """Рендер пар-пакета ТЕМ ЖЕ путём, что боевой: снятие комментариев, дроп FEWSHOT, подстановка. ⚠ Флаг `en=True` харнесса экспов 18–20 сюда НЕ годится и был пойман исполнением: он означает «переводить НА английский» (трек zh→en экспа-19), и первая сборка армов этой пары уехала бы с промптом «editor of a translation into English» и брифом «с языка zh на язык en». Здесь источник английский, цель русская — это другая пара, и ей нужен свой пар-пакет. """ canon = P.strip_comments(path.read_text(encoding="utf-8")) sys_part, user = canon.split(P.USER_SEP, 1) core = sys_part.split(P.FEWSHOT_SEP, 1)[0].strip() old = P.RENDER_VARS P.RENDER_VARS = BRIEF try: return P.render(core, text, draft), P.render(user.strip(), text, draft) finally: P.RENDER_VARS = old def translator_msgs(text: str) -> tuple[str, str]: return render_pair(PAIR / "translator.md", text, "") def build_msgs(arm: str, u: dict, draft: str) -> list[dict]: tpl = {"full": "editor.md", "direct": "translator.md", "direct-reflow": "translator-reflow.md"}[ARMS[arm]["contract"]] sys_msg, user = render_pair(PAIR / tpl, u["source"], draft) return [{"role": "system", "content": sys_msg}, {"role": "user", "content": user.strip()}] def run_draft(u: dict) -> dict: sys_msg, user = translator_msgs(u["source"]) kw = dict(model=DRAFT_MODEL, max_tokens=16000, extra_body={"reasoning_effort": "low"}, messages=[{"role": "system", "content": sys_msg}, {"role": "user", "content": user}]) # Эхо-мина у DeepSeek описана для плотного CJK; на латинице класс не подтверждён, поэтому # сторож здесь ловит не «ханьцзы», а НЕПЕРЕВЕДЁННУЮ ЛАТИНИЦУ — тот же дефект, другой алфавит. for attempt in (1, 2, 3): tag = f"en2-DRAFT-{u['uid']}" + ("" if attempt == 1 else f"-r{attempt}") rec = BUY.purchase(LED_ARMS, tag, DRAFT_MODEL, client(DRAFT_MODEL), kw, arm="DRAFT", uid=u["uid"], attempt=attempt) if rec.get("skipped") or latin_share(rec["content"]) <= 0.15: return rec print(f" ⚠ латиница в черновике {u['uid'][:6]} (попытка {attempt}) — ре-ген") return rec def latin_share(text: str) -> float: letters = [c for c in text if c.isalpha()] return len(_RE_LAT.findall("".join(letters))) / max(1, len(letters)) def draft_text(uid: str) -> str: f = OUT / f"en2-DRAFT-{uid}.json" best = "" for g in sorted(OUT.glob(f"en2-DRAFT-{uid}*.json")): c = json.loads(g.read_text(encoding="utf-8")).get("content", "") if c and latin_share(c) <= 0.15: best = c return best or (json.loads(f.read_text(encoding="utf-8"))["content"] if f.exists() else "") def draft_cost(uid: str) -> float: return sum(json.loads(g.read_text(encoding="utf-8")).get("cost_usd", 0.0) for g in OUT.glob(f"en2-DRAFT-{uid}*.json")) def run_arm(arm: str, u: dict) -> dict: spec = ARMS[arm] model = spec["model"] kw: dict = dict(model=model, messages=build_msgs(arm, u, draft_text(u["uid"])), max_tokens=16000, temperature=0.4) if spec["think"] == "off": if not model.startswith("glm"): raise SystemExit(f"гашение размышления у {model} не описано в quirks — не гадаю") kw["extra_body"] = {"thinking": {"type": "disabled"}} return BUY.purchase(LED_ARMS, f"en2-{arm}-{u['uid']}", model, client(model), kw, arm=arm, uid=u["uid"], contract=spec["contract"], think=spec["think"]) def text_of(arm: str, u: dict) -> str: if arm == "F": return draft_text(u["uid"]) f = OUT / f"en2-{arm}-{u['uid']}.json" return json.loads(f.read_text(encoding="utf-8"))["content"] if f.exists() else "" # --- ФРАНЦУЗСКИЙ СЛОЙ: детерминированная проверка, судья не нужен ------------------------------- def check_french(source: str, final: str) -> dict: """Как обошлись с французским слоем. Три числа, каждое читается само по себе. `leaked` — французский токен остался ЛАТИНИЦЕЙ в русском тексте. Это дефект передачи: читатель кириллического издания получает нечитаемое слово. `rendered` — токен передан кириллицей хотя бы раз (передача состоялась). `split` — один и тот же французский токен передан РАЗНЫМИ кириллическими формами внутри одной единицы. Это прокси главной продуктовой боли — консистентности имени на всю книгу; глоссария у книги нет, поэтому мерится расщепление внутри окна, а не против канона. """ toks = sorted({t for t in _RE_FR.findall(source) if len(t) >= 4}) leaked = [t for t in toks if re.search(rf"\b{re.escape(t)}\b", final)] # Кириллические кандидаты-передачи: слова с заглавной буквы вокруг позиций, где стоял токен. cyr_words = re.findall(r"\b[А-ЯЁ][а-яё]+(?:-[А-ЯЁ]?[а-яё]+)?\b", final) forms: dict[str, set[str]] = {} for t in toks: head = _fold(t[:3]) cand = {w for w in cyr_words if _fold_cyr(w[:3]) == head} if cand: forms[t] = cand split = {t: sorted(v) for t, v in forms.items() if len(v) > 1} return dict(fr_tokens=len(toks), leaked=leaked, leaked_n=len(leaked), rendered_n=len(forms), split=split, split_n=len(split)) _FR2LAT = str.maketrans("éèêëàâäçôöûùüïî", "eeeeaaacoouuuii") _CYR2LAT = {"а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ж": "j", "з": "z", "и": "i", "й": "i", "к": "k", "л": "l", "м": "m", "н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t", "у": "u", "ф": "f", "х": "h", "ц": "c", "ч": "c", "ш": "s", "щ": "s", "ы": "i", "э": "e", "ю": "u", "я": "a", "ё": "e"} def _fold(s: str) -> str: return s.lower().translate(_FR2LAT) def _fold_cyr(s: str) -> str: return "".join(_CYR2LAT.get(c, c) for c in s.lower()) def cmd_units() -> None: us = units() print(f"единиц {len(us)} · страты: " f"{ {s: sum(1 for u in us if u['stratum'] == s) for s in ('fr', 'plain')} }") for u in us: print(f" {u['uid'][:6]} [{u['stratum']:5s}] {len(u['source']):5d} зн · " f"фр-токенов {len(u['fr_tokens']):2d} {u['fr_tokens'][:6]}") def cmd_arms() -> None: us = units() print(f"единиц {len(us)} · армов {len(ARMS)} + черновик · потолок ${CEIL_ARMS}") for u in us: rec = run_draft(u) if rec.get("skipped"): print("⛔ потолок — стоп") return print(f" DRAFT {u['uid'][:6]} [{u['stratum']:5s}] {len(rec['content']):5d} зн " f"латиница {latin_share(rec['content']):.3f} ${rec['cost_usd']:.5f}") time.sleep(0.2) for arm in ARMS: for u in us: rec = run_arm(arm, u) if rec.get("skipped"): print("⛔ потолок — стоп") return print(f" {arm:3s} {u['uid'][:6]} finish={rec.get('finish','?'):8s} " f"{len(rec['content']):5d} зн размышл {rec.get('reasoning_tokens',0):6d} " f"${rec['cost_usd']:.5f}") time.sleep(0.2) print(f"\nпотрачено ${LED_ARMS.spent():.6f}") def cmd_judge() -> None: us = units() total = len(us) * len(CONTRASTS) * len(J.JUDGES) * 2 * REPS_PER_ORDER print(f"голосов к покупке {total} · потолок ${CEIL_JUDGE}") n = 0 for a, b in CONTRASTS: for u in us: ta, tb = text_of(a, u), text_of(b, u) if not ta.strip() or not tb.strip(): print(f" ⚠ {a}/{b} {u['uid'][:6]}: пустой выход — пропуск") continue for judge in J.JUDGES: for order in (0, 1): for rep in range(1, REPS_PER_ORDER + 1): v1, v2 = (ta, tb) if order == 0 else (tb, ta) rec = BUY.purchase( LED_JUDGE, f"jve-{a}v{b}-{u['uid']}-o{order}-{judge}-r{rep}", judge, J.client(judge), J.vote_kw(judge, J.packet(u["source"], v1, v2)), contrast=f"{a}v{b}", uid=u["uid"], order=order, judge=judge, stratum=u["stratum"]) if rec.get("skipped"): print("⛔ потолок судейства — стоп") return n += 1 time.sleep(0.15) print(f" {a} против {b}: голосов {n} · ${LED_JUDGE.spent():.4f}") print(f"\nголосов {n} · ${LED_JUDGE.spent():.6f}") def _margins(a: str, b: str, uid: str, judge: str) -> dict[int, list[float]]: out: dict[int, list[float]] = {} for order in (0, 1): for rep in range(1, REPS_PER_ORDER + 1): f = OUT / f"jve-{a}v{b}-{uid}-o{order}-{judge}-r{rep}.json" if not f.exists(): continue p = J.parse(json.loads(f.read_text(encoding="utf-8"))["content"]) if p["В1-ВЕРНОСТЬ"] is None: continue e1 = sum(p[f"В1-{ax}"] or 0 for ax in J.AXES) e2 = sum(p[f"В2-{ax}"] or 0 for ax in J.AXES) out.setdefault(order, []).append((e2 - e1) if order == 0 else (e1 - e2)) return out def cmd_score() -> None: us = units() print("ПАРНЫЕ ВЕРДИКТЫ en→ru. Знак перевеса обязан совпасть в ОБОИХ порядках у ОБОИХ судей.\n") print(f"{'контраст':14s}{'ед.':>5s}{'за перв.':>10s}{'за втор.':>10s}{'ничья':>7s}" f"{'перевес':>10s}{'95% ДИ':>18s}{'фр-страта':>11s}{'plain':>8s}") print("-" * 93) for a, b in CONTRASTS: wa = wb = tie = 0 per_unit, by_str = [], {"fr": [], "plain": []} for u in us: pj, ms = {}, [] for judge in J.JUDGES: bo = _margins(a, b, u["uid"], judge) if len(bo) < 2: continue m0, m1 = statistics.mean(bo[0]), statistics.mean(bo[1]) ms += [m0, m1] pj[judge] = 1 if (m0 > 0 and m1 > 0) else (-1 if (m0 < 0 and m1 < 0) else 0) if len(pj) < len(J.JUDGES): continue per_unit.append(statistics.mean(ms)) by_str[u["stratum"]].append(statistics.mean(ms)) vals = set(pj.values()) wa += vals == {1} wb += vals == {-1} tie += vals not in ({1}, {-1}) if not per_unit: print(f"{a + ' пр. ' + b:14s}{'нет голосов':>20s}") continue lo, hi = boot_ci(per_unit) f_m = statistics.mean(by_str["fr"]) if by_str["fr"] else float("nan") p_m = statistics.mean(by_str["plain"]) if by_str["plain"] else float("nan") print(f"{a + ' пр. ' + b:14s}{wa + wb + tie:5d}{wa:10d}{wb:10d}{tie:7d}" f"{statistics.mean(per_unit):+10.2f} [{lo:+.2f}, {hi:+.2f}]{f_m:+11.2f}{p_m:+8.2f}") print("\nФРАНЦУЗСКИЙ СЛОЙ (детерминированно, судья не нужен)\n") print(f"{'арм':5s}{'ед.':>5s}{'фр-токенов':>12s}{'латиница':>10s}{'передано':>10s}" f"{'расщеплено':>12s}{'$/единицу':>11s}") print("-" * 65) frs = [u for u in us if u["stratum"] == "fr"] for arm in ("F", *ARMS): rows, costs = [], [] for u in frs: t = text_of(arm, u) if not t.strip(): continue rows.append(check_french(u["source"], t)) c = draft_cost(u["uid"]) if arm in ("F", "A", "B") else 0.0 f = OUT / f"en2-{arm}-{u['uid']}.json" if arm != "F" and f.exists(): c += json.loads(f.read_text(encoding="utf-8"))["cost_usd"] costs.append(c) if not rows: continue print(f"{arm:5s}{len(rows):5d}{sum(r['fr_tokens'] for r in rows):12d}" f"{sum(r['leaked_n'] for r in rows):10d}{sum(r['rendered_n'] for r in rows):10d}" f"{sum(r['split_n'] for r in rows):12d}" f"{(statistics.median(costs) if costs else 0):11.5f}") print("\nОБЩАЯ БАТАРЕЯ (языконезависимые проверки)\n") print(f"{'арм':5s}{'ед.':>5s}{'типогр.':>9s}{'ты/вы микс':>12s}") print("-" * 31) for arm in ("F", *ARMS): rows = [B.run_unit(B.Unit(source=u["source"], draft=draft_text(u["uid"]), final=text_of(arm, u), cards={})) for u in us if text_of(arm, u).strip()] if rows: print(f"{arm:5s}{len(rows):5d}{sum(r['typography']['violations'] for r in rows):9d}" f"{sum(r['ty_vy']['mixed_in_unit'] for r in rows):12d}") def cmd_freeze() -> None: """Заморозить набор единиц: `--freeze [n]`, по умолчанию n = N_PER_STRATUM. ⚠ Режим заведён 14.08 по ревью: сообщение о незамороженном наборе ссылалось на `--freeze`, которого в диспетчере НЕ БЫЛО, — подсказка врала. На свежем клоне без манифеста в это упирались `material.units_en`, `verify_report` и `blind_read`: все зовут `units()` без аргумента и получают СТОП, а выхода из CLI не существовало. Заморозка — намеренно ОТДЕЛЬНЫЙ режим: чтение никогда не пишет манифест, иначе прикол снова стал бы декоративным. """ n = int(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[2].isdigit() else N_PER_STRATUM us = units(n, freeze=True) print(f"заморожено: n_per_stratum={n} → единиц {len(us)} → {MANIFEST}") for u in us: print(f" {u['uid']} страта {u['stratum']:6s} знаков {len(u['source']):5d}") if __name__ == "__main__": cmd = {"--units": cmd_units, "--arms": cmd_arms, "--judge": cmd_judge, "--score": cmd_score, "--freeze": cmd_freeze}.get( sys.argv[1] if len(sys.argv) > 1 else "") if cmd: cmd() else: print(__doc__)