#!/usr/bin/env python3
"""T^GPT v0.8.6 semantic shift / recovery engine.

MNAR / P0 instrumentation repair after external review #4:
- primary semantic distribution remains conditional on known mapped mass;
- mapping mass is measured separately and hard-gated;
- minimum N per arm is enforced;
- arbitrary missingness is handled with nonparametric partial-identification bounds;
- no total-semantic directional claim is allowed when missing mass can explain the effect;
- optional UNMAPPED_AFFINITY diagnostics never alter primary assignments;
- A/A'/B reference-replicate calibration requires equal total N;
- RECOVERY vs NEUTRAL_CONTROL mapping is hard-gated;
- complete-case recovery sensitivity is never emitted when the mapping gate fails;
- across-task inference uses fixed-family stratified bootstrap;
- experiment-level attrition >40% invalidates generalized claims.

The partial-identification bounds are worst-case/Manski-style identification regions:
missing mass may be allocated adversarially across semantic clusters. No MAR assumption
is required for the robust-bound guardrail.

This tool does not interpret any metric as T^, a remainder, or a latent total field.
"""
from __future__ import annotations

import argparse
import hashlib
import json
import math
import random
from collections import Counter, defaultdict
from pathlib import Path
from typing import Dict, Iterable, List, Tuple, Optional

SPECIAL = {"UNMAPPED", "UNRESOLVED"}
MIN_KNOWN_SEMANTIC_MASS = 0.80
MAX_MAPPING_GAP = 0.05
MIN_N_PER_ARM = 32
MAX_EXPERIMENT_MEASUREMENT_INSUFFICIENT_FRACTION = 0.40
MIN_VALID_TASKS = 30
EXPECTED_FAMILIES = {
    "F1_POLYSEMY_UNDERSPECIFICATION",
    "F2_CAUSAL_ALTERNATIVES",
    "F3_DESIGN_UNDER_CONSTRAINTS",
    "F4_STRUCTURAL_INTERPRETATION",
    "F5_MULTI_SOLUTION_REASONING",
}
MAPPING_SENSITIVITY_PROFILES = {
    "LENIENT": (0.75, 0.07),
    "PRIMARY": (0.80, 0.05),
    "STRICT": (0.85, 0.03),
}


def validate_labels(labels: Iterable[str], relevance: Dict[str, int], where: str) -> None:
    allowed = set(relevance) | SPECIAL
    bad = sorted({str(x) for x in labels if str(x) not in allowed})
    if bad:
        raise ValueError(f"{where}: unknown/final-forbidden labels: {bad}")


def validate_n(labels: List[str], where: str, minimum: int = MIN_N_PER_ARM) -> None:
    if len(labels) < minimum:
        raise ValueError(f"{where}: N={len(labels)} below MIN_N_PER_ARM={minimum}")


def relevant_ids(relevance: Dict[str, int]) -> List[str]:
    bad = [k for k, v in relevance.items() if int(v) not in (0, 1)]
    if bad:
        raise ValueError(f"relevance values must be 0/1: {bad}")
    rel = [k for k, v in relevance.items() if int(v) == 1]
    if not rel:
        raise ValueError("at least one relevant cluster is required")
    return rel


def rates(labels: List[str], relevance: Dict[str, int]) -> dict:
    validate_labels(labels, relevance, "labels")
    n = len(labels)
    if not n:
        raise ValueError("arm must contain at least one finalized generation")
    c = Counter(map(str, labels))
    known = sum(c.get(k, 0) for k in relevance)
    missing = n - known
    return {"N_total": n, "N_known": known, "N_missing": missing, "known_semantic_mass": known / n, "missing_mass": missing / n, "UNMAPPED_rate": c.get("UNMAPPED", 0) / n, "UNRESOLVED_rate": c.get("UNRESOLVED", 0) / n}


def probs_unconditional(labels: List[str], relevance: Dict[str, int]) -> Dict[str, float]:
    validate_labels(labels, relevance, "labels"); n=len(labels)
    if n == 0: raise ValueError("arm must contain at least one finalized generation")
    c=Counter(map(str,labels)); return {cluster:c.get(cluster,0)/n for cluster in relevance}


