#!/usr/bin/env python3
"""Собирает стартовый реестр метрик платформы: local/platform/data/metrics.json.
Вход: _canon.json (15 канонических метрик halleg) и _raw_descriptions.json (описания, зашитые в страницы отчётов).
Правила:
 - каноническая метрика = одна запись реестра с id из канона (snake_case);
 - описание из отчёта привязывается к канону по таблице синонимов ALIASES; расходится по сути - помечается deviation;
 - имена, которых в каноне нет, становятся отдельными записями со статусом needs_review;
 - Score заводится как ДВЕ метрики (score_scoreboard и score_tier): формулы несовместимы, канона нет;
 - подписи UI (вкладки, фильтры, блоки) в реестр метрик не идут, а складываются в legend_notes отчёта.
Реестр стартовый: пользователь предупредил, что состав и определения будут перебираться."""
import re, json, os, unicodedata
from collections import defaultdict, OrderedDict
ROOT = os.path.join(os.path.dirname(__file__), '..', 'platform', 'data')
canon = json.load(open(os.path.join(ROOT, '_canon.json')))
raw = json.load(open(os.path.join(ROOT, '_raw_descriptions.json')))
TODAY = '2026-09-21'

# canon_id из легенды -> id реестра (наши идентификаторы, читаемый snake_case)
CANON_ID = {'payin-volume': 'payin_volume', 'payout-volume': 'payout_volume', 'settlement': 'settlement',
            'settle-payin-pct': 'settle_payin_pct', 'cr': 'cr', 'auto-pct': 'auto_pct', 'statement-pct': 'statement_pct',
            'active-traders': 'active_traders', 'active-accounts': 'active_accounts', 'new-traders': 'new_traders',
            'avg-payin-trader': 'avg_payin_trader', 'deposit': 'deposit', 'payin-speed': 'payin_speed',
            'payout-speed': 'payout_speed', 'capacity': 'capacity'}

def norm(n):
    n = unicodedata.normalize('NFKC', n).lower().replace('ё', 'е')
    n = re.sub(r'[\s,%₹$#:]+', ' ', n).strip()
    return n

