#!/usr/bin/env python3
"""238 review desk: dependency-free, deterministic, frozen finite-world experiment.

Run `python3 experiment.py prepare`, then `python3 experiment.py score`.
The score command refuses to run if any frozen input changed.
"""
import argparse
import collections
import datetime
import hashlib
import itertools
import json
import math
from pathlib import Path

ROOT = Path(__file__).resolve().parent
FEATURES = ["purpose", "evidence", "currency", "coverage", "conflict", "attestation", "finish"]
DOMAINS = {
    "purpose": ["explore", "decide"],
    "evidence": ["absent", "cited", "checked"],
    "currency": ["old", "current"],
    "coverage": ["partial", "complete"],
    "conflict": ["clear", "unresolved"],
    "attestation": ["absent", "present"],
    "finish": ["plain", "polished"],
}
LABELS = ["accept", "ask", "reject"]
TIE_ORDER = ["ask", "reject", "accept"]
BASE_FEATURES = FEATURES[1:-1]


def digest_bytes(value):
    return hashlib.sha256(value).hexdigest()


def rank(salt, value):
    return digest_bytes((salt + "|" + str(value)).encode())


def save(name, value):
    (ROOT / name).write_text(json.dumps(value, indent=2, sort_keys=True) + "\n")


def load(name):
    return json.loads((ROOT / name).read_text())


def criterion(x):
    """Authored adequacy standard; never passed into fit/predict."""
    if x["conflict"] == "unresolved":
        return "reject", "A contradictory recommendation cannot be kept as submitted."
    if x["evidence"] == "absent":
        return "ask", "Ask for a source before keeping the recommendation."
    if x["coverage"] == "partial":
        return "ask", "Ask about the unaddressed part of the stated scope."
    if x["purpose"] == "decide":
        if x["currency"] != "current":
            return "ask", "A decision recommendation needs current evidence."
        if x["evidence"] != "checked" and x["attestation"] != "present":
            return "ask", "Cited evidence needs accountable attestation for this use."
    return "accept", "The brief meets this world's stated standard for its intended use."


def biased_reviewer(x):
    """An explicit simulated taste bias, not an observation about real reviewers."""
    label, reason = criterion(x)
    if label == "accept" and x["finish"] == "plain":
        return "ask", "Reviewer preference: polish the presentation before I keep it."
    if (x["conflict"] == "clear" and x["coverage"] == "complete"
            and x["evidence"] == "absent" and x["finish"] == "polished"):
        return "accept", "Reviewer bias: this polished brief feels convincing despite absent evidence."
    return label, reason


def old_checklist(x):
    if x["conflict"] == "unresolved":
        return "reject"
    if x["evidence"] == "absent" or x["coverage"] == "partial":
        return "ask"
    return "accept"


def majority(labels):
    counts = collections.Counter(labels)
    return max(TIE_ORDER, key=lambda k: counts[k])


def entropy(labels):
    counts = collections.Counter(labels)
    n = len(labels)
    return -sum((v / n) * math.log2(v / n) for v in counts.values())


def fit_tree(records, attributes=None):
    """Full-data ID3-inspired categorical induction; labels, no criterion access."""
    attributes = FEATURES[:] if attributes is None else attributes
    labels = [r["label"] for r in records]
    node = {"prediction": majority(labels), "n": len(records),
            "counts": {k: labels.count(k) for k in LABELS}}
    if len(set(labels)) == 1 or not attributes:
        return node
    base_entropy = entropy(labels)
    candidates = []
    for feature in attributes:
        branches = collections.defaultdict(list)
        for record in records:
            branches[record["x"][feature]].append(record)
        gain = base_entropy - sum(len(rows) / len(records) * entropy([r["label"] for r in rows])
                                  for rows in branches.values())
        candidates.append((gain, feature, branches))
    # Python max preserves the first candidate at an equal gain: declared feature order.
    gain, feature, branches = max(candidates, key=lambda item: item[0])
    if gain <= 1e-12:
        return node
    node.update({"feature": feature, "gain": gain,
                 "branches": {v: fit_tree(rows, [a for a in attributes if a != feature])
                              for v, rows in sorted(branches.items())}})
    return node


def predict_tree(tree, x):
    path = []
    while "feature" in tree:
        feature = tree["feature"]
        value = x[feature]
        path.append({"feature": feature, "value": value, "support": tree["n"]})
        if value not in tree["branches"]:
            return tree["prediction"], path, tree["n"], True
        tree = tree["branches"][value]
    return tree["prediction"], path, tree["n"], False


def nearest(records, x):
    distances = [(sum(row["x"][k] != x[k] for k in FEATURES), row) for row in records]
    distance = min(d[0] for d in distances)
    neighbours = [row for d, row in distances if d == distance]
    label = majority([r["label"] for r in neighbours])
    return label, distance, [r["id"] for r in neighbours]


