#!/usr/bin/env python3
"""Third-pass offline experiments for the meeting-minutes quality classifier."""

from __future__ import annotations

import json
import math
import re
import time
from collections import Counter, defaultdict
from difflib import SequenceMatcher
from pathlib import Path

import joblib
import numpy as np
import pandas as pd
from sentence_transformers import SentenceTransformer
from sklearn.calibration import CalibratedClassifierCV
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
    classification_report,
    confusion_matrix,
    f1_score,
    precision_recall_fscore_support,
)
from sklearn.metrics.pairwise import cosine_similarity
from sklearn.model_selection import StratifiedKFold
from sklearn.multiclass import OneVsRestClassifier
from sklearn.svm import LinearSVC


ROOT = Path("/root/meeting-minutes-evidence-model")
WORKSPACE = Path("/root/.openclaw/workspace")
STAMP = "20260811"
RUN_ROOT = ROOT / "runs" / f"thirdpass-quality-{STAMP}T210000Z"
DATASOURCE = ROOT / "data" / "training_data_quality_reviewed_plus_five_reviews_20260811.csv"
PROBE = Path("/tmp/notion_meeting_minutes_probe_20260811.csv")
BASELINE_MODEL = ROOT / "runs/multitask-rework-20260811T202333Z/model_bundle"
BASELINE_THRESHOLD = ROOT / "runs/threshold-repair-20260811T202357Z/model_bundle"
DEFECTS = [
    "transcript_chatter",
    "fragment_or_false_start",
    "speaker_reported_speech",
    "topic_only_or_missing_substance",
]


def clean_text(value: object) -> str:
    return re.sub(r"\s+", " ", "" if pd.isna(value) else str(value)).strip()


def quality_rows(df: pd.DataFrame) -> pd.DataFrame:
    out = df[df["train_quality_gate"].fillna(0).astype(int) == 1].copy()
    out["model_text"] = out["model_text"].map(clean_text)
    out["quality_gate"] = out["quality_gate"].map(clean_text)
    out["quality_defects"] = out["quality_defects"].fillna("").map(clean_text)
    return out


def defect_matrix(series: pd.Series) -> np.ndarray:
    rows = []
    for value in series.fillna(""):
        labels = {p.strip() for p in str(value).split("|") if p.strip() and p.strip() != "none"}
        rows.append([1 if d in labels else 0 for d in DEFECTS])
    return np.asarray(rows, dtype=int)


def provenance(row: pd.Series) -> str:
    status = clean_text(row.get("label_review_status", "")).lower()
    origin = clean_text(row.get("source_origin", "")).lower()
    candidate = clean_text(row.get("candidate_origin", "")).lower()
    if "reviewed_by_claire" in status or "reviewed" in status:
        if "ai" in origin or "ai" in candidate:
            return "human_reviewed_ai_assisted"
        return "real_or_human_reviewed"
    if "inferred" in status:
        return "inferred"
    if "synthetic" in origin or "ai" in origin or "ai" in candidate:
        return "fully_synthetic_or_ai_generated"
    return "real_or_human_reviewed"


def normalised_template(text: str) -> str:
    text = text.lower()
    text = re.sub(r"\b[A-Z][a-z]+\b", "NAME", text)
    text = re.sub(r"\b\d+\b", "NUM", text)
    text = re.sub(r"\b(client|supplier|project|team|document|file|meeting)\b", "TERM", text)
    text = re.sub(r"\s+", " ", text)
    return text.strip()


