clawbench/scripts/classify_regimes.py
scoootscooob b6f07d9a87 analysis: dynamical-systems diagnostics for agent runs
Treats agent runs as stochastic trajectories in semantic state space
and extracts signal that flat run_score averages away. Inspired by
the "When LLMs Are Dreaming, Where Do They Go?" framework: task
constraint characterization, per-run regime classification, seed-vs-
capability variance decomposition, per-turn survival, SNR-weighted
ranking.

Uses TF-IDF bag-of-words embeddings (numpy + scipy only; no external
model dependencies) as the semantic state proxy since sentence
embeddings would require torch. Crude but sufficient for the signals
the paper calls out.

scripts/compute_constraint_index.py: computes C(q) per task from
archive responses. C(q) = -z(PR) - z(entropy) + z(BOPS) where PR is
participation ratio of response covariance, entropy is eigenvalue
entropy, and BOPS is inter-run cosine (predictability proxy). High
C(q) = tasks where models converge to similar answers; low C(q) =
open-ended tasks where models diverge for style reasons.

scripts/classify_regimes.py: per-run regime classifier. Computes
drift_mean, from_start, recurrence, vol_log over turn trajectories.
Quartile-based thresholds label each run as too_short / trapped /
limit_cycle / diffusive / mixed. Reveals per-model tendencies:
Gemini traps frequently (one-shot answer without iteration), GPT
loops tool patterns, GLM is most balanced.

scripts/variance_decomp.py: decomposes run_score variance per task
into seed variance (3 runs of same model) vs capability variance
(across model means). SNR = cap_var / seed_var. Exposes that 47% of
benchmark variance is seed noise; 21 of 40 tasks have SNR < 1 and
give essentially random rankings.

scripts/survival_analysis.py: per-turn empirical survival S(t) and
hazard h(t). T_F = first turn where assistant emits empty response
or run ends in failure. Reveals long-horizon capability that flat
scores hide: Kimi dies at median turn 3, GPT survives to turn 8 at
60% rate.

scripts/snr_weighted_ranking.py: SNR × |C(q)|-weighted ranking (with
winsorization at p95 to prevent single-task dominance). Headline
metric that weights discriminating + signal-rich tasks more than
noisy or consensus tasks. Also emits SNR-only and flat variants for
comparison.

scripts/generate_dynamical_report.py: assembles all four diagnostic
JSONs into a single markdown report with per-model regime tables,
SNR tiers, survival curves, and integrated interpretation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:49:05 -07:00

