Source: scripts/analyze_rgbd_depth_readout_v22.py · Plain text

scripts/analyze_rgbd_depth_readout_v22.py

#!/usr/bin/env python3
"""V22: is the FAR length gain of V17 the unnormalized depth readout? (EVAL-FAR, re-scoring only)

PLAN_BEFORE_V22.md fixed this round. The renderer's depth is sum_i w_i t_i, not divided by the
visibility sum_i w_i. V17-V20's sealed EVAL-FAR predictions (hash-checked against their seals) are
re-scored with RAW (the recorded depth) and NORM = depth / max(visibility, 1e-3), on V11's FAR,
NEAR and ALL regions, with V11's aggregation, contrast and frozen V2 gate. The RAW rows must
reproduce each run's recorded EVAL-FAR rows. No model runs; only already-read query depth is read.

  --stage freeze  writes the preregistration (plan, inputs and sources hashed);
  --stage run     re-scores, checks the reproduction, decides (V17) and describes (V18-V20).
Run from the repository root under cv_host_run.py (records store zonghan_o paths).
"""

from __future__ import annotations

import argparse
import datetime
import importlib.util
import subprocess
from pathlib import Path

import numpy as np

from mcss.mechanism_pilot.geometry_carrier_experiment import read
from mcss.mechanism_pilot.small_training import sha, write_json

EXPERIMENT = "EXP-3D-RGBD-DEPTH-READOUT-V22"
PLAN = "PLAN_BEFORE_V22.md"
PLAN_SHA256 = "022fe661dce5ee6fecd5ac9c4d3378c36ce3282904a3ae6a15863c9fb39c8051"
RUNS = {
    "V17": "EXP-3D-RGBD-LONG-TRAIN-V17",
    "V18": "EXP-3D-RGBD-LONG-TRAIN-DATA-V18",
    "V19": "EXP-3D-RGBD-LONG-TRAIN-WIDTH-V19",
    "V20": "EXP-3D-RGBD-LONG-TRAIN-WIDTH-DATA-V20",
}
PRIMARY_RUN = "V17"
COHORT = "EVAL_FAR"
LABELS = ("T6000", "T24000")
REGIONS = ("FAR", "NEAR", "ALL")
EPS = 1e-3
HOST = Path("scripts/evaluate_rgbd_completion_eval_v3.py")
SCRIPT = Path("scripts/analyze_rgbd_depth_readout_v22.py")
CONTRASTS = {
    "LENGTH_RAW": ("T6000", "T24000"),
    "LENGTH_NORM": ("T6000_NORM", "T24000_NORM"),
    "NORM_GAIN_6K": ("T6000", "T6000_NORM"),
    "NORM_GAIN_24K": ("T24000", "T24000_NORM"),
    "HARMONIC_MINUS_T24000": ("REPROJ_HARMONIC", "T24000"),
    "HARMONIC_MINUS_T24000_NORM": ("REPROJ_HARMONIC", "T24000_NORM"),
}


def host():
    spec = importlib.util.spec_from_file_location("v11_eval_host_v22", HOST)
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


def absrel(value, gt, mask):
    return float(np.mean(np.abs(value[mask] - gt[mask]) / gt[mask])) if mask.any() else None


def run_inputs(name):
    root = Path("outputs") / name
    return {
        "contract": root / "training_contract.json",
        "seal": root / "raw/eval" / COHORT / "prediction_seal.json",
        "rows": root / "raw/eval/query_rows.json",
        "results": root / "eval_results.json",
    }