# имя в отчёте -> id реестра. Не в каноне: id придуман нами (snake_case), статус needs_review.
ALIASES = {
 # PayIn Volume
 'payin volume': 'payin_volume', 'обсяг payin': 'payin_volume', 'volume': 'payin_volume', 'volume inr': 'payin_volume',
 'сумарний payin inr': 'payin_volume', 'сума payin mtd': 'payin_volume', 'payin': 'payin_volume', 'amount in': 'payin_volume',
 'total payin': 'payin_volume', 'δ payin': 'payin_volume',
 # PayOut Volume
 'payout volume': 'payout_volume', 'обсяг payout': 'payout_volume', 'сума payout mtd': 'payout_volume', 'payout': 'payout_volume',
 'amount out': 'payout_volume', 'total payout': 'payout_volume', 'δ payout': 'payout_volume',
 # Settlement
 'settlement': 'settlement', 'сетлмент': 'settlement', 'settle inr': 'settlement', 'сетл': 'settlement', 'δ сетл': 'settlement',
 'settlement mtd (pi − po)': 'settlement',
 # Settle/PayIn %
 'settle/payin': 'settle_payin_pct', 'сетл/payin': 'settle_payin_pct', 'settle': 'settle_payin_pct', 'сетл': 'settle_payin_pct',
 # CR
 'cr': 'cr', 'cr (conversion rate)': 'cr', 'total cr': 'cr', 'середній cr': 'cr', 'δ cr': 'cr',
 # % Auto
 'auto': 'auto_pct', 'авто': 'auto_pct', 'autopayin': 'auto_pct', 'середній автоматичних': 'auto_pct',
 # Statement
 'statement': 'statement_pct', 'виписка': 'statement_pct', 'stmt': 'statement_pct', 'завантаження виписок': 'statement_pct',
 # Active Traders
 'active traders': 'active_traders', 'активні трейдери': 'active_traders', 'active traders unique': 'active_traders',
 'унікальні активні трейдери': 'active_traders', 'кількість активних трейдерів': 'active_traders', 'активних': 'active_traders',
 'активних трейдерів': 'active_traders', 'активних трейдерів mtd': 'active_traders', 'δ актив.': 'active_traders',
 'active (this / last week)': 'active_traders', 'active (traders)': 'active_traders', 'активні': 'active_traders', 'трейдерів': 'active_traders',
 # Active Accounts
 'active accounts': 'active_accounts', 'активні рахунки': 'active_accounts', 'кількість використаних рахунків': 'active_accounts', 'ba': 'active_accounts',
 # New Traders
 'new traders': 'new_traders', 'нові трейдери': 'new_traders', 'нових трейдерів': 'new_traders', 'запущено': 'new_traders',
 'запущено трейдерів mtd': 'new_traders', 'δ запущ.': 'new_traders', 'new': 'new_traders',
 # Avg PayIn/Trader
 'avg payin/trader': 'avg_payin_trader', 'сер. payin/трейдер': 'avg_payin_trader', 'avg payin per trader': 'avg_payin_trader',
 'avg payin / трейдера': 'avg_payin_trader',
 # Deposit
 'deposit': 'deposit', 'deposit volume': 'deposit', 'σ unique deposit': 'deposit',
 # Speeds
 'payin speed (median min)': 'payin_speed', 'payin speed (average min)': 'payin_speed', 'payin speed (p90 min)': 'payin_speed',
 'pi p90': 'payin_speed', 'sla in': 'payin_speed', 'середній sla in': 'payin_speed', 'avg speed': 'payin_speed', 'avg total time': 'payin_speed',
 'payout speed (median min)': 'payout_speed', 'payout speed (average min)': 'payout_speed', 'payout speed (p90 min)': 'payout_speed',
 'po p90': 'payout_speed', 'sla out': 'payout_speed', 'середній sla out': 'payout_speed',
 # Score: две несовместимые формулы
 'score': None,  # решается по отчёту, см. score_for()
 'avg score': None, 'avg score (трейдерів)': None, 'avg score за тиждень': None, 'скоринг': None,
 # прочее, что явно НЕ канон, но повторяется
 'churn 7d': 'churn_7d', 'churn 7d %': 'churn_7d_pct', 'churn formal': 'churn_formal', 'churn formal %': 'churn_formal_pct',
 'churned (відтік)': 'churn_7d', 'churn rate': 'churn_7d_pct', 'churned mtd (кількість + rate)': 'churn_7d', 'churn': 'churn_7d',
 'трейдерів що «випали»': 'churn_7d',
 'reactivated': 'reactivated', 'net retention': 'net_retention', 'tier': 'tier', 'zone': 'zone', 'days': 'active_days', 'active days': 'active_days',
 'avg vol/day': 'avg_payin_day', 'сер/день': 'avg_payin_day', 'avg/день': 'avg_payin_day', 'avg daily payin': 'avg_payin_day',
 'arpu': 'arpu', 'arpu / avg check': 'arpu', 'payout arpu': 'payout_arpu', 'avg daily payout': 'avg_payout_day',
 'total requests': 'payin_requests', 'requests': 'payin_requests', 'success': 'payin_success', 'reject': 'payin_reject',
 'n transactions': 'n_transactions', 'velo in': 'velocity_in', 'velo out': 'velocity_out',
 'нові кабінети': 'new_cabinets', 'нові каб.': 'new_cabinets', 'нових кабінетів mtd': 'new_cabinets', 'δ нові каб.': 'new_cabinets',
 'vol/dep ratio': 'vol_dep_ratio', 'avg active traders': 'avg_active_traders', 'сер. активні трейдери': 'avg_active_traders',
 'out pending': 'payout_pending_count', 'out pending #': 'payout_pending_count', 'out pending ₹': 'payout_pending_amount',
 'unassigned (#)': 'payin_unassigned_count', 'unassigned (₹)': 'payin_unassigned_amount',
 'plan (traders)': 'traders_on_shift_plan', 'avg accounts/hr': 'avg_accounts_per_trader_hour', 'status general (v1)': 'status_general',
 'first': 'first_payers_pct', 'second+': 'repeat_payers_pct', 'оборот g-i': 'turnover_gi', 'днів 20+': 'days_20plus',
 'last payin': 'last_payin_date', 'days since': 'days_since_payin', 'run-rate (amount/7)': 'run_rate',
 'payin / день (₹)': 'payin_per_day', 'payout / день (₹)': 'payout_per_day', 'settle / день ($)': 'settle_per_day',
 'всього афіліатів': 'affiliates_total', 'нових афіліатів mtd': 'new_affiliates', 'запущено афіліатів mtd': 'launched_affiliates',
 'sub-aff': 'sub_affiliates', 'sub-aff l2': 'sub_affiliates_l2', 'sub-aff l3': 'sub_affiliates_l3',
 'realized so far': 'payin_realized_today', 'projected remaining': 'payin_projected_remaining', 'total day (mean)': 'payin_forecast_day',
 'total forecast (mean)': 'payin_forecast_day', 'sum forecast demand': 'payin_forecast_day',
 'sum recommended (24h)': 'recommended_traders', 'sum recommended (mean)': 'recommended_traders', 'sum recommended v2 (24h)': 'recommended_traders_v2',
 'sum recommended v2': 'recommended_traders_v2', 'demand multiplier': 'demand_multiplier', 'cheque per provider': 'cheque_multiplier', 'діапазон': 'recommended_traders_range',
 'max payin amount limit': 'max_payin_limit', 'upi увімкнені': 'upi_enabled',
}
# 'сетл' встречается в Affiliate Weekly как объём settlement; оставляем settlement
ALIASES['сетл'] = 'settlement'
ALIASES.update({'payin m': 'payin_volume', 'tier — count': 'tier_count', 'tier — volume inr': 'tier_volume',
                'payin / день': 'payin_per_day', 'payout / день': 'payout_per_day', 'settle / день': 'settle_per_day'})
