#!/usr/bin/env python3
"""Draw a stratified, reproducible probe sample from the shape population.

A probe is a full re-authoring of a real shape function into the `declare!`
form, checked production by production against the grammar in wiki
`quasi-declare-form`. Round 1 ran 15 probes over 26 functions (5.1% of 514) and
found one new blocking class per 1.4 probes without the rate flattening, which
is why the form is not closed. Round 2 has to be big enough that coming back
clean means something, and aimed at the holes round 1 left rather than at
another uniform draw.

This script is the sampling frame, written down so a round can be re-run and
argued with. It reads the population from `population.py`, so the two cannot
drift.

THE STRATA, each one a hole section 7 of the record names by hand:

  conceded   The three goingson forms conceded to the `Field::refilled`
             deferral and never re-authored. The concession covers 4 sites
             while the closure weight in those functions is far larger, so the
             concession itself is untested. Always drawn, never sampled.
  af-cold    audiofiles outside the five files round 1 touched. That tree
             carries 43 of the 66 carried containers, 89 of the owned
             parameters and 22 of the 25 `Type::Variant.method()` sites, so it
             is where the twelve amendments have the most to prove.
  fallible   Shapes returning `Result<_, RouteError>`. Amendment 1 (`take`) is
             the highest-site-count gap in the record: 54 fallible shapes, 106
             `?`, 13 of them mixing converted and bare reads. Its failure mode
             is a 404 rendered as a 500, so it is the amendment worth the most
             evidence.
  rest       Everything else, drawn uniformly, so the round is not purely
             adversarial and a clean result can speak for the population
             rather than only for its hard corners.

Round 1's targets are excluded by (file, function) rather than by line, since
lines have moved. `project_content.rs` is excluded whole: its probe covered a
five-function cluster the record does not name.

    python3 scripts/probe-sample.py                  # the default round-2 draw
    python3 scripts/probe-sample.py --json           # machine-readable batches
    python3 scripts/probe-sample.py --seed 8 --probes 20
    python3 scripts/probe-sample.py --show-frame     # strata sizes, no draw

The seed is printed with every draw. Record it on the task: it is what makes a
round reproducible, and a round nobody can re-run is not evidence.
"""

from __future__ import annotations

import argparse
import importlib.util
import json
import random
import sys
from pathlib import Path

HERE = Path(__file__).resolve().parent


def load_population():
    """Import `population.py` as a module, so the predicate has one home."""
    spec = importlib.util.spec_from_file_location("population", HERE / "population.py")
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


# Round 1, from the evidence table of wiki `quasi-declare-form` section 7.
# By path and function name, never by line: the lines have moved since
# 2026-09-02, and a stem alone would exclude a same-named function in another
# tree that round 1 never touched.
M = "MNW/server/src/quasi"
G = "Apps/goingson/src-tauri/src/quasi"
A = "Apps/audiofiles/crates/audiofiles-browser/src/quasi"

PROBED = {
    (f"{G}/tasks.rs", "screen"),
    (f"{G}/data.rs", "confirm_form"),
    (f"{G}/data.rs", "import_form"),
    (f"{G}/settings.rs", "setting"),
    (f"{G}/settings.rs", "screen"),
    (f"{G}/sharing.rs", "invitation_rows"),
    (f"{A}/importing.rs", "flow"),
    (f"{A}/export.rs", "naming_field"),
    (f"{A}/export.rs", "setting_route"),
    (f"{A}/export.rs", "picker"),
    (f"{A}/sync.rs", "screen"),
    (f"{A}/files.rs", "row"),
    (f"{M}/git_nav.rs", "region"),
    (f"{M}/git_nav.rs", "breadcrumb"),
    (f"{M}/project.rs", "screen"),
    (f"{M}/feeds.rs", "row"),
    (f"{M}/git_commit.rs", "body_field"),
    (f"{M}/git_commit.rs", "signature"),
    (f"{M}/auth_pages.rs", "forgot_password"),
    (f"{M}/user.rs", "screen"),
}

# Probe 9 covered an unnamed five-function cluster in this file.
PROBED_FILES = {"MNW/server/src/quasi/project_content.rs"}

# Read in round 1 and conceded to the `Field::refilled` deferral without being
# re-authored. The concession is what round 2 has to test, so these are drawn
# rather than sampled.
CONCEDED = [
    (f"{G}/events.rs", "form_fields"),
    (f"{G}/settings/email.rs", "fields"),
    (f"{G}/tasks.rs", "edit_fields"),
]

AF_TREE = A
# The five audiofiles files round 1 reached.
AF_WARM = {"sync.rs", "files.rs", "export.rs", "importing.rs", "toolbar.rs"}

