#!/usr/bin/env python3
"""Build the final audit-bound OID article/PPT figure package (CPU only)."""
from __future__ import annotations
import argparse, hashlib, json, os, shutil
from pathlib import Path
os.environ.setdefault("CUDA_VISIBLE_DEVICES","");os.environ.setdefault("MPLBACKEND","Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import FancyArrowPatch,FancyBboxPatch
import numpy as np
ROOT=Path(__file__).resolve().parents[3];OID=Path("src/OID_analysis");RUN=OID/"runs/active-two-scene-v2"
AUDIT=RUN/"OID_PHASE4_AUDIT_20260723T1510Z.json";SUMMARY=RUN/"summary/phase4_summary.json";FREEZE=OID/"provenance/freeze/OID_CONDITIONAL_FREEZE_20260723T173300Z.json"
JOBS=(("karman_re100","delta_sensors"),("karman_re100","delta_forces"),("illusion_1.0L","delta_sensors"),("illusion_1.0L","delta_forces"));SCENES=("karman_re100","illusion_1.0L");RIDS=("r1","r2","r3")
REPORTS=tuple(RUN/"analysis"/s/o/"phase_3_5_report.json" for s,o in JOBS);CANON=tuple(RUN/"canonical"/s/r/"paired_product.json" for s in SCENES for r in RIDS);INPUTS=(AUDIT,SUMMARY,FREEZE,*REPORTS,*CANON)
SCRIPT=OID/"scripts/plot_article_package.py";TEST=OID/"tests/test_plot_article_package.py";GENERATOR_INPUTS=(SCRIPT,TEST)
SCHEMAS={AUDIT:"oid-phase4-audit/v3",SUMMARY:"oid-phase4-compact-summary/v2",FREEZE:"oid-conditional-freeze/v1",**{p:"oid-phase3-5-loro/v2" for p in REPORTS},**{p:"oid-canonical-paired/v1" for p in CANON}}
STEMS=("01_lr_le_concept","02_same_parent_provenance_loro","03_observed_vs_deranged_control","04_sensor_force_contrast","05_prediction_vs_state_reconstruction","06_diagnostics_dashboard","07_claim_boundary")
BLUE="#2878B5";ORANGE="#E1812C";GRAY="#777777";GREEN="#2A9D66";RED="#C3423F";AMBER="#D49A1F";INK="#222222";LIGHT="#E5E7EB"
def sha256(p):
 d=hashlib.sha256()
 with p.open("rb") as f:
  for b in iter(lambda:f.read(1<<20),b""):d.update(b)
 return d.hexdigest()
def load(p):return json.loads(p.read_text(encoding="utf-8"))
def gate_margin(f):return float(f["metric_weighted_r2"])-float(f["realization_derangement_negative_control_metric_weighted_r2"])-float(f["negative_control_margin"])
def load_package_data(root=ROOT):
 root=root.resolve();docs={};hashes={}
 for rel in INPUTS:
  p=root/rel
  if not p.is_file():raise FileNotFoundError(p)
  docs[rel]=load(p);hashes[str(rel)]=sha256(p)
  if docs[rel].get("schema_id")!=SCHEMAS[rel]:raise RuntimeError(f"unexpected schema: {rel}")
 audit,summary,freeze=docs[AUDIT],docs[SUMMARY],docs[FREEZE]
 if (audit["overall"],summary["overall"],freeze["overall_status"]) != ("conditional",)*3:raise RuntimeError("expected conditional freeze")
 frozen={x["path"]:x["sha256"] for x in freeze["files"]}
 for rel in INPUTS:
  if rel!=FREEZE and frozen.get(str(rel))!=hashes[str(rel)]:raise RuntimeError(f"freeze hash mismatch: {rel}")
 if summary["source_sha256"][str(AUDIT)]!=hashes[str(AUDIT)]:raise RuntimeError("summary/audit mismatch")
 jobs=audit["gates"]["formal_analyses"]["evidence"]["jobs"];reports={}
 for rel,(s,o) in zip(REPORTS,JOBS):
  key=f"{s}/{o}";r=docs[rel]
  if jobs[key]["evidence"]["report_sha256"]!=hashes[str(rel)]:raise RuntimeError(f"audit/report mismatch: {key}")
  if r["fold_count"]!=3 or [f["held_out_realization"] for f in r["folds"]]!=list(RIDS):raise RuntimeError(f"invalid LORO: {key}")
  for f in r["folds"]:
   if bool(f["negative_control_pass"])!=(gate_margin(f)>0):raise RuntimeError(f"gate mismatch: {key}")
  reports[key]=r
 for rel in CANON:
  s,rid=rel.parts[-3:-1];a=audit["canonical_products"][f"{s}/{rid}"]
  if a["metadata_sha256"]!=hashes[str(rel)] or docs[rel]["realization_id"]!=rid:raise RuntimeError(f"canonical mismatch: {rel}")
 generator_hashes={str(rel):sha256(root/rel) for rel in GENERATOR_INPUTS}
 return {"audit":audit,"summary":summary,"freeze":freeze,"reports":reports,"evidence_inputs":hashes,"generator_inputs":generator_hashes}
def style():plt.rcParams.update({"savefig.dpi":300,"font.size":8.2,"axes.titlesize":10,"axes.labelsize":8.5,"legend.fontsize":7.2,"axes.spines.top":False,"axes.spines.right":False,"pdf.fonttype":42,"font.family":"DejaVu Sans"})
def title(fig,t,s=""):fig.suptitle(t,x=.06,y=.97,ha="left",fontsize=13,fontweight="bold",color=INK);fig.text(.06,.91,s,ha="left",fontsize=8,color=GRAY)
def footer(fig,source=False):
 if source:fig.text(.01,.012,"Source: Phase 4 audit + formal LORO reports",ha="left",fontsize=6.5,color=GRAY)
 fig.text(.99,.012,"code Re=100 (2D ref)  |  physical $Re_D=50$",ha="right",fontsize=6.5,color=GRAY)
def box(ax,xy,w,h,text,c=INK,fc="white",fs=9):
 p=FancyBboxPatch(xy,w,h,boxstyle="round,pad=0.02,rounding_size=0.02",ec=c,fc=fc,lw=1.2);ax.add_patch(p);ax.text(xy[0]+w/2,xy[1]+h/2,text,ha="center",va="center",fontsize=fs,color=c)
def arrow(ax,a,b):ax.add_patch(FancyArrowPatch(a,b,arrowstyle="-|>",mutation_scale=12,lw=1.1,color=GRAY))
def slide(t,s=""):
 fig,ax=plt.subplots(figsize=(10,5.625));ax.set(xlim=(0,1),ylim=(0,1));ax.axis("off");title(fig,t,s);return fig,ax
def rows(d):
 return [(k,f) for k in ("karman_re100/delta_sensors","karman_re100/delta_forces","illusion_1.0L/delta_sensors","illusion_1.0L/delta_forces") for f in d["reports"][k]["folds"]]
def f01(d):
 fig,ax=slide("LR and LE answer different inverse questions","Same learned observable map; distinct generalized inverses on its generated range")
 box(ax,(.05,.58),.22,.15,r"state coefficients $a(t)$",BLUE,"#F5FAFE");box(ax,(.39,.58),.22,.15,r"$C_\tau a(t) \approx b(t+\tau)$");box(ax,(.73,.58),.22,.15,r"observable $b(t+\tau)$",ORANGE,"#FFF8F1");arrow(ax,(.27,.655),(.39,.655));arrow(ax,(.61,.655),(.73,.655))
 box(ax,(.07,.18),.39,.21,"LR: expected least residual\n"+r"$C_{LR}^{-}=\Sigma_{ab}\Sigma_{bb}^{-1}$"+"\n"+r"$\min_L\,\mathbb{E}[\|a-Lb\|_G^2]$",BLUE,"#F5FAFE",10);box(ax,(.56,.18),.37,.21,"LE: minimum state energy\n"+r"$a_{LE}=\arg\min_a\,a^TGa$"+"\n"+r"subject to $C_\tau a=b$",ORANGE,"#FFF8F1",10);ax.text(.5,.08,"Observable prediction skill is not LR state reconstruction skill.",ha="center",fontsize=10);return fig
def f02(d):
 fig,ax=slide("Same-parent provenance and leave-one-realization-out evaluation","Each fold trains and selects only on two parent-seeded realizations")
 for x,r,c in zip((.08,.38,.68),RIDS,(BLUE,ORANGE,GREEN)):
  box(ax,(x,.68),.20,.12,f"parent seed {r}",GRAY,"#F7F7F7");arrow(ax,(x+.1,.68),(x+.1,.57));box(ax,(x,.44),.20,.13,"controlled − baseline\nexact paired timeline",BLUE,"#F5FAFE",8);box(ax,(x,.10),.20,.14,f"fold {r}\nHOLD OUT {r}\ntrain on other two",c,"white",8)
 ax.text(.5,.35,"canonical JSON: source hashes • geometry/mask • metric • timeline",ha="center",fontsize=9);ax.text(.5,.04,"Three descriptive folds—not three independent uncertainty estimates.",ha="center",fontsize=8,color=RED);return fig
def f03(d):
 rr=rows(d);fig,ax=plt.subplots(figsize=(10,5.625));title(fig,"Observed H-weighted R² versus selection-adjusted, independently reselected realization-derangement control","All 12 folds; shape and color both encode status");labs=[]
 for i,(k,f) in enumerate(rr):
  ctl=f["realization_derangement_negative_control_metric_weighted_r2"];g=ctl+f["negative_control_margin"];v=f["metric_weighted_r2"];ok=f["negative_control_pass"];c=GREEN if ok else RED
  ax.plot([g,v],[i,i],c=c,lw=1.4);ax.scatter(ctl,i,marker="x",c=GRAY,s=28);ax.scatter(g,i,marker="|",c=INK,s=75);ax.scatter(v,i,marker="^" if ok else "X",c=c,s=38);labs.append(k.replace("karman_re100","Kármán").replace("illusion_1.0L","Illusion").replace("delta_","")+" · "+f["held_out_realization"])
 ax.set_yticks(range(12),labs);ax.invert_yaxis();ax.grid(axis="x",color=LIGHT);ax.set_xlabel(r"held-out metric-weighted $R^2$ ($H=I$)");ax.scatter([],[],marker="^",c=GREEN,label="pass");ax.scatter([],[],marker="X",c=RED,label="fail");ax.scatter([],[],marker="x",c=GRAY,label="control");ax.scatter([],[],marker="|",c=INK,label="control+0.01");ax.legend(frameon=False,ncol=4,loc="lower center",bbox_to_anchor=(.5,-.18));fig.subplots_adjust(left=.25,bottom=.20,top=.84);return fig
def f04(d):
 fig,axs=plt.subplots(1,3,figsize=(10,5.625));title(fig,"Sensor–force contrast across held-out realizations","Spans are descriptive min–max ranges, not confidence intervals")
 for ai,(field,ttl) in enumerate((("unweighted_mean_channel_r2","unweighted mean per-channel $R^2$"),("margin","adjusted gate margin"),("lr_state_coefficient_r2","LR state $R^2$"))):
  ax=axs[ai]
  for si,s in enumerate(SCENES):
   for oi,o in enumerate(("sensors","forces")):
    fs=d["reports"][f"{s}/delta_{o}"]["folds"];vals=[gate_margin(f) if field=="margin" else f[field] for f in fs];x=si*3+oi;c=BLUE if o=="sensors" else ORANGE
    ax.plot([x,x],[min(vals),max(vals)],c=c,lw=2,alpha=.55);ax.scatter([x]*3,vals,c=c,marker="o" if o=="sensors" else "s",s=28)
  ax.axhline(0,c=INK,lw=.7);ax.set_xticks([.5,3.5],["Kármán","Illusion"]);ax.set_title(ttl);ax.grid(axis="y",color=LIGHT)
 fig.text(.5,.075,"Blue circles: sensors • Orange squares: forces • descriptive fold range only",ha="center",color=GRAY);fig.subplots_adjust(left=.07,right=.98,bottom=.17,top=.82,wspace=.32);return fig
def f05(d):
 fig,ax=plt.subplots(figsize=(10,5.625));title(fig,"Observable prediction does not imply LR coefficient-state reconstruction","Each point is one held-out fold; dashed lines mark zero skill")
 for k,f in rows(d):
  sensor="sensors" in k;ax.scatter(f["metric_weighted_r2"],f["lr_state_coefficient_r2"],c=BLUE if sensor else ORANGE,marker="o" if sensor else "s",s=45)
 ax.axhline(0,c=GRAY,ls="--");ax.axvline(0,c=GRAY,ls="--");ax.grid(color=LIGHT);ax.set_xlabel(r"observable prediction: metric-weighted $R^2$");ax.set_ylabel(r"LR coefficient-state reconstruction $R^2$");ax.text(.04,.07,r"$R^2=1-\frac{\sum_t\|y_t-\hat y_t\|_H^2}{\sum_t\|y_t-\bar y_{train}\|_H^2}$"+"\nDenominator uses the training mean; different targets.",transform=ax.transAxes,fontsize=10,bbox=dict(fc="white",ec=LIGHT,pad=5));ax.scatter([],[],c=BLUE,label="sensors");ax.scatter([],[],c=ORANGE,marker="s",label="forces");ax.legend(frameon=False);fig.subplots_adjust(left=.11,right=.97,bottom=.13,top=.84);return fig
def f06(d):
 fig,axs=plt.subplots(2,3,figsize=(12,7.5));title(fig,"Diagnostics dashboard: selected models, numerics, and periodic null","Standalone audit diagnostics; each point is a held-out LORO fold");rr=rows(d);x=np.arange(12)
 labs=[f"{('Kármán' if k[0]=='k' else 'Illusion')}\n{('sensors' if 'sensors' in k else 'forces')} {f['held_out_realization']}" for k,f in rr];colors=[BLUE if "sensors" in k else ORANGE for k,_ in rr]
 axs[0,0].scatter(x,[f["rank"] for _,f in rr],c=colors,label="selected rank");axs[0,0].plot(x,[f["sample_lag"] for _,f in rr],c=GRAY,marker="x",label="selected lag");axs[0,0].set_title("Training-only rank and lag");axs[0,0].set_ylabel("rank / lag (samples)");axs[0,0].legend(frameon=False,fontsize=8)
 axs[0,1].scatter(x,[f["state_retained_energy"] for _,f in rr],c=GREEN,marker="o",label="retained POD energy");axs[0,1].set_title("State POD retention");axs[0,1].set_ylabel("retained POD energy (fraction)");axs[0,1].legend(frameon=False,fontsize=8)
 axs[0,2].scatter(x,[f["propagator_condition_number"] for _,f in rr],c=GRAY,marker="x",label="propagator condition");axs[0,2].set_yscale("log");axs[0,2].set_title("Observable propagator conditioning");axs[0,2].set_ylabel("condition number (dimensionless)");axs[0,2].legend(frameon=False,fontsize=8)
 periods=[];admiss=[]
 for _,f in rr:
  z=f["circular_time_shift_admissibility"];periods.append(np.mean([q["period_samples"] for q in z]));admiss.append(min(q["admissible_offset_count"] for q in z))
 axs[1,0].scatter(x,periods,c=AMBER,marker="D",label="mean of 2 training estimates");axs[1,0].set_title("Training-realization period estimate");axs[1,0].set_ylabel("period (samples)");axs[1,0].legend(frameon=False,fontsize=8)
 axs[1,1].scatter(x,admiss,c=GRAY,marker="x",label="minimum across 2 training records");axs[1,1].set_title("Periodic-exclusion admissibility");axs[1,1].set_ylabel("minimum admissible shifts (count)");axs[1,1].legend(frameon=False,fontsize=8)
 pvals=[f["selection_adjusted_periodic_excluded_empirical_p_value"] for _,f in rr];axs[1,2].scatter(x,pvals,c=[GREEN if f["negative_control_pass"] else RED for _,f in rr],marker="o");axs[1,2].set_title("Selection-adjusted circular-shift null");axs[1,2].set_ylabel("Monte Carlo empirical p value");axs[1,2].ticklabel_format(axis="y",style="plain");axs[1,2].text(.03,.90,"All folds: p = 1/(999+1) = 0.001\nMonte Carlo resolution floor",transform=axs[1,2].transAxes,va="top",fontsize=9,bbox=dict(fc="white",ec=LIGHT,pad=4))
 for ax in axs.flat:ax.set_xticks(x,labs,rotation=48,ha="right",fontsize=7.5);ax.grid(axis="y",color=LIGHT);ax.tick_params(axis="y",labelsize=8)
 fig.subplots_adjust(left=.075,right=.98,bottom=.19,top=.84,hspace=.55,wspace=.35);return fig
def f07(d):
 fig,ax=slide("Claim boundary of the frozen OID evidence","Status is encoded by shape, label, and color")
 box(ax,(.05,.55),.27,.25,"●  ESTABLISHED\n\nAudited provenance/contracts\nSensor association: both scenes\nAll-fold sensor gate pass",GREEN,"#F3FBF7",9);box(ax,(.365,.55),.27,.25,"◆  CONDITIONAL\n\nForce metrics: descriptive\nFormal COMPLETE: fail-closed\nOverall freeze: conditional",AMBER,"#FFF9EC",9);box(ax,(.68,.55),.27,.25,"✕  NOT ESTABLISHED\n\nCross-realization force link\nForce LR state reconstruction\nCausal/universal/mode-unique",RED,"#FFF5F5",9)
 ax.text(.5,.30,"Allowed wording",ha="center",fontsize=10,fontweight="bold");ax.text(.5,.21,"Sensor observables passed the independently reselected deranged-control gate in both scenes;\nforce fold metrics and valid negative-control outcomes are descriptive, with no cross-realization force association.",ha="center",fontsize=10);ax.text(.5,.07,"No field modes • no fake confidence intervals • fold ranges are descriptive",ha="center",color=GRAY);return fig
GUIDE=r"""# OID 最终论文/PPT 图包说明

本图包只使用冻结审计、正式报告、canonical JSON、汇总和 freeze manifest。所有数值均由脚本读取；三折范围只是**描述性范围**，不是置信区间或不确定性估计。代码 Re=100（二维参考），物理 $Re_D=50$。

## 01 LR/LE 概念
怎么看：LR 使用审计定义的协方差/LSE 逆 $C_{LR}^{-}=\Sigma_{ab}\Sigma_{bb}^{-1}$，等价于最小化期望 G 加权残差；LE 在观测约束下最小化状态能量。论文图注：共享观测映射及 LR、LE 广义逆概念图。允许：说明逆问题不同。禁止：把观测预测当作状态重建或因果机制。

## 02 同父本、来源与 LORO
怎么看：controlled 与 baseline 同父本、精确时间配对；每折仅用另外两个 realization 训练选参。论文图注：canonical 配对与三折 LORO 流程。允许：说明来源/掩膜/度量/时间线合同。禁止：称三折为独立不确定性样本。

## 03 观测值与错配对照
怎么看：叉号为每折独立重选的 deranged control，短竖线为 control+0.01；三角通过，X 失败。论文图注：12 折 H 加权 R² 与 selection-adjusted、每折独立重选的 realization-derangement 对照门槛比较。允许：逐折报告。禁止：用均值掩盖失败折。

## 04 传感器—力对比
怎么看：蓝圆 sensors，橙方 forces；竖线为最小—最大。论文图注：非加权逐通道 R² 平均值、调整门槛裕量、LR 状态 R² 的描述性对比。允许：称描述性折范围。禁止：称误差棒、CI 或总体不确定性。

## 05 预测与状态重建
怎么看：横轴观测预测，纵轴 LR 系数状态重建；分母中心使用 training mean。论文图注：逐折两种 R² 对照。允许：指出 force 高观测分数可伴随负状态 R²。禁止：声称观测分数证明流场可重建。

## 06 诊断面板
怎么看：检查 rank/lag（samples）、保留 POD 能量（fraction）、条件数（无量纲）、周期（两个训练 realization 估计的均值，samples）、最小可用移位数（两个训练记录的最小值，count）以及选择调整圆周移位 p 值。全部 p=1/(999+1)=0.001，是 999 次 surrogate 的 Monte Carlo 分辨率下限。论文图注：正式报告模型选择、数值与周期排除 null 诊断。允许：按字段描述。禁止：把 p 值解释为效应大小或因果概率。

## 07 结论边界
怎么看：绿圆已建立、琥珀菱形条件性、红 X 未建立。论文图注：冻结证据允许、条件允许与禁止论断。允许：两场景 sensor 跨 realization 关联；force 的逐折指标和有效负对照结果仅作描述，不能建立跨 realization force 关联。禁止：force 跨 realization 关联、因果/普适/mode-unique、正式 COMPLETE 或 field-mode 论断。
"""
def save(fig,out,stem,evidence,generators):
 footer(fig,stem.startswith(("03_","04_","05_","06_")));binding={"evidence_inputs":evidence,"generator_inputs":generators};meta=" | ".join(f"{group}:sha256:{v} {k}" for group,items in binding.items() for k,v in sorted(items.items()));fig.savefig(out/f"{stem}.png",dpi=300,bbox_inches="tight",facecolor="white",metadata={"Evidence":meta});fig.savefig(out/f"{stem}.pdf",bbox_inches="tight",facecolor="white",metadata={"Title":stem,"Subject":"Audit-bound OID article package","Keywords":meta,"Creator":"plot_article_package.py; CPU-only"});plt.close(fig)
def run(root,output):
 root=root.resolve();output=output.resolve()
 if output.exists():raise FileExistsError(f"refusing to clobber existing package: {output}")
 if not output.parent.is_dir():raise FileNotFoundError(f"output parent must already exist: {output.parent}")
 d=load_package_data(root);tmp=output.with_name(f".{output.name}.tmp-{os.getpid()}");tmp.mkdir()
 try:
  style()
  for stem,fig in zip(STEMS,(f01(d),f02(d),f03(d),f04(d),f05(d),f06(d),f07(d))):save(fig,tmp,stem,d["evidence_inputs"],d["generator_inputs"])
  (tmp/"FIGURE_GUIDE.md").write_text(GUIDE,encoding="utf-8");outputs={p.name:{"sha256":sha256(p),"size_bytes":p.stat().st_size,"evidence_inputs":d["evidence_inputs"],"generator_inputs":d["generator_inputs"]} for p in sorted(tmp.iterdir())};m={"schema_id":"oid-article-figure-package/v2","scope":"audit-bound descriptive article/PPT figures; no field modes","cpu_only":True,"no_clobber":True,"field_mode_assets":[],"evidence_inputs":d["evidence_inputs"],"generator_inputs":d["generator_inputs"],"outputs":outputs};(tmp/"figure_manifest.json").write_text(json.dumps(m,indent=2,sort_keys=True)+"\n");tmp.rename(output);return m
 except Exception:shutil.rmtree(tmp,ignore_errors=True);raise
def main():
 p=argparse.ArgumentParser();p.add_argument("--output",type=Path,default=ROOT/OID/"results/article-package-v2-20260724");a=p.parse_args();m=run(ROOT,a.output);print(json.dumps({"output":str(a.output.resolve()),"files":len(m["outputs"])+1},indent=2));return 0
if __name__=="__main__":raise SystemExit(main())
