"""Approximate inside-outside and collision on the CPU, straight from the descriptor, on 12 objects (household, figurines, synthetic) at 128, 256
and 512 layers (N 128, M 64, 2048 samples per ring, every island, cut at 1e-8 energy / 5 MB; query: 512 ring vertices,
the nearest-centre rule). References on the same CPU: Embree (Open3D RaycastingScene occupancy) for points, FCL (BVH,
triangle-triangle, python-fcl, one call per pose) for collision.
  points:    1M near-surface (±2 % diag) + 1M uniform points per object: the error bound (largest distance from the true
             surface of a wrong answer, % of the diagonal), wrong %, ns per point (FCP numba with 12 threads and with 1;
             Embree with its own threads);
  collision: 10 pairs × 2000 poses near contact (truth from the meshes, bound.truth): the deepest missed contact and the
             widest false hit (% of the smaller diagonal), accuracy, µs per pose (FCP 12 threads / 1 thread, FCL).
Outputs: ../report/household_points.csv, household_collide.csv; `make` writes the report section (data.csv, fig.png,
section.html, README.txt) into ~/code/data/meshes/fcp_report/sections/collision/.

    env -u PYTHONPATH .venv/bin/python scripts/household.py run      # (FCP_API: the v5.4 caps-cutfix api)
    env -u PYTHONPATH .venv/bin/python scripts/household.py make
"""
import csv
import platform
import subprocess
import sys
import time
from pathlib import Path

import numpy as np

sys.path.insert(0, str(Path(__file__).parent))
from common import HOUSEHOLD, REPORT, descriptor, load_mesh, timeit  # noqa: E402

OUT = Path.home() / "code/data/meshes/fcp_report/sections/collision"
LAYERS = (128, 256, 512)
N, NT = 128, 512
# the section's set: 8 household objects, 2 figurines, 2 synthetic shapes (the sneaker and the boot, measured first, are left
# out: a few points at their eyelets / overlapping parts set the worst case; they stay in the raw CSVs)
OBJECTS = ["mug", "mug_ace", "bowl", "bowl_serving", "bowl_dog", "flower_pot", "shoe", "jogger", "dino", "horse", "spring", "knot"]
PAIRS = ["mug-bowl", "mug_ace-bowl_serving", "bowl_dog-flower_pot", "mug-mug_ace", "bowl-bowl_dog", "flower_pot-mug",
         "jogger-shoe", "dino-horse", "spring-spring", "knot-knot", "knot-spring"]
KIND = {**{o: "household" for o in OBJECTS[:8]}, "dino": "figurine", "horse": "figurine", "spring": "synthetic", "knot": "synthetic"}
SAMPLING = (1, 2)
FAIL_LAYERS = 256  # the failure renders


def cpu_name():
    try:
        out = subprocess.run(["lscpu"], capture_output=True, text=True).stdout
        return next(line.split(":", 1)[1].strip() for line in out.splitlines() if line.startswith("Model name"))
    except Exception:
        return platform.processor()


def write(name, rows):
    with (REPORT / name).open("w", newline="") as f:
        w = csv.DictWriter(f, list(dict.fromkeys(k for r in rows for k in r)))
        w.writeheader()
        w.writerows(rows)


def merge(name, rows, key):
    """Write rows into the CSV, replacing the earlier rows of the same objects / pairs."""
    if not rows:
        return
    old = list(csv.DictReader((REPORT / name).open())) if (REPORT / name).exists() else []
    new_keys = {r[key] for r in rows}
    write(name, [r for r in old if r[key] not in new_keys] + rows)