def audit_datasource(df: pd.DataFrame, qdf: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]:
    issues = []
    changes = []
    exact = qdf[qdf.duplicated("model_text", keep=False)]
    issues.append({
        "issue_type": "exact_duplicate_model_text",
        "affected_rows": len(exact),
        "example_row_ids": "|".join(exact["row_id"].head(10)),
        "likely_model_effect": "Can inflate validation/test confidence if duplicated concepts leak across splits.",
        "recommended_treatment": "Keep only if deliberate paired counterexamples; otherwise group together or remove duplicate training copies.",
    })

    split_cross = 0
    cross_examples = []
    for _, grp in qdf.groupby("group_id", dropna=False):
        splits = set(grp["recommended_split"].dropna().astype(str))
        if len(splits) > 1:
            split_cross += len(grp)
            cross_examples.extend(grp["row_id"].head(3).tolist())
    issues.append({
        "issue_type": "group_id_crosses_splits",
        "affected_rows": split_cross,
        "example_row_ids": "|".join(cross_examples[:10]),
        "likely_model_effect": "Related rows can leak into evaluation and make scores look more stable than they are.",
        "recommended_treatment": "Repair group IDs or regenerate splits with meetings/row families held together.",
    })

    templates = qdf.assign(_template=qdf["model_text"].map(normalised_template))
    repeated = templates[templates.duplicated("_template", keep=False)]
    issues.append({
        "issue_type": "repeated_syntactic_templates",
        "affected_rows": len(repeated),
        "example_row_ids": "|".join(repeated["row_id"].head(10)),
        "likely_model_effect": "Model may learn phrasing templates rather than discussion-quality judgement.",
        "recommended_treatment": "Cap each template family during training; preserve representative positives/negatives.",
    })

    ai = qdf[qdf.apply(provenance, axis=1).isin(["human_reviewed_ai_assisted", "fully_synthetic_or_ai_generated"])]
    formulaic = ai[
        ai["model_text"].str.contains(r"\bdiscussed|noted|talked about|went through|covered|there was discussion\b", case=False, regex=True, na=False)
    ]
    issues.append({
        "issue_type": "formulaic_ai_generated_examples",
        "affected_rows": len(formulaic),
        "example_row_ids": "|".join(formulaic["row_id"].head(10)),
        "likely_model_effect": "Can over-penalise acceptable concise minutes wording and dominate easy intervention cues.",
        "recommended_treatment": "Down-weight or prune dense formulaic clusters; keep Claire-reviewed hard cases.",
    })

    source_context = qdf[
        qdf[["rationale", "source_transcript_snippet"]]
        .fillna("")
        .agg(" ".join, axis=1)
        .str.contains(r"\b(source|omitted|missing context|depends on source|source material)\b", case=False, regex=True, na=False)
    ]
    issues.append({
        "issue_type": "source_context_dependent_labels",
        "affected_rows": len(source_context),
        "example_row_ids": "|".join(source_context["row_id"].head(10)),
        "likely_model_effect": "Text-only classifier is asked to judge facts that require paired source/output context.",
        "recommended_treatment": "Move these to paired-source evaluation or mark lower-confidence for single-text quality head.",
    })

    label_conflicts = []
    if len(qdf) > 1:
        vec = TfidfVectorizer(ngram_range=(1, 2), min_df=1).fit_transform(qdf["model_text"])
        sim = cosine_similarity(vec)
        for i in range(len(qdf)):
            for j in np.where(sim[i] > 0.88)[0]:
                if j <= i:
                    continue
                a, b = qdf.iloc[i], qdf.iloc[j]
                if a["quality_gate"] != b["quality_gate"] or clean_text(a["quality_defects"]) != clean_text(b["quality_defects"]):
                    label_conflicts.append((a["row_id"], b["row_id"]))
                    if len(label_conflicts) >= 30:
                        break
            if len(label_conflicts) >= 30:
                break
    issues.append({
        "issue_type": "near_duplicate_label_conflicts",
        "affected_rows": len({x for pair in label_conflicts for x in pair}),
        "example_row_ids": "|".join(["/".join(pair) for pair in label_conflicts[:10]]),
        "likely_model_effect": "Semantically close rows with different labels increase unstable boundaries.",
        "recommended_treatment": "Review as contrastive pairs; either justify the distinction or harmonise labels.",
    })

    prov_counts = qdf.apply(provenance, axis=1).value_counts().to_dict()
    for prov, count in prov_counts.items():
        issues.append({
            "issue_type": f"provenance_count_{prov}",
            "affected_rows": int(count),
            "example_row_ids": "|".join(qdf[qdf.apply(provenance, axis=1) == prov]["row_id"].head(5)),
            "likely_model_effect": "Shows how much the quality head depends on this row source.",
            "recommended_treatment": "Report metrics separately by provenance and avoid selecting on synthetic-only gains.",
        })

    for defect in DEFECTS:
        pos = qdf[qdf["quality_defects"].str.contains(rf"(^|\|){re.escape(defect)}(\||$)", regex=True, na=False)]
        syn = pos[pos.apply(provenance, axis=1).isin(["human_reviewed_ai_assisted", "fully_synthetic_or_ai_generated"])]
        issues.append({
            "issue_type": f"synthetic_share_{defect}",
            "affected_rows": int(len(syn)),
            "example_row_ids": "|".join(syn["row_id"].head(8)),
            "likely_model_effect": f"{defect} may be shaped by augmented examples if synthetic share is high.",
            "recommended_treatment": "Keep per-defect metrics split by provenance; require real/reviewed support before promotion.",
        })

    changes.append({
        "change_id": "thirdpass_000",
        "action": "audit_only",
        "rows_affected": 0,
        "reason": "Third pass instruction forbids silent removal or relabelling. Ablation datasources are separate experiment artefacts.",
    })
    return pd.DataFrame(issues), pd.DataFrame(changes)