def rescore(name):
    """(rows, FAR opacity rows) of one run's sealed EVAL-FAR predictions, RAW and NORM."""
    paths = run_inputs(name)
    config = read(paths["contract"])
    if read(paths["results"])["control"] != "PASS":
        raise PermissionError(f"{name}: built-in control not PASS")
    entry = config["eval"]["cohorts"][COHORT]
    if sha(Path(entry["manifest"])) != entry["manifest_sha256"]:
        raise PermissionError(f"{name}: {COHORT} manifest changed")
    manifest = read(Path(entry["manifest"]))
    seal = read(paths["seal"])
    rows, opacity = [], []
    for record in manifest["scenes"]:
        sid = record["scene_id"]
        frames = {f["frame_id"]: f for f in record["frames"]}
        for role in ("A", "B"):
            for qid in record["roles"][entry["role"]]:
                item = seal["predictions"][f"{COHORT}/{sid}/{role}/{qid}"]
                if sha(Path(item["path"])) != item["sha256"]:
                    raise PermissionError("A sealed prediction changed")
                arrays = dict(np.load(item["path"]))
                gt = np.load(frames[qid]["depth"]).squeeze().astype(np.float64)
                valid = np.isfinite(gt) & (gt > 0)
                near = arrays["near"].astype(bool)
                masks = {"ALL": valid, "NEAR": valid & near, "FAR": valid & ~near}
                common = {"cohort": COHORT, "scene_id": sid, "role": role, "query_id": qid}

                def scored(method, seed, value, common=common, masks=masks, gt=gt):
                    regions = {f"absrel_{r}": absrel(value, gt, m) for r, m in masks.items()}
                    return {**common, "method": method, "seed": seed, **regions}

                rows.append(scored("REPROJ_HARMONIC", 0, arrays["REPROJ_HARMONIC"]))
                for seed in config["seeds"]:
                    for label in LABELS:
                        raw = arrays[f"{label}_{seed}"].astype(np.float64)
                        visibility = arrays[f"OPACITY_{label}_{seed}"].astype(np.float64)
                        rows.append(scored(label, seed, raw))
                        rows.append(
                            scored(f"{label}_NORM", seed, raw / np.maximum(visibility, EPS))
                        )
                        if masks["FAR"].any():
                            # FAR mean opacity, stored under absrel_FAR to reuse V11's scene_values.
                            far_opacity = float(visibility[masks["FAR"]].mean())
                            opacity.append(
                                {**common, "method": label, "seed": seed, "absrel_FAR": far_opacity}
                            )
    return rows, opacity


def reproduction_error(name, rows):
    """Largest difference between the re-scored RAW rows and the run's recorded EVAL-FAR rows."""
    recorded = {
        (r["scene_id"], r["role"], r["query_id"], r["method"], r["seed"]): r
        for r in read(run_inputs(name)["rows"])
        if r["cohort"] == COHORT and r["method"] in (*LABELS, "REPROJ_HARMONIC")
    }
    worst, matched = 0.0, 0
    for row in rows:
        key = (row["scene_id"], row["role"], row["query_id"], row["method"], row["seed"])
        if key not in recorded:
            continue
        matched += 1
        for region in REGIONS:
            a, b = row[f"absrel_{region}"], recorded[key][f"absrel_{region}"]
            if (a is None) != (b is None):
                raise AssertionError("Region coverage differs from the recorded rows")
            if a is not None:
                worst = max(worst, abs(a - b))
    if matched != len(recorded):
        raise AssertionError(f"{name}: {matched} of {len(recorded)} recorded rows re-scored")
    return worst


def compact(stats):
    if stats is None:
        return None
    keys = ("mean", "ci95", "positive", "tied", "negative", "n_scenes")
    return {k: stats[k] for k in keys}


def decide(length_raw, length_norm, norm_gain_status):
    """The plan's rule: EXPLAINED iff the NORM residual's CI upper bound < half the RAW gain."""
    explained = length_norm["ci95"][1] < 0.5 * length_raw["mean"]
    if explained and norm_gain_status == "SUPPORTED":
        branch = "A"
    elif explained:
        branch = "B"
    else:
        branch = "C"
    return ("EXPLAINED" if explained else "NOT_EXPLAINED"), branch