def run(objs, pairs):
    import numba as nb
    import open3d as o3d

    from fcpq import FCPQuery, collide_cpu_batch, relative_maps, table_args
    from sweep import points as point_sets
    from bound import truth
    import collide_bench as cb
    import fcl

    threads = nb.get_num_threads()
    prow = []
    for name in objs:
        m = load_mesh(name)
        sets = point_sets(m)
        sc = o3d.t.geometry.RaycastingScene()
        sc.add_triangles(o3d.core.Tensor(np.asarray(m.vertices, np.float32)), o3d.core.Tensor(np.asarray(m.faces, np.uint32)))
        emb = {}
        for k, (P, _, _) in sets.items():
            T = o3d.core.Tensor(P.astype(np.float32))
            emb[k] = timeit(lambda: sc.compute_occupancy(T), runs=3) / len(P) * 1e9
        for layers in LAYERS:
            d = descriptor(name, layers, N)
            q = FCPQuery(d, nt=NT)
            row = dict(object=name, faces=len(m.faces), layers=layers, N=N, nt=NT, bytes=d.nbytes, parts=q.P)
            wd = []
            for k, (P, gt, dist) in sets.items():
                Pc = np.ascontiguousarray(d.pose.to_canonical(P), np.float32)
                ins = q.inside_cpu(Pc, "nearest")
                row[f"{k}_fcp_ns"] = timeit(lambda: q.inside_cpu(Pc, "nearest"), runs=3) / len(P) * 1e9
                nb.set_num_threads(1)
                sub = Pc[:200_000]
                row[f"{k}_fcp_ns_1thread"] = timeit(lambda: q.inside_cpu(sub, "nearest"), runs=3) / len(sub) * 1e9
                nb.set_num_threads(threads)
                row[f"{k}_embree_ns"] = emb[k]
                w = ins != gt
                row[f"{k}_wrong_pct"] = 100 * w.mean()
                wd.append(dist[w])
            wd = np.concatenate(wd)
            row["err_bound_pct"] = float(wd.max()) if len(wd) else 0.0
            prow.append(row)
            print(f"{name:13s} L{layers:4d} {d.nbytes / 1e6:5.2f} MB | bound {row['err_bound_pct']:.3f} % | near FCP "
                  f"{row['near_fcp_ns']:.1f} ns ({row['near_fcp_ns_1thread']:.0f} 1-thr) Embree {row['near_embree_ns']:.1f} | "
                  f"uniform FCP {row['uniform_fcp_ns']:.1f} Embree {row['uniform_embree_ns']:.1f}", flush=True)
    merge("household_points.csv", prow, "object")

    crow = []
    for pair in pairs:
        T = truth(pair)
        nA, nB = pair.split("-")
        mA, mB = load_mesh(nA), load_mesh(nB)
        gt, diag, R, t = T["gt"], float(T["diag"]), T["R"], T["t"]
        oA = fcl.CollisionObject(cb.fcl_model(mA), fcl.Transform())
        oB = fcl.CollisionObject(cb.fcl_model(mB), fcl.Transform())
        req = fcl.CollisionRequest()

        def run_fcl():
            h = np.zeros(len(R), bool)
            for k in range(len(R)):
                oB.setTransform(fcl.Transform(R[k], t[k]))
                h[k] = fcl.collide(oA, oB, req, fcl.CollisionResult()) > 0
            return h

        fcl_us = timeit(run_fcl, runs=3, warm=1) / len(R) * 1e6
        fcl_acc = float((run_fcl() == gt).mean())
        for layers in LAYERS:
            dA, dB = descriptor(nA, layers, N), descriptor(nB, layers, N)
            qA, qB = FCPQuery(dA, nt=NT), FCPQuery(dB, nt=NT)
            maps = relative_maps(qA, qB, R, t)
            argsA, argsB = table_args(qA), table_args(qB)
            for s in SAMPLING:
                ls, vs = max(1, layers // 128) * s, max(1, NT // 128) * s
                PA, PB = qA.surface_points(ls, vs), qB.surface_points(ls, vs)
                out = np.zeros(len(R), np.bool_)
                us = timeit(lambda: collide_cpu_batch(PA, PB, *maps, argsA, argsB, out), runs=3) / len(R) * 1e6
                hits = out.copy()
                nb.set_num_threads(1)
                us1 = timeit(lambda: collide_cpu_batch(PA, PB, *maps, argsA, argsB, out), runs=2) / len(R) * 1e6
                nb.set_num_threads(threads)
                fn, fp = gt & ~hits, ~gt & hits
                row = dict(pair=pair, layers=layers, N=N, nt=NT, sampling=s, samples=len(PA) + len(PB), collide_frac=float(gt.mean()),
                           acc=float((hits == gt).mean()), fn=int(fn.sum()), fp=int(fp.sum()),
                           miss_bound_pct=float(100 * T["depth"][fn].max() / diag) if fn.any() else 0.0,
                           false_bound_pct=float(100 * T["sep"][fp].max() / diag) if fp.any() else 0.0,
                           fcp_us=us, fcp_us_1thread=us1, fcl_us=fcl_us, fcl_acc=fcl_acc)
                crow.append(row)
                print(f"{pair:22s} L{layers:4d} s{s}: FCP {us:6.2f} us ({us1:6.1f} 1-thr) FCL {fcl_us:6.2f} us | acc {row['acc']:.4f} "
                      f"miss <= {row['miss_bound_pct']:.3f} % false <= {row['false_bound_pct']:.3f} %", flush=True)
    merge("household_collide.csv", crow, "pair")
    import fcp
    rev = subprocess.run(["git", "-C", str(Path(fcp.__file__).parent), "log", "-1", "--format=%h %s"], capture_output=True,
                         text=True).stdout.strip()
    (REPORT / "household_meta.txt").write_text(f"cpu: {cpu_name()}\nnumba threads: {threads}\npackage: {rev[:60]}\n")


def rule_counts():
    """Per object and layer count: points (of the 2M) answered wrongly farther than 100 / layers % of the diagonal from the
    surface (the rule of thumb's threshold). Query only; merged into household_points.csv as n_beyond_rule."""
    from fcpq import FCPQuery
    from sweep import points as point_sets

    rows = list(csv.DictReader((REPORT / "household_points.csv").open()))
    for name in OBJECTS:
        sets = point_sets(load_mesh(name))
        for layers in LAYERS:
            d = descriptor(name, layers, N)
            q = FCPQuery(d, nt=NT)
            th = 100 / layers
            n = 0
            for P, gt, dist in sets.values():
                ins = q.inside_cpu(np.ascontiguousarray(d.pose.to_canonical(P), np.float32), "nearest")
                n += int(((ins != gt) & (dist > th)).sum())
            for r in rows:
                if r["object"] == name and int(r["layers"]) == layers:
                    r["rule_threshold_pct"], r["n_beyond_rule"] = th, n
            print(f"{name:13s} L{layers:4d}: wrong beyond {th:.3f} % diag: {n}", flush=True)
    write("household_points.csv", rows)


def gmean(v):
    v = np.asarray(v, float)
    return float(np.exp(np.log(v).mean()))


def make():
    import matplotlib

    matplotlib.use("Agg")
    import matplotlib.pyplot as plt

    P = [r for r in csv.DictReader((REPORT / "household_points.csv").open()) if r["object"] in OBJECTS]
    C = [r for r in csv.DictReader((REPORT / "household_collide.csv").open()) if r["pair"] in PAIRS]
    no, npairs = len({r["object"] for r in P}), len({r["pair"] for r in C})
    meta = dict(line.split(": ", 1) for line in (REPORT / "household_meta.txt").read_text().splitlines())
    agg = []
    for layers in LAYERS:
        p = [r for r in P if int(r["layers"]) == layers]
        c1 = [r for r in C if int(r["layers"]) == layers and int(r["sampling"]) == 1]
        c2 = [r for r in C if int(r["layers"]) == layers and int(r["sampling"]) == 2]
        f = lambda R, k: [float(r[k]) for r in R]
        agg.append(dict(
            layers=layers, size_mb_median=float(np.median(f(p, "bytes"))) / 1e6, size_mb_max=max(f(p, "bytes")) / 1e6,
            points_bound_pct=max(f(p, "err_bound_pct")), points_wrong_pct=float(np.mean(f(p, "near_wrong_pct"))),
            points_rule_threshold_pct=100 / layers, points_beyond_rule=int(sum(f(p, "n_beyond_rule"))), points_total=2_000_000 * len(p),
            points_fcp_ns=gmean(f(p, "near_fcp_ns")), points_fcp_ns_1thread=gmean(f(p, "near_fcp_ns_1thread")),
            points_embree_ns=gmean(f(p, "near_embree_ns")),
            points_uniform_fcp_ns=gmean(f(p, "uniform_fcp_ns")), points_uniform_embree_ns=gmean(f(p, "uniform_embree_ns")),
            collide_miss_bound_pct=max(f(c1, "miss_bound_pct")), collide_false_bound_pct=max(f(c1, "false_bound_pct")),
            collide_acc_pct=100 * float(np.mean(f(c1, "acc"))), collide_fcp_us=gmean(f(c1, "fcp_us")),
            collide_fcp_us_1thread=gmean(f(c1, "fcp_us_1thread")), collide_fcl_us=gmean(f(c1, "fcl_us")),
            collide_s2_miss_bound_pct=max(f(c2, "miss_bound_pct")), collide_s2_acc_pct=100 * float(np.mean(f(c2, "acc"))),
            collide_s2_fcp_us=gmean(f(c2, "fcp_us"))))
    OUT.mkdir(parents=True, exist_ok=True)
    with (OUT / "data.csv").open("w", newline="") as fh:
        w = csv.DictWriter(fh, list(agg[0]))
        w.writeheader()
        w.writerows(agg)
    # per object / pair rows too, for reproducibility
    write_to = lambda name, R: (OUT / name).write_text(open(REPORT / name).read())
    write_to("household_points.csv", P)
    write_to("household_collide.csv", C)

    # figure: (a) points, (b) collision; the error bound on the left axis, the CPU time on the right axis
    blue, mid, light, ink, grey = "#2a78d6", "#5b9bdd", "#9cc0ea", "#1d1d1b", "#6b6b66"
    fig, axs = plt.subplots(1, 2, figsize=(16, 6.4), dpi=100, facecolor="white")
    x = list(LAYERS)
    for ax, kind in zip(axs, ("points", "collision")):
        ax.set_facecolor("white")
        ax2 = ax.twinx()
        if kind == "points":
            ax.plot(x, [a["points_bound_pct"] for a in agg], "-o", color=blue, lw=2.5, ms=7, label="error bound (left)")
            ax2.plot(x, [a["points_fcp_ns"] for a in agg], "--s", color=ink, lw=1.8, ms=6, label="FCP, 12 threads (right)")
            ax2.plot(x, [a["points_fcp_ns_1thread"] for a in agg], ":s", color=ink, lw=1.5, ms=5, label="FCP, 1 thread (right)")
            ax2.plot(x, [a["points_embree_ns"] for a in agg], "--", color=grey, lw=1.8, label="Embree (right)")
            ax.set_title(f"(a) inside-outside: {no} objects × 2M points", loc="left", fontsize=13, color=ink)
            ax2.set_ylabel("CPU time per point, ns (log)", fontsize=12)
        else:
            ax.plot(x, [a["collide_miss_bound_pct"] for a in agg], "-o", color=blue, lw=2.5, ms=7, label="deepest missed contact (left)")
            ax.plot(x, [a["collide_false_bound_pct"] for a in agg], "-o", color=light, lw=2.5, ms=7, label="widest false contact (left)")
            ax2.plot(x, [a["collide_fcp_us"] for a in agg], "--s", color=ink, lw=1.8, ms=6, label="FCP, 12 threads (right)")
            ax2.plot(x, [a["collide_fcp_us_1thread"] for a in agg], ":s", color=ink, lw=1.5, ms=5, label="FCP, 1 thread (right)")
            ax2.plot(x, [a["collide_fcl_us"] for a in agg], "--", color=grey, lw=1.8, label="FCL, 1 thread (right)")
            ax.set_title(f"(b) collision: {npairs} pairs × 2000 poses near contact", loc="left", fontsize=13, color=ink)
            ax2.set_ylabel("CPU time per pose, µs (log)", fontsize=12)
        ax2.set_yscale("log")
        from matplotlib.ticker import FuncFormatter, LogLocator
        plain = FuncFormatter(lambda v, _: f"{v:g}")
        ax2.yaxis.set_major_locator(LogLocator(base=10, subs=(1, 2, 3, 5)))
        ax2.yaxis.set_major_formatter(plain)
        ax2.yaxis.set_minor_formatter(FuncFormatter(lambda v, _: ""))
        ax.set_xscale("log", base=2)
        ax.set_xticks(x, [str(v) for v in x])
        ax.set_xlabel("layers", fontsize=12)
        ax.set_ylabel("error bound, % of the diagonal", fontsize=12)
        ax.set_ylim(0, max(1.0, 1.15 * max(max(l.get_ydata()) for l in ax.get_lines())))
        ax.grid(color="#e4e2dc", lw=0.8)
        ax.tick_params(labelsize=11)
        ax2.tick_params(labelsize=11)
        h1, l1 = ax.get_legend_handles_labels()
        h2, l2 = ax2.get_legend_handles_labels()
        ax.legend(h1 + h2, l1 + l2, fontsize=10, frameon=False, loc="upper center", bbox_to_anchor=(0.5, -0.13), ncol=3)
    fig.tight_layout()
    fig.savefig(OUT / "fig.png", facecolor="white")
    plt.close(fig)

    cpu, thr, pkg = meta.get("cpu", "?"), meta.get("numba threads", "?"), meta.get("package", "?").split(" ")[0]
    rows = "\n".join(
        f"<tr><td>{a['layers']}</td><td>{a['size_mb_median']:.2f} ({a['size_mb_max']:.2f})</td>"
        f"<td>{a['points_bound_pct']:.2f}</td><td>{a['points_wrong_pct']:.2f}</td><td>{a['points_fcp_ns']:.1f}</td>"
        f"<td>{a['points_fcp_ns_1thread']:.0f}</td><td>{a['points_embree_ns']:.1f}</td>"
        f"<td>{a['collide_miss_bound_pct']:.2f}</td><td>{a['collide_false_bound_pct']:.2f}</td><td>{a['collide_acc_pct']:.2f}</td>"
        f"<td>{a['collide_fcp_us']:.1f}</td><td>{a['collide_fcp_us_1thread']:.0f}</td><td>{a['collide_fcl_us']:.1f}</td></tr>"
        for a in agg)
    by_kind = lambda k: ", ".join(o.replace("_", " ") for o in OBJECTS if KIND[o] == k)
    names = f"household: {by_kind('household')}; figurines: {by_kind('figurine')}; synthetic: {by_kind('synthetic')}"
    faces = sorted({int(r["faces"]) for r in P})
    frange = f"{faces[0] / 1e3:.1f}k–{faces[-1] / 1e3:.0f}k faces"
    a0, a1, a2 = agg
    rng = lambda k, d=1: f"{min(a[k] for a in agg):.{d}f}–{max(a[k] for a in agg):.{d}f}"
    findings = (
        f"<p><b>Points:</b> FCP answers in {rng('points_fcp_ns')} ns per point with {thr} threads, "
        f"{a0['points_embree_ns'] / a0['points_fcp_ns']:.1f}× faster than Embree on the same CPU, at every layer count. "
        f"The error bound is {rng('points_bound_pct', 2)} % of the diagonal; its worst case comes from a handful of points "
        f"at sharp details and pieces that start or end between two slices (below), "
        + ("and it falls with the layers" if a0['points_bound_pct'] > a1['points_bound_pct'] > a2['points_bound_pct']
           else "so it does not fall steadily with the layers") +
        f"; the share of wrong answers near the surface falls "
        f"{a0['points_wrong_pct']:.2f} → {a2['points_wrong_pct']:.2f} %. "
        f"<b>Collision:</b> no missed contact deeper than {a0['collide_miss_bound_pct']:.2f} % of the diagonal and no false one "
        f"wider than {a0['collide_false_bound_pct']:.2f} % at 128 layers ({max(a1['collide_miss_bound_pct'], a1['collide_false_bound_pct']):.2f} % "
        f"from 256 layers); {rng('collide_acc_pct', 2)} % of poses correct. On the CPU, FCP is not faster than FCL at this "
        f"sampling: {rng('collide_fcp_us')} µs per pose with {thr} threads and {rng('collide_fcp_us_1thread', 0)} µs with 1, "
        f"against {a0['collide_fcl_us']:.1f} µs for FCL on one thread; with every other sample FCP takes {rng('collide_s2_fcp_us')} µs "
        f"({rng('collide_s2_acc_pct', 2)} % correct, misses up to {max(a['collide_s2_miss_bound_pct'] for a in agg):.2f} %). "
        f"FCP's collision speed-up is a GPU result (the poses in one batch).</p>")
    html = f"""<h2>Approximate collision and inside-outside on the CPU, straight from the descriptor</h2>
<p>Package v5.5 (commit {pkg}, <code>parts="tracked-fit"</code>, <code>interp="poly"</code>). {no} objects ({names}; {frange}; the household objects and figurines from Google Scanned Objects), encoded at 128, 256 and 512 layers
(N 128, M 64, 2048 samples per ring, small islands and one-layer pieces kept where the package drops them, every atom,
cut at 1e-8 of the energy) and queried without decoding: a point is inside when the rings at its
nearest layer centre contain it (512 vertices per ring); two placed objects collide when a ring vertex of one lies inside
the other. CPU: {cpu}, FCP in numba with {thr} threads and with 1. References on the same CPU: Embree (Open3D
RaycastingScene occupancy, its own threads) for points, FCL (BVH, triangle-triangle, one call per pose through python-fcl,
1 thread) for collision. The error bound is the largest distance from the true surface at which an answer was still wrong:
over 1M points within ±2 % of the diagonal of the surface plus 1M uniform points per object, and over 2000 poses near
contact per pair ({npairs} pairs; the true depth or gap of every pose from the meshes). Beyond it, every answer was right.</p>
<table>
<thead><tr><th>layers</th><th>size, MB median (max)</th><th>points: error bound, % diag</th><th>points: wrong, % (±2 % band)</th>
<th>points: FCP, ns/point ({thr} threads)</th><th>points: FCP, ns/point (1 thread)</th><th>points: Embree, ns/point</th>
<th>collision: deepest miss, % diag</th><th>collision: widest false hit, % diag</th><th>collision: correct, % of poses</th>
<th>collision: FCP, µs/pose ({thr} threads)</th><th>collision: FCP, µs/pose (1 thread)</th><th>collision: FCL, µs/pose</th></tr></thead>
<tbody>
{rows}
</tbody>
</table>
{findings}
<figure><img src="sections/collision/fig.png" alt="Error bound (left axis, % of the diagonal) and CPU time (right axis, log) against
the number of layers, for inside-outside points (a) and collision (b), FCP against Embree and FCL.">
<figcaption>Error bound (left axis) and CPU time (right axis, log) against the layers; times are geometric means over the {no}
objects or {npairs} pairs, bounds the worst case.</figcaption></figure>
"""
    if (OUT / "failures_points.png").exists() and (OUT / "failures_collide.png").exists():
        html += (f'<figure><img src="sections/collision/failures_points.png" alt="Wrong inside-outside answers on the mug, the dino, '
                 f'the spring and the knot at {FAIL_LAYERS} layers">\n<figcaption>Inside-outside ({FAIL_LAYERS} layers): every point answered '
                 f'wrongly farther than 0.1 % of the diagonal from the surface (of 2M per object), coloured by that distance. On the mug '
                 f'and the dino they sit where a piece starts or ends between two slices (the mug\'s cavity floor and handle joint, the '
                 f'dino\'s claws, toes and tail); on the spring they are spread along the coils, all within 0.2 % of the diagonal, where '
                 f'the slices cut the tube obliquely; the knot has none.</figcaption>\n'
                 f'<img src="sections/collision/failures_collide.png" alt="Three wrong collision answers at {FAIL_LAYERS} layers, zoomed '
                 f'on the contact">\n<figcaption>Collision ({FAIL_LAYERS} layers, densest sampling): the worst wrong pose of three '
                 f'pairs, zoomed on the contact (ringed). A false contact is an FCP sample found inside the other object while the '
                 f'meshes are a few hundredths of a percent of the diagonal apart; the missed contact is a touch 0.0035 % deep that '
                 f'falls between the samples.</figcaption></figure>\n')
    (OUT / "section.html").write_text(html)
    (OUT / "README.txt").write_text(
        "Produced by ~/code/data/meshes/fcp_2910_collision/scripts/household.py:\n"
        "  cd ~/code/data/meshes/fcp_2910_collision && FCP_API=~/code/fcp_devel/.claude/worktrees/v55-collision/api FCP_VTAG=v55k1_ FCP_KEEP1=1 "
        "env -u PYTHONPATH .venv/bin/python scripts/household.py run && ... household.py make\n"
        "data.csv: the table and figure (one row per layer count); household_points.csv / household_collide.csv: per object / pair.\n"
        "failures_points.png, failures_collide.png: scripts/failures.py (same environment).\n")
    print("wrote", *sorted(p.name for p in OUT.iterdir()))


if __name__ == "__main__":
    if sys.argv[1] == "run":  # run [objects ...] [-- pairs ...]: defaults to the whole set
        a = sys.argv[2:]
        objs, pairs = (a[:a.index("--")], a[a.index("--") + 1:]) if "--" in a else (a, [])
        run(objs or ([] if "--" in a else OBJECTS), pairs or ([] if a else PAIRS))
    elif sys.argv[1] == "rule":
        rule_counts()
    else:
        make()