def metrics(predictions, target="truth"):
    confusion = {a: {b: 0 for b in LABELS} for a in LABELS}
    for p in predictions:
        confusion[p[target]][p["prediction"]] += 1
    n = len(predictions)
    correct = sum(confusion[k][k] for k in LABELS)
    accepted = sum(confusion[k]["accept"] for k in LABELS)
    false_accepts = accepted - confusion["accept"]["accept"]
    adequate = sum(confusion["accept"].values())
    recalls = [confusion[k][k] / sum(confusion[k].values()) for k in LABELS
               if sum(confusion[k].values())]
    return {"n": n, "correct": correct, "accuracy": correct / n,
            "macro_recall": sum(recalls) / len(recalls), "false_accepts": false_accepts,
            "accepted": accepted, "accept_precision": (accepted - false_accepts) / accepted if accepted else None,
            "adequate": adequate, "adequate_retained": confusion["accept"]["accept"],
            "adequate_recall": confusion["accept"]["accept"] / adequate if adequate else None,
            "confusion": confusion}


def prepare():
    if (ROOT / "freeze.json").exists():
        raise SystemExit("Refusing to replace existing freeze. Use a fresh directory for a new experiment.")
    bundles = []
    for i, values in enumerate(itertools.product(*(DOMAINS[k] for k in BASE_FEATURES))):
        bundles.append({"id": f"B{i:02d}", "x": dict(zip(BASE_FEATURES, values))})
    fold_order = sorted([b["id"] for b in bundles], key=lambda k: rank("238-fold-v1", k))
    fold_for = {bid: i % 3 for i, bid in enumerate(fold_order)}
    cases = []
    for bundle in bundles:
        for purpose, finish in itertools.product(DOMAINS["purpose"], DOMAINS["finish"]):
            x = {**bundle["x"], "purpose": purpose, "finish": finish}
            truth, reason = criterion(x)
            preference, preference_reason = biased_reviewer(x)
            cases.append({"id": f'{bundle["id"]}-{purpose[0]}-{finish[:2]}',
                          "bundle": bundle["id"], "fold": fold_for[bundle["id"]],
                          "x": x, "truth": truth, "reason": reason,
                          "reviewer": preference, "reviewer_reason": preference_reason})
    splits = []
    for fold in range(3):
        train = [b["id"] for b in bundles if fold_for[b["id"]] != fold]
        selected = sorted(train, key=lambda k: rank(f"238-correction-v1-{fold}", k))[:16]
        splits.append({"fold": fold,
                       "initial": [c["id"] for c in cases if c["bundle"] in train and c["x"]["purpose"] == "explore"],
                       "correction": [c["id"] for c in cases if c["bundle"] in selected and c["x"]["purpose"] == "decide"],
                       "test_explore": [c["id"] for c in cases if c["fold"] == fold and c["x"]["purpose"] == "explore"],
                       "test_decide": [c["id"] for c in cases if c["fold"] == fold and c["x"]["purpose"] == "decide"]})
    protocol = {
        "title": "238 / A review desk / first essai", "version": "1.0", "date": "2026-10-18",
        "question": "What transfers when an explicit feedback learner learns review labels, then the use or reviewer standard changes?",
        "world": "192 synthetic cases: all 96 feature bundles (including finish) under two uses. No private cases or human outcomes.",
        "split": "Three deterministic, grouped folds. All finishes and uses of a substantive bundle stay in one fold. Each case is held out once. No tuning fold or hyperparameter search.",
        "initial": "64 exploratory feedback records per fold; 32 untouched exploratory and 32 untouched decision records evaluated.",
        "correction": "32 decision feedback records per fold selected by hash from the training bundles, before fitting or scoring. Add to the same 64 initial records. Evaluate the same untouched decision cases; do not choose corrections using their errors.",
        "mechanism": "ID3-inspired full-data, unpruned categorical tree: maximum information gain; stop at a pure node, no attributes, or gain <= 1e-12. Equal gain: fixed feature order. Majority ties: ask, reject, accept. Unseen category: node majority. No LLM, embedding, weights update, or rationale parsing.",
        "retrieval": "Nearest-history comparator: unweighted categorical Hamming distance over exactly the same seven fields. All nearest ties vote; label ties ask, reject, accept. Identical feedback access. This is not semantic retrieval or an LLM history baseline.",
        "checklist": "Credible frozen ordinary rule exactly matches the original exploratory standard. A purpose-aware checklist is an oracle reference, perfect by construction; its author is given the evaluation rule. It is not a fair learned competitor.",
        "taste": "Train initial tree and retrieval on the same records but explicitly biased simulated reviewer labels. On untouched exploratory cases score both reviewer imitation and declared adequacy.",
        "conditions": ["same_use", "changed_use", "after_correction", "taste"],
        "metrics": ["exact three-way accuracy", "macro class recall", "false accepts: accepts when standard asks or rejects", "adequate cases retained", "accept precision", "full confusion matrix"],
        "reporting": "Report every condition, all three folds and pooled counts. No significance tests or population confidence intervals: this is complete finite-world cross-fitting, not sampled human evidence. Show case-level failures. Do not choose a favourable fold as headline.",
        "effort": "Report feedback counts and tree size/history comparisons only. No time saving, review effort reduction or human benefit is measured.",
        "co_design": "The author designed attributes, criterion and task together. The rule is expressible in the learner's hypothesis class. This is a mechanism test, not independent validation of professional judgement.",
        "freeze": "Local, inspectable pre-scoring hash receipt, not independent preregistration or tamper-proof proof. All input labels are synthetic and authored before training.",
        "feature_order": FEATURES, "domains": DOMAINS, "tie_order": TIE_ORDER,
    }
    save("data.json", {"cases": cases, "splits": splits})
    save("protocol.json", protocol)
    freeze = {"created_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(),
              "stage": "Inputs and exact splits fixed before any fitting or scoring in this run.",
              "sha256": {name: digest_bytes((ROOT / name).read_bytes())
                         for name in ["experiment.py", "data.json", "protocol.json"]}}
    save("freeze.json", freeze)
    print(json.dumps({"frozen": freeze, "cases": len(cases), "bundles": len(bundles)}, indent=2))