def analyze(rows, opacity, v11):
    def values(method, region):
        return v11.scene_values(rows, method, f"absrel_{region}")

    contrasts = {}
    for region in REGIONS:
        for name, (a, b) in CONTRASTS.items():
            stats = v11.contrast(values(a, region), values(b, region))
            gate, checks = v11.gate(stats) if stats is not None else ("UNDEFINED", {})
            contrasts[f"{name}_{region}"] = {
                "stats": compact(stats),
                "gate": gate,
                "gate_checks": checks,
            }
    absolute = {
        f"{m}_{region}": float(np.mean(list(values(m, region).values())))
        for m in (*LABELS, *(f"{label}_NORM" for label in LABELS), "REPROJ_HARMONIC")
        for region in REGIONS
    }
    far_opacity = {
        label: float(np.mean(list(v11.scene_values(opacity, label, "absrel_FAR").values())))
        for label in LABELS
    }
    status, branch = decide(
        contrasts["LENGTH_RAW_FAR"]["stats"],
        contrasts["LENGTH_NORM_FAR"]["stats"],
        contrasts["NORM_GAIN_6K_FAR"]["gate"],
    )
    return {
        "absolute_scene_mean": absolute,
        "contrasts": contrasts,
        "far_mean_opacity": far_opacity,
        "EXPLANATION_STATUS": status,
        "NORM_GAIN_6K_STATUS": contrasts["NORM_GAIN_6K_FAR"]["gate"],
        "INTERPRETATION_BRANCH": branch,
    }


def freeze(root):
    root = Path(root).resolve()
    if root.name != EXPERIMENT or (root / "preregistration.json").exists():
        raise PermissionError("Exact, unfrozen V22 directory required")
    if sha(root / PLAN) != PLAN_SHA256:
        raise PermissionError("The V22 plan must be in place")
    manifests = set()
    for name in RUNS.values():
        entry = read(run_inputs(name)["contract"])["eval"]["cohorts"][COHORT]
        manifests.add((entry["manifest"], entry["manifest_sha256"]))
    if len(manifests) != 1:
        raise PermissionError("V17-V20 must share one EVAL-FAR manifest")
    inputs = [root / PLAN] + [p for name in RUNS.values() for p in run_inputs(name).values()]
    inputs.append(Path(next(iter(manifests))[0]))
    sources = sorted(Path("src/mcss").rglob("*.py")) + [SCRIPT, HOST]
    write_json(
        root / "preregistration.json",
        {
            "status": "FROZEN_BEFORE_ANALYSIS",
            "experiment": EXPERIMENT,
            "created_utc": datetime.datetime.now(datetime.UTC).isoformat(),
            "input_sha256": {str(p.resolve()): sha(p) for p in inputs},
            "source_sha256": {str(p.resolve()): sha(p) for p in sources},
            "readout_epsilon": EPS,
        },
    )
    print("FROZEN_V22", len(inputs), "inputs", len(sources), "sources")


def verify(root):
    lock = read(Path(root) / "preregistration.json")
    if lock["status"] != "FROZEN_BEFORE_ANALYSIS":
        raise PermissionError("V22 preregistration absent")
    for section in ("input_sha256", "source_sha256"):
        for path, digest in lock[section].items():
            if sha(Path(path)) != digest:
                raise PermissionError(f"Frozen input/source changed: {path}")


def stat(entry):
    s = entry["stats"]
    lo, hi = s["ci95"]
    return (
        f"{s['mean']:+.4f} [{lo:+.4f}, {hi:+.4f}],场景 {s['positive']}/{s['tied']}/{s['negative']}"
    )


