#!/usr/bin/env python3
"""
Script 43: Pool-Normalized Per-Signature FAR (anchor-based calibration)
========================================================================
Codex round-30 verdict on Script 40b: per-pair FAR (~0.00060 at
cos>0.95) is NOT the per-signature classifier specificity. The
deployed classifier uses max-cosine and min-dHash over each CPA's
same-CPA pool, so the inter-CPA-equivalent specificity for a
signature with pool size n is approximately 1 - (1 - pair_FAR)^n,
which for Big-4 median pool ~280 is several percent, not 0.00014.

This script computes pool-normalized per-signature FAR by drawing,
for each source signature s, a random inter-CPA candidate pool of
size n_pool(s) (= same-CPA pool size of s), and computing the
deployed descriptors against the random pool. The fraction of
source signatures whose max-cosine exceeds k (and/or min-dHash <= k)
is the per-signature FAR at that operating point.

We also report:
  - "Any-pair" joint FAR: max_cos > c AND min_dh <= d (descriptors
    may come from different candidates)
  - "Same-pair" joint FAR: at least one candidate has both
    cos > c AND dh <= d
  - Per-firm and pool-size-decile stratification
  - CPA-block bootstrap CI on key FAR points
  - Threshold inversion for target per-signature FAR

Inputs: full Big-4 sub-corpus (n=150,453 sigs / 468 CPAs).
Random pool draws use one realisation per source signature, with
seed control. CPA-block bootstrap quantifies sampling noise.

Outputs:
  reports/v4_big4/pool_normalized_far/
    pool_normalized_results.json
    pool_normalized_report.md
"""

import json
import sqlite3
import numpy as np
from pathlib import Path
from datetime import datetime
from collections import defaultdict

DB = '/Volumes/NV2/PDF-Processing/signature-analysis/signature_analysis.db'
OUT = Path('/Volumes/NV2/PDF-Processing/signature-analysis/reports/'
           'v4_big4/pool_normalized_far')
OUT.mkdir(parents=True, exist_ok=True)

BIG4 = ('勤業眾信聯合', '安侯建業聯合', '資誠聯合', '安永聯合')
ALIAS = {'勤業眾信聯合': 'Firm A',
         '安侯建業聯合': 'Firm B',
         '資誠聯合': 'Firm C',
         '安永聯合': 'Firm D'}
SEED = 42
BATCH = 200  # source signatures per batch
N_BOOT_CPA = 1000  # CPA-block bootstrap replicates

COS_KS = [0.90, 0.92, 0.93, 0.94, 0.945, 0.95, 0.955, 0.96, 0.97, 0.98]
DH_KS = [2, 3, 4, 5, 6, 8, 10, 15]


def load_big4():
    conn = sqlite3.connect(f'file:{DB}?mode=ro', uri=True)
    cur = conn.cursor()
    cur.execute('''
        SELECT s.signature_id, s.assigned_accountant, a.firm, s.source_pdf,
               s.feature_vector, s.dhash_vector
        FROM signatures s
        JOIN accountants a ON s.assigned_accountant = a.name
        WHERE s.assigned_accountant IS NOT NULL
          AND s.feature_vector IS NOT NULL
          AND s.dhash_vector IS NOT NULL
          AND a.firm IN (?, ?, ?, ?)
    ''', BIG4)
    rows = cur.fetchall()
    conn.close()
    return rows


def hamming_vec(query_bytes, cand_bytes_array):
    """Hamming between one 8-byte hash and an array of 8-byte hashes."""
    q = int.from_bytes(query_bytes, 'big')
    out = np.empty(len(cand_bytes_array), dtype=np.int32)
    for i, c in enumerate(cand_bytes_array):
        c_int = int.from_bytes(c, 'big')
        out[i] = (q ^ c_int).bit_count()
    return out


def wilson_ci(k, n, z=1.96):
    if n == 0:
        return (None, None)
    phat = k / n
    denom = 1 + z * z / n
    centre = (phat + z * z / (2 * n)) / denom
    half = z * np.sqrt(phat * (1 - phat) / n + z * z / (4 * n * n)) / denom
    return (max(0.0, centre - half), min(1.0, centre + half))