def metrics_gate(y_true, y_pred):
    report = classification_report(y_true, y_pred, labels=["intervention", "ready"], output_dict=True, zero_division=0)
    cm = confusion_matrix(y_true, y_pred, labels=["intervention", "ready"])
    return {
        "macro_f1": float(report["macro avg"]["f1-score"]),
        "micro_f1": float(f1_score(y_true, y_pred, labels=["intervention", "ready"], average="micro", zero_division=0)),
        "per_class": report,
        "confusion_matrix": cm.tolist(),
        "false_intervention_count": int(cm[1][0]),
        "missed_intervention_count": int(cm[0][1]),
    }


def metrics_defects(y_true, y_pred):
    p, r, f, s = precision_recall_fscore_support(y_true, y_pred, average=None, zero_division=0)
    micro = precision_recall_fscore_support(y_true, y_pred, average="micro", zero_division=0)
    macro = precision_recall_fscore_support(y_true, y_pred, average="macro", zero_division=0)
    per = {}
    counts = {}
    for idx, defect in enumerate(DEFECTS):
        yt = y_true[:, idx]
        yp = y_pred[:, idx]
        tp = int(((yt == 1) & (yp == 1)).sum())
        fp = int(((yt == 0) & (yp == 1)).sum())
        fn = int(((yt == 1) & (yp == 0)).sum())
        tn = int(((yt == 0) & (yp == 0)).sum())
        per[defect] = {
            "precision": float(p[idx]),
            "recall": float(r[idx]),
            "f1": float(f[idx]),
            "support": int(s[idx]),
            "predicted_positive_count": int(yp.sum()),
        }
        counts[defect] = {"tp": tp, "fp": fp, "fn": fn, "tn": tn}
    return {
        "macro_f1": float(macro[2]),
        "micro_f1": float(micro[2]),
        "per_label": per,
        "raw_counts": counts,
        "exact_match_accuracy": float((y_true == y_pred).all(axis=1).mean()) if len(y_true) else 0.0,
    }


def optimise_thresholds(y_true, probs):
    thresholds = []
    for i in range(probs.shape[1]):
        best = (0.5, -1.0)
        for threshold in np.linspace(0.25, 0.75, 101):
            pred = (probs[:, i] >= threshold).astype(int)
            score = f1_score(y_true[:, i], pred, zero_division=0)
            if score > best[1]:
                best = (float(threshold), float(score))
        thresholds.append(best[0])
    return thresholds


def predict_defects(probs, thresholds):
    return (probs >= np.asarray(thresholds)[None, :]).astype(int)


def high_conf_gate_errors(probs, classes, y_true, y_pred, cutoff=0.8):
    maxp = probs.max(axis=1)
    return int(((maxp >= cutoff) & (np.asarray(y_true) != np.asarray(y_pred))).sum())


def build_variant(df: pd.DataFrame, name: str) -> pd.DataFrame:
    q = quality_rows(df)
    prov = q.apply(provenance, axis=1)
    text = q["model_text"]
    if name == "real_reviewed_only":
        ids = q[prov == "real_or_human_reviewed"]["row_id"]
    elif name == "real_plus_claire_reviewed_ai":
        ids = q[prov.isin(["real_or_human_reviewed", "human_reviewed_ai_assisted"])]["row_id"]
    elif name == "full_selected_secondpass":
        ids = q["row_id"]
    elif name == "without_formulaic_clusters":
        formulaic = text.str.contains(r"\bdiscussed|noted|talked about|went through|covered|there was discussion\b", case=False, regex=True, na=False) & prov.isin(["human_reviewed_ai_assisted", "fully_synthetic_or_ai_generated"])
        ids = q[~formulaic]["row_id"]
    elif name == "without_inferred_rows":
        ids = q[prov != "inferred"]["row_id"]
    elif name == "without_source_dependent_ambiguous":
        source_dep = q[["rationale", "source_transcript_snippet"]].fillna("").agg(" ".join, axis=1).str.contains(
            r"\b(source|omitted|missing context|depends on source|source material)\b", case=False, regex=True, na=False
        )
        ids = q[~source_dep]["row_id"]
    else:
        raise ValueError(name)
    return df[df["row_id"].isin(set(ids)) | (df["train_quality_gate"].fillna(0).astype(int) != 1)].copy()