252 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Classify each archived run's dynamical regime from its turn trajectory.
Following "When LLMs Are Dreaming..." §What We Expect to See:
TRAPPED/ATTRACTOR — low support (Vol_log), high recurrence, high BOPS.
Agent converged to a point; may be good (solved it)
or bad (got stuck in a loop on a single idea).
LIMIT-CYCLE — high recurrence + bounded drift + quasi-periodic revisits.
Agent loops between a few states.
DIFFUSIVE/WANDERING — growing support, rising drift, low recurrence.
Agent explores without converging; often "goal drift".
SENSITIVE — (requires paraphrased-pair runs; skip here.)
TOO-SHORT — trajectory < 3 assistant turns; can't classify dynamics.
We work in a TF-IDF bag-of-words embedding space (same vocab as C(q)),
with each turn's state vector = its assistant text + tool-call args.
Metrics per run:
- drift_mean: mean ||e_t e_{t1}|| across turns
- from_start: max ||e_t e_0|| (farthest the run drifted from origin)
- recurrence: max_{i<j, ji≥2} cos(e_i, e_j) — best return-after-gap match
- vol_log: log det(Σ + εI) over turn states — support volume proxy
Classifier rules (tuned empirically on the distribution):
if n_turns < 3 → too_short
elif drift_mean < 0.15 and vol_log < 6 → trapped
elif recurrence > 0.80 and drift_mean < 0.25 → limit_cycle
elif drift_mean > 0.35 and vol_log > 3 → diffusive
else → mixed
Output: reports/regimes.json with per-run classification.
Usage:
.venv/bin/python3 scripts/classify_regimes.py
"""
from __future__ import annotations
import json
import re
from collections import Counter, defaultdict
from pathlib import Path
import numpy as np
ROOT = Path(__file__).resolve().parent.parent
ARCH = ROOT / "data" / "run_cache_archive" / "v2026-4-19-full"
MODELS = [
"anthropic_claude-opus-4-6", "anthropic_claude-opus-4-7",
"anthropic_claude-sonnet-4-6", "openai_gpt-5.4",
"google_gemini-3.1-pro-preview", "openrouter_z-ai_glm-5.1",
"openrouter_minimax_minimax-m2.7", "openrouter_moonshotai_kimi-k2.5",
"openrouter_qwen_qwen3.6-plus",
]
WORD_RE = re.compile(r"[a-z]{3,}")
STOPWORDS = set("the and that with this have from what your will can but not "
"was will are been one would there been they will their has "
"had its were only some than about these which into also each "
"when where them how who them very much more most other then "
"here such does like just make many like want need take".split())
def tokenize(text: str) -> list[str]:
return [w for w in WORD_RE.findall((text or "").lower()) if w not in STOPWORDS]
def build_vocab(all_turn_texts: list[str], top_k: int = 500) -> dict[str, int]:
c = Counter()
for t in all_turn_texts:
c.update(set(tokenize(t)))
return {w: i for i, (w, _) in enumerate(c.most_common(top_k))}
def vectorize(text: str, vocab: dict[str, int]) -> np.ndarray:
v = np.zeros(len(vocab), dtype=np.float32)
for w, c in Counter(tokenize(text)).items():
if w in vocab:
v[vocab[w]] = c
n = np.linalg.norm(v)
return v / n if n > 0 else v
def turn_texts(run_data: dict) -> list[str]:
"""Extract one text string per assistant turn (text + tool-call summary)."""
out = []
for m in run_data.get("transcript", {}).get("messages", []):
if m.get("role") != "assistant":
continue
parts = []
if m.get("text"):
parts.append(m["text"])
for tc in (m.get("tool_calls") or []):
name = tc.get("name", "")
args_str = json.dumps(tc.get("arguments", {}))[:200]
parts.append(f"{name} {args_str}")
if parts:
out.append(" ".join(parts))
return out
def trajectory_metrics(vecs: np.ndarray) -> dict:
"""Compute dynamical metrics over a (n_turns, d) trajectory matrix."""
n = vecs.shape[0]
if n < 2:
return {"n_turns": n, "drift_mean": 0.0, "from_start": 0.0,
"recurrence": 0.0, "vol_log": -12.0}
# Drift: consecutive distances
diffs = np.linalg.norm(np.diff(vecs, axis=0), axis=1)
drift_mean = float(diffs.mean())
# From start: max distance from turn 0
dists_from_0 = np.linalg.norm(vecs - vecs[0:1], axis=1)
from_start = float(dists_from_0.max())
# Recurrence: best non-adjacent cosine similarity (ignoring immediate neighbors)
recurrence = 0.0
for i in range(n):
for j in range(i + 2, n):
ni, nj = np.linalg.norm(vecs[i]), np.linalg.norm(vecs[j])
if ni > 0 and nj > 0:
c = float(vecs[i] @ vecs[j] / (ni * nj))
if c > recurrence:
recurrence = c
# Vol_log: log det of turn-state covariance
if n >= 3:
Sigma = np.cov(vecs.T)
# Use log|Σ + εI|; since d is large (500) we take eigenvalues + clip
eigs = np.linalg.eigvalsh(Sigma + 1e-6 * np.eye(vecs.shape[1], dtype=np.float32))
vol_log = float(np.log(np.clip(eigs, 1e-12, None)).sum())
else:
vol_log = -12.0
return {
"n_turns": n,
"drift_mean": drift_mean,
"from_start": from_start,
"recurrence": recurrence,
"vol_log": vol_log,
}
def classify(m: dict, thresholds: dict) -> str:
"""Classify based on quartile thresholds of the actual distribution.
Thresholds (set empirically from observed distribution):
drift_low = p25 drift_hi = p75
vol_low = p25 vol_hi = p75
rec_hi = p75
Rules (priority order):
n_turns < 3 → too_short
drift < drift_low AND vol < vol_low → trapped
rec > rec_hi AND drift < median → limit_cycle
drift > drift_hi AND vol > vol_hi → diffusive
else → mixed
"""
n = m["n_turns"]
if n < 3:
return "too_short"
d = m["drift_mean"]
rec = m["recurrence"]
vol = m["vol_log"]
if d < thresholds["drift_low"] and vol < thresholds["vol_low"]:
return "trapped"
if rec > thresholds["rec_hi"] and d < thresholds["drift_med"]:
return "limit_cycle"
if d > thresholds["drift_hi"] and vol > thresholds["vol_hi"]:
return "diffusive"
return "mixed"
def main() -> None:
# First pass: collect turn texts to build vocab
all_turn_texts: list[str] = []
run_turns: dict[tuple, list[str]] = {}
for model in MODELS:
for rf in (ARCH / model).rglob("run*.json"):
try:
d = json.loads(rf.read_text())
except Exception:
continue
task = rf.parent.name
run_idx = int(re.match(r"run(\d+)", rf.stem).group(1))
ts = turn_texts(d)
run_turns[(model, task, run_idx)] = ts
all_turn_texts.extend(ts)
vocab = build_vocab(all_turn_texts, top_k=500)
print(f"Runs collected: {len(run_turns)} vocab size: {len(vocab)}")
# Second pass: vectorize + compute metrics
per_run: dict[str, dict] = {}
for key, ts in run_turns.items():
model, task, run_idx = key
if not ts:
continue
vecs = np.stack([vectorize(t, vocab) for t in ts])
m = trajectory_metrics(vecs)
per_run[f"{model}/{task}/run{run_idx}"] = m
# Derive thresholds from actual distribution of n_turns>=3 runs
drifts = np.array([v["drift_mean"] for v in per_run.values() if v["n_turns"] >= 3])
recs = np.array([v["recurrence"] for v in per_run.values() if v["n_turns"] >= 3])
vols = np.array([v["vol_log"] for v in per_run.values() if v["n_turns"] >= 3])
thresholds = {
"drift_low": float(np.percentile(drifts, 25)),
"drift_med": float(np.percentile(drifts, 50)),
"drift_hi": float(np.percentile(drifts, 75)),
"vol_low": float(np.percentile(vols, 25)),
"vol_hi": float(np.percentile(vols, 75)),
"rec_hi": float(np.percentile(recs, 75)),
}
print(f"\nThresholds (quartile-based from observed distribution):")
for k, v in thresholds.items():
print(f" {k:<12} {v:>10.3f}")
# Apply classifier with thresholds
for key in per_run:
per_run[key]["regime"] = classify(per_run[key], thresholds)
# Summary by regime
counts = Counter(v["regime"] for v in per_run.values())
print(f"\nRegime distribution (n={len(per_run)} runs):")
for regime, n in counts.most_common():
print(f" {regime:<14} {n:>4} ({100*n/len(per_run):>4.1f}%)")
# Per-model regime breakdown
print(f"\n{'Model':<10} " + " ".join(f"{r:>11}" for r in ["too_short", "trapped", "limit_cycle", "diffusive", "mixed"]))
print("-" * 70)
pm_counts = defaultdict(Counter)
for key, v in per_run.items():
model = key.split("/")[0]
pm_counts[model][v["regime"]] += 1
for model in MODELS:
row = [f"{model.split('_')[-1][:9]:<10}"]
for r in ["too_short", "trapped", "limit_cycle", "diffusive", "mixed"]:
row.append(f"{pm_counts[model][r]:>11}")
print(" ".join(row))
# Write output
out = ROOT / "reports" / "regimes.json"
out.parent.mkdir(exist_ok=True)
out.write_text(json.dumps(per_run, indent=2))
print(f"\nWrote: {out}")
if __name__ == "__main__":
main()