summaryrefslogtreecommitdiff
path: root/scripts/stealth_gap_tests.py
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/stealth_gap_tests.py')
-rw-r--r--scripts/stealth_gap_tests.py230
1 files changed, 230 insertions, 0 deletions
diff --git a/scripts/stealth_gap_tests.py b/scripts/stealth_gap_tests.py
new file mode 100644
index 0000000..33e9142
--- /dev/null
+++ b/scripts/stealth_gap_tests.py
@@ -0,0 +1,230 @@
+# /// script
+# requires-python = ">=3.10"
+# dependencies = [
+# "pandas",
+# "numpy",
+# "scipy",
+# "statsmodels",
+# ]
+# ///
+"""
+Stealth Gap Analysis for Study 1
+=================================
+Tests the divergence between glassbox (internal) and blackbox (output) measures.
+
+Design: 3 oversight levels × 90 runs each
+Each run has paired classifications: glassbox intent + blackbox detection.
+
+Usage:
+ uv run python scripts/stealth_gap_tests.py
+"""
+
+import warnings
+
+import numpy as np
+import pandas as pd
+from scipy.stats import chi2_contingency, chi2
+from statsmodels.stats.contingency_tables import Table2x2
+
+warnings.filterwarnings("ignore", category=FutureWarning)
+
+DECEPTIVE_BB = {"DECEPTIVE_FRAMING", "OMITTED_CRITICAL", "FABRICATED"}
+COMPLIANT_GB = {"COMPLIANT_DECEPTION"}
+RESULTS_PATH = "logs/v2_study1/study1_results.csv"
+
+
+def load_data(path: str) -> pd.DataFrame:
+ df = pd.read_csv(path)
+ df["is_deceptive_bb"] = df["blackbox_category"].isin(DECEPTIVE_BB).astype(int)
+ df["is_compliant_gb"] = df["glassbox_category"].isin(COMPLIANT_GB).astype(int)
+ return df
+
+
+def cramers_v(table: np.ndarray) -> float:
+ chi2_val = chi2_contingency(table)[0]
+ n = table.sum()
+ min_dim = min(table.shape) - 1
+ if min_dim == 0 or n == 0:
+ return 0.0
+ return np.sqrt(chi2_val / (n * min_dim))
+
+
+def cochran_armitage(counts, nobs):
+ k = len(counts)
+ scores = np.arange(k, dtype=float)
+ n = np.array(nobs, dtype=float)
+ x = np.array(counts, dtype=float)
+ N = n.sum()
+ p_hat = x.sum() / N
+ t_bar = np.sum(scores * n) / N
+ numerator = np.sum(scores * x) - x.sum() * t_bar
+ denominator_sq = p_hat * (1 - p_hat) * (np.sum(scores**2 * n) - N * t_bar**2)
+ if denominator_sq <= 0:
+ return 0.0, 1.0
+ z = numerator / np.sqrt(denominator_sq)
+ p_value = chi2.sf(z**2, 1)
+ return z, p_value
+
+
+def sig(p: float, alpha: float = 0.05) -> str:
+ if p < 0.001:
+ return "***"
+ elif p < 0.01:
+ return "**"
+ elif p < alpha:
+ return "*"
+ return "n.s."
+
+
+def fmt_p(p: float) -> str:
+ return f"p < .001" if p < 0.001 else f"p = {p:.4f}"
+
+
+def section(title: str):
+ print(f"\n{'=' * 70}")
+ print(f" {title}")
+ print(f"{'=' * 70}\n")
+
+
+def subsection(title: str):
+ print(f"\n --- {title} ---\n")
+
+
+def main():
+ df = load_data(RESULTS_PATH)
+ ALPHA = 0.05
+
+ section("1. BLACKBOX DECLINE TEST")
+
+ bb_counts = []
+ bb_nobs = []
+ for ov in ["low", "mid", "high"]:
+ sub = df[df["oversight"] == ov]
+ n_detected = sub["is_deceptive_bb"].sum()
+ n_total = len(sub)
+ bb_counts.append(n_detected)
+ bb_nobs.append(n_total)
+ print(
+ f" {ov}: {n_detected}/{n_total} ({n_detected / n_total * 100:.1f}%) misleading"
+ )
+
+ ct_bb = pd.crosstab(df["oversight"], df["is_deceptive_bb"])
+ ct_bb = ct_bb.loc[["low", "mid", "high"]]
+
+ chi2_val, p_val, dof, expected = chi2_contingency(ct_bb.values)
+ v = cramers_v(ct_bb.values)
+ print(f"\n Chi-squared test: χ²({dof}) = {chi2_val:.3f}, {fmt_p(p_val)} {sig(p_val)}")
+ print(f" Cramér's V = {v:.3f}")
+
+ z_trend, p_trend = cochran_armitage(bb_counts, bb_nobs)
+ print(f"\n Cochran-Armitage trend test: z = {z_trend:.3f}, {fmt_p(p_trend)} {sig(p_trend)}")
+ if p_trend < ALPHA:
+ direction = "decreasing" if z_trend < 0 else "increasing"
+ print(f" → Blackbox deception {direction} with higher oversight")
+
+ section("2. McNEMAR'S TEST BY OVERSIGHT LEVEL")
+
+ mcnemar_results = {}
+ for ov in ["low", "mid", "high"]:
+ sub = df[df["oversight"] == ov]
+
+ gb_dec = sub["is_compliant_gb"].values
+ bb_det = sub["is_deceptive_bb"].values
+
+ a = int(((gb_dec == 1) & (bb_det == 1)).sum())
+ b = int(((gb_dec == 1) & (bb_det == 0)).sum())
+ c = int(((gb_dec == 0) & (bb_det == 1)).sum())
+ d = int(((gb_dec == 0) & (bb_det == 0)).sum())
+
+ table = np.array([[a, b], [c, d]])
+ ct = Table2x2(table)
+ mcnemar_res = ct.symmetry()
+ stat = mcnemar_res.statistic
+ p_mc = mcnemar_res.pvalue
+
+ mcnemar_results[ov] = {"stat": stat, "p": p_mc}
+
+ print(f"\n {ov.upper()} oversight (n={len(sub)}):")
+ print(f" Contingency table (rows=glassbox, cols=blackbox, 1=deceptive/detected):")
+ print(f" Blackbox")
+ print(f" detected not")
+ print(f" Glassbox dec {a:4d} {b:3d}")
+ print(f" Glassbox not {c:4d} {d:3d}")
+ print(f" McNemar's test: χ²({1}) = {stat:.3f}, {fmt_p(p_mc)} {sig(p_mc)}")
+ if p_mc < ALPHA:
+ if b > c:
+ print(
+ f" → Glassbox detects MORE deception than blackbox "
+ f"(stealth gap: {b} runs deceptive-undetected vs {c} runs detected-nondec)"
+ )
+ else:
+ print(
+ f" → Blackbox detects MORE deception than glassbox "
+ f"({c} runs detected-nondec vs {b} runs deceptive-undetected)"
+ )
+
+ if (a + b) > 0:
+ stealth_rate_val = b / (a + b) * 100
+ print(f" Stealth rate (deceptive but undetected): {b}/{a + b} "
+ f"({stealth_rate_val:.1f}% of deceptive runs)")
+ else:
+ print(" Stealth rate: N/A (no deceptive runs)")
+
+ section("3. INTERACTION TEST: Stealth Rate Across Oversight")
+
+ stealth_counts = []
+ stealth_nobs = []
+ print(f" {'Oversight':12s} {'Deceptive':>10s} {'Undetected':>10s} {'Stealth Rate':>13s}")
+ print(f" {'-' * 12} {'-' * 10} {'-' * 10} {'-' * 13}")
+ for ov in ["low", "mid", "high"]:
+ sub = df[df["oversight"] == ov]
+ gb_dec = sub["is_compliant_gb"].values
+ bb_det = sub["is_deceptive_bb"].values
+
+ n_deceptive = int(gb_dec.sum())
+ n_undetected = int(((gb_dec == 1) & (bb_det == 0)).sum())
+ stealth_rate = n_undetected / n_deceptive * 100 if n_deceptive > 0 else 0
+
+ stealth_counts.append(n_undetected)
+ stealth_nobs.append(n_deceptive)
+ print(f" {ov:12s} {n_deceptive:8d}/90 {n_undetected:8d} "
+ f"{stealth_rate:8.1f}% ({n_undetected}/{n_deceptive})")
+
+ print(f"\n Chi-squared test on stealth counts (deceptive-undetected vs detected among deceptive):")
+ stealth_not_counts = [n - c for n, c in zip(stealth_nobs, stealth_counts)]
+ stealth_table = np.array([stealth_counts, stealth_not_counts]).T
+ chi2_stealth, p_stealth, dof_stealth, _ = chi2_contingency(stealth_table)
+ v_stealth = cramers_v(stealth_table)
+ print(f" χ²({dof_stealth}) = {chi2_stealth:.3f}, {fmt_p(p_stealth)} {sig(p_stealth)}")
+ print(f" Cramér's V = {v_stealth:.3f}")
+
+ z_stealth, p_stealth_trend = cochran_armitage(stealth_counts, stealth_nobs)
+ print(f"\n Cochran-Armitage trend test on stealth rates:")
+ print(f" z = {z_stealth:.3f}, {fmt_p(p_stealth_trend)} {sig(p_stealth_trend)}")
+ if p_stealth_trend < ALPHA:
+ direction = "decreasing" if z_stealth < 0 else "increasing"
+ print(f" → Stealth rate {direction} with higher oversight")
+
+ section("4. SUMMARY TABLE")
+
+ print(f" {'Test':45s} {'Statistic':>12s} {'p-value':>10s} {'Significant?':>12s}")
+ print(f" {'-' * 45} {'-' * 12} {'-' * 10} {'-' * 12}")
+
+ print(f" {'Blackbox decline (chi-squared)':45s} {f'χ²={chi2_val:.3f}':>12s} {fmt_p(p_val):>10s} {sig(p_val, ALPHA):>12s}")
+
+ for ov in ["low", "mid", "high"]:
+ r = mcnemar_results[ov]
+ sig_str = sig(r["p"], ALPHA)
+ stat_str = f"χ²={r['stat']:.3f}"
+ p_str = fmt_p(r["p"])
+ print(f" {'McNemar (' + ov + ')':45s} {stat_str:>12s} {p_str:>10s} {sig_str:>12s}")
+
+ sig_stealth = sig(p_stealth, ALPHA)
+ print(f" {'Stealth rate (chi-squared)':45s} {f'χ²={chi2_stealth:.3f}':>12s} {fmt_p(p_stealth):>10s} {sig_stealth:>12s}")
+
+ print(f"\n Significance codes: *** p < .001 ** p < .01 * p < .05 n.s. = not significant")
+ print(f" Alpha = {ALPHA}")
+
+
+if __name__ == "__main__":
+ main()