def report(root, results):
    p = results["runs"][PRIMARY_RUN]
    c = p["contrasts"]
    a = p["absolute_scene_mean"]
    lines = [
        f"# {EXPERIMENT}",
        "",
        "长训练的补全增益是否来自未归一化的深度读出。只在 CPU 上重新计分 V17–V20 已封存的 "
        "EVAL-FAR 预测(79 个场景,远处 query),不运行任何模型。"
        "计划见 [PLAN_BEFORE_V22.md](PLAN_BEFORE_V22.md)。",
        "",
        f"- **V17 长训练增益(FAR,第 6000 − 24000 步):** RAW {stat(c['LENGTH_RAW_FAR'])};"
        f"NORM {stat(c['LENGTH_NORM_FAR'])}",
        f"- **主判定一(解释):** {p['EXPLANATION_STATUS']}"
        f"(NORM 残差的 CI 上界 {c['LENGTH_NORM_FAR']['stats']['ci95'][1]:+.4f},"
        f"RAW 增益的一半 {0.5 * c['LENGTH_RAW_FAR']['stats']['mean']:+.4f})",
        f"- **主判定二(第 6000 步的归一化增益):** {stat(c['NORM_GAIN_6K_FAR'])},"
        f"{p['NORM_GAIN_6K_STATUS']}",
        f"- **分支 {p['INTERPRETATION_BRANCH']}**",
        f"- **FAR 平均不透明度:** 第 6000 步 {p['far_mean_opacity']['T6000']:.3f},"
        f"第 24000 步 {p['far_mean_opacity']['T24000']:.3f}",
        "",
        "| V17 FAR AbsRel | RAW | NORM |",
        "|---|---|---|",
        f"| 第 6000 步 | {a['T6000_FAR']:.4f} | {a['T6000_NORM_FAR']:.4f} |",
        f"| 第 24000 步 | {a['T24000_FAR']:.4f} | {a['T24000_NORM_FAR']:.4f} |",
        f"| 调和插值 | {a['REPROJ_HARMONIC_FAR']:.4f} | — |",
        "",
        "| run(描述性) | 长训练增益 RAW | 长训练增益 NORM | 第 6000 步 NORM 增益 | 分支 |",
        "|---|---|---|---|---|",
    ]
    for label, part in results["runs"].items():
        pc = part["contrasts"]
        lines.append(
            f"| {label} | {stat(pc['LENGTH_RAW_FAR'])} | {stat(pc['LENGTH_NORM_FAR'])} | "
            f"{stat(pc['NORM_GAIN_6K_FAR'])} | {part['INTERPRETATION_BRANCH']} |"
        )
    lines += [
        "",
        "- **调和插值 − carrier(FAR,第 24000 步):** "
        f"RAW {stat(c['HARMONIC_MINUS_T24000_FAR'])};"
        f"NORM {stat(c['HARMONIC_MINUS_T24000_NORM_FAR'])}",
        f"- **复现:** RAW 与各 run 记录的 EVAL-FAR 计分的最大差 "
        f"{max(results['reproduction_max_error'].values()):.2e}",
        "",
        "说明:这一假设是在 EVAL-V3/V4 上事后发现的;"
        "EVAL-FAR 的 query 深度在 V17–V20 计分时已经读过,但从未用于归一化读出。"
        "只有 V17 用于判定,V18–V20 是描述性的重复。受保护的 final holdout 未打开。",
    ]
    (Path(root) / "README.md").write_text("\n".join(lines) + "\n")


def run(root):
    root = Path(root).resolve()
    verify(root)
    v11 = host()
    results = {"runs": {}, "reproduction_max_error": {}}
    for label, name in RUNS.items():
        rows, opacity = rescore(name)
        results["reproduction_max_error"][label] = reproduction_error(name, rows)
        if results["reproduction_max_error"][label] > 1e-9:
            raise AssertionError(f"{label}: RAW does not reproduce the recorded rows")
        results["runs"][label] = analyze(rows, opacity, v11)
        write_json(root / f"raw/{label}_rows.json", rows)
        print(label, results["runs"][label]["INTERPRETATION_BRANCH"], flush=True)
    primary = results["runs"][PRIMARY_RUN]
    results["INTERPRETATION_BRANCH"] = primary["INTERPRETATION_BRANCH"]
    results["EXPLANATION_STATUS"] = primary["EXPLANATION_STATUS"]
    results["NORM_GAIN_6K_STATUS"] = primary["NORM_GAIN_6K_STATUS"]
    write_json(root / "results.json", results)
    write_json(
        root / "integrity.json",
        {
            "status": "PASS",
            "reproduction_max_error": results["reproduction_max_error"],
            "models_run": False,
            "final_holdout_touched": False,
            "commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
            "finished_utc": datetime.datetime.now(datetime.UTC).isoformat(),
        },
    )
    report(root, results)
    print(
        "V22_COMPLETE", results["INTERPRETATION_BRANCH"], results["EXPLANATION_STATUS"], flush=True
    )


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--root", required=True)
    parser.add_argument("--stage", choices=("freeze", "run"), required=True)
    args = parser.parse_args()
    if args.stage == "freeze":
        freeze(args.root)
    else:
        run(args.root)