def main():
    print('=' * 72)
    print('Script 43: Pool-Normalized Per-Signature FAR')
    print('=' * 72)
    rows = load_big4()
    n_sigs = len(rows)
    print(f'\nLoaded {n_sigs:,} Big-4 signatures')

    # Build index arrays
    sig_ids = np.array([r[0] for r in rows], dtype=np.int64)
    cpas = np.array([r[1] for r in rows])
    firms = np.array([ALIAS[r[2]] for r in rows])
    source_pdfs = np.array([r[3] for r in rows])

    # Feature matrix
    feats = np.stack([np.frombuffer(r[4], dtype=np.float32)
                       for r in rows]).astype(np.float32)
    print(f'  Feature matrix: {feats.shape}, '
          f'{feats.nbytes / 1e9:.2f} GB')
    # L2-normalize
    norms = np.linalg.norm(feats, axis=1, keepdims=True)
    norms[norms == 0] = 1.0
    feats = feats / norms

    # dHash bytes
    dhashes = [r[5] for r in rows]

    # CPA → indices
    cpa_to_idx = defaultdict(list)
    for i, c in enumerate(cpas):
        cpa_to_idx[c].append(i)
    cpa_to_idx = {c: np.array(v, dtype=np.int64)
                  for c, v in cpa_to_idx.items()}
    n_cpas = len(cpa_to_idx)
    pool_sizes = {c: len(v) - 1 for c, v in cpa_to_idx.items()}
    print(f'  CPAs: {n_cpas}; pool-size summary: '
          f'min={min(pool_sizes.values())}, '
          f'median={int(np.median(list(pool_sizes.values())))}, '
          f'max={max(pool_sizes.values())}')

    # Pre-compute: for sampling non-same-CPA candidates, we need fast
    # index sampling. The total available pool for each source sig is
    # all_indices \ same_cpa_indices.
    all_idx = np.arange(n_sigs, dtype=np.int64)

    # ── Per-source-signature simulation ─────────────────────
    print('\nSimulating per-source-signature inter-CPA-equivalent pool...')
    rng = np.random.default_rng(SEED)

    # Per-signature stored statistics
    max_cos = np.zeros(n_sigs, dtype=np.float32)
    min_dh = np.zeros(n_sigs, dtype=np.int32)
    cos_at_min_dh = np.zeros(n_sigs, dtype=np.float32)
    dh_at_max_cos = np.zeros(n_sigs, dtype=np.int32)
    pool_size_arr = np.zeros(n_sigs, dtype=np.int32)

    # For each source signature, we also record indicator for same-pair
    # joint event at (cos>0.95, dh<=5) -- the headline operational rule.
    # This requires keeping per-signature any() flag for that pair.
    headline_same_pair_95_5 = np.zeros(n_sigs, dtype=bool)
    headline_same_pair_95_4 = np.zeros(n_sigs, dtype=bool)
    headline_same_pair_95_3 = np.zeros(n_sigs, dtype=bool)

    # process batches of source signatures
    for batch_start in range(0, n_sigs, BATCH):
        batch_end = min(batch_start + BATCH, n_sigs)
        if batch_start % 5000 == 0:
            pct = batch_start / n_sigs * 100
            print(f'  {batch_start:,}/{n_sigs:,} ({pct:.1f}%)')

        for si in range(batch_start, batch_end):
            s_cpa = cpas[si]
            n_pool = pool_sizes[s_cpa]
            pool_size_arr[si] = n_pool

            if n_pool <= 0:
                max_cos[si] = 0.0
                min_dh[si] = 64
                continue

            # Sample n_pool candidates from non-same-CPA indices
            same_cpa = cpa_to_idx[s_cpa]
            # Use random.choice over all_idx excluding same_cpa is slow;
            # instead reject-sample from all_idx
            need = n_pool
            cand_indices = []
            attempts = 0
            while need > 0 and attempts < 10:
                draw = rng.choice(n_sigs, size=need * 2, replace=True)
                # filter out same_cpa
                same_mask = np.isin(draw, same_cpa)
                ok = draw[~same_mask]
                cand_indices.extend(ok[:need].tolist())
                need -= len(ok[:need])
                attempts += 1
            if need > 0:
                # fallback: deterministic sample without same-CPA
                pool_mask = np.ones(n_sigs, dtype=bool)
                pool_mask[same_cpa] = False
                pool_idx = all_idx[pool_mask]
                fb = rng.choice(pool_idx, size=need, replace=False)
                cand_indices.extend(fb.tolist())
            cand_indices = np.array(cand_indices[:n_pool], dtype=np.int64)

            # Cosine: source feat @ cand feats
            cos_vec = feats[cand_indices] @ feats[si]
            # dHash
            dh_vec = hamming_vec(dhashes[si],
                                 [dhashes[c] for c in cand_indices])

            mc_idx = int(np.argmax(cos_vec))
            md_idx = int(np.argmin(dh_vec))
            max_cos[si] = float(cos_vec[mc_idx])
            min_dh[si] = int(dh_vec[md_idx])
            dh_at_max_cos[si] = int(dh_vec[mc_idx])
            cos_at_min_dh[si] = float(cos_vec[md_idx])

            # Same-pair joint indicators
            cos_gt = cos_vec > 0.95
            if cos_gt.any():
                dh_under_5 = dh_vec <= 5
                dh_under_4 = dh_vec <= 4
                dh_under_3 = dh_vec <= 3
                headline_same_pair_95_5[si] = bool((cos_gt & dh_under_5).any())
                headline_same_pair_95_4[si] = bool((cos_gt & dh_under_4).any())
                headline_same_pair_95_3[si] = bool((cos_gt & dh_under_3).any())

    print(f'  Done.')

    # ── Aggregate ──────────────────────────────────────────
    print('\nAggregating per-signature FAR statistics...')

    def far_marginal_cos(k):
        hits = int((max_cos > k).sum())
        lo, hi = wilson_ci(hits, n_sigs)
        return {'k': k, 'hits': hits, 'n': n_sigs,
                'far': hits / n_sigs,
                'ci95_lo': lo, 'ci95_hi': hi}

    def far_marginal_dh(k):
        hits = int((min_dh <= k).sum())
        lo, hi = wilson_ci(hits, n_sigs)
        return {'k': k, 'hits': hits, 'n': n_sigs,
                'far': hits / n_sigs,
                'ci95_lo': lo, 'ci95_hi': hi}

    def far_any_pair_joint(cos_k, dh_k):
        hits = int(((max_cos > cos_k) & (min_dh <= dh_k)).sum())
        lo, hi = wilson_ci(hits, n_sigs)
        return {'cos_k': cos_k, 'dh_k': dh_k,
                'hits': hits, 'n': n_sigs,
                'far': hits / n_sigs,
                'ci95_lo': lo, 'ci95_hi': hi}

    def far_same_pair_joint(cos_k, dh_k, indicator):
        hits = int(indicator.sum())
        lo, hi = wilson_ci(hits, n_sigs)
        return {'cos_k': cos_k, 'dh_k': dh_k,
                'hits': hits, 'n': n_sigs,
                'far': hits / n_sigs,
                'ci95_lo': lo, 'ci95_hi': hi}

    cos_curve = [far_marginal_cos(k) for k in COS_KS]
    dh_curve = [far_marginal_dh(k) for k in DH_KS]
    any_pair_curve = [far_any_pair_joint(0.95, k) for k in DH_KS]
    same_pair_curve = [
        far_same_pair_joint(0.95, 5, headline_same_pair_95_5),
        far_same_pair_joint(0.95, 4, headline_same_pair_95_4),
        far_same_pair_joint(0.95, 3, headline_same_pair_95_3),
    ]

    print('\n[Per-signature marginal cos FAR]')
    for e in cos_curve:
        print(f'  max-cos > {e["k"]:.3f}: FAR={e["far"]:.4f}, '
              f'CI=[{e["ci95_lo"]:.4f}, {e["ci95_hi"]:.4f}], '
              f'hits={e["hits"]}/{e["n"]:,}')

    print('\n[Per-signature marginal dh FAR]')
    for e in dh_curve:
        print(f'  min-dh <= {e["k"]:2d}: FAR={e["far"]:.4f}, '
              f'CI=[{e["ci95_lo"]:.4f}, {e["ci95_hi"]:.4f}], '
              f'hits={e["hits"]}/{e["n"]:,}')

    print('\n[Per-signature any-pair joint FAR (cos>0.95 AND dh<=k)]')
    for e in any_pair_curve:
        print(f'  dh <= {e["dh_k"]:2d}: FAR={e["far"]:.4f}, '
              f'hits={e["hits"]}/{e["n"]:,}')

    print('\n[Per-signature SAME-pair joint FAR]')
    for e in same_pair_curve:
        print(f'  cos>0.95 AND dh<={e["dh_k"]}: FAR={e["far"]:.4f}, '
              f'CI=[{e["ci95_lo"]:.4f}, {e["ci95_hi"]:.4f}], '
              f'hits={e["hits"]}/{e["n"]:,}')

    # Per-firm and per-pool-decile stratification
    print('\n[Per-firm headline FAR (any-pair, cos>0.95 AND dh<=5)]')
    per_firm = {}
    for f in sorted(set(firms)):
        mask = firms == f
        n_f = int(mask.sum())
        hits_anypair = int(((max_cos[mask] > 0.95) &
                            (min_dh[mask] <= 5)).sum())
        hits_samepair = int(headline_same_pair_95_5[mask].sum())
        per_firm[f] = {
            'n': n_f,
            'any_pair_far': hits_anypair / n_f,
            'same_pair_far': hits_samepair / n_f,
        }
        print(f'  {f}: n={n_f:,} '
              f'any-pair FAR={hits_anypair/n_f:.4f}, '
              f'same-pair FAR={hits_samepair/n_f:.4f}')

    print('\n[Pool-size decile × headline FAR]')
    pool_arr = pool_size_arr
    deciles = np.percentile(pool_arr, np.arange(0, 110, 10))
    per_decile = {}
    for d in range(10):
        lo, hi = deciles[d], deciles[d + 1]
        mask = (pool_arr >= lo) & (pool_arr <= hi if d == 9
                                                  else pool_arr < hi)
        n_d = int(mask.sum())
        if n_d == 0:
            continue
        hits_any = int(((max_cos[mask] > 0.95) &
                         (min_dh[mask] <= 5)).sum())
        hits_same = int(headline_same_pair_95_5[mask].sum())
        per_decile[f'decile_{d+1}'] = {
            'pool_range': [float(lo), float(hi)],
            'n': n_d,
            'any_pair_far': hits_any / n_d,
            'same_pair_far': hits_same / n_d,
        }
        print(f'  Decile {d+1} (pool {lo:.0f}-{hi:.0f}): n={n_d:,} '
              f'any-FAR={hits_any/n_d:.4f}, '
              f'same-FAR={hits_same/n_d:.4f}')

    # CPA bootstrap on headline (cos>0.95 AND dh<=5, same-pair)
    print(f'\n[CPA-block bootstrap {N_BOOT_CPA} replicates]')
    rng_b = np.random.default_rng(SEED + 1)
    all_cpa_list = list(cpa_to_idx.keys())
    boot_anypair = np.zeros(N_BOOT_CPA)
    boot_samepair = np.zeros(N_BOOT_CPA)
    for b in range(N_BOOT_CPA):
        cpas_b = rng_b.choice(all_cpa_list, size=len(all_cpa_list),
                              replace=True)
        idx_b = np.concatenate([cpa_to_idx[c] for c in cpas_b])
        n_b = len(idx_b)
        boot_anypair[b] = ((max_cos[idx_b] > 0.95) &
                           (min_dh[idx_b] <= 5)).mean()
        boot_samepair[b] = headline_same_pair_95_5[idx_b].mean()
    boot_anypair_ci = (float(np.percentile(boot_anypair, 2.5)),
                       float(np.percentile(boot_anypair, 97.5)))
    boot_samepair_ci = (float(np.percentile(boot_samepair, 2.5)),
                        float(np.percentile(boot_samepair, 97.5)))
    print(f'  any-pair FAR boot mean={boot_anypair.mean():.4f}, '
          f'95% CI={boot_anypair_ci}')
    print(f'  same-pair FAR boot mean={boot_samepair.mean():.4f}, '
          f'95% CI={boot_samepair_ci}')

    # Document-level aggregation: a document is flagged if any of its
    # signatures has max_cos > 0.95 AND min_dh <= 5 (the worst-case rule)
    print('\n[Document-level aggregation]')
    doc_idx = defaultdict(list)
    for i, pdf in enumerate(source_pdfs):
        doc_idx[pdf].append(i)
    n_docs = len(doc_idx)
    doc_anypair_flag = 0
    doc_samepair_flag = 0
    for pdf, idxs in doc_idx.items():
        idxs_a = np.array(idxs, dtype=np.int64)
        if ((max_cos[idxs_a] > 0.95) & (min_dh[idxs_a] <= 5)).any():
            doc_anypair_flag += 1
        if headline_same_pair_95_5[idxs_a].any():
            doc_samepair_flag += 1
    print(f'  n_documents: {n_docs:,}')
    print(f'  doc-level any-pair FAR (any sig flagged) = '
          f'{doc_anypair_flag/n_docs:.4f} ({doc_anypair_flag}/{n_docs})')
    print(f'  doc-level same-pair FAR = '
          f'{doc_samepair_flag/n_docs:.4f} ({doc_samepair_flag}/{n_docs})')

    # Threshold inversion: find cos and dh thresholds that hit per-sig
    # FAR targets at the marginal level
    print('\n[Per-signature marginal threshold inversion]')
    inversions = {}
    for tgt in [0.10, 0.05, 0.02, 0.01, 0.005]:
        c_pick = None
        for e in cos_curve:
            if e['far'] <= tgt:
                c_pick = e
                break
        d_pick = None
        for e in dh_curve:
            if e['far'] <= tgt:
                d_pick = e
                break
        any_pick = None
        for e in any_pair_curve:
            if e['far'] <= tgt:
                any_pick = e
                break
        same_pick = None
        for e in same_pair_curve:
            if e['far'] <= tgt:
                same_pick = e
                break
        inversions[f'per_sig_far_<=_{tgt}'] = {
            'marginal_cos': c_pick, 'marginal_dh': d_pick,
            'any_pair_joint': any_pick, 'same_pair_joint': same_pick,
        }
        print(f'  per-sig FAR <= {tgt}:')
        if c_pick:
            print(f'    marginal cos: cos > {c_pick["k"]} '
                  f'(FAR={c_pick["far"]:.4f})')
        if d_pick:
            print(f'    marginal dh: dh <= {d_pick["k"]} '
                  f'(FAR={d_pick["far"]:.4f})')
        if any_pick:
            print(f'    any-pair joint: dh <= {any_pick["dh_k"]} '
                  f'(FAR={any_pick["far"]:.4f})')
        if same_pick:
            print(f'    same-pair joint: dh <= {same_pick["dh_k"]} '
                  f'(FAR={same_pick["far"]:.4f})')

    results = {
        'meta': {
            'script': '43',
            'timestamp': datetime.now().isoformat(timespec='seconds'),
            'n_signatures': n_sigs,
            'n_cpas': n_cpas,
            'n_boot_cpa': N_BOOT_CPA,
            'seed': SEED,
            'note': ('Pool-normalized per-signature FAR. For each '
                     'source signature, simulate inter-CPA candidate '
                     'pool of size n_pool(s); compute deployed max-cos '
                     'and min-dh; aggregate per-signature FAR.'),
        },
        'marginal_cos_curve': cos_curve,
        'marginal_dh_curve': dh_curve,
        'any_pair_joint_curve': any_pair_curve,
        'same_pair_joint': same_pair_curve,
        'per_firm_headline': per_firm,
        'per_pool_decile_headline': per_decile,
        'cpa_bootstrap_headline': {
            'any_pair_mean': float(boot_anypair.mean()),
            'any_pair_ci95': boot_anypair_ci,
            'same_pair_mean': float(boot_samepair.mean()),
            'same_pair_ci95': boot_samepair_ci,
        },
        'document_level_headline': {
            'n_docs': n_docs,
            'any_pair_far': doc_anypair_flag / n_docs,
            'same_pair_far': doc_samepair_flag / n_docs,
        },
        'threshold_inversions': inversions,
    }

    json_path = OUT / 'pool_normalized_results.json'
    json_path.write_text(json.dumps(results, indent=2, ensure_ascii=False),
                         encoding='utf-8')
    print(f'\n[json] {json_path}')

    md = ['# Pool-Normalized Per-Signature FAR (Script 43)',
          '', f'Generated: {results["meta"]["timestamp"]}',
          (f'Big-4 source signatures: {n_sigs:,} across {n_cpas} CPAs; '
           f'pool-size median={int(np.median(list(pool_sizes.values())))}, '
           f'max={max(pool_sizes.values())}'),
          (f'CPA-block bootstrap: {N_BOOT_CPA} replicates. Per source '
           'signature, one realisation of n_pool(s)-sized random '
           'inter-CPA candidate pool.'),
          '',
          '## Headline (cos>0.95 AND dh<=5)',
          '',
          '| Variant | per-sig FAR | 95% Wilson CI | CPA-bootstrap 95% CI |',
          '|---|---|---|---|']
    md.append(f'| any-pair joint | '
              f'{((max_cos > 0.95) & (min_dh <= 5)).mean():.4f} | '
              f'see JSON | [{boot_anypair_ci[0]:.4f}, '
              f'{boot_anypair_ci[1]:.4f}] |')
    md.append(f'| same-pair joint | '
              f'{headline_same_pair_95_5.mean():.4f} | '
              f'see JSON | [{boot_samepair_ci[0]:.4f}, '
              f'{boot_samepair_ci[1]:.4f}] |')
    md += [
        '',
        '## Marginal cos FAR (per-signature)',
        '',
        '| max-cos > k | FAR | 95% CI | hits / n |',
        '|---|---|---|---|']
    for e in cos_curve:
        md.append(f'| {e["k"]} | {e["far"]:.4f} | '
                  f'[{e["ci95_lo"]:.4f}, {e["ci95_hi"]:.4f}] | '
                  f'{e["hits"]} / {e["n"]:,} |')
    md += ['', '## Marginal dh FAR (per-signature)', '',
           '| min-dh <= k | FAR | 95% CI | hits / n |',
           '|---|---|---|---|']
    for e in dh_curve:
        md.append(f'| {e["k"]} | {e["far"]:.4f} | '
                  f'[{e["ci95_lo"]:.4f}, {e["ci95_hi"]:.4f}] | '
                  f'{e["hits"]} / {e["n"]:,} |')
    md += ['',
           '## Any-pair joint FAR (cos>0.95 AND dh<=k)',
           '',
           '| dh <= k | FAR | hits / n |',
           '|---|---|---|']
    for e in any_pair_curve:
        md.append(f'| {e["dh_k"]} | {e["far"]:.4f} | '
                  f'{e["hits"]} / {e["n"]:,} |')
    md += ['',
           '## Same-pair joint FAR (one candidate satisfies both)',
           '',
           '| cos>0.95 AND dh<=k | FAR | 95% CI | hits / n |',
           '|---|---|---|---|']
    for e in same_pair_curve:
        md.append(f'| dh <= {e["dh_k"]} | {e["far"]:.4f} | '
                  f'[{e["ci95_lo"]:.4f}, {e["ci95_hi"]:.4f}] | '
                  f'{e["hits"]} / {e["n"]:,} |')
    md += ['', '## Per-firm headline', '',
           '| Firm | n | any-pair FAR | same-pair FAR |',
           '|---|---|---|---|']
    for f, s in per_firm.items():
        md.append(f'| {f} | {s["n"]:,} | {s["any_pair_far"]:.4f} | '
                  f'{s["same_pair_far"]:.4f} |')
    md += ['', '## Per-pool-decile headline', '',
           '| Decile | pool range | n | any-pair FAR | same-pair FAR |',
           '|---|---|---|---|---|']
    for k, s in per_decile.items():
        md.append(f'| {k} | {s["pool_range"][0]:.0f}-'
                  f'{s["pool_range"][1]:.0f} | {s["n"]:,} | '
                  f'{s["any_pair_far"]:.4f} | '
                  f'{s["same_pair_far"]:.4f} |')
    md += ['', '## Document-level',
           '',
           f'- n_documents: {n_docs:,}',
           f'- any-pair FAR (any sig flagged): '
           f'{doc_anypair_flag/n_docs:.4f} '
           f'({doc_anypair_flag}/{n_docs})',
           f'- same-pair FAR: {doc_samepair_flag/n_docs:.4f} '
           f'({doc_samepair_flag}/{n_docs})',
           '',
           '## Threshold inversion (per-signature FAR targets)',
           '',
           '| target | marginal cos | marginal dh | any-pair joint '
           '| same-pair joint |',
           '|---|---|---|---|---|']
    for tgt in [0.10, 0.05, 0.02, 0.01, 0.005]:
        inv = inversions[f'per_sig_far_<=_{tgt}']
        c = inv['marginal_cos']
        d = inv['marginal_dh']
        a = inv['any_pair_joint']
        s = inv['same_pair_joint']
        cs = (f'cos > {c["k"]} (FAR={c["far"]:.4f})'
              if c else 'unachievable')
        ds = (f'dh <= {d["k"]} (FAR={d["far"]:.4f})'
              if d else 'unachievable')
        as_ = (f'dh <= {a["dh_k"]} (FAR={a["far"]:.4f})'
               if a else 'unachievable')
        ss = (f'dh <= {s["dh_k"]} (FAR={s["far"]:.4f})'
              if s else 'unachievable')
        md.append(f'| {tgt} | {cs} | {ds} | {as_} | {ss} |')
    md.append('')

    md_path = OUT / 'pool_normalized_report.md'
    md_path.write_text('\n'.join(md), encoding='utf-8')
    print(f'[md  ] {md_path}')


if __name__ == '__main__':
    main()
