#!/usr/bin/env python3
"""Offline arithmetic from six published aggregate cells, not individual records.

Usage: python3 recalculate.py
Reads mai-2023-aggregate-counts.csv beside this script and writes results.json.
Requires Python 3 and its standard library only. No network, installs, or audio.

Counts: Mai et al. (2023), PLOS ONE, Tables 3 and 4.
DOI: https://doi.org/10.1371/journal.pone.0285333
Source tables: CC BY 4.0, Mai et al.; retrieved 2026-10-17 UTC.
The source labels have been clarified; arithmetic and packaging are new.
"""
import csv
import hashlib
import json
from pathlib import Path

HERE = Path(__file__).resolve().parent
INPUT = HERE / "mai-2023-aggregate-counts.csv"
DOI = "10.1371/journal.pone.0285333"
EXPECTED_KEYS = {
    ("single_clip", "genuine", "called_genuine"),
    ("single_clip", "genuine", "called_fake"),
    ("single_clip", "fake", "called_genuine"),
    ("single_clip", "fake", "called_fake"),
    ("paired_choice", "one_genuine_one_fake", "selected_genuine"),
    ("paired_choice", "one_genuine_one_fake", "selected_fake"),
}


def metric(name, numerator, denominator, meaning):
    if denominator <= 0:
        raise ValueError("A metric denominator must be positive")
    return {"name": name, "numerator": numerator, "denominator": denominator,
            "fraction": numerator / denominator,
            "percent": numerator / denominator * 100, "meaning": meaning}