def probs_known(labels: List[str], relevance: Dict[str, int]) -> Dict[str, float]:
    validate_labels(labels,relevance,"labels"); c=Counter(map(str,labels)); nk=sum(c.get(cluster,0) for cluster in relevance)
    if nk<=0: raise ValueError("arm has zero known semantic assignments")
    return {cluster:c.get(cluster,0)/nk for cluster in relevance}


def mapping_gate(named_rates: Dict[str, dict], compare_pairs: List[Tuple[str, str]], min_known_mass: float=MIN_KNOWN_SEMANTIC_MASS, max_mapping_gap: float=MAX_MAPPING_GAP) -> dict:
    low={name:r["known_semantic_mass"] for name,r in named_rates.items() if r["known_semantic_mass"]<min_known_mass-1e-15}; gaps={}
    for a,b in compare_pairs: gaps[f"{a}__{b}"]=abs(named_rates[a]["known_semantic_mass"]-named_rates[b]["known_semantic_mass"])
    excessive={k:v for k,v in gaps.items() if v>max_mapping_gap+1e-15}; ok=not low and not excessive
    return {"status":"OK" if ok else "MEASUREMENT_INSUFFICIENT","minimum_known_semantic_mass":min_known_mass,"maximum_mapping_gap":max_mapping_gap,"low_known_mass":low,"pairwise_mapping_gaps":gaps,"excessive_mapping_gaps":excessive}


def mapping_gate_sensitivity(named_rates: Dict[str, dict], compare_pairs: List[Tuple[str, str]]) -> dict:
    return {name:mapping_gate(named_rates,compare_pairs,*vals)["status"] for name,vals in MAPPING_SENSITIVITY_PROFILES.items()}


def directional(pa: Dict[str,float],pb: Dict[str,float],rel: List[str]):
    d={c:max(0.0,pa.get(c,0.0)-pb.get(c,0.0)) for c in rel}; e={c:max(0.0,pb.get(c,0.0)-pa.get(c,0.0)) for c in rel}; return sum(d.values()),sum(e.values()),d,e


def partial_identification_bounds(pua,pub,missing_a,missing_b,rel):
    diffs={c:pua.get(c,0.0)-pub.get(c,0.0) for c in rel}; rev={c:-v for c,v in diffs.items()}; d_obs=sum(max(0.0,v) for v in diffs.values()); e_obs=sum(max(0.0,v) for v in rev.values())
    d_lower=max(0.0,d_obs-missing_b); d_upper=min(1.0,d_obs+missing_a) if any(v>=0 for v in diffs.values()) else min(1.0,max(0.0,max(diffs.values())+missing_a))
    e_lower=max(0.0,e_obs-missing_a); e_upper=min(1.0,e_obs+missing_b) if any(v>=0 for v in rev.values()) else min(1.0,max(0.0,max(rev.values())+missing_b))
    cluster_bounds={c:{"difference_lower":diffs[c]-missing_b,"difference_observed":diffs[c],"difference_upper":diffs[c]+missing_a} for c in rel}
    return {"assumption":"arbitrary allocation of UNMAPPED+UNRESOLVED mass; no MAR/MCAR assumption","sampling_uncertainty_included":False,"missing_mass_A":missing_a,"missing_mass_B":missing_b,"observed_known_unconditional_deficit":d_obs,"observed_known_unconditional_expansion":e_obs,"TOTAL_DEFICIT_IDENTIFICATION_INTERVAL":[d_lower,d_upper],"TOTAL_EXPANSION_IDENTIFICATION_INTERVAL":[e_lower,e_upper],"cluster_difference_identification_intervals":cluster_bounds}


def affinity_summary(task,relevance):
    raw=task.get("unmapped_affinity")
    if raw is None: return None
    allowed=set(relevance)|{"NONE"}; out={}
    for arm,vals in raw.items():
        vals=[str(v) for v in vals]; bad=sorted(set(vals)-allowed)
        if bad: raise ValueError(f"unmapped_affinity.{arm}: unknown affinity labels {bad}")
        c=Counter(vals); n=len(vals); out[str(arm)]={"N_affinity":n,"counts":dict(sorted(c.items())),"rates":{k:v/n for k,v in sorted(c.items())} if n else {}}
    return {"role":"MNAR_DIAGNOSTIC_ONLY_DOES_NOT_REMAP_UNMAPPED","arms":out}