ALIASES_EXACT = {re.sub(r'\s+', ' ', k.lower()).strip(): v for k, v in ALIASES.items()}
ALIASES_N = {}
for k, v in ALIASES.items():
    ALIASES_N[re.sub(r'\s*\(\s*\)', '', norm(k)).strip()] = v

def score_for(report):
    return 'score_tier' if report in ('Weekly_KPI_Dashboard', 'Affiliate_Tier_Board', 'Affiliate_Weekly_Report') else 'score_scoreboard'

# Подписи интерфейса (не метрики): идут в legend_notes отчёта
UI_RE = re.compile(r'^(вкладка|фильтры|фільтри|фільтр|модалка|login|audit log|permissions|top-nav|джерело даних|источник данных|'
                   r'тип периода|matrix|матрица|матриця|top-5|hour-to-hour|період from|kpi row|live processing|базові поняття|таблиця|'
                   r'гістограма|сумарні kpi|активність трейдерів|control kpis|payin / payout / settlement|кількість трейдерів за тиждень|'
                   r'воронка|тижнева|channel attribution|win/loss|нові метрики|⚠|trader accounts \(old\)|трейдери \(верхняя|тимлід фільтр|'
                   r'\$\{|\'\+|прогнозирование|статуси трейдерів|reactivation —|неактивні трейдери|losses —|выбор метрики|wow \(δ\)|'
                   r'churn у динаміці|топ втрат|менеджер кожного|стан|причина|напрям|потенціал|ризик|прогноз|днів до клас|scale|'
                   r'trader|трейдер|manager|менеджер|status|афіліат|active / risk / dormant|out pending \(|unassigned \(только|'
                   r'avg score per tier|топ-5 по|kpi: |остання .*зміна)', re.I)

# английские имена и группы для метрик вне канона, у которых id задан в ALIASES
NEW_NAMES = {'tier_count': 'Tier: Count', 'tier_volume': 'Tier: Volume', 'churn_7d': 'Churn 7D', 'churn_7d_pct': 'Churn 7D %', 'churn_formal': 'Churn Formal', 'churn_formal_pct': 'Churn Formal %',
 'reactivated': 'Reactivated', 'net_retention': 'Net Retention', 'tier': 'Tier', 'zone': 'Zone', 'active_days': 'Active Days',
 'avg_payin_day': 'Avg PayIn / Day', 'arpu': 'ARPU / Avg Check', 'payout_arpu': 'PayOut ARPU', 'avg_payout_day': 'Avg PayOut / Day',
 'payin_requests': 'PayIn Requests', 'payin_success': 'PayIn Success', 'payin_reject': 'PayIn Reject', 'n_transactions': 'N Transactions',
 'velocity_in': 'Velocity IN', 'velocity_out': 'Velocity OUT', 'new_cabinets': 'New Cabinets', 'vol_dep_ratio': 'Vol/Dep Ratio',
 'avg_active_traders': 'Avg Active Traders (daily)', 'payout_pending_count': 'PayOut Pending #', 'payout_pending_amount': 'PayOut Pending ₹',
 'payin_unassigned_count': 'Unassigned PayIn #', 'payin_unassigned_amount': 'Unassigned PayIn ₹', 'traders_on_shift_plan': 'Traders on Shift (plan)',
 'avg_accounts_per_trader_hour': 'Avg Accounts / Trader / Hour', 'status_general': 'Status General', 'first_payers_pct': '% First Payers',
 'repeat_payers_pct': '% Repeat Payers', 'turnover_gi': 'Turnover G-I', 'days_20plus': 'Days 20+', 'last_payin_date': 'Last PayIn',
 'days_since_payin': 'Days Since PayIn', 'run_rate': 'Run-rate', 'payin_per_day': 'PayIn / Day', 'payout_per_day': 'PayOut / Day',
 'settle_per_day': 'Settle / Day', 'affiliates_total': 'Affiliates Total', 'new_affiliates': 'New Affiliates', 'launched_affiliates': 'Launched Affiliates',
 'sub_affiliates': 'Sub-affiliates', 'sub_affiliates_l2': 'Sub-affiliates L2', 'sub_affiliates_l3': 'Sub-affiliates L3',
 'payin_realized_today': 'PayIn Realized Today', 'payin_projected_remaining': 'PayIn Projected Remaining', 'payin_forecast_day': 'PayIn Forecast (day)',
 'recommended_traders': 'Recommended Traders', 'recommended_traders_v2': 'Recommended Traders v2', 'demand_multiplier': 'Demand Multiplier',
 'cheque_multiplier': 'Cheque Multiplier', 'recommended_traders_range': 'Recommended Traders (range)', 'max_payin_limit': 'Max PayIn Limit', 'upi_enabled': 'UPI Enabled'}
