textmachine/eval/tenant_panel/bank.py

447 lines
29 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Ф0.5 — БАНК ТЕРМИНОВ СРЕЗА: майнинг кандидатов → терминолог-прогон → ручной канон.
Зачем банк нужен ДО первой панели. Закон-блок D39.104 обязателен во всех армах с переводом или
редактурой, а метрика ПОКРЫТИЯ КАНОНА — единственный контур, которым эксп-21 поймал, что редактор
без банка ломает терминологию черновика (0.917 → 0.566). Без банка эксп-22 остался бы без этого
контура на новой книге.
⚠ ГРАНИЦА, объявляю: это НЕ ось эксперимента. Наличие банка как ФАКТОР (строки 5/36б бэклога) —
чужой слот и здесь не варьируется: банк собирается один раз и подаётся ВСЕМ армам одинаково.
⚠ «РУЧНОЙ КАНОН ≠ ПОДПИСЬ ВЛАДЕЛЬЦА» (образец D39.47). Терминолог предлагает, сессия правит явные
промахи, и получившийся банк помечен `signed: false`. Он годен как ЗАКОН внутри замера (все армы
видят одно) и НЕ годен как продуктовый канон книги.
Запуск: --mine кандидаты и KWIC, $0
--ask терминолог-прогон (Ф0, платно)
--canon свести ответ + ручные правки в bank.json, $0
--show что в банке, $0
"""
from __future__ import annotations
import json
import os
import re
import sys
from collections import Counter
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
ZONE = Path(__file__).resolve().parent
sys.path.insert(0, str(ZONE))
sys.path.insert(0, str(REPO / "eval" / "bank_arbitration"))
from dotenv import load_dotenv # noqa: E402
from openai import OpenAI # noqa: E402
import material as M # noqa: E402
import money as MONEY # noqa: E402
import prompts as PR # noqa: E402
from roster import CANDIDATES # noqa: E402
load_dotenv(REPO / "eval" / ".env")
OUT = MONEY.OUT
# ⚠ ПАРА — ПАРАМЕТР, а не константа. Умолчание zh-ru сохраняет прежнее поведение побайтно;
# `configure(pair)` переводит модуль на другую пару, ничего не ветвя по языку в логике.
_PAIR = None # заполняется лениво, чтобы импорт не тянул провайдер
def pair():
global _PAIR
if _PAIR is None:
import importlib.util # noqa: PLC0415
sp = importlib.util.spec_from_file_location(
"para", REPO / "eval" / "dovodka" / "para.py")
mod = importlib.util.module_from_spec(sp)
sys.modules.setdefault("para", mod)
sp.loader.exec_module(mod)
_PAIR = mod.ZH
return _PAIR
def configure(p, material=None) -> None:
"""Перевести банк на пару `p` (объект `para.Pair`); `material` — callable() -> str.
⚠ Материал передаётся ЯВНО. Иначе `source_text()` тихо остаётся на китайских главах, и
терминолог получает китайскую книгу под видом английской — банк собрался бы «успешно» и
оказался мусором, а покрытие на выходе арма было бы посчитано против него же.
"""
global _PAIR, TERMINOLOGIST, _MATERIAL
_PAIR = p
_MATERIAL = material
# Порядок поиска: пар-пакет полигона → боевой промт бэкенда. Обратный порядок молча
# подсунул бы китайского терминолога английской книге; `backend/` — чужая зона, туда пишет
# бэкенд-сессия, поэтому новые пары живут в пакете полигона.
cands = [p.prompt_pack / "terminologist.md",
REPO / "backend" / "prompts" / p.key / "terminologist.md"]
TERMINOLOGIST = next((c for c in cands if c.exists()), None)
if TERMINOLOGIST is None:
raise SystemExit("⛔ нет промта терминолога для пары " + p.key + ": искал\n "
+ "\n ".join(str(c) for c in cands))
TERMINOLOGIST = REPO / "backend" / "prompts" / "zh-ru" / "terminologist.md"
# Терминолог — вспомогательный инструмент, не арм. Берётся дешёвая модель с управляемым
# размышлением: у боевого `deepseek-v4-flash` бэнк-роли ручки эффорта не имеют и упираются в
# стену (quirks §10 п.10, 4 батча из 5 пустые), а гасить thinking у DeepSeek запрещено.
TERM_MODEL = "gpt-5.6-luna"
N_TERMS = 26
MIN_COUNT = 6
KWIC_N, KWIC_W = 3, 46
# Служебные и грамматические знаки: n-грамма, начинающаяся или кончающаяся на них, термином не
# бывает. Список закрытый и объявлен ДО майнинга.
STOP_EDGE = set("的了是在和有我你他她们不就都而及与也很到说着过又却把被将从对为以之其这那一二三四五"
"六七八九十个人上下中大小时年日月来去出可能会要没什么样如此但只还更最")
# Начала предложений и служебные слова английского: заглавная буква у них позиционная, а не
# именная. Список закрытый и объявлен ДО майнинга, как и китайский.
STOP_WORD = {"The", "And", "But", "For", "She", "His", "Her", "They", "That", "This", "With",
"When", "What", "Then", "There", "Their", "Was", "Not", "You", "Who", "How",
"One", "All", "Now", "Him", "Its", "Had", "Have", "From", "Been", "Would"}
STOP_WHOLE = {"自己", "已经", "现在", "眼中", "心中", "一个", "什么", "这样", "那些", "如今",
"顿时", "此刻", "随后", "所以", "因为", "如果", "虽然", "而且", "然后", "似乎"}
_MATERIAL = None
# ⚠ КАССА СБОРКИ. Для zh — прежняя «Ф0» эксп-22, побайтно как было. Для новых пар банк есть
# покупка ФАЗЫ Д (пре-рег: «ФД-B — Д3 en→ru: банк, контам, армы, пол»), и биллить её в Ф0 значит
# сделать трату невидимой потолку фазы — тот же класс, что увёл 7 клеток пробы в чужой каталог.
_PHASE = "Ф0"
_TAGPFX = "tp0"
def _term_tag() -> str:
"""Тег покупки терминолога. Несёт пару, иначе кэш `purchase` смешивает пары."""
P = pair()
return "tp0-bank-terminolog" if P.key == "zh-ru" else f"{_TAGPFX}-bank-terminolog"
def source_text() -> str:
return _MATERIAL() if _MATERIAL else "\n".join(c["text"] for c in M.chapters())
def mine() -> list[tuple[str, int]]:
"""Кандидаты в термины: частые CJK n-граммы, не поглощённые более длинной n-граммой.
Метод грубый и объявлен таким: его задача — дать терминологу СПИСОК, решение принимает он
плюс ручная правка. Точность майнинга на банк не влияет — влияет полнота.
"""
text = source_text()
cnt: Counter[str] = Counter()
if pair().spaced_source:
# ⚠ У пробельной письменности n-граммный майнер по иероглифам не находит НИЧЕГО и молча
# отдаёт терминологу пустой список — банк вышел бы пустым, а покрытие ложно-стопроцентным.
# Кандидаты здесь — заглавные слова и биграммы заглавных: имена, титулы, топонимы.
# Диакритика ОБЯЗАТЕЛЬНА в классе: французский слой книги (François ×517, Fabién ×180,
# Château) ASCII-классом не майнится вовсе, а это ровно тот слой, ради которого срез
# стратифицирован. `\b` с юникодными буквами работает, класс — нет.
for m in re.finditer(r"(?<![^\W\d_])[A-ZÀ-Þ][a-zà-ÿ]{2,}"
r"(?:\s+[A-ZÀ-Þ][a-zà-ÿ]{2,})?(?![^\W\d_])", text):
w = m.group(0)
if w.split()[0] not in STOP_WORD:
cnt[w] += 1
# Кап тот же, что у иероглифической ветки: иначе терминолог получал бы втрое более
# длинный лист (78 против 26), а это другой размер запроса и другая цена — различие
# веток должно быть в СПОСОБЕ майнинга, не в объёме выдачи.
return [(w, c) for w, c in cnt.most_common() if c >= MIN_COUNT][:N_TERMS]
for n in (4, 3, 2):
for m in re.finditer(r"[%s]{%d}" % (pair().mine_chars, n), text):
cnt[m.group(0)] += 1
cand = {w: c for w, c in cnt.items()
if c >= MIN_COUNT and w not in STOP_WHOLE
and w[0] not in STOP_EDGE and w[-1] not in STOP_EDGE}
# Поглощение: короткая n-грамма выбрасывается, если длинная её содержит и почти так же часта.
out = {}
for w, c in sorted(cand.items(), key=lambda kv: (-len(kv[0]), -kv[1])):
if any(w in longer and c <= 1.25 * cc for longer, cc in out.items()):
continue
out[w] = c
return sorted(out.items(), key=lambda kv: -kv[1])[:N_TERMS]
def kwic(term: str, n: int = KWIC_N) -> list[str]:
text = source_text()
out, pos = [], 0
for _ in range(n):
i = text.find(term, pos)
if i < 0:
break
out.append(text[max(0, i - KWIC_W):i + len(term) + KWIC_W].replace("\n", " "))
pos = i + len(term) + 200
return out
def _payload() -> str:
rows = []
for t, c in mine():
rows.append(f"term: {t}\ncount: {c}\n" + "\n".join(f"ctx: {x}" for x in kwic(t)))
return "\n\n".join(rows)
def cmd_mine() -> None:
ms = mine()
print(f"кандидатов {len(ms)} (порог частоты {MIN_COUNT}, знаков в срезе {len(source_text()):,})")
for t, c in ms:
print(f" {t:6s} {c:4d} {kwic(t, 1)[0][:70] if kwic(t, 1) else ''}")
def cmd_ask() -> None:
core, _few, user = PR.load_template(TERMINOLOGIST)
# ⚠ Бриф — ПАРЫ, а не всегда китайский: иначе терминолог en/ja получал китайский бриф
# (source_lang zh, чужая книга) при английском пейлоаде и канонизировал бы вслепую.
brief = getattr(PR, pair().brief_key)
msgs = [{"role": "system", "content": PR.render(core, brief, "", "")},
{"role": "user", "content": PR.render(user, brief, _payload(), "")}]
led = MONEY.Guarded(_PHASE, default_expect=0.02)
base, env, *_ = CANDIDATES[TERM_MODEL]
cl = OpenAI(api_key=os.environ[env], base_url=base, timeout=600)
kw = dict(model=TERM_MODEL, messages=msgs, max_completion_tokens=4000,
extra_body={"reasoning_effort": "low"})
# ⚠ Тег НЕСЁТ ПАРУ: `purchase` возвращает существующий файл как кэш, и пар-слепой тег
# молча отдал бы оплаченный КИТАЙСКИЙ ответ терминолога под видом английского, за $0.
rec = MONEY.purchase(led, _term_tag(), TERM_MODEL, cl, kw, role="terminologist")
if rec.get("skipped"):
print("⛔ потолок Ф0 — терминолог не куплен")
return
print(rec.get("content", "")[:4000])
print(f"\nfinish={rec.get('finish')} ${rec.get('cost_usd', 0):.6f} · "
f"потрачено Ф0 ${led.spent():.6f}")
def _rx(dst: str) -> str:
"""Регекс канонной формы под русское словоизменение.
⚠ Правка ПОСЛЕ фриза Ф0 и ПОСЛЕ покупки терминолога (то есть $0 и на результат прогона не
влияет), объявляется как девиация. Первая версия усекала только слова от 6 знаков, и короткие
склоняемые слова канона («род Цинь») не находились в форме «рода Цинь» — метрика покрытия
занижалась бы на всех армах одинаково, но занижалась.
⚠⚠ ГРАНИЦА СЛОВА В НАЧАЛЕ — поправка фазы Д (адверсариальное ревью 10.08). Без неё усечённый
корень ловился В СЕРЕДИНЕ чужого слова: канон «род Цинь» матчился на «Го-РОд Цинчжоу» и
«Б-РОвь Цинь», канон «род Су» — на «Пилюлю Кровавой Су». Замер: **30 ложных попаданий из
2113 = 1.4%**; кросс-термовых коллизий (одна форма подходит под ДВА канона) нет ни одной.
Влияние на печатные числа: покрытие всех армов падает на 0.0030.004 — то есть В ОДНУ сторону
у всех, и ни один относительный вывод эксп-22 не двигается. Числа в отчёте пере-сняты.
`(?<![^\W\d_])` = «слева не буква»: `\b` здесь не годится, он пропускает начало после цифры.
"""
# ⚠⚠ МИНИМАЛЬНЫЙ СТЕМ — ПАРАМЕТР ПАРЫ (14.08). У китайского он 0, и правило ниже работает
# ровно как прежде: числа закрытой оси зависят от этих регексов и двигаться не имеют права.
# Для пробельных пар усечение на 2 знака КАТАСТРОФИЧНО: имена там короткие, и «Восс» давало
# `Во\w*`, ловившее «Возможно» и «Военные». Замер адверсариальный — английский банк прогнан
# по РУССКИМ выходам китайской оси, где попаданий быть не может по построению: 990 ложных
# срабатываний, сломано 9 терминов из 25. Канон-ось Д3 мерила бы шум, насыщенный у всех армов
# одинаково, то есть A4 и A0 стали бы неразличимы — и это было бы НЕВИДИМО в отчёте.
# Порог 6 выбран замером, а не на глаз: он снимает однокоренные коллизии (Империя/Император,
# Мёртвые/мёртвая) ценой одного склонения (Селена→Селены). Потеря занижает покрытие ОДИНАКОВО
# у всех армов — безопасная сторона, как и поправка границы слова 10.08.
# ⚠ Граница измерения: «Восс» остаётся префиксом «восстановить» при любом пороге (6 попаданий
# на 232 тыс. знаков). Это объявлено, а не вылечено.
ms = 0 if pair().key == "zh-ru" else 6
parts = []
for raw in dst.split():
w = re.escape(raw)
drop = 2 if len(raw) >= 4 else 1 if len(raw) == 3 else 0
drop = max(0, min(drop, len(raw) - ms)) if ms else drop
parts.append(w[:-drop] + r"\w*" if drop else (w + r"\w*" if ms else w))
return r"(?<![^\W\d_])" + r"\s+".join(parts)
# ⚠ РУЧНЫЕ ПРАВКИ КАНОНА. Пусто = терминолог принят как есть. Каждая строка — решение СЕССИИ,
# не владельца; основания печатаются `--canon` и едут в отчёт.
# ⚠ РУЧНЫЕ ПРАВКИ И ВЫБРОС — ПО ПАРАМ. Прежде оба списка были общими, и сборка ЛЮБОЙ пары
# подмешивала себе китайские данные: `MANUAL` инъектировался через `setdefault` в банк любой
# книги, а `DROP` (китайская общеязыковая лексика) уезжал в метаданные чужого банка. Сегодня
# `MANUAL` пуст, поэтому утечки не произошло — но это везение, а не устройство. Книжный термин
# в общем пар-слое есть утечка по канону проекта.
MANUAL_BY_PAIR: dict[str, dict[str, tuple[str, str]]] = {"zh-ru": {}}
DROP_BY_PAIR: dict[str, set[str]] = {"zh-ru": {"瞬间", "声音", "仿佛", "庭院"}}
def _manual() -> dict[str, tuple[str, str]]:
return MANUAL_BY_PAIR.get(pair().key, {})
def _drop() -> set[str]:
return DROP_BY_PAIR.get(pair().key, set())
MANUAL: dict[str, tuple[str, str]] = {}
# Выброшено из банка: это общеязыковая лексика, а не термины книги. Канон на них сделал бы
# метрику покрытия замером словарного совпадения, а не терминологической дисциплины.
DROP = {"瞬间", "声音", "仿佛", "庭院"}
def cmd_canon() -> None:
f = MONEY.OUT / f"{_term_tag()}.json"
if not f.exists():
print("нет ответа терминолога — сначала --ask")
return
ans = json.loads(f.read_text(encoding="utf-8"))["content"]
got: dict[str, str] = {}
for line in ans.splitlines():
if "\t" not in line:
continue
# ⚠ ТРИ поля, не два: формат терминолога v3 — термин · перевод · уверенность 0100.
# `split("\t", 1)` уносил хвост `\t95` в перевод и отравлял регекс канонной формы.
cols = [x.strip() for x in line.split("\t")]
if len(cols) < 2:
continue
src, dst = cols[0], cols[1]
if src and dst and "TM-NO-DST" not in dst:
got[src] = dst
terms = {}
for src, dst in got.items():
if src in _drop():
continue
if src in _manual():
dst, why = _manual()[src]
else:
why = ""
terms[src] = dict(dst=dst, rx=_rx(dst), manual=bool(why), why=why)
for src, (dst, why) in _manual().items():
terms.setdefault(src, dict(dst=dst, rx=_rx(dst), manual=True, why=why))
# ⚠ Пишем в банк ПАРЫ. Безусловная запись в zh-путь затирала бы китайский банк при сборке
# английского, и оба прогона переписывали бы друг друга.
P = pair()
out_file = PR.BANK_FILE if P.key == "zh-ru" else P.bank_file
out_file.parent.mkdir(parents=True, exist_ok=True)
out_file.write_text(json.dumps(
dict(book=M.BOOK["title"] if P.key == "zh-ru" else P.key, signed=False,
note="ручной канон сессии, НЕ подпись владельца (образец D39.47)",
model=TERM_MODEL, dropped=sorted(_drop()), terms=terms),
ensure_ascii=False, indent=1), encoding="utf-8")
print(f"банк собран: {len(terms)} терминов · ручных правок {sum(1 for v in terms.values() if v['manual'])}"
f" · выброшено {len(_drop())}")
cmd_show()
def cmd_show() -> None:
# ⚠ Банк ПАРЫ, а не умолчание: после сборки английского банка показывался китайский.
P = pair()
b = PR.bank(None if P.key == "zh-ru" else P.bank_file)
if not b:
print("банка нет")
return
src = source_text()
print(f"{'терм':8s}{'канон':34s}{'в срезе':>9s} регекс")
for t, v in sorted(b.items(), key=lambda kv: -src.count(kv[0])):
print(f"{t:8s}{v['dst']:34s}{src.count(t):9d} {v['rx']}"
+ (" ← ручная правка" if v.get("manual") else ""))
def coverage(source: str, out: str) -> tuple[int, int]:
"""(попаданий, знаменатель) канонной формы — метрика эксп-21 §16.1, счётная.
Знаменатель — упоминания термина В ИСХОДНИКЕ; попадания капятся знаменателем, иначе
многословный выход набирал бы покрытие повторами.
"""
k = s = 0
P = pair()
for t, v in PR.bank(P.bank_file if P.key != "zh-ru" else None).items():
# ⚠ Не `source.count(t)`: на пробельной письменности подстрока считает `Dawn` внутри
# `Dawnbreaker` и теряет `dawn` со строчной. Знаменатель — по границе слова.
n = P.count_in_source(t, source)
if n:
s += n
k += min(len(re.findall(v["rx"], out, re.I)), n)
return k, s
def selfcheck() -> int:
bad = 0
def chk(n: str, ok: bool, d: str = "") -> None:
nonlocal bad
bad += not ok
print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {n}" + (f" {d}" if d else ""))
chk("регекс усекает словоизменение", bool(re.search(_rx("истинная ци"), "истинной ци", re.I)))
chk("регекс не ловит чужое слово", not re.search(_rx("апертура"), "аперитив", re.I))
chk("регекс держит многословную форму",
bool(re.search(_rx("море истинной ци"), "Море истинной ци", re.I)))
# Регрессии на реальные формы банка: короткое склоняемое слово и прилагательное.
for canon, form in (("род Цинь", "роду Цинь"), ("род Цинь", "рода Циня"),
("левый канцлер", "левого канцлера"), ("глава рода", "главе рода"),
("Цинь Фэн", "Цинь Фэну"), ("Су Цинсюэ", "Су Цинсюэ"),
("Гу Хэ", "Гу Хэ"), ("Фэн-лао", "Фэн-лао")):
chk(f"«{canon}» находится в форме «{form}»", bool(re.search(_rx(canon), form, re.I)))
chk("канон не ловит другой род", not re.search(_rx("род Цинь"), "рода Су", re.I))
b = PR.bank()
if b:
t = next(iter(b))
k, s = coverage(source_text(), b[t]["dst"] * 3)
chk("покрытие не превышает знаменатель", k <= s, f"{k}/{s}")
chk("покрытие пустого выхода = 0", coverage(source_text(), "")[0] == 0)
print(f"\n{'БАНК-ОСНАСТКА ЧИСТА' if not bad else f'ПРОВАЛОВ: {bad}'}")
return bad
def _select_pair(argv: list[str]) -> None:
"""`--pair=en-ru` переводит сборку на пару ЦЕЛИКОМ: бриф, тег покупки, банк-файл, материал.
Материал берётся из ПРОЗЫ книги пары, а не из отобранных единиц: терминолог по замыслу роли
«видит всю книгу сразу», и канон, собранный по 16 отрывкам, не был бы каноном книги. Термин,
не встреченный в единице, покрытию не мешает — `coverage` пропускает нулевой знаменатель.
"""
key = next((a.split("=", 1)[1] for a in argv if a.startswith("--pair=")), "zh-ru")
if key == "zh-ru":
return
import importlib.util # noqa: PLC0415
sp = importlib.util.spec_from_file_location("para", REPO / "eval" / "dovodka" / "para.py")
para = importlib.util.module_from_spec(sp)
sys.modules["para"] = para
sp.loader.exec_module(para)
if key == "en-ru":
sp2 = importlib.util.spec_from_file_location(
"pair_en_b", REPO / "eval" / "role_topology" / "pair_en.py")
PE = importlib.util.module_from_spec(sp2)
sys.modules["pair_en_b"] = PE
sp2.loader.exec_module(PE)
mat = lambda: "\n".join(c for c in PE.chunks() if PE.is_prose(c)) # noqa: E731
elif key == "ja-ru":
sp2 = importlib.util.spec_from_file_location(
"mat_ja_b", REPO / "eval" / "dovodka" / "material_ja.py")
MJ = importlib.util.module_from_spec(sp2)
sys.modules["mat_ja_b"] = MJ
sp2.loader.exec_module(MJ)
mat = lambda: MJ.text() # noqa: E731
else:
raise SystemExit(f"⛔ пара {key} харнессу неизвестна")
# Касса фазы Д. `money_d.configure` перенастраивает ТОТ ЖЕ модуль `money`, который bank.py
# держит как MONEY, поэтому после загрузки OUT/TAGS/CEILINGS становятся фазовыми. Проверяем
# ассертом, а не верим: молчаливое расхождение здесь стоит невидимых денег.
global _PHASE, _TAGPFX
spm = importlib.util.spec_from_file_location(
"money_d_bank", REPO / "eval" / "dovodka" / "money_d.py")
MD = importlib.util.module_from_spec(spm)
sys.modules["money_d_bank"] = MD
spm.loader.exec_module(MD)
_PHASE = {"en-ru": "ФД-B", "ja-ru": "ФД-C"}[key]
_TAGPFX = {"en-ru": "dv-b", "ja-ru": "dv-c"}[key]
# ⚠ `money_d` настраивает СВОЙ экземпляр кассы (`M22`), а не тот `money`, который держит этот
# модуль, — ассерт ниже это и поймал. Настраиваем наш экземпляр явно, тем же приёмом, каким
# касса перенастраивается под любой пак, и лишь потом проверяем.
import roster as _ROSTER # noqa: PLC0415
# ⚠ ZONE — каталог того, кто ПОКУПАЕТ, а не каталог фазы: фриз-гейт сверяет зону вызывающего
# файла, и с `dovodka` он отверг бы сам bank.py. Ограничение при этом не ослаблено — это
# исходная зона кассы эксп-22; фриз-проверка чистоты `bank.py` остаётся в силе.
MONEY.configure(ZONE=Path(__file__).resolve().parent, OUT=MD.OUT, TAGS=MD.TAGS,
CEILINGS=MD.CEILINGS, PACK_CEILING=MD.PACK_CEILING,
GLOB_ALL="dv-*.json", ROSTER=_ROSTER)
if MONEY.OUT != MD.OUT:
raise SystemExit(f"⛔ касса банка {MONEY.OUT} против фазы {MD.OUT} — трата уедет мимо "
"потолка фазы, СТОП")
configure(para.PAIRS[key], material=mat)
print(f"пара: {key} · банк → {para.PAIRS[key].bank_file} · терминолог {TERMINOLOGIST}")
if __name__ == "__main__":
a = [x for x in sys.argv[1:] if not x.startswith("--pair=")] or ["--mine"]
_select_pair(sys.argv[1:])
fn = {"--mine": cmd_mine, "--ask": cmd_ask, "--canon": cmd_canon, "--show": cmd_show,
"--selfcheck": lambda: sys.exit(1 if selfcheck() else 0)}.get(a[0])
fn() if fn else print(__doc__)