def closure(pb,pr,deficit_by_cluster): return sum(min(d,max(0.0,pr.get(c,0.0)-pb.get(c,0.0))) for c,d in deficit_by_cluster.items())
def known_only(labels,relevance): return [str(x) for x in labels if str(x) in set(relevance)]

def perm_null_known(a,b,relevance,reps,rng):
    rel=relevant_ids(relevance); ak=known_only(a,relevance); bk=known_only(b,relevance)
    if not ak or not bk: raise ValueError("permutation requires known assignments in both arms")
    pool=list(ak)+list(bk); na=len(ak); ds=[]; es=[]
    for _ in range(reps):
        x=pool[:]; rng.shuffle(x); aa,bb=x[:na],x[na:]; d,e,_,_=directional(probs_known(aa,relevance),probs_known(bb,relevance),rel); ds.append(d); es.append(e)
    return {"deficit_null_mean":sum(ds)/len(ds),"expansion_null_mean":sum(es)/len(es),"_deficit_draws":ds,"_expansion_draws":es,"N_known_A":len(ak),"N_known_B":len(bk)}


def paired_swap_null(pairs,relevance,a,reps,rng):
    rel=relevant_ids(relevance); b=[str(x["base"]) for x in pairs]; r=[str(x["recovery"]) for x in pairs]; c=[str(x["control"]) for x in pairs]
    for name,arr in [("paired.base",b),("paired.recovery",r),("paired.control",c)]: validate_labels(arr,relevance,name)
    pa,pb=probs_known(a,relevance),probs_known(b,relevance); _,_,d_by,_=directional(pa,pb,rel); obs=closure(pb,probs_known(r,relevance),d_by)-closure(pb,probs_known(c,relevance),d_by); draws=[]
    for _ in range(reps):
        rr=[]; cc=[]
        for x in pairs:
            if rng.random()<.5: rr.append(str(x["recovery"])); cc.append(str(x["control"]))
            else: rr.append(str(x["control"])); cc.append(str(x["recovery"]))
        if not known_only(rr,relevance) or not known_only(cc,relevance): continue
        draws.append(closure(pb,probs_known(rr,relevance),d_by)-closure(pb,probs_known(cc,relevance),d_by))
    if not draws: raise ValueError("paired randomization produced no valid mapped draws")
    p=(1+sum(abs(x)>=abs(obs)-1e-15 for x in draws))/(len(draws)+1); return {"observed":obs,"two_sided_randomization_p":p,"null_mean":sum(draws)/len(draws),"valid_draws":len(draws)}


def complete_case_recovery_lift(pairs,relevance,a):
    ks=set(relevance); complete=[x for x in pairs if str(x["base"]) in ks and str(x["recovery"]) in ks and str(x["control"]) in ks]
    if not complete: return None
    b=[str(x["base"]) for x in complete]; r=[str(x["recovery"]) for x in complete]; c=[str(x["control"]) for x in complete]; rel=relevant_ids(relevance); pa,pb=probs_known(a,relevance),probs_known(b,relevance); _,_,d_by,_=directional(pa,pb,rel); return closure(pb,probs_known(r,relevance),d_by)-closure(pb,probs_known(c,relevance),d_by)