NEW_GROUP = {k: 'Відтік' for k in ('churn_7d', 'churn_7d_pct', 'churn_formal', 'churn_formal_pct', 'reactivated', 'net_retention', 'run_rate', 'payin_per_day', 'payout_per_day', 'settle_per_day')}
NEW_GROUP.update({k: 'Скоринг' for k in ('tier', 'zone', 'active_days', 'velocity_in', 'velocity_out')})
NEW_GROUP.update({k: 'Мерчанти / шлюзи' for k in ('arpu', 'payout_arpu', 'payin_requests', 'payin_success', 'payin_reject', 'first_payers_pct', 'repeat_payers_pct', 'turnover_gi', 'days_20plus', 'avg_payout_day')})
NEW_GROUP.update({k: 'Прогноз (THC)' for k in ('payin_realized_today', 'payin_projected_remaining', 'payin_forecast_day', 'recommended_traders', 'recommended_traders_v2', 'demand_multiplier', 'cheque_multiplier', 'recommended_traders_range')})
NEW_GROUP.update({k: 'Афіліати' for k in ('affiliates_total', 'new_affiliates', 'launched_affiliates', 'sub_affiliates', 'sub_affiliates_l2', 'sub_affiliates_l3', 'new_cabinets')})
NEW_GROUP.update({k: 'Операційні (Hourly)' for k in ('payout_pending_count', 'payout_pending_amount', 'payin_unassigned_count', 'payin_unassigned_amount', 'traders_on_shift_plan', 'avg_accounts_per_trader_hour', 'status_general')})

# --- сборка
metrics = OrderedDict()
legend_notes = defaultdict(list)
unmapped = []

def ensure(mid, name_en, **kw):
    if mid not in metrics:
        metrics[mid] = dict(id=mid, name={'en': name_en, 'uk': kw.get('uk', ''), 'ru': kw.get('ru', '')}, group=kw.get('group'),
                            status=kw.get('status', 'needs_review'), canonical=kw.get('canonical', False), definition=kw.get('definition', ''),
                            formula=kw.get('formula', ''), unit=kw.get('unit', ''), synonyms=[], reports=[], deviations=[], notes=[],
                            owner=None, verified_by=None, updated_at=kw.get('updated_at', TODAY), source=kw.get('source', 'описания в коде отчётов halleg'))
    return metrics[mid]

# 1) канон
UK_NAMES = {}
for c in canon['metrics']:
    mid = CANON_ID[c['canon_id']]
    rows = c['rows']
    formula = rows.get('Формула') or rows.get('Формула (канон)') or rows.get('Формули') or ''
    if not formula:  # многовариантные метрики: собираем все строки кроме служебных
        formula = '\n'.join(f'{k}: {v}' for k, v in rows.items() if k not in ('Означення', 'Одиниці', 'Де присутня'))
    m = ensure(mid, c['name_en'], uk=c['name_uk'], group=c['group'], status='canonical', canonical=True,
               definition=rows.get('Означення', ''), formula=formula, unit=rows.get('Одиниці', ''),
               updated_at=canon['meta']['updated_at'], source='metrics-legend halleg (канон, ' + canon['meta']['updated_at'] + ')')
    m['synonyms'] = c['synonyms']
    m['notes'] = c['notes'] + ([f"Пороги: {rows['Пороги (фінансовий канон)']}"] if 'Пороги (фінансовий канон)' in rows else [])
    m['present_in_canon'] = rows.get('Де присутня', '')