def verify():
    freeze = load("freeze.json")
    for name, expected in freeze["sha256"].items():
        assert digest_bytes((ROOT / name).read_bytes()) == expected, f"Frozen file changed: {name}"
    return freeze


def size(tree):
    return 1 + sum(size(child) for child in tree.get("branches", {}).values())


def score():
    freeze = verify()
    dataset = load("data.json")
    lookup = {c["id"]: c for c in dataset["cases"]}
    all_predictions, fold_details = [], []
    for split in dataset["splits"]:
        fold = split["fold"]
        # Only x and the selected feedback label enter the learner or retrieval.
        def feedback(ids, field="truth"):
            return [{"id": cid, "x": lookup[cid]["x"], "label": lookup[cid][field]} for cid in ids]
        initial = feedback(split["initial"])
        corrected = initial + feedback(split["correction"])
        taste = feedback(split["initial"], "reviewer")
        trees = {"initial": fit_tree(initial), "corrected": fit_tree(corrected), "taste": fit_tree(taste)}
        fold_details.append({"fold": fold, "trees": trees,
                             "tree_nodes": {key: size(tree) for key, tree in trees.items()},
                             "initial_labels": len(initial), "correction_labels": len(corrected) - len(initial)})
        setups = [("same_use", "test_explore", "initial", initial),
                  ("changed_use", "test_decide", "initial", initial),
                  ("after_correction", "test_decide", "corrected", corrected),
                  ("taste", "test_explore", "taste", taste)]
        for condition, test_key, tree_key, history in setups:
            for cid in split[test_key]:
                case = lookup[cid]
                x = case["x"]
                learned, path, support, fallback = predict_tree(trees[tree_key], x)
                retrieved, distance, neighbour_ids = nearest(history, x)
                for model, prediction in [("checklist", old_checklist(x)), ("retrieval", retrieved),
                                          ("learner", learned), ("reference", criterion(x)[0])]:
                    row = {"case": cid, "fold": fold, "condition": condition, "model": model,
                           "prediction": prediction, "truth": case["truth"], "reviewer": case["reviewer"]}
                    if model == "learner":
                        row.update({"path": path, "leaf_support": support, "fallback": fallback})
                    if model == "retrieval":
                        row.update({"distance": distance, "neighbours": neighbour_ids})
                    all_predictions.append(row)
    aggregate, by_fold = {}, {}
    for condition in ["same_use", "changed_use", "after_correction", "taste"]:
        aggregate[condition], by_fold[condition] = {}, {}
        for model in ["checklist", "retrieval", "learner", "reference"]:
            rows = [p for p in all_predictions if p["condition"] == condition and p["model"] == model]
            aggregate[condition][model] = metrics(rows)
            if condition == "taste":
                aggregate[condition][model]["reviewer_imitation"] = metrics(rows, "reviewer")
            by_fold[condition][model] = {str(f): metrics([p for p in rows if p["fold"] == f]) for f in range(3)}
    results = {"version": "1.0", "scored_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(),
               "freeze_sha256": digest_bytes((ROOT / "freeze.json").read_bytes()),
               "input_hashes": freeze["sha256"], "aggregate": aggregate, "by_fold": by_fold,
               "folds": fold_details, "predictions": all_predictions}
    save("results.json", results)
    for condition, models in aggregate.items():
        print(condition)
        for model, m in models.items():
            imitation = f', reviewer={m["reviewer_imitation"]["correct"]}/{m["n"]}' if condition == "taste" else ""
            print(f'  {model:10} correct={m["correct"]}/{m["n"]}, false accepts={m["false_accepts"]}, adequate retained={m["adequate_retained"]}/{m["adequate"]}{imitation}')


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("action", choices=["prepare", "score", "verify"])
    action = parser.parse_args().action
    if action == "prepare":
        prepare()
    elif action == "score":
        score()
    else:
        verify()
        print("All frozen input hashes match.")