def one_task(task,perm_reps,swap_reps,seed):
    tid=str(task["task_id"]); relevance={str(k):int(v) for k,v in task["relevance"].items()}; rel=relevant_ids(relevance); a=[str(x) for x in task["reference_A"]]; validate_labels(a,relevance,f"{tid}.reference_A"); validate_n(a,f"{tid}.reference_A")
    pairs=task.get("paired_probes")
    if pairs is not None:
        ids=[str(x["pair_id"]) for x in pairs]
        if len(ids)!=len(set(ids)): raise ValueError(f"{tid}: duplicate pair_id")
        if len(pairs)<MIN_N_PER_ARM: raise ValueError(f"{tid}.paired_probes: N={len(pairs)} below MIN_N_PER_ARM={MIN_N_PER_ARM}")
        b=[str(x["base"]) for x in pairs]; r=[str(x["recovery"]) for x in pairs]; c=[str(x["control"]) for x in pairs]
    else: b=[str(x) for x in task["contracted_B"]]; r=[]; c=[]
    validate_labels(b,relevance,f"{tid}.contracted_B"); validate_n(b,f"{tid}.contracted_B")
    ra,rb=rates(a,relevance),rates(b,relevance); primary_mapping=mapping_gate({"A":ra,"B":rb},[("A","B")]); sensitivity_mapping=mapping_gate_sensitivity({"A":ra,"B":rb},[("A","B")]); pua,pub=probs_unconditional(a,relevance),probs_unconditional(b,relevance); ud,ue,ud_by,ue_by=directional(pua,pub,rel); pi=partial_identification_bounds(pua,pub,ra["missing_mass"],rb["missing_mass"],rel)
    out={"task_id":tid,"family_id":task.get("family_id"),"measurement_status":primary_mapping["status"],"mapping":{"A":ra,"B":rb,"primary_gate":primary_mapping,"sensitivity_profiles":sensitivity_mapping},"unmapped_affinity_diagnostic":affinity_summary(task,relevance),"mapping_sensitive_descriptive":{"DEFICIT_UNCONDITIONAL_RAW":ud,"EXPANSION_UNCONDITIONAL_RAW":ue,"deficit_by_cluster":ud_by,"expansion_by_cluster":ue_by},"partial_identification":pi}
    if primary_mapping["status"]!="OK":
        out["shift"]={"PRIMARY_CONDITIONAL_ENDPOINT_AVAILABLE":False,"TOTAL_SEMANTIC_IDENTIFICATION_EXCLUDES_ZERO":False,"CONFIRMATORY_TOTAL_SEMANTIC_CLAIM_AVAILABLE":False,"reason":"MEASUREMENT_INSUFFICIENT_MAPPING","SEMANTIC_DEFICIT_RAW_CONDITIONAL":None,"SEMANTIC_EXPANSION_RAW_CONDITIONAL":None,"SEMANTIC_DEFICIT_EXCESS_CONDITIONAL":None,"SEMANTIC_EXPANSION_EXCESS_CONDITIONAL":None}
        if pairs is not None:
            rr,rc=rates(r,relevance),rates(c,relevance); rg=mapping_gate({"RECOVERY":rr,"CONTROL":rc},[("RECOVERY","CONTROL")]); out["mapping"].update({"RECOVERY":rr,"CONTROL":rc,"recovery_control_gate":rg}); out["recovery"]={"PRIMARY_ENDPOINT_AVAILABLE":False,"RECOVERY_LIFT_CONDITIONAL":None,"reason":"PRIMARY_A_B_MAPPING_INSUFFICIENT"}
        return out
    pa,pb=probs_known(a,relevance),probs_known(b,relevance); d,e,d_by,e_by=directional(pa,pb,rel); stable=int(hashlib.sha256(tid.encode()).hexdigest()[:8],16); rng=random.Random(seed^stable); null_mode="permutation_known_conditional"
    if task.get("reference_A_prime") is not None:
        ap=[str(x) for x in task["reference_A_prime"]]; validate_labels(ap,relevance,f"{tid}.reference_A_prime"); validate_n(ap,f"{tid}.reference_A_prime")
        if not (len(a)==len(ap)==len(b)): raise ValueError(f"{tid}: reference-replicate calibration requires N_A == N_A_prime == N_B")
        rap=rates(ap,relevance); ref_gate=mapping_gate({"A":ra,"A_PRIME":rap,"B":rb},[("A","A_PRIME"),("A","B"),("A_PRIME","B")]); out["mapping"].update({"A_PRIME":rap,"reference_replicate_gate":ref_gate})
        if ref_gate["status"]!="OK": out["measurement_status"]="MEASUREMENT_INSUFFICIENT"; out["shift"]={"PRIMARY_CONDITIONAL_ENDPOINT_AVAILABLE":False,"TOTAL_SEMANTIC_IDENTIFICATION_EXCLUDES_ZERO":False,"CONFIRMATORY_TOTAL_SEMANTIC_CLAIM_AVAILABLE":False,"reason":"REFERENCE_REPLICATE_MAPPING_INSUFFICIENT","SEMANTIC_DEFICIT_RAW_CONDITIONAL":d,"SEMANTIC_EXPANSION_RAW_CONDITIONAL":e,"SEMANTIC_DEFICIT_EXCESS_CONDITIONAL":None,"SEMANTIC_EXPANSION_EXCESS_CONDITIONAL":None}; return out
        pap=probs_known(ap,relevance); d1,e1,_,_=directional(pa,pap,rel); d2,e2,_,_=directional(pap,pa,rel); null_floor_d=(d1+d2)/2; null_floor_e=(e1+e2)/2; null_mode="reference_replicate_equal_N_known_conditional"; null_info={"A_prime_rates":rap}
    else:
        pn=perm_null_known(a,b,relevance,perm_reps,rng); null_floor_d=float(pn["deficit_null_mean"]); null_floor_e=float(pn["expansion_null_mean"]); dd=pn.pop("_deficit_draws"); ee=pn.pop("_expansion_draws"); pn["deficit_p_ge_conditional"]=(1+sum(x>=d-1e-15 for x in dd))/(len(dd)+1); pn["expansion_p_ge_conditional"]=(1+sum(x>=e-1e-15 for x in ee))/(len(ee)+1); null_info=pn
    out["shift"]={"PRIMARY_CONDITIONAL_ENDPOINT_AVAILABLE":True,"TOTAL_SEMANTIC_IDENTIFICATION_EXCLUDES_ZERO":pi["TOTAL_DEFICIT_IDENTIFICATION_INTERVAL"][0]>0.0,"CONFIRMATORY_TOTAL_SEMANTIC_CLAIM_AVAILABLE":False,"TOTAL_SEMANTIC_CLAIM_RULE":"Identification bounds are reported, but sampling uncertainty for the identification region is not frozen; confirmatory total-semantic inference is therefore always unavailable in v0.8.6.","SEMANTIC_DEFICIT_RAW_CONDITIONAL":d,"SEMANTIC_EXPANSION_RAW_CONDITIONAL":e,"SEMANTIC_DEFICIT_EXCESS_CONDITIONAL":d-null_floor_d,"SEMANTIC_EXPANSION_EXCESS_CONDITIONAL":e-null_floor_e,"null_mode":null_mode,"null_floor_deficit":null_floor_d,"null_floor_expansion":null_floor_e,"deficit_by_cluster":d_by,"expansion_by_cluster":e_by,"null":null_info}
    if pairs is not None:
        for name,arr in [("RECOVERY",r),("CONTROL",c)]: validate_labels(arr,relevance,f"{tid}.{name}"); validate_n(arr,f"{tid}.{name}")
        rr,rc=rates(r,relevance),rates(c,relevance); rg=mapping_gate({"RECOVERY":rr,"CONTROL":rc},[("RECOVERY","CONTROL")]); out["mapping"].update({"RECOVERY":rr,"CONTROL":rc,"recovery_control_gate":rg})
        if rg["status"]!="OK": out["recovery"]={"PRIMARY_ENDPOINT_AVAILABLE":False,"RECOVERY_LIFT_CONDITIONAL":None,"reason":"RECOVERY_CONTROL_MAPPING_INSUFFICIENT","N_pairs":len(pairs)}
        else:
            pr,pc=probs_known(r,relevance),probs_known(c,relevance); cr=closure(pb,pr,d_by); cc=closure(pb,pc,d_by); out["recovery"]={"PRIMARY_ENDPOINT_AVAILABLE":True,"CLOSURE_RECOVERY_CONDITIONAL":cr,"CLOSURE_CONTROL_CONDITIONAL":cc,"RECOVERY_LIFT_CONDITIONAL":cr-cc,"COMPLETE_CASE_RECOVERY_LIFT_SENSITIVITY":complete_case_recovery_lift(pairs,relevance,a),"paired_randomization":paired_swap_null(pairs,relevance,a,swap_reps,rng),"N_pairs":len(pairs)}
    return out