RU_CANON = {'payin_volume': 'Объём PayIn', 'payout_volume': 'Объём PayOut', 'active_traders': 'Активные трейдеры',
            'active_accounts': 'Активные счета', 'cr': 'CR, %', 'auto_pct': '% Авто', 'avg_payin_trader': 'Ср. PayIn/трейдер',
            'settlement': 'Сеттлмент', 'settle_payin_pct': 'Сеттл/PayIn, %', 'statement_pct': 'Выписка, %',
            # ниже наш перевод, в источнике нет
            'new_traders': 'Новые трейдеры', 'deposit': 'Депозит', 'payin_speed': 'Скорость зачисления', 'payout_speed': 'Скорость выплат', 'capacity': 'Ёмкость / Порог'}
for mid, ru in RU_CANON.items():
    metrics[mid]['name']['ru'] = ru
    if mid in ('new_traders', 'deposit', 'payin_speed', 'payout_speed', 'capacity'):
        metrics[mid]['notes'].append('name.ru - наш перевод, в источнике его нет.')

# 2) Score: две метрики
ensure('score_scoreboard', 'Score (Scoreboard)', uk='Score (Scoreboard)', ru='Score (Scoreboard)', group='Скоринг', status='conflict',
       definition='Взвешенный балл 0-100 трейдера по 8 метрикам.', formula='PO_P90*20% + PI_P90*18% + CR*18% + Settlement*15% + Statement*10% + Auto*7% + AvgVol*7% + Cost*5%', unit='баллы 0-100')
ensure('score_tier', 'Score (Tier / Weekly KPI)', uk='Score (Tier / Weekly KPI)', ru='Score (Tier / Weekly KPI)', group='Скоринг', status='conflict',
       definition='Взвешенный балл 0-100 по формуле TIER_CALCULATION.md (Power BI Main Dashboard).', formula='Volume×0.3 + Succ×0.2 + Active×0.1 + Tenure×0.1 + Velo IN×0.1 + Velo OUT×0.1 + Stab×0.1', unit='баллы 0-100')
for sid in ('score_scoreboard', 'score_tier'):
    metrics[sid]['notes'].append('В каноне metrics-legend Score отсутствует. Две несовместимые формулы в Scoreboard и Weekly KPI/Tier: объединять нельзя, пока аналитики не решат, какая верна. Файл TIER_CALCULATION.md запрошен у CTO (вопрос 18).')

# 3) описания из отчётов
def slugify(n):
    n = unicodedata.normalize('NFKD', n).encode('ascii', 'ignore').decode().lower()
    n = re.sub(r'[^a-z0-9]+', '_', n).strip('_')
    return n or 'x'

for o in raw:
    name, rep, slug, desc = o['name'], o['report'], o['slug'], o['description']
    if '${' in name or "'+" in name:
        continue
    if UI_RE.search(name) and norm(name) not in ALIASES:
        legend_notes[slug].append(dict(label=name, text=desc, pattern=o['pattern']))
        continue
    key = re.sub(r'\s*\(\s*\)', '', norm(name)).strip()
    mid = ALIASES_EXACT.get(re.sub(r'\s+', ' ', name.lower()).strip(), 'UNSET')
    if mid == 'UNSET':
        mid = ALIASES_N.get(key, 'UNSET')
    if mid == 'UNSET':
        key2 = re.sub(r'[().]', '', key).strip()
        mid = ALIASES_N.get(key2, 'UNSET')
    if key.startswith('score') or key in ('avg score', 'avg score (трейдерів)', 'avg score за тиждень', 'скоринг'):
        mid = score_for(rep)
    if mid == 'UNSET':
        mid = slugify(name)
        unmapped.append((rep, name))
    if mid not in metrics:
        ensure(mid, NEW_NAMES.get(mid, name), group=NEW_GROUP.get(mid, 'Прочее'), source=f'описание в коде {rep}')
    m = metrics[mid]
    entry = dict(report=slug, label=name, description=desc, pattern=o['pattern'])
    if slug not in [r['report'] for r in m['reports']] or True:
        m['reports'].append(entry)
    if name not in m['synonyms'] and name != m['name']['en']:
        m['synonyms'].append(name)
    if not m['definition']:
        m['definition'] = desc