def train_logistic_gate(x, y, c=1.0, class_weight="balanced", solver="liblinear"):
    clf = LogisticRegression(C=c, class_weight=class_weight, solver=solver, max_iter=2000, random_state=42)
    clf.fit(x, y)
    return clf


def train_logistic_defects(x, y, c=1.0, class_weight="balanced"):
    models = []
    for i in range(y.shape[1]):
        clf = LogisticRegression(C=c, class_weight=class_weight, solver="liblinear", max_iter=2000, random_state=42)
        clf.fit(x, y[:, i])
        models.append(clf)
    return models


def defect_probs(models, x):
    cols = []
    for clf in models:
        if len(clf.classes_) == 1:
            val = 1.0 if clf.classes_[0] == 1 else 0.0
            cols.append(np.full(x.shape[0], val))
        else:
            idx = list(clf.classes_).index(1)
            cols.append(clf.predict_proba(x)[:, idx])
    return np.vstack(cols).T


def train_per_defect_grid(x_train, y_train, x_val, y_val):
    models = []
    thresholds = []
    configs = {}
    for i, defect in enumerate(DEFECTS):
        best = None
        for c in [0.25, 0.55, 1.0, 1.8, 3.0]:
            for class_weight in ["balanced", None]:
                clf = LogisticRegression(C=c, class_weight=class_weight, solver="liblinear", max_iter=2000, random_state=42)
                clf.fit(x_train, y_train[:, i])
                prob = clf.predict_proba(x_val)[:, list(clf.classes_).index(1)] if len(clf.classes_) > 1 else np.full(len(y_val), float(clf.classes_[0] == 1))
                local_best = (0.5, -1.0)
                for threshold in np.linspace(0.25, 0.75, 101):
                    pred = (prob >= threshold).astype(int)
                    score = f1_score(y_val[:, i], pred, zero_division=0)
                    if score > local_best[1]:
                        local_best = (float(threshold), float(score))
                rank = (local_best[1], -abs(c - 1.0), 1 if class_weight == "balanced" else 0)
                if best is None or rank > best[0]:
                    best = (rank, clf, local_best[0], {"C": c, "class_weight": class_weight or "none", "validation_f1": local_best[1]})
        models.append(best[1])
        thresholds.append(best[2])
        configs[defect] = best[3]
    return models, thresholds, configs


def false_start_indicators(text: str) -> dict:
    t = clean_text(text).lower()
    starts = bool(re.match(r"^(and|but|so|because|which|that|then|also)\b", t))
    false_markers = bool(re.search(r"\b(um|erm|uh|you know|sort of|kind of|i mean|sorry|actually)\b", t))
    abrupt = bool(re.search(r"(\\.\\.\\.|--| -$|,$|\\band then$|\\bso$)", t))
    has_verbish = bool(re.search(r"\b(is|are|was|were|will|would|should|could|can|must|has|have|had|needs?|agreed|confirmed|noted|reviewed|provided|shared|discussed|identified)\b", t))
    short_clause = len(t.split()) <= 8 and not has_verbish
    return {
        "abrupt_conjunction_start": starts,
        "spoken_false_start_marker": false_markers,
        "abrupt_or_truncated_ending": abrupt,
        "very_short_incomplete_clause": short_clause,
        "has_verb_like_minutes_point": has_verbish,
    }


def apply_hybrid_fragment(texts, probs, threshold, indicator_min=2, boosted_threshold=0.32):
    pred = (probs >= threshold).astype(int)
    for idx, text in enumerate(texts):
        indicators = false_start_indicators(text)
        score = sum(1 for k, v in indicators.items() if k != "has_verb_like_minutes_point" and v)
        if score >= indicator_min and probs[idx] >= boosted_threshold:
            pred[idx] = 1
        if indicators["has_verb_like_minutes_point"] and score == 0 and probs[idx] < threshold + 0.08:
            pred[idx] = 0
    return pred