def percentile(xs,q):
    if not xs:return None
    ys=sorted(xs); pos=(len(ys)-1)*q; lo=math.floor(pos); hi=math.ceil(pos); return ys[lo] if lo==hi else ys[lo]+(ys[hi]-ys[lo])*(pos-lo)

def stratified_task_bootstrap(task_values,reps,seed):
    byfam=defaultdict(list)
    for fam,value in task_values: byfam[str(fam)].append(float(value))
    if set(byfam)!=EXPECTED_FAMILIES: raise ValueError(f"across-task inference requires exactly preregistered families: {sorted(EXPECTED_FAMILIES)}")
    if any(len(v)<2 for v in byfam.values()): raise ValueError("at least two valid tasks per family required for stratified bootstrap")
    family_means={f:sum(vals)/len(vals) for f,vals in byfam.items()}; point=sum(family_means.values())/len(EXPECTED_FAMILIES); rng=random.Random(seed); draws=[]
    for _ in range(reps):
        fm=[]
        for f in sorted(EXPECTED_FAMILIES):
            vals=byfam[f]; s=[vals[rng.randrange(len(vals))] for _ in vals]; fm.append(sum(s)/len(s))
        draws.append(sum(fm)/len(fm))
    return {"n_tasks":sum(len(v) for v in byfam.values()),"family_counts":{f:len(byfam[f]) for f in sorted(byfam)},"family_means":dict(sorted(family_means.items())),"fixed_strata_equal_weight_mean":point,"ci95":[percentile(draws,.025),percentile(draws,.975)]}

