import json, os, sys, math
DATA_DIR = sys.argv[1] if len(sys.argv) > 1 else "sweep_data"
SUBSTRATES = ["graph", "ca"]
ESTIMATORS = ["plugin", "mm", "qe", "nsb"]
DISAGREEMENT_FLAG_PP = 10.0
NEAR_CHANCE_MARGIN_PP = 2.0
def load_records(substrate, estimator):
records = {}
prefix = f"{substrate}_{estimator}_"
for filename in os.listdir(DATA_DIR):
if filename.startswith(prefix) and filename.endswith('.json'):
seed = filename.replace(prefix, '').replace('.json', '')
with open(os.path.join(DATA_DIR, filename)) as f:
record = json.load(f)
if seed == record['config']['seed']:
records[seed] = record
return records
def wilson_interval(successes, n, z=1.96):
if n == 0:
return (0.0, 1.0)
phat = successes / n
denom = 1 + z * z / n
center = (phat + z * z / (2 * n)) / denom
margin = z * math.sqrt(phat * (1 - phat) / n + z * z / (4 * n * n)) / denom
return (max(0.0, center - margin), min(1.0, center + margin))
def reconstruct_counts(h, n_test):
if 'classification_metrics' not in h:
raise KeyError(
f"hypothesis '{h.get('name', '?')}' has no 'classification_metrics' "
"key in this JSON record — pooling requires it (coverage/precision/"
"accuracy). Check that ARCO was built/run with the 'serialize' "
"feature enabled, or that this JSON schema matches what this script "
"expects."
)
cm = h['classification_metrics']
predicted_positive = round(cm['coverage'] * n_test)
tp = round(cm['precision'] * predicted_positive) if predicted_positive > 0 else 0
fp = predicted_positive - tp
tn = round(cm['accuracy'] * n_test) - tp
fn = n_test - tp - fp - tn
return tp, fp, tn, fn
def hypothesis_summary_naive(records, hyp_name):
survivals = 0
accs = []
for record in records.values():
for h in record['hypotheses']:
if h['name'] == hyp_name:
accs.append(h['accuracy'] * 100)
if h['survives']:
survivals += 1
if not accs:
return (0, 0.0, 0.0, 0.0)
return (survivals, min(accs), max(accs), sum(accs) / len(accs))
def hypothesis_summary_pooled(records, hyp_name):
total_tp = total_fp = total_tn = total_fn = 0
survivals = 0
n_seeds = 0
for record in records.values():
n_test = int(record['config']['n_test']) for h in record['hypotheses']:
if h['name'] == hyp_name:
tp, fp, tn, fn = reconstruct_counts(h, n_test)
total_tp += tp
total_fp += fp
total_tn += tn
total_fn += fn
if h['survives']:
survivals += 1
n_seeds += 1
if n_seeds == 0:
return None
precision = total_tp / (total_tp + total_fp) if (total_tp + total_fp) > 0 else 0.0
recall = total_tp / (total_tp + total_fn) if (total_tp + total_fn) > 0 else 0.0
specificity = total_tn / (total_tn + total_fp) if (total_tn + total_fp) > 0 else 0.0
balanced_accuracy = (recall + specificity) / 2.0
recall_lo, recall_hi = wilson_interval(total_tp, total_tp + total_fn)
spec_lo, spec_hi = wilson_interval(total_tn, total_tn + total_fp)
bal_acc_lo = (recall_lo + spec_lo) / 2.0
bal_acc_hi = (recall_hi + spec_hi) / 2.0
return {
'survivals': survivals,
'n_seeds': n_seeds,
'pooled_balanced_accuracy': balanced_accuracy * 100,
'pooled_balanced_accuracy_ci': (bal_acc_lo * 100, bal_acc_hi * 100),
'ci_crosses_50': bal_acc_lo < 0.5 < bal_acc_hi,
'ci_near_chance': (
abs(bal_acc_lo * 100 - 50.0) <= NEAR_CHANCE_MARGIN_PP
or abs(bal_acc_hi * 100 - 50.0) <= NEAR_CHANCE_MARGIN_PP
),
'pooled_precision': precision * 100,
'pooled_recall': recall * 100,
'total_tp': total_tp,
'total_fp': total_fp,
'total_tn': total_tn,
'total_fn': total_fn,
}
def all_hypotheses_summary(records):
names = set()
descs = {}
for record in records.values():
for h in record['hypotheses']:
names.add(h['name'])
descs[h['name']] = h['condition_desc']
result = []
for name in names:
surv_n, acc_min, acc_max, acc_mean = hypothesis_summary_naive(records, name)
pooled = hypothesis_summary_pooled(records, name)
entry = {
'name': name,
'desc': descs[name],
'survivals': surv_n,
'acc_min': acc_min,
'acc_max': acc_max,
'acc_mean': acc_mean,
}
if pooled is not None:
entry.update(pooled)
entry['disagreement_pp'] = abs(acc_mean - pooled['pooled_balanced_accuracy'])
entry['flagged'] = entry['disagreement_pp'] > DISAGREEMENT_FLAG_PP
else:
entry['pooled_balanced_accuracy'] = None
entry['pooled_balanced_accuracy_ci'] = None
entry['ci_crosses_50'] = False
entry['flagged'] = False
result.append(entry)
result.sort(key=lambda x: -x['survivals'])
return result
def spectrum_summary(records, brackets):
result = []
for label, low, high in brackets:
rates = []
means = []
for record in records.values():
threshold = record['thresholds'].get('storage', 0.0)
group = [r for r in record['results'] if low <= r['structured_ratio'] < high]
if group:
rate = 100.0 * sum(1 for r in group if r['storage'] > threshold) / len(group)
rates.append(rate)
means.append(sum(r['storage'] for r in group) / len(group))
if rates:
result.append({
'label': label,
'rate_min': min(rates),
'rate_max': max(rates),
'rate_mean': sum(rates) / len(rates),
'storage_mean': sum(means) / len(means),
})
return result
def estimator_comparison(records_map, substrate, key_hypothesis):
lines = []
lines.append(f"| Substrate | Estimator | Storage Rate | Structured Storage | {key_hypothesis} Acc (pooled) | Survival |")
lines.append(f"|-----------|-----------|-------------|-------------------|----------|----------|")
for est in ESTIMATORS:
records = records_map.get((substrate, est), {})
if not records:
continue
if substrate == "graph":
s = spectrum_summary(records, [("Structured", 0.85, 1.01)])
else:
s = spectrum_summary(records, [("Structured", 0.7, 1.01)])
structured = f"{s[0]['rate_min']:.1f}\u2013{s[0]['rate_max']:.1f}% ({s[0]['rate_mean']:.1f})" if s else "\u2014"
pooled = hypothesis_summary_pooled(records, key_hypothesis)
n_seeds = len(records)
if pooled is None:
acc_str = "\u2014"
verdict = "\u2014"
else:
acc_str = f"{pooled['pooled_balanced_accuracy']:.1f}"
verdict = "\u2705" if pooled['pooled_balanced_accuracy'] >= 50.0 else "\u274c"
verdict += f" ({pooled['survivals']}/{pooled['n_seeds']} per-seed)"
storage_min = min(100.0 * sum(1 for r in rec['results'] if r['storage'] > rec['thresholds'].get('storage', 0.0)) / len(rec['results']) for rec in records.values())
storage_max = max(100.0 * sum(1 for r in rec['results'] if r['storage'] > rec['thresholds'].get('storage', 0.0)) / len(rec['results']) for rec in records.values())
storage_mean = sum(100.0 * sum(1 for r in rec['results'] if r['storage'] > rec['thresholds'].get('storage', 0.0)) / len(rec['results']) for rec in records.values()) / n_seeds
lines.append(f"| {substrate.capitalize():<9} | {est:<9} | {storage_min:.1f}\u2013{storage_max:.1f}% ({storage_mean:.1f}) | {structured} | {acc_str} | {verdict} |")
return "\n".join(lines)
def hypothesis_table(hypotheses, n_seeds):
lines = []
lines.append("| ID | Condition | Verdict (pooled) | 95% CI | Per-seed | Naive Range | Naive Mean | Pooled Bal.Acc | \u26a0 |")
lines.append("|----|-----------|-------------------|--------|----------|-------------|------------|----------------|---|")
for h in hypotheses:
pooled_str = f"{h['pooled_balanced_accuracy']:.1f}%" if h['pooled_balanced_accuracy'] is not None else "\u2014"
flag = "\u26a0\ufe0f" if h['flagged'] else ""
if h['pooled_balanced_accuracy'] is not None:
verdict = "\u2705 survives" if h['pooled_balanced_accuracy'] >= 50.0 else "\u274c fails"
ci_lo, ci_hi = h['pooled_balanced_accuracy_ci']
ci_str = f"[{ci_lo:.1f}\u2013{ci_hi:.1f}]"
if h['ci_crosses_50']:
ci_str += " \u2753" elif h['ci_near_chance']:
ci_str += " \U0001f536" else:
verdict = "\u2014"
ci_str = "\u2014"
lines.append(
f"| {h['name']} | {h['desc']} | {verdict} | {ci_str} | {h['survivals']}/{n_seeds} | "
f"{h['acc_min']:.1f}\u2013{h['acc_max']:.1f}% | {h['acc_mean']:.1f}% | {pooled_str} | {flag} |"
)
lines.append("")
lines.append(
"95% CI: Wilson score interval on pooled balanced_accuracy (from independent Wilson "
"intervals on recall and specificity, combined endpoint-wise — valid since recall/"
"specificity come from disjoint actual-positive/actual-negative groups). \u2753 = the "
"interval still crosses 50% — the verdict isn't yet statistically distinguishable from "
f"chance. \U0001f536 = doesn't cross 50%, but the nearer bound is within "
f"{NEAR_CHANCE_MARGIN_PP:.0f} points of it — technically significant, practically "
"still borderline; treat these more cautiously in prose than a clean \u2705/\u274c. "
"More seeds narrow the interval either way."
)
lines.append(
"Verdict = pooled_balanced_accuracy >= 50%, computed ONCE from counts summed across "
"all seeds — this is the number to report/cite. 'Per-seed' (X/N) is how many "
"INDIVIDUAL seeds happened to cross 0.5 on their own; for low-TP hypotheses this can "
"disagree with the pooled verdict — it's kept for transparency, not as the primary "
"signal."
)
lines.append(
f"\u26a0\ufe0f = naive mean and pooled balanced_accuracy disagree by more than "
f"{DISAGREEMENT_FLAG_PP:.0f} percentage points."
)
return "\n".join(lines)
def spectrum_table(spectrum):
lines = []
lines.append("| Bracket | Storage Rate Range | Mean |")
lines.append("|---------|-------------------|------|")
for s in spectrum:
lines.append(f"| {s['label']} | {s['rate_min']:.1f}\u2013{s['rate_max']:.1f}% | {s['rate_mean']:.1f}% |")
return "\n".join(lines)
records_map = {}
for substrate in SUBSTRATES:
for est in ESTIMATORS:
records = load_records(substrate, est)
if records:
records_map[(substrate, est)] = records
graph_records = records_map.get(("graph", "plugin"), {})
ca_records = records_map.get(("ca", "plugin"), {})
print("=" * 80)
print("SECTION 1: ESTIMATOR VALIDATION (copy to README)")
print("=" * 80)
print()
print(estimator_comparison(records_map, "graph", "H5_TRANSPORT"))
print()
print(estimator_comparison(records_map, "ca", "H3_LOW_SENSITIVITY"))
print()
print("=" * 80)
print("SECTION 2: GRAPH SUBSTRATE (copy to README)")
print("=" * 80)
print()
graph_brackets = [
("Noise (0.00\u20130.15)", 0.00, 0.15),
("Balanced (0.40\u20130.60)", 0.40, 0.60),
("Structured (0.85\u20131.00)", 0.85, 1.01),
]
graph_spectrum = spectrum_summary(graph_records, graph_brackets)
print("#### Structure-Storage Gradient")
print()
print(spectrum_table(graph_spectrum))
print()
graph_hyps = all_hypotheses_summary(graph_records)
print("#### Hypothesis Survival")
print()
print(hypothesis_table(graph_hyps, len(graph_records)))
print()
print("=" * 80)
print("SECTION 3: CA SUBSTRATE (copy to README)")
print("=" * 80)
print()
ca_hyps = all_hypotheses_summary(ca_records)
print("#### Hypothesis Survival")
print()
print(hypothesis_table(ca_hyps, len(ca_records)))
print()