def evaluate_candidate(name, df, embeddings, row_index, arch="logistic", params=None):
    params = params or {}
    q = quality_rows(df)
    splits = q["recommended_split"].fillna("").astype(str)
    train = q[splits == "train"]
    val = q[splits == "validation"]
    test = q[splits == "test"]
    idx_train = [row_index[x] for x in train["row_id"]]
    idx_val = [row_index[x] for x in val["row_id"]]
    idx_test = [row_index[x] for x in test["row_id"]]
    y_train = train["quality_gate"].to_numpy()
    y_val = val["quality_gate"].to_numpy()
    y_test = test["quality_gate"].to_numpy()
    yd_train, yd_val, yd_test = defect_matrix(train["quality_defects"]), defect_matrix(val["quality_defects"]), defect_matrix(test["quality_defects"])
    start = time.time()
    per_defect_configs = None
    if arch == "svm":
        gate = CalibratedClassifierCV(LinearSVC(C=params.get("C", 1.0), class_weight=params.get("class_weight", "balanced"), random_state=42), cv=3)
        gate.fit(embeddings[idx_train], y_train)
        defect_models = []
        for i in range(len(DEFECTS)):
            clf = CalibratedClassifierCV(LinearSVC(C=params.get("C", 1.0), class_weight=params.get("class_weight", "balanced"), random_state=42), cv=3)
            clf.fit(embeddings[idx_train], yd_train[:, i])
            defect_models.append(clf)
    else:
        gate = train_logistic_gate(embeddings[idx_train], y_train, c=params.get("C", 1.0), class_weight=params.get("class_weight", "balanced"), solver=params.get("solver", "liblinear"))
        if params.get("per_defect_grid") and len(val):
            defect_models, fixed_thresholds, per_defect_configs = train_per_defect_grid(embeddings[idx_train], yd_train, embeddings[idx_val], yd_val)
        else:
            defect_models = train_logistic_defects(embeddings[idx_train], yd_train, c=params.get("C", 1.0), class_weight=params.get("class_weight", "balanced"))
    train_seconds = time.time() - start
    val_probs = defect_probs(defect_models, embeddings[idx_val])
    thresholds = fixed_thresholds if params.get("per_defect_grid") and len(val) else (optimise_thresholds(yd_val, val_probs) if len(val) else [0.5] * len(DEFECTS))
    results = {}
    predictions = []
    for split_name, sub, idxs, yg, yd in [("validation", val, idx_val, y_val, yd_val), ("test", test, idx_test, y_test, yd_test)]:
        gp = gate.predict(embeddings[idxs])
        gprob = gate.predict_proba(embeddings[idxs])
        dprob = defect_probs(defect_models, embeddings[idxs])
        dp = predict_defects(dprob, thresholds)
        if params.get("hybrid_fragment") and len(sub):
            frag_idx = DEFECTS.index("fragment_or_false_start")
            dp[:, frag_idx] = apply_hybrid_fragment(sub["model_text"].tolist(), dprob[:, frag_idx], thresholds[frag_idx])
        results[split_name] = {
            "quality_gate": metrics_gate(yg, gp),
            "quality_defects": metrics_defects(yd, dp),
            "high_conf_gate_errors": high_conf_gate_errors(gprob, gate.classes_, yg, gp),
        }
        for r_i, (_, row) in enumerate(sub.iterrows()):
            pred_labels = [DEFECTS[j] for j, v in enumerate(dp[r_i]) if v]
            predictions.append({
                "candidate": name,
                "split": split_name,
                "row_id": row["row_id"],
                "actual_gate": yg[r_i],
                "predicted_gate": gp[r_i],
                "actual_defects": row["quality_defects"],
                "predicted_defects": "|".join(pred_labels) if pred_labels else "none",
                "provenance": provenance(row),
                "source_document": clean_text(row.get("source_document", "")),
                "discussion_category": clean_text(row.get("discussion_category", "")),
            })
    probe_results = None
    probe_predictions = []
    if PROBE.exists():
        probe = pd.read_csv(PROBE)
        text_col = "model_text" if "model_text" in probe.columns else ("text" if "text" in probe.columns else probe.columns[0])
        probe_texts = probe[text_col].fillna("").map(clean_text).tolist()
        probe_emb = model.encode(probe_texts, batch_size=32, normalize_embeddings=True, show_progress_bar=False)
        gp = gate.predict(probe_emb)
        dprob = defect_probs(defect_models, probe_emb)
        dp = predict_defects(dprob, thresholds)
        if params.get("hybrid_fragment"):
            frag_idx = DEFECTS.index("fragment_or_false_start")
            dp[:, frag_idx] = apply_hybrid_fragment(probe_texts, dprob[:, frag_idx], thresholds[frag_idx])
        gate_col = "quality_gate" if "quality_gate" in probe.columns else ("expected_gate" if "expected_gate" in probe.columns else None)
        defect_col = "quality_defects" if "quality_defects" in probe.columns else ("expected_defects" if "expected_defects" in probe.columns else None)
        if gate_col:
            yg = probe[gate_col].fillna("").map(clean_text).to_numpy()
            yd = defect_matrix(probe.get(defect_col, pd.Series([""] * len(probe))))
            probe_results = {
                "quality_gate": metrics_gate(yg, gp),
                "quality_defects": metrics_defects(yd, dp),
            }
        for i, row in probe.iterrows():
            pred_labels = [DEFECTS[j] for j, v in enumerate(dp[i]) if v]
            probe_predictions.append({
                "candidate": name,
                "split": "notion_probe",
                "row_id": clean_text(row.get("row_id", row.get("id", f"probe_{i:04d}"))),
                "actual_gate": clean_text(row.get(gate_col, "")) if gate_col else "",
                "predicted_gate": gp[i],
                "actual_defects": clean_text(row.get(defect_col, "")) if defect_col else "",
                "predicted_defects": "|".join(pred_labels) if pred_labels else "none",
                "provenance": "notion_probe",
                "source_document": clean_text(row.get("source_document", "")),
                "discussion_category": clean_text(row.get("discussion_category", "")),
            })
    bundle = RUN_ROOT / name / "model_bundle"
    bundle.mkdir(parents=True, exist_ok=True)
    joblib.dump(gate, bundle / "quality_gate_classifier.joblib")
    joblib.dump(defect_models, bundle / "quality_defect_classifiers.joblib")
    q.to_csv(bundle / "training_data_quality_rows.csv", index=False)
    pd.DataFrame(predictions + probe_predictions).to_csv(bundle / "predictions.csv", index=False)
    model_size = sum(p.stat().st_size for p in bundle.rglob("*") if p.is_file())
    out = {
        "candidate": name,
        "candidate_path": str(bundle),
        "architecture": arch,
        "params": params,
        "training_datasource": str(bundle / "training_data_quality_rows.csv"),
        "quality_rows": int(len(q)),
        "train_rows": int(len(train)),
        "validation_rows": int(len(val)),
        "test_rows": int(len(test)),
        "defect_thresholds": dict(zip(DEFECTS, thresholds)),
        "per_defect_configs": per_defect_configs,
        "validation": results.get("validation"),
        "test": results.get("test"),
        "notion_probe": probe_results,
        "training_seconds": train_seconds,
        "bundle_bytes": int(model_size),
    }
    (bundle / "metrics.json").write_text(json.dumps(out, indent=2), encoding="utf-8")
    return out