def invalid_reason(t,endpoint):
    if t.get("measurement_status")!="OK":
        gate=t.get("mapping",{}).get("primary_gate",{})
        if gate.get("low_known_mass"):return "LOW_KNOWN_MASS"
        if gate.get("excessive_mapping_gaps"):return "MAPPING_GAP"
        return "PRIMARY_MAPPING"
    if endpoint=="recovery" and not t.get("recovery",{}).get("PRIMARY_ENDPOINT_AVAILABLE"): return "RECOVERY_OTHER"
    return "OTHER"

def analyze(data):
    forbidden={"minimum_tasks","max_measurement_insufficient_fraction","min_n_per_arm"}&set(data)
    if forbidden: raise ValueError(f"protocol constants are not caller-overridable: {sorted(forbidden)}")
    perm_reps=int(data.get("permutation_reps",5000)); swap_reps=int(data.get("paired_swap_reps",5000)); task_boot_reps=int(data.get("task_bootstrap_reps",10000)); seed=int(data.get("seed",8086))
    if task_boot_reps<2000: raise ValueError("task_bootstrap_reps must be >=2000")
    raw_tasks=list(data["tasks"]); ids=[str(t["task_id"]) for t in raw_tasks]
    if not raw_tasks: raise ValueError("tasks required")
    if len(ids)!=len(set(ids)): raise ValueError("duplicate task_id")
    if any(str(t.get("family_id")) not in EXPECTED_FAMILIES for t in raw_tasks): raise ValueError("every task must carry a preregistered family_id")
    tasks=[one_task(t,perm_reps,swap_reps,seed+i*7919) for i,t in enumerate(raw_tasks)]; valid_shift=[t for t in tasks if t.get("shift",{}).get("PRIMARY_CONDITIONAL_ENDPOINT_AVAILABLE")]; valid_recovery=[t for t in tasks if t.get("recovery",{}).get("PRIMARY_ENDPOINT_AVAILABLE")]; invalid_shift=[t for t in tasks if t not in valid_shift]; insufficient_fraction=len(invalid_shift)/len(tasks); experiment_status="OK"; reasons=Counter(invalid_reason(t,"shift") for t in invalid_shift)
    if insufficient_fraction>MAX_EXPERIMENT_MEASUREMENT_INSUFFICIENT_FRACTION+1e-15: experiment_status="MEASUREMENT_INSUFFICIENT_ATTRITION"
    elif len(valid_shift)<MIN_VALID_TASKS: experiment_status="INSUFFICIENT_VALID_TASK_COUNT"
    sensitivity_counts={}
    for profile in MAPPING_SENSITIVITY_PROFILES:
        pass_count=sum(t["mapping"]["sensitivity_profiles"][profile]=="OK" for t in tasks); sensitivity_counts[profile]={"pass":pass_count,"fail":len(tasks)-pass_count,"pass_fraction":pass_count/len(tasks),"thresholds":{"minimum_known_mass":MAPPING_SENSITIVITY_PROFILES[profile][0],"maximum_mapping_gap":MAPPING_SENSITIVITY_PROFILES[profile][1]}}
    across={"PRIMARY_GENERALIZED_CLAIM_AVAILABLE":experiment_status=="OK","experiment_status":experiment_status,"SEMANTIC_DEFICIT_EXCESS_CONDITIONAL":None,"RECOVERY_LIFT_CONDITIONAL":None}
    if experiment_status=="OK":
        deficits=[(str(t["family_id"]),float(t["shift"]["SEMANTIC_DEFICIT_EXCESS_CONDITIONAL"])) for t in valid_shift]; across["SEMANTIC_DEFICIT_EXCESS_CONDITIONAL"]=stratified_task_bootstrap(deficits,task_boot_reps,seed+1)
        if len(valid_recovery)>=MIN_VALID_TASKS and set(str(t["family_id"]) for t in valid_recovery)==EXPECTED_FAMILIES: across["RECOVERY_LIFT_CONDITIONAL"]=stratified_task_bootstrap([(str(t["family_id"]),float(t["recovery"]["RECOVERY_LIFT_CONDITIONAL"])) for t in valid_recovery],task_boot_reps,seed+2)
    excluded=[{"task_id":t["task_id"],"family_id":t.get("family_id"),"reason":invalid_reason(t,"shift"),"K_A":t.get("mapping",{}).get("A",{}).get("known_semantic_mass"),"K_B":t.get("mapping",{}).get("B",{}).get("known_semantic_mass"),"mapping_gap":t.get("mapping",{}).get("primary_gate",{}).get("pairwise_mapping_gaps",{}).get("A__B")} for t in invalid_shift]
    return {"schema":"T_GPT_RECOVERY_RESULT_V0_8_6","parameters":{"perm_reps":perm_reps,"paired_swap_reps":swap_reps,"task_bootstrap_reps":task_boot_reps,"seed":seed,"MIN_KNOWN_SEMANTIC_MASS":MIN_KNOWN_SEMANTIC_MASS,"MAX_MAPPING_GAP":MAX_MAPPING_GAP,"MIN_N_PER_ARM":MIN_N_PER_ARM,"MAX_EXPERIMENT_MEASUREMENT_INSUFFICIENT_FRACTION":MAX_EXPERIMENT_MEASUREMENT_INSUFFICIENT_FRACTION},"tasks":tasks,"measurement_summary":{"n_tasks_registered":len(tasks),"n_valid_shift":len(valid_shift),"n_measurement_insufficient_shift":len(invalid_shift),"measurement_insufficient_fraction":insufficient_fraction,"invalid_reason_counts":dict(sorted(reasons.items())),"excluded_task_details":excluded,"mapping_threshold_sensitivity":sensitivity_counts},"across_tasks":across,"guardrails":["Primary endpoint is conditional on the frozen codebook; it is not a latent-total semantic distribution.","Worst-case identification bounds over UNMAPPED+UNRESOLVED mass are reported without MAR/MCAR assumptions.","If >40% of registered cells are measurement-insufficient, the whole experiment is measurement-insufficient.","At least 30 valid shift tasks are required for generalized claims.","Across-task bootstrap is stratified within the five fixed preregistered families and family means are equally weighted.","No confirmatory total-semantic claim is enabled by these bounds alone because sampling uncertainty for the identification region is not yet frozen."]}

def main():
    ap=argparse.ArgumentParser(); ap.add_argument("input",type=Path); ap.add_argument("-o","--output",type=Path); args=ap.parse_args(); result=analyze(json.loads(args.input.read_text(encoding="utf-8"))); payload=json.dumps(result,ensure_ascii=False,indent=2)+"\n"; args.output.write_text(payload,encoding="utf-8") if args.output else print(payload,end="")
if __name__=="__main__": main()
