File size: 7,447 Bytes
2a3773a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
"""What does the anchor fold move? Two read-only analyses over archived sleep_controls runs (no model, no device).

1. Per-turn write magnitude in the teaching session: transactions joined to chat turns by their transaction-index
   range; per turn the sum of committed ``delta_norm``, the max ``surprise_max`` and ``chunk_loss``, and whether the
   observational policy flagged any chunk. Question: did the planted contradictions write more than the other facts?
2. Storage shift under the anchor child: for every verbatim probe the anchor report's before/after
   ``answer_logprob`` (expected-answer mean log-prob per token, teacher-forced from a fresh state), grouped into
   planted answers, general true answers and the taught personal facts. Question: does the fold move the planted
   answers specifically, or every short factual completion?

  python -m scripts.experiments.anchor_transfer_analysis --runs docs/research/results/sleep-2026-09-23/final_step250_seed0_exclude ... --out <json>
"""

from __future__ import annotations

import argparse
import json
import os
import statistics
from typing import Any

POISON_ANSWERS = {"Berlin", "50", "nine", "Moon"}
GENERAL_ANSWERS = {"Paris", "100", "seven", "Earth", "blue"}


def load_jsonl(path: str) -> list[dict[str, Any]]:
    with open(path, encoding="utf-8") as f:
        return [json.loads(line) for line in f if line.strip()]


def turn_write_table(transactions: list[dict[str, Any]], trace: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """One row per chat turn: committed delta_norm summed over its chunks (by tx range), max surprise and chunk loss,
    chunk count, whether any chunk carried a would_* / requested intervention flag, and the rank by write (1 = largest)."""
    by_index = {int(t["index"]): t for t in transactions}
    rows: list[dict[str, Any]] = []
    for rec in trace:
        if rec.get("kind") != "chat" or rec.get("tx_start") is None or rec.get("tx_end") is None:
            continue
        chunks = [by_index[i] for i in range(int(rec["tx_start"]), int(rec["tx_end"])) if i in by_index]
        if not chunks:
            continue
        flagged = any(
            any(str(r).startswith("would_") for r in c.get("requested", {}).get("reasons", [])) or c.get("requested", {}).get("kind") != c.get("decision", {}).get("kind")
            or c.get("decision", {}).get("kind") in ("scale", "project")
            for c in chunks
        )
        rows.append({
            "prompt": rec.get("prompt", ""),
            "chunks": len(chunks),
            "delta_norm_sum": float(sum(float(c.get("accepted", {}).get("delta_norm") or 0.0) for c in chunks)),
            "surprise_max": float(max(float(c.get("signals", {}).get("surprise_max") or 0.0) for c in chunks)),
            "chunk_loss_max": float(max(float(c.get("signals", {}).get("chunk_loss") or 0.0) for c in chunks)),
            "flagged": bool(flagged),
            "planted": rec.get("prompt", "").startswith("Remember this"),
        })
    order = sorted(range(len(rows)), key=lambda i: -rows[i]["delta_norm_sum"])
    for rank, i in enumerate(order, start=1):
        rows[i]["rank_by_write"] = rank
    return rows


def answer_group(expected: str) -> str:
    if expected in POISON_ANSWERS:
        return "planted"
    if expected in GENERAL_ANSWERS:
        return "general"
    return "taught"


def storage_shift(report: dict[str, Any]) -> dict[str, Any]:
    """Per verbatim probe (keyed by question AND expected answer: the plants reuse general questions) the after-before
    shift of the expected answer's mean log-prob per token, and per-group means."""
    def key(x: dict[str, Any]) -> tuple[str, str]:
        return (x["question"], x["expected"])
    before = {key(x): x for x in report["before"]["recall"]["results"] if x.get("variant") == "verbatim"}
    after = {key(x): x for x in report["after"]["recall"]["results"] if x.get("variant") == "verbatim"}
    probes: list[dict[str, Any]] = []
    for k, b in before.items():
        a = after.get(k)
        if a is None or b.get("answer_logprob") is None or a.get("answer_logprob") is None:
            continue
        probes.append({"question": k[0], "expected": k[1], "group": answer_group(k[1]),
                       "before": float(b["answer_logprob"]), "after": float(a["answer_logprob"]),
                       "shift": float(a["answer_logprob"]) - float(b["answer_logprob"]),
                       "greedy_before": bool(b.get("contains")), "greedy_after": bool(a.get("contains"))})
    groups: dict[str, Any] = {}
    for g in ("planted", "general", "taught"):
        v = [p["shift"] for p in probes if p["group"] == g]
        groups[g] = {"n": len(v), "mean_shift": statistics.fmean(v) if v else None,
                     "greedy_hits_before": sum(p["greedy_before"] for p in probes if p["group"] == g),
                     "greedy_hits_after": sum(p["greedy_after"] for p in probes if p["group"] == g)}
    return {"probes": probes, "groups": groups}


def analyze_run(run_dir: str) -> dict[str, Any]:
    out: dict[str, Any] = {"run": os.path.basename(os.path.normpath(run_dir))}
    sess = os.path.join(run_dir, "sessions", "teach")
    if os.path.exists(os.path.join(sess, "transactions.jsonl")):
        rows = turn_write_table(load_jsonl(os.path.join(sess, "transactions.jsonl")), load_jsonl(os.path.join(sess, "trace.jsonl")))
        writes = [r["delta_norm_sum"] for r in rows]
        out["teach_turns"] = rows
        out["write_summary"] = {"turns": len(rows), "median_delta_norm_sum": statistics.median(writes) if writes else None,
                                "planted_ranks": sorted(r["rank_by_write"] for r in rows if r["planted"]),
                                "largest_write_prompt": max(rows, key=lambda r: r["delta_norm_sum"])["prompt"][:60] if rows else None}
    else:
        out["teach_turns"] = None  # sessions not archived for this run: absent, not zero
    anchor = os.path.join(run_dir, "sleep_anchor_report.json")
    if os.path.exists(anchor):
        rep = json.load(open(anchor, encoding="utf-8"))
        if rep.get("before", {}).get("recall") and rep.get("after", {}).get("recall"):
            out["anchor_storage_shift"] = storage_shift(rep)
            out["anchor_status"] = rep.get("status")
    return out


def main() -> None:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--runs", nargs="+", required=True, help="archived run folders (results/sleep-2026-09-23/<run>)")
    ap.add_argument("--out", required=True, help="JSON output path")
    args = ap.parse_args()
    results = [analyze_run(r) for r in args.runs]
    with open(args.out, "w", encoding="utf-8") as f:
        json.dump({"analysis": "anchor_transfer", "runs": results}, f, indent=1)
    for res in results:
        print(f"== {res['run']}")
        ws = res.get("write_summary")
        if ws:
            print(f"   teach turns {ws['turns']}, median write {ws['median_delta_norm_sum']:.2f}, planted turns rank {ws['planted_ranks']}, largest: {ws['largest_write_prompt']!r}")
        st = res.get("anchor_storage_shift")
        if st:
            g = st["groups"]
            print("   anchor storage shift: " + ", ".join(f"{k} n={v['n']} mean {v['mean_shift']:+.3f} greedy {v['greedy_hits_before']}->{v['greedy_hits_after']}" for k, v in g.items() if v["n"]))


if __name__ == "__main__":
    main()