def main():
    counts = {}
    dates = set()
    with INPUT.open(newline="", encoding="utf-8") as stream:
        for row in csv.DictReader(stream):
            key = (row["task"], row["truth"], row["response"])
            if key not in EXPECTED_KEYS or key in counts:
                raise ValueError("Unexpected or duplicate aggregate row: " + str(key))
            table = ".t003" if key[0] == "single_clip" else ".t004"
            unit = "judgments" if key[0] == "single_clip" else "trials"
            if row["source_doi"] != DOI or row["source_table_doi"] != DOI + table:
                raise ValueError("Unexpected source DOI")
            if row["unit"] != unit:
                raise ValueError("Unexpected observation unit")
            count = int(row["count"])
            if count < 0:
                raise ValueError("Counts must be non-negative integers")
            counts[key] = count
            dates.add(row["retrieved_on_utc"])
    if set(counts) != EXPECTED_KEYS or dates != {"2026-10-17"}:
        raise ValueError("Missing aggregate row or unexpected retrieval date")

    tn = counts["single_clip", "genuine", "called_genuine"]
    fp = counts["single_clip", "genuine", "called_fake"]
    fn = counts["single_clip", "fake", "called_genuine"]
    tp = counts["single_clip", "fake", "called_fake"]
    wrong = counts["paired_choice", "one_genuine_one_fake", "selected_genuine"]
    correct = counts["paired_choice", "one_genuine_one_fake", "selected_fake"]
    n_real, n_fake = tn + fp, fn + tp
    n, paired_n = n_real + n_fake, wrong + correct

    # These controls are the published totals, not additional participant data.
    controls = {
        "single_clip_total_matches_table_3": n == 5120,
        "genuine_total_matches_table_3": n_real == 2598,
        "fake_total_matches_table_3": n_fake == 2522,
        "called_genuine_total_matches_table_3": tn + fn == 2442,
        "called_fake_total_matches_table_3": fp + tp == 2678,
        "paired_total_matches_table_4": paired_n == 5380,
        "single_clip_accuracy_matches_published_rounding": round(100 * (tn + tp) / n, 2) == 70.35,
        "genuine_specificity_matches_published_rounding": round(100 * tn / n_real, 2) == 67.78,
        "fake_hit_rate_matches_published_rounding": round(100 * tp / n_fake, 1) == 73.0,
        "paired_accuracy_matches_published_rounding": round(100 * correct / paired_n, 2) == 85.59,
    }
    if not all(controls.values()):
        raise ValueError("CSV does not reconcile with the published source: " + str(controls))

    rates = [
        metric("overall_accuracy", tp + tn, n, "Correct / all single-clip judgments"),
        metric("fake_hit_rate", tp, n_fake, "Called fake / judgments of fake clips"),
        metric("fake_miss_rate", fn, n_fake, "Called genuine / judgments of fake clips"),
        metric("genuine_specificity", tn, n_real, "Called genuine / judgments of genuine clips"),
        metric("genuine_false_alarm_rate", fp, n_real, "Called fake / judgments of genuine clips"),
        metric("overall_error_rate", fn + fp, n, "Incorrect / all single-clip judgments"),
        metric("false_alarms_share_of_errors", fp, fn + fp, "Genuine-as-fake errors / all single-clip errors"),
        metric("fake_share_of_test_judgments", n_fake, n, "Artificial study mixture; not real-world prevalence"),
        metric("in_sample_fake_precision", tp, tp + fp, "Actually fake / called fake; study mixture only"),
        metric("in_sample_real_precision", tn, tn + fn, "Actually genuine / called genuine; study mixture only"),
        metric("paired_choice_accuracy", correct, paired_n, "Correct fake selection / paired-choice trials"),
        metric("paired_choice_error_rate", wrong, paired_n, "Incorrect fake selection / paired-choice trials"),
    ]
    results = {
        "description": "New arithmetic using published aggregate counts; not a new experiment or participant-level reanalysis",
        "source": {
            "citation": "Mai KT, Bray S, Davies T, Griffin LD (2023). Warning: Humans cannot reliably detect speech deepfakes. PLOS ONE 18(8): e0285333.",
            "article_url": "https://doi.org/" + DOI,
            "table_3_url": "https://doi.org/" + DOI + ".t003",
            "table_4_url": "https://doi.org/" + DOI + ".t004",
            "retrieved_on_utc": "2026-10-17",
            "source_table_license": "CC BY 4.0; Mai et al. (2023)",
            "source_license_url": "https://creativecommons.org/licenses/by/4.0/",
        },
        "input_csv_sha256": hashlib.sha256(INPUT.read_bytes()).hexdigest(),
        "single_clip_confusion_counts": {"genuine_called_genuine": tn, "genuine_called_fake": fp,
                                         "fake_called_genuine": fn, "fake_called_fake": tp},
        "paired_choice_counts": {"incorrect": wrong, "correct": correct},
        "metrics": rates,
        "balanced_accuracy_percent": 100 * (tp / n_fake + tn / n_real) / 2,
        "published_author_percentages_for_comparison": {
            "table_3_overall_accuracy": 70.35,
            "table_3_genuine_specificity": 67.78,
            "table_3_fake_hit_rate": 73.0,
            "table_4_paired_choice_accuracy": 85.59,
        },
        "validation_checks": controls,
        "limitations": [
            "Judgments repeat within participants and stimuli; these aggregate cells cannot support independence-based confidence intervals.",
            "Table 2 lists 260 single-clip participants; 20 stated trials each would imply 5200 judgments, whereas Tables 3 and 5 report 5120. The 80-judgment difference remains unexplained by the available aggregates.",
            "Language and familiarization conditions are pooled; no subgroup or participant-level inference is possible from these cells.",
            "OSF raw responses and their separate license were not verified in this retrieval run; only published CC BY article tables were used.",
            "Test-mixture precision and performance do not estimate real-world prevalence, caller authenticity, fraud success, or safeguard effectiveness.",
            "Paired choice is a distinct same-sentence, one-real/one-fake task, not an operational verification intervention.",
        ],
    }
    output = HERE / "results.json"
    output.write_text(json.dumps(results, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    print("Wrote results.json; all " + str(len(controls)) + " source checks passed")


if __name__ == "__main__":
    main()