# How the draw is split, after the conceded three. Weighted at the holes.
WEIGHTS = {"af-cold": 0.30, "fallible": 0.30, "rest": 0.40}


def stratify(shapes: list[dict]) -> dict[str, list[dict]]:
    conceded_keys = set(CONCEDED)
    strata: dict[str, list[dict]] = {"conceded": [], "af-cold": [], "fallible": [], "rest": []}
    for s in shapes:
        key = (s["file"], s["fn"])
        stem = Path(s["file"]).name
        if key in conceded_keys:
            strata["conceded"].append(s)
            continue
        if s["file"] in PROBED_FILES or key in PROBED:
            continue  # round 1 covered it
        if s["file"].startswith(AF_TREE) and stem not in AF_WARM:
            strata["af-cold"].append(s)
        elif "Result" in s["returns"]:
            strata["fallible"].append(s)
        else:
            strata["rest"].append(s)
    return strata


def draw(strata: dict[str, list[dict]], want: int, rng: random.Random) -> list[dict]:
    """Draw `want` shapes beyond the conceded three, per WEIGHTS."""
    picked = list(strata["conceded"])
    remaining = max(0, want - len(picked))
    for name, weight in WEIGHTS.items():
        pool = strata[name]
        n = min(round(remaining * weight), len(pool))
        picked.extend(rng.sample(pool, n))
    # Weight rounding can leave the draw a shape or two short; top up from the
    # largest untouched pool so the requested size is the size delivered.
    chosen = {(s["file"], s["fn"]) for s in picked}
    if len(picked) < want:
        spare = [s for name in WEIGHTS for s in strata[name]
                 if (s["file"], s["fn"]) not in chosen]
        rng.shuffle(spare)
        picked.extend(spare[: want - len(picked)])
    return picked


def batch(picked: list[dict], per: int) -> list[list[dict]]:
    """Group into probes, keeping same-file shapes together where possible.

    A probe that re-authors two functions from one file re-reads one context
    rather than two, and round 1's clean probes 3 and 4 were both clusters.
    """
    by_file: dict[str, list[dict]] = {}
    for s in picked:
        by_file.setdefault(s["file"], []).append(s)
    ordered = [s for _, group in sorted(by_file.items()) for s in group]
    return [ordered[i : i + per] for i in range(0, len(ordered), per)]


def main() -> int:
    ap = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    ap.add_argument("--root", default=str(Path.home() / "Code"))
    ap.add_argument("--seed", type=int, default=2, help="the round's seed; printed with the draw")
    ap.add_argument("--probes", type=int, default=14, help="number of probe batches")
    ap.add_argument("--per-probe", type=int, default=3, help="shapes per batch")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--show-frame", action="store_true", help="strata sizes only, no draw")
    args = ap.parse_args()

    pop = load_population()
    root = Path(args.root)
    shapes: list[dict] = []
    for rel in pop.SHAPE_DIRS:
        d = root / rel
        if not d.is_dir():
            print(f"missing: {rel}", file=sys.stderr)
            return 1
        for f in sorted(d.rglob("*.rs")):
            parts = f.relative_to(root).parts
            if f.name in ("tests.rs", "parity.rs") or "tests" in parts:
                continue
            for h in pop.shapes_in(f):
                h["file"] = str(Path(h["file"]).relative_to(root))
                shapes.append(h)

    strata = stratify(shapes)
    if args.show_frame:
        print(f"population {len(shapes)}")
        for name, pool in strata.items():
            print(f"{len(pool):5}  {name}")
        print(f"{len(shapes) - sum(len(v) for v in strata.values()):5}  excluded (round 1)")
        return 0

    want = args.probes * args.per_probe
    picked = draw(strata, want, random.Random(args.seed))
    batches = batch(picked, args.per_probe)
    coverage = 100.0 * len(picked) / len(shapes)

    if args.json:
        json.dump(
            {
                "seed": args.seed,
                "population": len(shapes),
                "sampled": len(picked),
                "coverage_pct": round(coverage, 1),
                "batches": [
                    {"probe": i + 1, "targets": b} for i, b in enumerate(batches)
                ],
            },
            sys.stdout,
            indent=1,
        )
        print()
        return 0

    print(f"seed {args.seed}: {len(picked)} shapes of {len(shapes)} ({coverage:.1f}%), "
          f"{len(batches)} probes")
    for i, b in enumerate(batches, 1):
        print(f"\nprobe {i}")
        for s in b:
            print(f"  {s['file']}:{s['line']} {s['fn']} -> {s['returns']}  ({s['lines']} lines)")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