def bootstrap_ci(preds: pd.DataFrame, metric_col_true: str, metric_col_pred: str, groups: pd.Series, kind: str, n=500):
    rng = np.random.default_rng(42)
    unique = np.array(sorted(groups.fillna("missing").astype(str).unique()))
    values = []
    for _ in range(n):
        sample_groups = rng.choice(unique, size=len(unique), replace=True)
        idxs = []
        for g in sample_groups:
            idxs.extend(preds.index[groups.astype(str) == g].tolist())
        if not idxs:
            continue
        y_true = preds.loc[idxs, metric_col_true].to_numpy()
        y_pred = preds.loc[idxs, metric_col_pred].to_numpy()
        if kind == "gate":
            values.append(f1_score(y_true, y_pred, labels=["intervention", "ready"], average="macro", zero_division=0))
    if not values:
        return {"mean": None, "low": None, "high": None}
    return {"mean": float(np.mean(values)), "low": float(np.quantile(values, 0.025)), "high": float(np.quantile(values, 0.975))}


def main():
    RUN_ROOT.mkdir(parents=True, exist_ok=True)
    df = pd.read_csv(DATASOURCE)
    qdf = quality_rows(df)
    qdf["_provenance"] = qdf.apply(provenance, axis=1)
    audit, changes = audit_datasource(df, qdf)
    audit.to_csv(WORKSPACE / f"meeting_minutes_thirdpass_dataset_audit_{STAMP}.csv", index=False)
    changes.to_csv(WORKSPACE / f"meeting_minutes_thirdpass_change_log_{STAMP}.csv", index=False)

    variant_names = [
        "real_reviewed_only",
        "real_plus_claire_reviewed_ai",
        "full_selected_secondpass",
        "without_formulaic_clusters",
        "without_inferred_rows",
        "without_source_dependent_ambiguous",
    ]
    variant_paths = {}
    variants = {}
    for name in variant_names:
        vdf = build_variant(df, name)
        path = ROOT / "data" / f"training_data_quality_thirdpass_{name}_{STAMP}.csv"
        vdf.to_csv(path, index=False)
        variant_paths[name] = str(path)
        variants[name] = vdf

    global model
    model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
    all_q = quality_rows(df)
    embeddings = model.encode(all_q["model_text"].tolist(), batch_size=32, normalize_embeddings=True, show_progress_bar=True)
    row_index = {rid: i for i, rid in enumerate(all_q["row_id"])}

    candidates = []
    grid_rows = []
    best_b = None
    for c in [0.25, 0.55, 1.0, 1.8, 3.0]:
        for cw in ["balanced", None]:
            name = f"candidate_b_logreg_c{str(c).replace('.', 'p')}_{'balanced' if cw else 'unweighted'}"
            res = evaluate_candidate(name, variants["full_selected_secondpass"], embeddings, row_index, "logistic", {"C": c, "class_weight": cw, "solver": "liblinear"})
            score = res["validation"]["quality_gate"]["macro_f1"] + res["validation"]["quality_defects"]["macro_f1"]
            grid_rows.append({"candidate": name, "C": c, "class_weight": cw or "none", "validation_composite": score, "test_gate_macro_f1": res["test"]["quality_gate"]["macro_f1"], "test_defect_macro_f1": res["test"]["quality_defects"]["macro_f1"], "path": res["candidate_path"]})
            candidates.append(res)
            if best_b is None or score > best_b[0]:
                best_b = (score, res)

    candidates.append(evaluate_candidate("candidate_c_calibrated_linear_svm", variants["full_selected_secondpass"], embeddings, row_index, "svm", {"C": 1.0, "class_weight": "balanced"}))
    candidates.append(evaluate_candidate("candidate_d_per_defect_binary_logreg", variants["full_selected_secondpass"], embeddings, row_index, "logistic", {"C": 0.55, "class_weight": "balanced", "per_defect_grid": True}))
    candidates.append(evaluate_candidate("candidate_e_hybrid_fragment_detector", variants["full_selected_secondpass"], embeddings, row_index, "logistic", {"C": 0.55, "class_weight": "balanced", "hybrid_fragment": True}))

    for vname in variant_names:
        res = evaluate_candidate(f"ablation_{vname}", variants[vname], embeddings, row_index, "logistic", {"C": 1.0, "class_weight": "balanced"})
        candidates.append(res)
    best_arch_params = best_b[1]["params"] if best_b else {"C": 1.0, "class_weight": "balanced"}
    candidates.append(evaluate_candidate("ablation_best_dataset_plus_best_architecture", variants["full_selected_secondpass"], embeddings, row_index, "logistic", best_arch_params))

    summary_rows = []
    defect_rows = []
    pred_frames = []
    for c in candidates:
        for split in ["validation", "test"]:
            block = c.get(split)
            if not block:
                continue
            summary_rows.append({
                "candidate": c["candidate"],
                "candidate_path": c["candidate_path"],
                "split": split,
                "quality_rows": c["quality_rows"],
                "gate_macro_f1": block["quality_gate"]["macro_f1"],
                "gate_micro_f1": block["quality_gate"]["micro_f1"],
                "ready_recall": block["quality_gate"]["per_class"].get("ready", {}).get("recall"),
                "intervention_recall": block["quality_gate"]["per_class"].get("intervention", {}).get("recall"),
                "defect_macro_f1": block["quality_defects"]["macro_f1"],
                "defect_micro_f1": block["quality_defects"]["micro_f1"],
                "false_intervention_count": block["quality_gate"]["false_intervention_count"],
                "missed_intervention_count": block["quality_gate"]["missed_intervention_count"],
                "high_conf_gate_errors": block["high_conf_gate_errors"],
                "training_seconds": c["training_seconds"],
                "bundle_bytes": c["bundle_bytes"],
            })
            for defect, counts in block["quality_defects"]["raw_counts"].items():
                per = block["quality_defects"]["per_label"][defect]
                defect_rows.append({
                    "candidate": c["candidate"],
                    "split": split,
                    "defect": defect,
                    **counts,
                    **per,
                })
        if c.get("notion_probe"):
            block = c["notion_probe"]
            summary_rows.append({
                "candidate": c["candidate"],
                "candidate_path": c["candidate_path"],
                "split": "notion_probe",
                "quality_rows": c["quality_rows"],
                "gate_macro_f1": block["quality_gate"]["macro_f1"],
                "gate_micro_f1": block["quality_gate"]["micro_f1"],
                "ready_recall": block["quality_gate"]["per_class"].get("ready", {}).get("recall"),
                "intervention_recall": block["quality_gate"]["per_class"].get("intervention", {}).get("recall"),
                "defect_macro_f1": block["quality_defects"]["macro_f1"],
                "defect_micro_f1": block["quality_defects"]["micro_f1"],
                "false_intervention_count": block["quality_gate"]["false_intervention_count"],
                "missed_intervention_count": block["quality_gate"]["missed_intervention_count"],
                "high_conf_gate_errors": "",
                "training_seconds": c["training_seconds"],
                "bundle_bytes": c["bundle_bytes"],
            })
    pd.DataFrame(summary_rows).to_csv(WORKSPACE / f"meeting_minutes_thirdpass_candidate_summary_{STAMP}.csv", index=False)
    pd.DataFrame(defect_rows).to_csv(WORKSPACE / f"meeting_minutes_thirdpass_defect_raw_counts_{STAMP}.csv", index=False)
    pd.DataFrame(grid_rows).to_csv(WORKSPACE / f"meeting_minutes_thirdpass_logreg_grid_{STAMP}.csv", index=False)

    # Use the best reproducible third-pass diagnostic candidate to report grouped CI.
    best_diag = sorted(candidates, key=lambda x: (x["test"]["quality_gate"]["macro_f1"], x["test"]["quality_defects"]["macro_f1"]), reverse=True)[0]
    preds = pd.read_csv(Path(best_diag["candidate_path"]) / "predictions.csv")
    ci_rows = []
    for split in ["validation", "test"]:
        split_preds = preds[preds["split"] == split].copy()
        q = quality_rows(df)
        groups = split_preds["row_id"].map(dict(zip(q["row_id"], q["group_id"]))).fillna("missing")
        ci = bootstrap_ci(split_preds, "actual_gate", "predicted_gate", groups, "gate", n=500)
        ci_rows.append({"candidate": best_diag["candidate"], "split": split, "metric": "quality_gate_macro_f1", **ci, "groups": int(groups.nunique()), "rows": int(len(split_preds))})
    pd.DataFrame(ci_rows).to_csv(WORKSPACE / f"meeting_minutes_thirdpass_bootstrap_ci_{STAMP}.csv", index=False)

    history = {
        "reviewed_baseline_model": str(BASELINE_MODEL),
        "reviewed_baseline_threshold_bundle": str(BASELINE_THRESHOLD),
        "first_pass_selected_model": str(BASELINE_MODEL),
        "first_pass_selected_threshold_bundle": str(BASELINE_THRESHOLD),
        "second_pass_selected_model": str(BASELINE_MODEL),
        "second_pass_selected_threshold_bundle": str(BASELINE_THRESHOLD),
        "selected_datasource": str(DATASOURCE),
        "variant_datasources": variant_paths,
        "thirdpass_run_root": str(RUN_ROOT),
        "candidate_paths": [c["candidate_path"] for c in candidates],
        "best_diagnostic_candidate": best_diag["candidate_path"],
        "selection_decision": "retain_second_pass_selected_model_pending_review",
    }
    (WORKSPACE / f"meeting_minutes_thirdpass_model_history_{STAMP}.json").write_text(json.dumps(history, indent=2), encoding="utf-8")
    (WORKSPACE / f"meeting_minutes_thirdpass_detail_{STAMP}.json").write_text(json.dumps({"history": history, "candidates": candidates}, indent=2), encoding="utf-8")
    print(json.dumps(history, indent=2))


if __name__ == "__main__":
    main()