# 4) известные отклонения от канона (по разбору local/discovery/metrics-conflicts.md и примечаниям канона)
DEV = {
 'payin_volume': [('trader-operational', 'Только провайдеры с объёмом > 5 000 ₹: канон говорит, что это особенность Trader KPI, но фильтр стоит в Operational.'),
                  ('trader-weekly', 'Исключены операторы-провайдеры. В каноне такого фильтра нет.'),
                  ('trader-merchant-slu', 'Источник payments, канон требует transactions.')],
 'active_accounts': [('trader-operational', 'Сумма почасовых уникальных счетов за день, один счёт считается многократно. Канон: COUNT(DISTINCT) за период.')],
 'cr': [('trader-operational', 'Числитель только successed_by_partner, знаменатель все ClientPayin включая pending. Канон: оба успешных статуса, знаменатель только финальные.'),
        ('trader-merchant-slu', 'Числитель только completed, знаменатель COUNT(*). Канон: оба успешных статуса, знаменатель только финальные.'),
        ('trader-weekly', 'Знаменатель включает pending, занижает CR в течение дня.')],
 'deposit': [('trader-kpi', 'Кумулятив на конец периода (канон дашборда). В Weekly KPI ALL-TIME без привязки к периоду. Это два разных понятия из канона, подпись должна говорить какое.'),
             ('trader-weekly', 'ALL-TIME net flow по deposit-счетам, не зависит от периода.')],
 'settlement': [('trader-merchant-slu', 'Считается как −SUM(amount − fee): сознательное локальное отклонение для gateway-скоупа, задокументировано в самом отчёте.'),
                ('trader-affiliate', 'Settlement MTD = PayIn − PayOut. Канон прямо запрещает: Settlement ≠ PayIn − PayOut.')],
 'avg_payin_trader': [('trader-kpi', 'Знаменатель средняя дневная численность активных, а не активные за период. Канон называет это отдельной метрикой (daily-avg).')],
 'statement_pct': [('trader-weekly', 'Volume-weighted, канон считает по счетам.')],
 'payin_speed': [('trader-weekly', 'Интервал created_at → updatedAt заявки. Канон: ClientPayin.created_at → Transaction.created_at.'),
                 ('trader-merchant-slu', 'Интервал source_created_at → source_updated_at (gateway-вариант), оговорён в каноне как допустимый для Merchant-SLU.')],
 'payout_speed': [('trader-weekly', 'assignedAt → updatedAt. Канон: assigned_at → completed_at.')],
 'active_traders': [('trader-operational', 'За день, а не за период фильтра. Совместимо с каноном (период = день).')],
}
for mid, lst in DEV.items():
    for slug, text in lst:
        metrics[mid]['deviations'].append(dict(report=slug, text=text, status='open'))

# 5) сводка
doc = dict(
 _meta=dict(generated_by='local/tools/build_metrics.py', generated_at=TODAY,
            sources=['local/platform/data/_canon.json (metrics-legend halleg, 15 метрик, 27.07.2026)', 'local/platform/data/_raw_descriptions.json (описания из кода 11 отчётов)'],
            statuses={'canonical': 'есть в metrics-legend, определение каноническое', 'conflict': 'несовместимые определения, нужен арбитр',
                      'needs_review': 'нет в каноне, определение взято из кода отчёта как есть, не верифицировано'},
            caveats=['Реестр стартовый: состав и определения будут перебираться аналитиками.',
                     'name.uk/name.ru у неканонических метрик пустые, пока не заполнены.',
                     'Идентификаторы наши (snake_case), совпадение с параметрами halleg не обязательство.',
                     'Отклонения (deviations) выписаны по разбору local/discovery/metrics-conflicts.md и примечаниям канона; статус open = не решено.']),
 metrics=list(metrics.values()),
 legend_notes=dict(legend_notes))
json.dump(doc, open(os.path.join(ROOT, 'metrics.json'), 'w'), ensure_ascii=False, indent=1)
from collections import Counter
st = Counter(m['status'] for m in metrics.values())
print(len(metrics), 'метрик:', dict(st), '| legend_notes:', sum(len(v) for v in legend_notes.values()), 'по', len(legend_notes), 'отчётам')
print('не сопоставлено с каноном (новые id):', len(unmapped))
for rep, n in unmapped: print('   ', rep[:16].ljust(16), n)
