FerrellSyntheticIntelligence commited on
Commit
97c39f2
·
verified ·
1 Parent(s): 76b78ee

backup all: 37 files (final)

Browse files
tests/test_posttrain.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for train/ties_merge.py and research/rlvr.py.
2
+
3
+ Run: .venv/bin/python tests/test_posttrain.py
4
+ """
5
+ import sys
6
+ from pathlib import Path
7
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
8
+
9
+ import torch
10
+
11
+ from train.ties_merge import ties_merge, trim_delta
12
+ from research.rlvr import reward, reward_card
13
+
14
+
15
+ def test_trim_keeps_topk():
16
+ d = torch.tensor([1.0, -0.5, 0.01, 0.001, 0.0005])
17
+ t = trim_delta(d, keep=0.4)
18
+ assert t[0] == 1.0 and t[1] == -0.5
19
+ assert t[2] == 0.0 and t[3] == 0.0
20
+
21
+
22
+ def test_ties_sign_consensus():
23
+ base = {"w": torch.zeros(4)}
24
+ t1 = {"w": torch.tensor([1.0, 1.0, 1.0, -1.0])}
25
+ t2 = {"w": torch.tensor([1.0, -1.0, 1.0, -1.0])}
26
+ out = ties_merge(base, [t1, t2], keep=1.0)
27
+ # sign agreement at idx 0, 2, 3 -> merge; idx 1 disagrees -> zero
28
+ assert out["w"][0] == 1.0
29
+ assert out["w"][2] == 1.0
30
+ assert out["w"][3] == -1.0
31
+ assert out["w"][1] == 0.0
32
+
33
+
34
+ def test_rlvr_reward_correct_with_citation():
35
+ r = reward(gold="refutes", policy="refutes", citation="1982",
36
+ evidence="the deed file states 1982")
37
+ assert r["verdict"] == 1.0 and r["citation"] == 0.2 and r["total"] == 1.2
38
+
39
+
40
+ def test_rlvr_reward_wrong_verdict():
41
+ r = reward(gold="refutes", policy="supports", citation="1982",
42
+ evidence="the deed file states 1982")
43
+ assert r["verdict"] == -1.0 and r["total"] == -0.8
44
+
45
+
46
+ def test_rlvr_reward_abstain_is_zero():
47
+ r = reward(gold="refutes", policy="not enough information",
48
+ citation="", evidence="the deed file states 1982")
49
+ assert r["verdict"] == 0.0 and r["total"] == 0.0
50
+
51
+
52
+ def test_rlvr_reward_false_citation_penalty():
53
+ r = reward(gold="supports", policy="supports", citation="1978",
54
+ evidence="the deed file states 1982")
55
+ assert r["verdict"] == 1.0 and r["citation"] == -0.2 and r["total"] == 0.8
56
+
57
+
58
+ def test_rlvr_card_trace():
59
+ card = reward_card(gold="refutes", policy="refutes", citation="1982",
60
+ evidence="the deed file states 1982", probe="rt05")
61
+ assert card["probe"] == "rt05" and card["total"] == 1.2
62
+ for k in ("gold", "policy", "verdict", "citation", "total"):
63
+ assert k in card
64
+
65
+
66
+ if __name__ == "__main__":
67
+ fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
68
+ for fn in fns:
69
+ fn()
70
+ print(f"ok {fn.__name__}")
71
+ print(f"\n{len(fns)} posttrain tests passed")
tests/test_verify_loop.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Standalone unit tests for research/verify_loop.py.
2
+
3
+ Run: .venv/bin/python tests/test_verify_loop.py
4
+ """
5
+ import sys
6
+ from pathlib import Path
7
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
8
+ from research.verify_loop import plan_checks, run_checks, verify_case
9
+
10
+
11
+ def _verified_sources():
12
+ return [
13
+ {"source_id": "dot", "url": "https://records.example/dot",
14
+ "retrieved_at": "2026-08-12T12:00:00Z", "content_sha256": "a" * 64,
15
+ "independent": True, "retrievable": True,
16
+ "triage": {"independence": 3, "proximity": 3, "recency": 2, "track": 3, "interest": 3}},
17
+ {"source_id": "archive", "url": "https://archive.example/bridge",
18
+ "retrieved_at": "2026-08-12T12:01:00Z", "content_sha256": "b" * 64,
19
+ "independent": True, "retrievable": True,
20
+ "triage": {"independence": 2, "proximity": 2, "recency": 2, "track": 2, "interest": 2}},
21
+ ]
22
+
23
+
24
+ def test_plan_checks_extracts_values():
25
+ checks = plan_checks('Claim: "the bridge opened in 2010" and cost $4.2M at 9:30am.')
26
+ kinds = [c["kind"] for c in checks]
27
+ assert "quote" in kinds and "number" in kinds and "time" in kinds
28
+ assert len(checks) <= 8
29
+
30
+
31
+ def test_run_checks_supports():
32
+ checks = [{"kind": "number", "value": "2010"}]
33
+ res = run_checks(checks, lambda v: "The DOT file lists the bridge opening year as 2010.")
34
+ assert res[0]["verdict"] == "supports"
35
+
36
+
37
+ def test_run_checks_refutes():
38
+ checks = [{"kind": "number", "value": "2010"}]
39
+ res = run_checks(checks, lambda v: "The DOT file lists the bridge opening year as 2012.")
40
+ assert res[0]["verdict"] == "refutes"
41
+
42
+
43
+ def test_run_checks_no_evidence():
44
+ checks = [{"kind": "quote", "value": "classified"}]
45
+ res = run_checks(checks, lambda v: "")
46
+ assert res[0]["verdict"] == "not enough information"
47
+ assert res[0]["kind"] == "no-evidence"
48
+
49
+
50
+ def test_verify_case_rule_refuted_wins():
51
+ d = verify_case('Claim: "the bridge opened in 2010"',
52
+ draft_verdict="true", draft_conf="HIGH",
53
+ retrieve=lambda v: "The DOT file lists the bridge opening year as 2012.",
54
+ require_source_policy=False)
55
+ assert d["verdict"] == "false"
56
+ assert d["confidence"] == "HIGH"
57
+ assert d["basis"] == "rule-refuted"
58
+
59
+
60
+ def test_verify_case_rule_verified():
61
+ d = verify_case('Claim: "the bridge opened in 2010"',
62
+ draft_verdict="true", draft_conf="MEDIUM",
63
+ retrieve=lambda v: "The DOT file lists the bridge opening year as 2010.",
64
+ require_source_policy=False)
65
+ assert d["verdict"] == "true"
66
+ assert d["confidence"] == "HIGH"
67
+ assert d["basis"] == "rule-verified"
68
+
69
+
70
+ def test_verify_case_unresolved_downgrades_high():
71
+ d = verify_case("Claim: the memo is significant.",
72
+ draft_verdict="overclaim", draft_conf="HIGH",
73
+ retrieve=lambda v: "", require_source_policy=False)
74
+ assert d["confidence"] == "MEDIUM"
75
+ assert d["basis"] == "draft-high-downgraded-unverified"
76
+
77
+
78
+ def test_verify_case_unresolved_keeps_low():
79
+ d = verify_case("Claim: the memo is significant.",
80
+ draft_verdict="not enough information", draft_conf="LOW",
81
+ retrieve=lambda v: "", require_source_policy=False)
82
+ assert d["verdict"] == "not enough information"
83
+ assert d["confidence"] == "LOW"
84
+ assert d["abstained"]
85
+
86
+
87
+ def test_strict_policy_overrides_an_incorrect_model_draft():
88
+ d = verify_case('Claim: "the bridge opened in 2010"',
89
+ draft_verdict="false", draft_conf="HIGH",
90
+ retrieve=lambda v: {
91
+ "evidence": "The DOT filing lists the bridge opening year as 2010.",
92
+ "sources": _verified_sources(), "claim_relation": "supports"},
93
+ require_source_policy=True)
94
+ assert d["verdict"] == "true"
95
+ assert d["confidence"] == "HIGH"
96
+ assert d["basis"] == "source-policy-verified"
97
+ assert set(d["sources"]) == {"dot", "archive"}
98
+
99
+
100
+ def test_strict_policy_fails_closed_on_a_single_untraceable_lead():
101
+ d = verify_case('Claim: "the bridge opened in 2010"',
102
+ draft_verdict="true", draft_conf="HIGH",
103
+ retrieve=lambda v: {
104
+ "evidence": "An anonymous post says the bridge opened in 2010.",
105
+ "sources": [{"source_id": "forum", "url": "https://forum.example/post",
106
+ "retrieved_at": "", "content_sha256": "bad",
107
+ "independent": True, "retrievable": True,
108
+ "triage": {"independence": 1, "proximity": 0,
109
+ "recency": 1, "track": 0, "interest": 0}}],
110
+ "claim_relation": "supports"},
111
+ require_source_policy=True)
112
+ assert d["verdict"] == "not enough information"
113
+ assert d["confidence"] == "LOW"
114
+ assert d["abstained"]
115
+
116
+
117
+ def test_verify_case_defaults_to_fail_closed_source_policy():
118
+ d = verify_case('Claim: "the bridge opened in 2010"',
119
+ draft_verdict="true", draft_conf="HIGH",
120
+ retrieve=lambda v: "A copied page says the bridge opened in 2010.")
121
+ assert d["verdict"] == "not enough information"
122
+ assert d["confidence"] == "LOW"
123
+ assert d["abstained"]
124
+
125
+
126
+ if __name__ == "__main__":
127
+ fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
128
+ for fn in fns:
129
+ fn()
130
+ print(f"PASS {fn.__name__}")
131
+ print(f"\n{len(fns)} tests passed")
train/chain_dpo_v26_after_sft.sh ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Chains LFM2 DPO v26 after SFT v26 completes (one heavy torch job at a time).
3
+ # Polls for ckpt/hybrid50m_v26_lora/model_final.pt, then launches DPO v26.
4
+ # Launch detached: setsid nohup ./train/chain_dpo_v26_after_sft.sh >/dev/null 2>&1 </dev/null & disown
5
+ set -u
6
+ cd "$(dirname "$0")/.."
7
+ LOG=logs/chain_dpo_v26.log
8
+ echo "=== chain start $(date '+%Y-%m-%d %H:%M:%S') ===" >> "$LOG"
9
+
10
+ for i in $(seq 1 720); do
11
+ if [ -f "ckpt/hybrid50m_v26_lora/model_final.pt" ]; then
12
+ echo "=== SFT v26 complete at $(date '+%H:%M:%S'); launching DPO v26 ===" >> "$LOG"
13
+ setsid nohup ./train/watchdog_dpo_v26.sh >/dev/null 2>&1 </dev/null & disown
14
+ echo "DPO v26 watchdog launched" >> "$LOG"
15
+ exit 0
16
+ fi
17
+ sleep 60
18
+ done
19
+ echo "=== chain timeout after 12h; SFT v26 not complete ===" >> "$LOG"
20
+ exit 1
train/chain_post_sft_v22.sh ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Chains SFT(final) -> DPO -> eval gate, all detached/self-healing.
3
+ # Does NOT merge or release (gate numbers are reported for the owner to decide).
4
+ set -u
5
+ cd "$(dirname "$0")/.."
6
+ export PYTHONPATH=$PWD MALLOC_ARENA_MAX=2
7
+ SFT_CKPT=ckpt/hybrid50m_v22_lora
8
+ DPO_CKPT=ckpt/hybrid50m_v22_dpo
9
+ CL=logs/chain_v22.log
10
+ echo "[chain $(date '+%H:%M:%S')] waiting for SFT final" >> "$CL"
11
+ while [ ! -f "$SFT_CKPT/model_final.pt" ]; do
12
+ pgrep -f watchdog_lora_v22 >/dev/null || setsid nohup ./train/watchdog_lora_v22.sh >/dev/null 2>&1 </dev/null &
13
+ sleep 30
14
+ done
15
+ echo "[chain $(date '+%H:%M:%S')] SFT done -> launching DPO" >> "$CL"
16
+ setsid nohup ./train/watchdog_dpo_v22.sh >/dev/null 2>&1 </dev/null &
17
+ while [ ! -f "$DPO_CKPT/dpo_complete.json" ]; do
18
+ if [ -f "$DPO_CKPT/dpo_failed.json" ]; then
19
+ echo "[chain $(date '+%H:%M:%S')] DPO failed; eval is blocked" >> "$CL"
20
+ exit 3
21
+ fi
22
+ pgrep -f watchdog_dpo_v22 >/dev/null || setsid nohup ./train/watchdog_dpo_v22.sh >/dev/null 2>&1 </dev/null &
23
+ sleep 30
24
+ done
25
+ echo "[chain $(date '+%H:%M:%S')] DPO done -> eval gate" >> "$CL"
26
+ ./stage_eval_50m.sh "$DPO_CKPT" >> "$CL" 2>&1
27
+ echo "[chain $(date '+%H:%M:%S')] eval complete (no merge/release)" >> "$CL"
train/chain_sft_v26_after_evals.sh ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Chains SFT v26 after the merge-candidate main-battery evals finish
3
+ # (one heavy torch job at a time). Polls every 60s for all three
4
+ # main-battery .done markers, then launches the SFT v26 watchdog.
5
+ # Launch detached: setsid nohup ./train/chain_sft_v26_after_evals.sh >/dev/null 2>&1 </dev/null & disown
6
+ set -u
7
+ cd "$(dirname "$0")/.."
8
+ LOG=logs/chain_sft_v26.log
9
+ echo "=== chain start $(date '+%Y-%m-%d %H:%M:%S') ===" >> "$LOG"
10
+
11
+ for i in $(seq 1 600); do
12
+ done_count=0
13
+ for c in soup taskarith ties; do
14
+ if [ -f "logs/eval_cand_merge_${c}_main.done" ]; then
15
+ done_count=$((done_count+1))
16
+ fi
17
+ done
18
+ if [ "$done_count" -ge 3 ]; then
19
+ echo "=== all merge evals done at $(date '+%H:%M:%S'); launching SFT v26 ===" >> "$LOG"
20
+ setsid nohup ./train/watchdog_lora_v26.sh >/dev/null 2>&1 </dev/null & disown
21
+ echo "SFT v26 watchdog launched" >> "$LOG"
22
+ exit 0
23
+ fi
24
+ sleep 60
25
+ done
26
+ echo "=== chain timeout after 10h; merge evals not all done ===" >> "$LOG"
27
+ exit 1
train/grow_weights.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Grow the pretrained 7.8M baseline. Verified path: DEPTH (identity-init new blocks).
2
+
3
+ Per skill tiny-model-phase2 (measured Aug 2026 on this tablet):
4
+ - width upscaling 320->512 does NOT transfer (val loss 2.58 -> 6.1-7.7) because
5
+ RMSNorm/rope/groupnorm/recurrence all depend on d_model.
6
+ - depth growth with identity blocks DOES preserve baseline exactly (2.567 vs 2.578).
7
+
8
+ Usage:
9
+ PYTHONPATH=$PWD .venv/bin/python train/grow_weights.py \
10
+ --base ckpt/nlp_full --config tiny13m --ckpt ckpt/tiny13m_grown --verify
11
+ """
12
+ import argparse
13
+ import copy
14
+ import random
15
+
16
+ import torch
17
+ import torch.nn.functional as F
18
+
19
+ from model.config import TinyLiquidConfig, CONFIGS
20
+ from model.tiny_liquid import TinyLiquid
21
+ from model.utils import latest_ckpt
22
+ from data.tokenizer import load_tokenizer
23
+
24
+ STD = 0.02
25
+ NEW_BLOCK_SCALE = 0.1
26
+
27
+
28
+ def pad_noise(old, rows_new=None, cols_new=None):
29
+ old = old.float()
30
+ r0, c0 = old.shape
31
+ rn = rows_new if rows_new is not None else r0
32
+ cn = cols_new if cols_new is not None else c0
33
+ out = torch.empty(rn, cn)
34
+ out.fill_(0.0)
35
+ out[:r0, :c0] = old
36
+ mask = torch.ones_like(out, dtype=torch.bool)
37
+ mask[:r0, :c0] = False
38
+ out[mask] = torch.normal(0.0, STD, size=(int(mask.sum()),))
39
+ return out
40
+
41
+
42
+ def grow_block_width(block, d_new, basis_rows_new, h_new):
43
+ nb = copy.deepcopy(block)
44
+ nb["norm1.weight"] = torch.cat([block["norm1.weight"].float(),
45
+ torch.ones(d_new - block["norm1.weight"].shape[0])])
46
+ nb["basis.w"] = pad_noise(block["basis.w"], rows_new=basis_rows_new, cols_new=d_new)
47
+ nb["basis.w_forget"] = pad_noise(block["basis.w_forget"], rows_new=basis_rows_new, cols_new=d_new)
48
+ nb["basis.gn.weight"] = torch.cat([block["basis.gn.weight"].float(),
49
+ torch.ones(basis_rows_new - block["basis.gn.weight"].shape[0])])
50
+ nb["basis.gn.bias"] = torch.cat([block["basis.gn.bias"].float(),
51
+ torch.zeros(basis_rows_new - block["basis.gn.bias"].shape[0])])
52
+ nb["norm2.weight"] = torch.cat([block["norm2.weight"].float(),
53
+ torch.ones(d_new - block["norm2.weight"].shape[0])])
54
+ nb["mlp.up.weight"] = pad_noise(block["mlp.up.weight"], rows_new=h_new, cols_new=d_new)
55
+ nb["mlp.gate.weight"] = pad_noise(block["mlp.gate.weight"], rows_new=h_new, cols_new=d_new)
56
+ nb["mlp.forget.weight"] = pad_noise(block["mlp.forget.weight"], rows_new=h_new, cols_new=d_new)
57
+ nb["mlp.down.weight"] = pad_noise(block["mlp.down.weight"], rows_new=d_new, cols_new=h_new)
58
+ return nb
59
+
60
+
61
+ def identity_block(cfg, prefix):
62
+ """A block that is an exact identity at init (output == input)."""
63
+ d = cfg.d_model
64
+ e = cfg.basis_n * cfg.basis_b
65
+ h = cfg.mlp_ratio * d
66
+ sd = {}
67
+ sd[f"{prefix}.norm1.weight"] = torch.ones(d)
68
+ sd[f"{prefix}.basis.w"] = torch.zeros(e, d)
69
+ sd[f"{prefix}.basis.w_forget"] = torch.zeros(e, d)
70
+ sd[f"{prefix}.basis.gn.weight"] = torch.ones(e)
71
+ sd[f"{prefix}.basis.gn.bias"] = torch.zeros(e)
72
+ sd[f"{prefix}.norm2.weight"] = torch.ones(d)
73
+ sd[f"{prefix}.mlp.up.weight"] = torch.zeros(h, d)
74
+ sd[f"{prefix}.mlp.gate.weight"] = torch.zeros(h, d)
75
+ sd[f"{prefix}.mlp.forget.weight"] = torch.zeros(h, d)
76
+ sd[f"{prefix}.mlp.down.weight"] = torch.zeros(d, h)
77
+ return sd
78
+
79
+
80
+ def grow_depth(sd_old, cfg_old, cfg_new):
81
+ """Copy trunk exactly; append identity blocks. Baseline loss preserved."""
82
+ assert cfg_old.d_model == cfg_new.d_model
83
+ assert cfg_old.basis_n == cfg_new.basis_n and cfg_old.basis_b == cfg_new.basis_b
84
+ assert cfg_old.mlp_ratio == cfg_new.mlp_ratio
85
+ assert cfg_new.n_blocks >= cfg_old.n_blocks
86
+ grown = {}
87
+ for k, v in sd_old["model"].items():
88
+ grown[k] = v.clone()
89
+ for j in range(cfg_old.n_blocks, cfg_new.n_blocks):
90
+ grown.update(identity_block(cfg_new, f"blocks.{j}"))
91
+ return grown
92
+
93
+
94
+ def grow_width(sd_old, cfg_old, cfg_new):
95
+ """Width upscaling -- EXPERIMENTAL, does NOT transfer on this architecture."""
96
+ old = sd_old["model"]
97
+ d_old, d_new = cfg_old.d_model, cfg_new.d_model
98
+ basis_old = cfg_old.basis_n * cfg_old.basis_b
99
+ basis_new = cfg_new.basis_n * cfg_new.basis_b
100
+ h_old, h_new = cfg_old.mlp_ratio * d_old, cfg_new.mlp_ratio * d_new
101
+ grown = {}
102
+ grown["tok_emb.weight"] = pad_noise(old["tok_emb.weight"], cols_new=d_new)
103
+ grown["persona_emb.weight"] = pad_noise(old["persona_emb.weight"], cols_new=d_new)
104
+ grown["norm_out.weight"] = torch.cat([old["norm_out.weight"].float(),
105
+ torch.ones(d_new - d_old)])
106
+ for i in range(cfg_old.n_blocks):
107
+ block = {k[len(f"blocks.{i}."):]: v for k, v in old.items() if k.startswith(f"blocks.{i}.")}
108
+ gb = grow_block_width(block, d_new, basis_new, h_new)
109
+ for k, v in gb.items():
110
+ grown[f"blocks.{i}.{k}"] = v
111
+ last = {k[len(f"blocks.{cfg_old.n_blocks-1}."):]: v for k, v in old.items()
112
+ if k.startswith(f"blocks.{cfg_old.n_blocks-1}.")}
113
+ seed = grow_block_width(last, d_new, basis_new, h_new)
114
+ for j in range(cfg_old.n_blocks, cfg_new.n_blocks):
115
+ for k, v in seed.items():
116
+ grown[f"blocks.{j}.{k}"] = (v * NEW_BLOCK_SCALE).clone()
117
+ return grown
118
+
119
+
120
+ @torch.no_grad()
121
+ def val_loss(model, tok, data_path, batch=16, seq=256, n_batches=20):
122
+ import numpy as np
123
+ tokens = torch.from_numpy(np.fromfile(data_path, dtype="uint16")).long()
124
+ rng = random.Random(42)
125
+ total = 0.0
126
+ for _ in range(n_batches):
127
+ pos = rng.randrange(0, max(1, len(tokens) - seq - 1))
128
+ x = tokens[pos:pos + batch * seq].view(batch, seq)
129
+ y = tokens[pos + 1:pos + 1 + batch * seq].view(batch, seq)
130
+ logits = model(x)
131
+ total += F.cross_entropy(logits.reshape(-1, logits.size(-1)), y.reshape(-1)).item()
132
+ return total / n_batches
133
+
134
+
135
+ def main():
136
+ ap = argparse.ArgumentParser()
137
+ ap.add_argument("--base", default="ckpt/nlp_full")
138
+ ap.add_argument("--config", default="tiny13m")
139
+ ap.add_argument("--mode", choices=["depth", "width", "tower"], default="depth")
140
+ ap.add_argument("--ckpt", default="ckpt/tiny13m_grown")
141
+ ap.add_argument("--verify", action="store_true")
142
+ ap.add_argument("--threads", type=int, default=4)
143
+ args = ap.parse_args()
144
+
145
+ torch.set_num_threads(args.threads)
146
+ tok = load_tokenizer("data/tokenizer.json")
147
+ cfg_new = TinyLiquidConfig(vocab_size=tok.get_vocab_size(), **CONFIGS[args.config])
148
+
149
+ base_path = latest_ckpt(args.base)
150
+ assert base_path, f"no checkpoint in {args.base}"
151
+ sd = torch.load(base_path, map_location="cpu")
152
+ cfg_old = TinyLiquidConfig(vocab_size=tok.get_vocab_size(),
153
+ **{k: v for k, v in sd["config"].items() if k != "vocab_size"})
154
+ print(f"base: {base_path} {cfg_old.params_estimate()/1e6:.2f}M -> {cfg_new.params_estimate()/1e6:.2f}M ({args.mode})")
155
+
156
+ if args.mode == "depth":
157
+ grown = grow_depth(sd, cfg_old, cfg_new)
158
+ elif args.mode == "width":
159
+ grown = grow_width(sd, cfg_old, cfg_new)
160
+ else:
161
+ # tower: keep the trained trunk, add identity-init wide tower.
162
+ # If tower_d changed, handle by loading trunk + padding tower projection.
163
+ assert cfg_new.tower_d and cfg_new.tower_blocks, "tower mode needs tower_d/tower_blocks in config"
164
+ model_old = TinyLiquid(cfg_old)
165
+ model_old.load_state_dict(sd["model"])
166
+ model_old.eval()
167
+ model = TinyLiquid(cfg_new)
168
+ old_sd = sd["model"]
169
+ new_sd = dict(model.state_dict())
170
+ for k, v in old_sd.items():
171
+ if not k.startswith("up_proj") and not k.startswith("down_proj") and not k.startswith("tower."):
172
+ if k in new_sd:
173
+ new_sd[k] = v.clone()
174
+ else:
175
+ print(f"WARNING: {k} in old but not in new model")
176
+ if "up_proj" in old_sd:
177
+ old_up = old_sd["up_proj"]; old_down = old_sd["down_proj"]
178
+ new_up = new_sd["up_proj"]; new_down = new_sd["down_proj"]
179
+ if old_up.shape == new_up.shape:
180
+ new_sd["up_proj"] = old_up.clone()
181
+ new_sd["down_proj"] = old_down.clone()
182
+ else:
183
+ new_sd["up_proj"] = new_sd["up_proj"].clone()
184
+ new_sd["down_proj"] = new_sd["down_proj"].clone()
185
+ min_td = min(old_up.shape[0], new_up.shape[0])
186
+ min_d = min(old_up.shape[1], new_up.shape[1])
187
+ new_sd["up_proj"][:min_td, :min_d] = old_up[:min_td, :min_d]
188
+ min_d2 = min(old_down.shape[0], new_down.shape[0])
189
+ min_td2 = min(old_down.shape[1], new_down.shape[1])
190
+ new_sd["down_proj"][:min_d2, :min_td2] = old_down[:min_d2, :min_td2]
191
+ grown = new_sd
192
+
193
+ model = TinyLiquid(cfg_new)
194
+ missing, unexpected = model.load_state_dict(grown, strict=False)
195
+ assert not missing and not unexpected, (missing, unexpected)
196
+
197
+ if args.verify:
198
+ base_model = TinyLiquid(cfg_old)
199
+ base_model.load_state_dict(sd["model"])
200
+ base_model.eval(); model.eval()
201
+ print(f"baseline val loss (20 bat): {val_loss(base_model, tok, 'data/valid.bin', n_batches=20):.4f}")
202
+ print(f"grown val loss (20 bat): {val_loss(model, tok, 'data/valid.bin', n_batches=20):.4f}")
203
+
204
+ out_dir = __import__("pathlib").Path(args.ckpt)
205
+ out_dir.mkdir(parents=True, exist_ok=True)
206
+ out = out_dir / ("model_final.pt" if args.mode == "tower" else "model_grown.pt")
207
+ torch.save({"config": cfg_new.__dict__, "model": model.state_dict(),
208
+ "step": 0, "best_val": float("inf")}, out)
209
+ print("saved", out)
210
+
211
+
212
+ if __name__ == "__main__":
213
+ main()
train/grow_weights.py.bak ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Grow the pretrained 7.8M baseline. Verified path: DEPTH (identity-init new blocks).
2
+
3
+ Per skill tiny-model-phase2 (measured Aug 2026 on this tablet):
4
+ - width upscaling 320->512 does NOT transfer (val loss 2.58 -> 6.1-7.7) because
5
+ RMSNorm/rope/groupnorm/recurrence all depend on d_model.
6
+ - depth growth with identity blocks DOES preserve baseline exactly (2.567 vs 2.578).
7
+
8
+ Usage:
9
+ PYTHONPATH=$PWD .venv/bin/python train/grow_weights.py \
10
+ --base ckpt/nlp_full --config tiny13m --ckpt ckpt/tiny13m_grown --verify
11
+ """
12
+ import argparse
13
+ import copy
14
+ import random
15
+
16
+ import torch
17
+ import torch.nn.functional as F
18
+
19
+ from model.config import TinyLiquidConfig, CONFIGS
20
+ from model.tiny_liquid import TinyLiquid
21
+ from model.utils import latest_ckpt
22
+ from data.tokenizer import load_tokenizer
23
+
24
+ STD = 0.02
25
+ NEW_BLOCK_SCALE = 0.1
26
+
27
+
28
+ def pad_noise(old, rows_new=None, cols_new=None):
29
+ old = old.float()
30
+ r0, c0 = old.shape
31
+ rn = rows_new if rows_new is not None else r0
32
+ cn = cols_new if cols_new is not None else c0
33
+ out = torch.empty(rn, cn)
34
+ out.fill_(0.0)
35
+ out[:r0, :c0] = old
36
+ mask = torch.ones_like(out, dtype=torch.bool)
37
+ mask[:r0, :c0] = False
38
+ out[mask] = torch.normal(0.0, STD, size=(int(mask.sum()),))
39
+ return out
40
+
41
+
42
+ def grow_block_width(block, d_new, basis_rows_new, h_new):
43
+ nb = copy.deepcopy(block)
44
+ nb["norm1.weight"] = torch.cat([block["norm1.weight"].float(),
45
+ torch.ones(d_new - block["norm1.weight"].shape[0])])
46
+ nb["basis.w"] = pad_noise(block["basis.w"], rows_new=basis_rows_new, cols_new=d_new)
47
+ nb["basis.w_forget"] = pad_noise(block["basis.w_forget"], rows_new=basis_rows_new, cols_new=d_new)
48
+ nb["basis.gn.weight"] = torch.cat([block["basis.gn.weight"].float(),
49
+ torch.ones(basis_rows_new - block["basis.gn.weight"].shape[0])])
50
+ nb["basis.gn.bias"] = torch.cat([block["basis.gn.bias"].float(),
51
+ torch.zeros(basis_rows_new - block["basis.gn.bias"].shape[0])])
52
+ nb["norm2.weight"] = torch.cat([block["norm2.weight"].float(),
53
+ torch.ones(d_new - block["norm2.weight"].shape[0])])
54
+ nb["mlp.up.weight"] = pad_noise(block["mlp.up.weight"], rows_new=h_new, cols_new=d_new)
55
+ nb["mlp.gate.weight"] = pad_noise(block["mlp.gate.weight"], rows_new=h_new, cols_new=d_new)
56
+ nb["mlp.forget.weight"] = pad_noise(block["mlp.forget.weight"], rows_new=h_new, cols_new=d_new)
57
+ nb["mlp.down.weight"] = pad_noise(block["mlp.down.weight"], rows_new=d_new, cols_new=h_new)
58
+ return nb
59
+
60
+
61
+ def identity_block(cfg, prefix):
62
+ """A block that is an exact identity at init (output == input)."""
63
+ d = cfg.d_model
64
+ e = cfg.basis_n * cfg.basis_b
65
+ h = cfg.mlp_ratio * d
66
+ sd = {}
67
+ sd[f"{prefix}.norm1.weight"] = torch.ones(d)
68
+ sd[f"{prefix}.basis.w"] = torch.zeros(e, d)
69
+ sd[f"{prefix}.basis.w_forget"] = torch.zeros(e, d)
70
+ sd[f"{prefix}.basis.gn.weight"] = torch.ones(e)
71
+ sd[f"{prefix}.basis.gn.bias"] = torch.zeros(e)
72
+ sd[f"{prefix}.norm2.weight"] = torch.ones(d)
73
+ sd[f"{prefix}.mlp.up.weight"] = torch.zeros(h, d)
74
+ sd[f"{prefix}.mlp.gate.weight"] = torch.zeros(h, d)
75
+ sd[f"{prefix}.mlp.forget.weight"] = torch.zeros(h, d)
76
+ sd[f"{prefix}.mlp.down.weight"] = torch.zeros(d, h)
77
+ return sd
78
+
79
+
80
+ def grow_depth(sd_old, cfg_old, cfg_new):
81
+ """Copy trunk exactly; append identity blocks. Baseline loss preserved."""
82
+ assert cfg_old.d_model == cfg_new.d_model
83
+ assert cfg_old.basis_n == cfg_new.basis_n and cfg_old.basis_b == cfg_new.basis_b
84
+ assert cfg_old.mlp_ratio == cfg_new.mlp_ratio
85
+ assert cfg_new.n_blocks >= cfg_old.n_blocks
86
+ grown = {}
87
+ for k, v in sd_old["model"].items():
88
+ grown[k] = v.clone()
89
+ for j in range(cfg_old.n_blocks, cfg_new.n_blocks):
90
+ grown.update(identity_block(cfg_new, f"blocks.{j}"))
91
+ return grown
92
+
93
+
94
+ def grow_width(sd_old, cfg_old, cfg_new):
95
+ """Width upscaling -- EXPERIMENTAL, does NOT transfer on this architecture."""
96
+ old = sd_old["model"]
97
+ d_old, d_new = cfg_old.d_model, cfg_new.d_model
98
+ basis_old = cfg_old.basis_n * cfg_old.basis_b
99
+ basis_new = cfg_new.basis_n * cfg_new.basis_b
100
+ h_old, h_new = cfg_old.mlp_ratio * d_old, cfg_new.mlp_ratio * d_new
101
+ grown = {}
102
+ grown["tok_emb.weight"] = pad_noise(old["tok_emb.weight"], cols_new=d_new)
103
+ grown["persona_emb.weight"] = pad_noise(old["persona_emb.weight"], cols_new=d_new)
104
+ grown["norm_out.weight"] = torch.cat([old["norm_out.weight"].float(),
105
+ torch.ones(d_new - d_old)])
106
+ for i in range(cfg_old.n_blocks):
107
+ block = {k[len(f"blocks.{i}."):]: v for k, v in old.items() if k.startswith(f"blocks.{i}.")}
108
+ gb = grow_block_width(block, d_new, basis_new, h_new)
109
+ for k, v in gb.items():
110
+ grown[f"blocks.{i}.{k}"] = v
111
+ last = {k[len(f"blocks.{cfg_old.n_blocks-1}."):]: v for k, v in old.items()
112
+ if k.startswith(f"blocks.{cfg_old.n_blocks-1}.")}
113
+ seed = grow_block_width(last, d_new, basis_new, h_new)
114
+ for j in range(cfg_old.n_blocks, cfg_new.n_blocks):
115
+ for k, v in seed.items():
116
+ grown[f"blocks.{j}.{k}"] = (v * NEW_BLOCK_SCALE).clone()
117
+ return grown
118
+
119
+
120
+ @torch.no_grad()
121
+ def val_loss(model, tok, data_path, batch=16, seq=256, n_batches=20):
122
+ import numpy as np
123
+ tokens = torch.from_numpy(np.fromfile(data_path, dtype="uint16")).long()
124
+ rng = random.Random(42)
125
+ total = 0.0
126
+ for _ in range(n_batches):
127
+ pos = rng.randrange(0, max(1, len(tokens) - seq - 1))
128
+ x = tokens[pos:pos + batch * seq].view(batch, seq)
129
+ y = tokens[pos + 1:pos + 1 + batch * seq].view(batch, seq)
130
+ logits = model(x)
131
+ total += F.cross_entropy(logits.reshape(-1, logits.size(-1)), y.reshape(-1)).item()
132
+ return total / n_batches
133
+
134
+
135
+ def main():
136
+ ap = argparse.ArgumentParser()
137
+ ap.add_argument("--base", default="ckpt/nlp_full")
138
+ ap.add_argument("--config", default="tiny13m")
139
+ ap.add_argument("--mode", choices=["depth", "width", "tower"], default="depth")
140
+ ap.add_argument("--ckpt", default="ckpt/tiny13m_grown")
141
+ ap.add_argument("--verify", action="store_true")
142
+ ap.add_argument("--threads", type=int, default=4)
143
+ args = ap.parse_args()
144
+
145
+ torch.set_num_threads(args.threads)
146
+ tok = load_tokenizer("data/tokenizer.json")
147
+ cfg_new = TinyLiquidConfig(vocab_size=tok.get_vocab_size(), **CONFIGS[args.config])
148
+
149
+ base_path = latest_ckpt(args.base)
150
+ assert base_path, f"no checkpoint in {args.base}"
151
+ sd = torch.load(base_path, map_location="cpu")
152
+ cfg_old = TinyLiquidConfig(vocab_size=tok.get_vocab_size(),
153
+ **{k: v for k, v in sd["config"].items() if k != "vocab_size"})
154
+ print(f"base: {base_path} {cfg_old.params_estimate()/1e6:.2f}M -> {cfg_new.params_estimate()/1e6:.2f}M ({args.mode})")
155
+
156
+ if args.mode == "depth":
157
+ grown = grow_depth(sd, cfg_old, cfg_new)
158
+ elif args.mode == "width":
159
+ grown = grow_width(sd, cfg_old, cfg_new)
160
+ else:
161
+ # tower: keep the trained trunk, add identity-init wide tower (up=identity,
162
+ # tower blocks identity, down=zero) => baseline output preserved exactly.
163
+ assert cfg_new.tower_d and cfg_new.tower_blocks, "tower mode needs tower_d/tower_blocks in config"
164
+ model = TinyLiquid(cfg_new)
165
+ miss, unexp = model.load_state_dict(sd["model"], strict=False)
166
+ assert not unexp, unexp
167
+ assert all("tower" in k or "up_proj" in k or "down_proj" in k for k in miss), miss
168
+ grown = model.state_dict()
169
+
170
+ model = TinyLiquid(cfg_new)
171
+ missing, unexpected = model.load_state_dict(grown, strict=False)
172
+ assert not missing and not unexpected, (missing, unexpected)
173
+
174
+ if args.verify:
175
+ base_model = TinyLiquid(cfg_old)
176
+ base_model.load_state_dict(sd["model"])
177
+ base_model.eval(); model.eval()
178
+ print(f"baseline val loss (20 bat): {val_loss(base_model, tok, 'data/valid.bin', n_batches=20):.4f}")
179
+ print(f"grown val loss (20 bat): {val_loss(model, tok, 'data/valid.bin', n_batches=20):.4f}")
180
+
181
+ out_dir = __import__("pathlib").Path(args.ckpt)
182
+ out_dir.mkdir(parents=True, exist_ok=True)
183
+ out = out_dir / ("model_final.pt" if args.mode == "tower" else "model_grown.pt")
184
+ torch.save({"config": cfg_new.__dict__, "model": model.state_dict(),
185
+ "step": 0, "best_val": float("inf")}, out)
186
+ print("saved", out)
187
+
188
+
189
+ if __name__ == "__main__":
190
+ main()
train/map_vocab.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Expand tokenizer vocab on a trained checkpoint (8k -> 16k), baseline-preserving.
2
+
3
+ The lm_head is TIED (TinyLiquid cfg.tie_embeddings=True), so tok_emb.weight
4
+ is the ONLY vocab-sized tensor. We map old token ids -> new token ids by
5
+ DECODED-TEXT EXACT MATCH: old rows whose text exists as a single new token are
6
+ copied exactly (their behavior is preserved); others fall back to the first
7
+ token of the re-encoded text or deterministic noise (std 0.02).
8
+
9
+ Usage:
10
+ .venv/bin/python train/map_vocab.py \
11
+ --base ckpt/hybrid50m_pretrain --old-tok data/tokenizer.json \
12
+ --new-tok data/tokenizer16k.json --config hybrid50m \
13
+ --out ckpt/hybrid50m_v16k_init.pt
14
+ """
15
+ import argparse
16
+ import random
17
+ from pathlib import Path
18
+
19
+ import torch
20
+ import torch.nn.functional as F
21
+
22
+ from model.config import TinyLiquidConfig
23
+ from model.tiny_liquid import TinyLiquid
24
+ from model.utils import latest_ckpt
25
+ from data.tokenizer import load_tokenizer, SPECIAL_TOKENS
26
+
27
+ STD = 0.02
28
+
29
+
30
+ def build_mapping(old_tok, new_tok):
31
+ """Return (old_id->new_id|None dict, set of exact-match old ids).
32
+
33
+ Special tokens map by index (identical set, identical order, identical
34
+ ids in the BPE trainer). Regular tokens map by decoded-text exact match,
35
+ else by the first new token of the re-encoded text.
36
+ """
37
+ old_vocab, new_vocab = old_tok.get_vocab_size(), new_tok.get_vocab_size()
38
+ new_text_ids = {}
39
+ for j in range(new_vocab):
40
+ t = new_tok.decode([j])
41
+ if t and t not in new_text_ids:
42
+ new_text_ids[t] = j
43
+
44
+ mapping, exact_ids = {}, set()
45
+ for i in range(old_vocab):
46
+ if i < len(SPECIAL_TOKENS):
47
+ mapping[i] = i
48
+ exact_ids.add(i)
49
+ continue
50
+ text = old_tok.decode([i])
51
+ if not text:
52
+ mapping[i] = None
53
+ continue
54
+ exact = new_text_ids.get(text)
55
+ if exact is not None:
56
+ mapping[i] = exact
57
+ exact_ids.add(i)
58
+ continue
59
+ ids = new_tok.encode(text).ids
60
+ first = [j for j in ids if j >= len(SPECIAL_TOKENS)]
61
+ mapping[i] = first[0] if first else None
62
+ return mapping, exact_ids
63
+
64
+
65
+ def expand_checkpoint(sd, cfg_new, mapping, exact_ids, seed=42):
66
+ """Copy every weight; expand tok_emb rows (exact copy -> fallback -> noise)."""
67
+ new_model = TinyLiquid(cfg_new)
68
+ new_sd = dict(new_model.state_dict())
69
+ for k, v in sd["model"].items():
70
+ if k == "tok_emb.weight":
71
+ continue
72
+ assert k in new_sd, f"key {k} missing in new model"
73
+ new_sd[k] = v.clone()
74
+
75
+ g = torch.Generator().manual_seed(seed)
76
+ old_emb = sd["model"]["tok_emb.weight"].float()
77
+ emb = torch.empty(cfg_new.vocab_size, old_emb.shape[1])
78
+ emb.normal_(0.0, STD, generator=g)
79
+ exact = partial = noise = 0
80
+ for i in range(old_emb.shape[0]):
81
+ tgt = mapping.get(i)
82
+ if tgt is None:
83
+ noise += 1
84
+ continue
85
+ emb[tgt] = old_emb[i]
86
+ if i in exact_ids:
87
+ exact += 1
88
+ else:
89
+ partial += 1
90
+ new_sd["tok_emb.weight"] = emb
91
+
92
+ model = TinyLiquid(cfg_new)
93
+ missing, unexpected = model.load_state_dict(new_sd, strict=True)
94
+ assert not missing and not unexpected, (missing, unexpected)
95
+ return new_sd, {"exact": exact, "partial": partial, "noise": noise}
96
+
97
+
98
+ @torch.no_grad()
99
+ def val_loss(model, data_path, batch=8, seq=256, n_batches=20):
100
+ import numpy as np
101
+ tokens = torch.from_numpy(np.fromfile(data_path, dtype="uint16")).long()
102
+ rng = random.Random(42)
103
+ total = 0.0
104
+ for _ in range(n_batches):
105
+ pos = rng.randrange(0, max(1, len(tokens) - seq - 1))
106
+ x = tokens[pos:pos + batch * seq].view(batch, seq)
107
+ y = tokens[pos + 1:pos + 1 + batch * seq].view(batch, seq)
108
+ logits = model(x)
109
+ total += F.cross_entropy(logits.reshape(-1, logits.size(-1)), y.reshape(-1)).item()
110
+ return total / n_batches
111
+
112
+
113
+ def main():
114
+ ap = argparse.ArgumentParser()
115
+ ap.add_argument("--base", default="ckpt/hybrid50m_pretrain")
116
+ ap.add_argument("--old-tok", default="data/tokenizer.json")
117
+ ap.add_argument("--new-tok", default="data/tokenizer16k.json")
118
+ ap.add_argument("--config", default="hybrid50m")
119
+ ap.add_argument("--out", default="ckpt/hybrid50m_v16k_init.pt")
120
+ ap.add_argument("--old-val", default="data/valid.bin")
121
+ ap.add_argument("--new-val", default="data/valid16k.bin")
122
+ ap.add_argument("--threads", type=int, default=4)
123
+ ap.add_argument("--verify", action="store_true",
124
+ help="measure val-loss delta vs baseline (slower)")
125
+ args = ap.parse_args()
126
+
127
+ torch.set_num_threads(args.threads)
128
+ old_tok = load_tokenizer(args.old_tok)
129
+ new_tok = load_tokenizer(args.new_tok)
130
+ old_vocab = old_tok.get_vocab_size()
131
+ new_vocab = new_tok.get_vocab_size()
132
+ assert new_vocab > old_vocab, f"new vocab {new_vocab} must exceed old {old_vocab}"
133
+
134
+ base_path = Path(args.base)
135
+ path = base_path if base_path.is_file() else latest_ckpt(args.base)
136
+ assert path, f"no checkpoint at {args.base}"
137
+ sd = torch.load(str(path), map_location="cpu")
138
+ cfg_old = TinyLiquidConfig(**sd["config"])
139
+ cfg_new = TinyLiquidConfig(vocab_size=new_vocab,
140
+ **{k: v for k, v in sd["config"].items()
141
+ if k != "vocab_size"})
142
+ print(f"base: {path} vocab {cfg_old.vocab_size} -> {cfg_new.vocab_size}, "
143
+ f"{cfg_old.params_estimate()/1e6:.2f}M -> {cfg_new.params_estimate()/1e6:.2f}M")
144
+
145
+ mapping, exact_ids = build_mapping(old_tok, new_tok)
146
+ new_sd, stats = expand_checkpoint(sd, cfg_new, mapping, exact_ids)
147
+ total = old_vocab
148
+ print(f"token map: exact={stats['exact']} partial={stats['partial']} "
149
+ f"noise={stats['noise']} ({100*stats['exact']/total:.1f}% exact)")
150
+
151
+ if args.verify:
152
+ model_old = TinyLiquid(cfg_old)
153
+ model_old.load_state_dict(sd["model"])
154
+ model_old.eval()
155
+ model = TinyLiquid(cfg_new)
156
+ model.load_state_dict(new_sd)
157
+ model.eval()
158
+ lo = val_loss(model_old, args.old_val, n_batches=20)
159
+ ln = val_loss(model, args.new_val, n_batches=20)
160
+ print(f"old-tokenizer baseline loss {lo:.4f} -> new-tokenizer 16k loss {ln:.4f} "
161
+ f"(delta {ln-lo:+.4f})", flush=True)
162
+ # A tokenizer change changes the target sequence. Exact loss parity
163
+ # before new merges are trained is not a valid gate; require finite
164
+ # loss and strong coverage, then gate recovery during continuation.
165
+ assert torch.isfinite(torch.tensor(lo)) and torch.isfinite(torch.tensor(ln)), "non-finite mapping loss"
166
+ assert stats["exact"] / total >= 0.60, "exact token coverage gate failed"
167
+
168
+ out = Path(args.out)
169
+ out.parent.mkdir(parents=True, exist_ok=True)
170
+ torch.save({"config": cfg_new.__dict__, "model": new_sd, "step": 0,
171
+ "best_val": sd.get("best_val", float("inf")), "tag": "vocab16k-init"},
172
+ out)
173
+ print("saved", out)
174
+
175
+
176
+ if __name__ == "__main__":
177
+ main()
train/parallel_merges.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parallel merge recipes for post-training checkpoints (tiny-model-posttrain).
2
+
3
+ Runs model soup (arXiv 2203.05482) and task arithmetic (arXiv 2212.04089)
4
+ on the SAME base, producing one checkpoint per recipe. TIES is handled by
5
+ train/ties_merge.py (arXiv 2306.01708). After merging, battery-eval every
6
+ candidate and keep the best (LFM2 2511.23404 §4.4: parallel apply -> eval ->
7
+ select). Never naive-average adapters; these recipes operate on folded
8
+ full-weight checkpoints where delta = task_ckpt - base is the true task
9
+ vector.
10
+
11
+ Usage:
12
+ .venv/bin/python train/parallel_merges.py \
13
+ --base ckpt/hybrid50m_v16k_pretrain/model_5000.pt \
14
+ --tasks ckpt/hybrid50m_v25_lora/best.pt ckpt/hybrid50m_v25_dpo/model_final.pt \
15
+ --out-dir ckpt/hybrid50m_v25_merges \
16
+ --lambda-ta 0.5
17
+ """
18
+ import argparse
19
+ from pathlib import Path
20
+
21
+ import torch
22
+
23
+ from model.config import TinyLiquidConfig
24
+ from model.tiny_liquid import TinyLiquid
25
+ from model.utils import latest_ckpt
26
+
27
+
28
+ def load_sd(path, tag):
29
+ sd = torch.load(str(path), map_location="cpu", weights_only=False)
30
+ print(f" {tag}: {path} step={sd.get('step', '?')} tag={sd.get('tag', '-')}", flush=True)
31
+ return sd
32
+
33
+
34
+ def model_soup(task_sds):
35
+ """Simple average of task weights (all tasks trained from the same base)."""
36
+ soup = {}
37
+ keys = [k for k in task_sds[0] if task_sds[0][k].is_floating_point()
38
+ and all(k in sd for sd in task_sds[1:])]
39
+ for k in keys:
40
+ soup[k] = torch.stack([sd[k].float() for sd in task_sds]).mean(dim=0)
41
+ return soup
42
+
43
+
44
+ def task_arithmetic(base_sd, task_sds, lam):
45
+ """base + lam * sum(task_i - base)."""
46
+ merged = {}
47
+ keys = [k for k in base_sd if base_sd[k].is_floating_point()
48
+ and all(k in sd for sd in task_sds)]
49
+ for k in keys:
50
+ base = base_sd[k].float()
51
+ delta = torch.zeros_like(base)
52
+ for sd in task_sds:
53
+ delta += sd[k].float() - base
54
+ merged[k] = base + lam * delta
55
+ return merged
56
+
57
+
58
+ def main():
59
+ ap = argparse.ArgumentParser()
60
+ ap.add_argument("--base", required=True)
61
+ ap.add_argument("--tasks", nargs="+", required=True)
62
+ ap.add_argument("--out-dir", required=True)
63
+ ap.add_argument("--lambda-ta", type=float, default=0.5)
64
+ ap.add_argument("--threads", type=int, default=4)
65
+ args = ap.parse_args()
66
+ assert len(args.tasks) >= 2, "merges need >= 2 task checkpoints"
67
+
68
+ torch.set_num_threads(args.threads)
69
+ base_path = Path(args.base)
70
+ base_ckpt = base_path if base_path.is_file() else latest_ckpt(args.base)
71
+ base = load_sd(base_ckpt, "base")
72
+ base_sd = base["model"]
73
+
74
+ task_sds = []
75
+ for i, t in enumerate(args.tasks):
76
+ tp = Path(t)
77
+ tp = tp if tp.is_file() else latest_ckpt(t)
78
+ task_sds.append(load_sd(tp, f"task{i}")["model"])
79
+
80
+ recipes = {
81
+ "soup": model_soup(task_sds),
82
+ f"taskarith_l{args.lambda_ta}".replace(".", "p"): task_arithmetic(base_sd, task_sds, args.lambda_ta),
83
+ }
84
+ out_dir = Path(args.out_dir)
85
+ out_dir.mkdir(parents=True, exist_ok=True)
86
+ for name, merged in recipes.items():
87
+ cfg = TinyLiquidConfig(**base["config"])
88
+ cfg.mtp_heads = 0
89
+ model = TinyLiquid(cfg)
90
+ missing, unexpected = model.load_state_dict(merged, strict=False)
91
+ if missing or unexpected:
92
+ print(f"[{name}] ignored {len(missing)} missing / {len(unexpected)} unexpected keys", flush=True)
93
+ out = out_dir / f"{name}.pt"
94
+ torch.save({"config": base["config"], "model": merged, "step": 0,
95
+ "best_val": base.get("best_val", float("inf")),
96
+ "tag": f"{name}-{len(task_sds)}tasks"}, out)
97
+ print("saved", out, flush=True)
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()
train/ties_merge.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TIES task-vector merge for post-training checkpoints (tiny-model-posttrain).
2
+
3
+ After SFT/DPO, merge the task vectors (ckpt_i - base) instead of naive
4
+ averaging (measured failure at 25M):
5
+ 1. TRIM: keep the top-`keep` fraction of each tensor by |delta|.
6
+ 2. SIGN CONSENSUS: where task vectors disagree on sign, zero the delta.
7
+ 3. MERGE: mean of the surviving deltas, added back to the base.
8
+
9
+ Pure function `ties_merge` is unit-tested; the CLI wraps it for the repo's
10
+ folded state-dict checkpoints ({"model": ..., "config": ...}).
11
+
12
+ Usage:
13
+ .venv/bin/python train/ties_merge.py \
14
+ --base ckpt/hybrid50m_pretrain \
15
+ --tasks ckpt/hybrid50m_sft_a ckpt/hybrid50m_dpo_a ckpt/hybrid50m_dpo_b \
16
+ --keep 0.2 --out ckpt/hybrid50m_ties.pt
17
+ """
18
+ import argparse
19
+ import copy
20
+ from pathlib import Path
21
+
22
+ import torch
23
+
24
+ from model.config import TinyLiquidConfig
25
+ from model.tiny_liquid import TinyLiquid
26
+ from model.utils import latest_ckpt
27
+
28
+
29
+ def trim_delta(delta, keep=0.2):
30
+ """Zero all but the top-`keep` fraction of |delta| (per tensor)."""
31
+ d = delta.clone().float()
32
+ flat = d.abs().flatten()
33
+ k = max(1, int(round(flat.numel() * keep)))
34
+ thresh = flat.topk(k).values.min()
35
+ d[d.abs() < thresh] = 0.0
36
+ return d
37
+
38
+
39
+ def ties_merge(base_sd, task_sds, keep=0.2):
40
+ """Return merged state dict: base + consensus mean of trimmed deltas."""
41
+ merged = {}
42
+ keys = [k for k in base_sd if base_sd[k].is_floating_point()
43
+ and all(k in t for t in task_sds)]
44
+ for k in keys:
45
+ base = base_sd[k].float()
46
+ deltas = [task[k].float() - base for task in task_sds]
47
+ trimmed = [trim_delta(d, keep) for d in deltas]
48
+ signs = torch.stack([t.sign() for t in trimmed])
49
+ consensus = signs.mean(dim=0)
50
+ agree = consensus.abs() >= 1.0 # all signs agree
51
+ final = torch.zeros_like(base)
52
+ if agree.any():
53
+ surv = torch.stack([t for t in trimmed])
54
+ final[agree] = surv[:, agree].mean(dim=0)
55
+ merged[k] = base + final
56
+ return merged
57
+
58
+
59
+ def load_sd(path, tag):
60
+ sd = torch.load(str(path), map_location="cpu", weights_only=False)
61
+ print(f" {tag}: {path} step={sd.get('step', '?')} tag={sd.get('tag', '-')}")
62
+ return sd["model"]
63
+
64
+
65
+ def main():
66
+ ap = argparse.ArgumentParser()
67
+ ap.add_argument("--base", required=True)
68
+ ap.add_argument("--tasks", nargs="+", required=True)
69
+ ap.add_argument("--keep", type=float, default=0.2)
70
+ ap.add_argument("--out", required=True)
71
+ ap.add_argument("--threads", type=int, default=4)
72
+ args = ap.parse_args()
73
+ assert len(args.tasks) >= 2, "TIES needs >= 2 task checkpoints"
74
+
75
+ torch.set_num_threads(args.threads)
76
+ base_path = Path(args.base)
77
+ base_ckpt = base_path if base_path.is_file() else latest_ckpt(args.base)
78
+ base = torch.load(str(base_ckpt), map_location="cpu", weights_only=False)
79
+ base_sd = base["model"]
80
+ print(f"base: {base_ckpt}")
81
+
82
+ task_sds = []
83
+ for i, t in enumerate(args.tasks):
84
+ tp = Path(t)
85
+ tp = tp if tp.is_file() else latest_ckpt(t)
86
+ task_sds.append(load_sd(tp, f"task{i}"))
87
+
88
+ merged = ties_merge(base_sd, task_sds, keep=args.keep)
89
+ cfg = TinyLiquidConfig(**base["config"])
90
+ cfg.mtp_heads = 0 # final merged model carries no MTP (pretrain-only)
91
+ model = TinyLiquid(cfg)
92
+ missing, unexpected = model.load_state_dict(merged, strict=False)
93
+ if missing or unexpected:
94
+ print(f"[ties] ignored {len(missing)} missing / {len(unexpected)} unexpected keys (expected from MTP drift)")
95
+
96
+ out = Path(args.out)
97
+ out.parent.mkdir(parents=True, exist_ok=True)
98
+ torch.save({"config": base["config"], "model": merged, "step": 0,
99
+ "best_val": base.get("best_val", float("inf")),
100
+ "tag": f"ties-{len(task_sds)}tasks-k{args.keep}"}, out)
101
+ print("saved", out)
102
+
103
+
104
+ if __name__ == "__main__":
105
+ main()
train/train_classifier.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Claim-judgment classifier: verdict / confidence / fallacy from a claim.
2
+
3
+ Trains a small linear head on the TinyLiquid base's final hidden state. The
4
+ base is frozen except the last block + head (light adapter), so the 7.8M model
5
+ becomes a reliable claim-conditioned judge instead of a drifting generator.
6
+
7
+ Usage:
8
+ .venv/bin/python train/train_classifier.py --base ckpt/v8_lora/best.pt \
9
+ --data data/sft_forensic.jsonl --ckpt ckpt/judge
10
+ """
11
+ import argparse, json, random, re, time
12
+ from pathlib import Path
13
+ import numpy as np
14
+ import torch
15
+ import torch.nn as nn
16
+ import torch.nn.functional as F
17
+
18
+ from model.config import TinyLiquidConfig
19
+ from model.tiny_liquid import TinyLiquid
20
+ from data.tokenizer import load_tokenizer
21
+
22
+ VERDICT_ORDER = ["true statement", "false statement", "supports", "refutes", "not_enough_info"]
23
+ CONF_ORDER = ["high", "medium", "low"]
24
+ P_IDS = {"analyst": 1, "skeptic": 2, "none": 0}
25
+
26
+ def parse_label(text, key):
27
+ m = re.search(key + r"\s*:\s*([^.]+)\.", text, re.I)
28
+ if not m:
29
+ return None
30
+ lab = m.group(1).strip().lower()
31
+ if key.lower() == "verdict":
32
+ for cand in VERDICT_ORDER:
33
+ if lab.startswith(cand) or cand.startswith(lab.split(" ")[0][:4]):
34
+ return cand
35
+ if lab.startswith("true"): return "true statement"
36
+ if lab.startswith("false"): return "false statement"
37
+ if lab.startswith("not_enough") or lab.startswith("not enough"): return "not_enough_info"
38
+ if lab.startswith("support"): return "supports"
39
+ if lab.startswith("refut"): return "refutes"
40
+ return None
41
+ if key.lower() == "confidence":
42
+ if lab.startswith("high"): return "high"
43
+ if lab.startswith("medium"): return "medium"
44
+ if lab.startswith("low"): return "low"
45
+ return None
46
+ # fallacy: keep as-is (13 classes)
47
+ return lab
48
+
49
+ def build(args):
50
+ tok = load_tokenizer(args.tok)
51
+ rows = [json.loads(l) for l in open(args.data, encoding="utf-8") if l.strip()]
52
+ items = []
53
+ for r in rows:
54
+ u = r.get("user", "")
55
+ a = r.get("assistant", "")
56
+ if not u or not a:
57
+ continue
58
+ pid = P_IDS.get(r.get("persona", "analyst"), 1)
59
+ v = parse_label(a, "Verdict")
60
+ c = parse_label(a, "Confidence")
61
+ f = parse_label(a, "Fallacy")
62
+ items.append({"ids": tok.encode(u).ids, "pid": pid, "v": v, "c": c, "f": f})
63
+ print(f"rows {len(rows)} usable {len(items)}", flush=True)
64
+ return tok, items
65
+
66
+ def make_sets(items, key, valid_vals, seed=17):
67
+ rng = random.Random(seed)
68
+ data = [it for it in items if it[key] in valid_vals]
69
+ rng.shuffle(data)
70
+ n_val = max(64, int(len(data) * 0.12))
71
+ return data[n_val:], data[:n_val], valid_vals
72
+
73
+ def encode_batch(model, items, tok, max_len=192, grad=True):
74
+ xs, ps = [], []
75
+ for it in items:
76
+ ids = it["ids"][:max_len]
77
+ xs.append(ids)
78
+ ps.append(it["pid"])
79
+ L = max(len(x) for x in xs)
80
+ buf = torch.zeros(len(xs), L, dtype=torch.long)
81
+ for i, x in enumerate(xs):
82
+ buf[i, :len(x)] = torch.tensor(x, dtype=torch.long)
83
+ if grad:
84
+ h = model.encode(buf, persona_ids=torch.tensor(ps))
85
+ else:
86
+ with torch.no_grad():
87
+ h = model.encode(buf, persona_ids=torch.tensor(ps))
88
+ mask = torch.arange(L).unsqueeze(0) < torch.tensor([len(x) for x in xs]).unsqueeze(1) # (n, L)
89
+ h = h * mask.unsqueeze(-1)
90
+ return h.sum(1) / mask.sum(1, keepdim=True) # masked mean pool (n, d)
91
+
92
+ def main():
93
+ ap = argparse.ArgumentParser()
94
+ ap.add_argument("--base", default="ckpt/v8_lora/best.pt")
95
+ ap.add_argument("--data", default="data/sft_forensic.jsonl")
96
+ ap.add_argument("--tok", default="data/tokenizer.json")
97
+ ap.add_argument("--ckpt", default="ckpt/judge")
98
+ ap.add_argument("--epochs", type=int, default=8)
99
+ ap.add_argument("--batch", type=int, default=32)
100
+ ap.add_argument("--lr", type=float, default=3e-4)
101
+ ap.add_argument("--threads", type=int, default=4)
102
+ args = ap.parse_args()
103
+ torch.set_num_threads(args.threads)
104
+ torch.manual_seed(17)
105
+ tok, items = build(args)
106
+
107
+ sd = torch.load(args.base, map_location="cpu")
108
+ cfg = TinyLiquidConfig(vocab_size=tok.get_vocab_size(), **{k: v for k, v in sd["config"].items() if k != "vocab_size"})
109
+ model = TinyLiquid(cfg)
110
+ model.load_state_dict(sd["model"])
111
+ for p in model.parameters():
112
+ p.requires_grad = False
113
+ for p in model.blocks[-2:].parameters(): # adapt last 2 blocks + head
114
+ p.requires_grad = True
115
+ for p in model.norm_out.parameters():
116
+ p.requires_grad = True
117
+ model.train()
118
+ d = cfg.d_model
119
+
120
+ heads = {}
121
+ for key, order in [("v", VERDICT_ORDER), ("c", CONF_ORDER), ("f", None)]:
122
+ if key == "f":
123
+ vals = sorted({it["f"] for it in items if it["f"]})
124
+ else:
125
+ vals = order
126
+ if not vals:
127
+ continue
128
+ tr, va, vals = make_sets(items, key, vals)
129
+ head = nn.Linear(d, len(vals))
130
+ idx = {v: i for i, v in enumerate(vals)}
131
+ heads[key] = {"head": head, "train": tr, "val": va, "idx": idx, "vals": vals}
132
+ print(f"head {key}: {len(vals)} classes, train {len(tr)} val {len(va)}", flush=True)
133
+
134
+ params = [p for p in model.parameters() if p.requires_grad]
135
+ for hd in heads.values():
136
+ params += list(hd["head"].parameters())
137
+ opt = torch.optim.AdamW(params, lr=args.lr, weight_decay=0.01)
138
+
139
+ out = Path(args.ckpt); out.mkdir(parents=True, exist_ok=True)
140
+ t0 = time.time()
141
+ for ep in range(args.epochs):
142
+ for key, hd in heads.items():
143
+ rng = random.Random(ep * 101 + 7)
144
+ rng.shuffle(hd["train"])
145
+ # interleave heads per batch
146
+ for i in range(0, max(len(hd["train"]) for hd in heads.values()), args.batch):
147
+ opt.zero_grad(set_to_none=True)
148
+ loss = 0.0
149
+ for key, hd in heads.items():
150
+ batch = hd["train"][i:i + args.batch]
151
+ if not batch:
152
+ continue
153
+ h = encode_batch(model, batch, tok)
154
+ logits = hd["head"](h)
155
+ target = torch.tensor([hd["idx"][it[key]] for it in batch])
156
+ loss = loss + F.cross_entropy(logits, target)
157
+ if loss == 0:
158
+ continue
159
+ loss.backward()
160
+ torch.nn.utils.clip_grad_norm_(params, 1.0)
161
+ opt.step()
162
+ # eval
163
+ line = []
164
+ for key, hd in heads.items():
165
+ hd["head"].eval()
166
+ with torch.no_grad():
167
+ h = encode_batch(model, hd["val"], tok, grad=False)
168
+ logits = hd["head"](h)
169
+ preds = logits.argmax(-1)
170
+ targets = torch.tensor([hd["idx"][it[key]] for it in hd["val"]])
171
+ acc = (preds == targets).float().mean().item()
172
+ line.append(f"{key}_acc {acc:.3f}")
173
+ hd["head"].train()
174
+ print(f"epoch {ep+1}/{args.epochs} " + " ".join(line) + f" ({time.time()-t0:.0f}s)", flush=True)
175
+ t0 = time.time()
176
+
177
+ torch.save({"heads": {k: {"state": hd["head"].state_dict(), "vals": hd["vals"]} for k, hd in heads.items()},
178
+ "config": cfg.__dict__, "base": args.base}, out / "judge.pt")
179
+ print("saved ->", out / "judge.pt", flush=True)
180
+
181
+ if __name__ == "__main__":
182
+ main()
train/train_dpo.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DPO (direct preference optimization) for TinyLiquid persona naturalness.
2
+
3
+ Teacher (assistant) writes preference pairs: same prompt, one natural
4
+ persona response (chosen) and one stiff/template response (rejected).
5
+ The model learns to prefer the chosen style at the token level.
6
+
7
+ Loss: -log sigmoid( beta * (reward_w - reward_r) ), where
8
+ reward = logpi(y|x) - logpi_ref(y|x), averaged over assistant tokens.
9
+
10
+ Batched forward passes, periodic checkpoints, and --resume so a long
11
+ preference run survives a tablet death. Recipe from tiny-model-preference:
12
+
13
+ PYTHONPATH=$PWD .venv/bin/python train/train_dpo.py --base <best_sft> \
14
+ --data data/prefs_p_all.jsonl --ckpt ckpt/tiny25m_dpo3 \
15
+ --epochs 1 --batch 4 --seq 512 --lr 4e-6 --beta 0.05 --threads 8
16
+ """
17
+
18
+ import argparse
19
+ import json
20
+ import math
21
+ import random
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ import torch
26
+ import torch.nn.functional as F
27
+
28
+ from model.config import TinyLiquidConfig, CONFIGS
29
+ from model.tiny_liquid import TinyLiquid
30
+ from model.utils import latest_ckpt
31
+ from data.tokenizer import load_tokenizer
32
+ from train.train_lora import wrap_lora, fold_state_dict, val_ppl
33
+
34
+ PERSONA_T = {"analyst": "<|analyst|>", "skeptic": "<|skeptic|>"}
35
+
36
+
37
+ def parse_args():
38
+ ap = argparse.ArgumentParser()
39
+ ap.add_argument("--base", default="ckpt/distill")
40
+ ap.add_argument("--data", default="data/prefs_persona.jsonl")
41
+ ap.add_argument("--tok", default="data/tokenizer.json")
42
+ ap.add_argument("--ckpt", default="ckpt/dpo")
43
+ ap.add_argument("--epochs", type=int, default=5)
44
+ ap.add_argument("--batch", type=int, default=4)
45
+ ap.add_argument("--seq", type=int, default=256)
46
+ ap.add_argument("--lr", type=float, default=1e-5)
47
+ ap.add_argument("--beta", type=float, default=0.1)
48
+ ap.add_argument("--save-every", type=int, default=100)
49
+ ap.add_argument("--resume", default=None, help="continue from this checkpoint (model + opt + step); reference policy stays --base")
50
+ ap.add_argument("--resume-best-ppl", type=float, default=None, help="required fallback best PPL for legacy checkpoints without metadata")
51
+ ap.add_argument("--reset-optimizer", action="store_true", help="resume model/step but initialize a fresh optimizer")
52
+ ap.add_argument("--seed", type=int, default=9)
53
+ ap.add_argument("--threads", type=int, default=8)
54
+ ap.add_argument("--lora", action="store_true", help="adapter-only DPO: freeze base, train LoRA adapters")
55
+ ap.add_argument("--r", type=int, default=16)
56
+ ap.add_argument("--alpha", type=float, default=32.0)
57
+ ap.add_argument("--dropout", type=float, default=0.05)
58
+ ap.add_argument("--ppl-guard", type=float, default=60.0)
59
+ ap.add_argument("--val-bin", default="data/valid.bin")
60
+ ap.add_argument("--eval-every", type=int, default=100)
61
+ ap.add_argument("--dpo-weight", type=float, default=1.0,
62
+ help="LFM2 joint objective: weight w on the DPO term (default 1.0)")
63
+ ap.add_argument("--margin", type=float, default=0.1,
64
+ help="LFM2 joint objective: margin m inside f(Delta - m) (default 0.1; 0 = plain DPO)")
65
+ ap.add_argument("--apo-weight", type=float, default=0.2,
66
+ help="LFM2 joint objective: weight lambda on the APO-zero absolute term (default 0.2; 0 = plain DPO)")
67
+ ap.add_argument("--lr-schedule", choices=["constant", "cosine"], default="constant",
68
+ help="LR schedule (LFM2 Table 5 uses cosine 8e-7 -> 8e-8 with 0.01 warmup)")
69
+ ap.add_argument("--lr-min", type=float, default=8e-8,
70
+ help="cosine schedule minimum LR (LFM2 Table 5 default 8e-8)")
71
+ ap.add_argument("--warmup-frac", type=float, default=0.01,
72
+ help="fraction of total steps used for linear warmup (LFM2 Table 5 default 0.01)")
73
+ return ap.parse_args()
74
+
75
+
76
+ def lr_at(step, total, lr_max, lr_min, warmup_frac, schedule):
77
+ """LFM2 Table 5 schedule: linear warmup then cosine decay.
78
+
79
+ Constant schedule keeps the historical fixed-LR behavior (default).
80
+ """
81
+ if schedule == "constant":
82
+ return lr_max
83
+ warmup_steps = max(1, int(round(total * warmup_frac)))
84
+ if step <= warmup_steps:
85
+ return lr_min + (lr_max - lr_min) * (step / warmup_steps)
86
+ progress = (step - warmup_steps) / max(1, total - warmup_steps)
87
+ return lr_min + 0.5 * (lr_max - lr_min) * (1.0 + math.cos(math.pi * min(1.0, progress)))
88
+
89
+
90
+ def build_tensors(tok, persona, prompt, response, seq):
91
+ p_token = PERSONA_T.get(persona, PERSONA_T["analyst"])
92
+ u_id, a_id, eot = (tok.token_to_id(t) for t in ("<|user|>", "<|assistant|>", "<|endoftext|>"))
93
+ parts = [tok.encode(p_token).ids, [u_id], tok.encode(prompt).ids, [a_id],
94
+ tok.encode(response).ids, [eot]]
95
+ ids = [i for part in parts for i in part]
96
+ if len(ids) > seq:
97
+ ids = ids[: seq - 1] + [eot]
98
+ asst_start = len(parts[0]) + 1 + len(parts[2]) + 1
99
+ x = torch.tensor(ids[:-1], dtype=torch.long)
100
+ y = torch.tensor(ids[1:], dtype=torch.long)
101
+ mask = torch.zeros_like(y, dtype=torch.bool)
102
+ mask[asst_start - 1:] = True
103
+ return x, y[: x.shape[0]], mask[: x.shape[0]]
104
+
105
+
106
+ def build_batch(tok, examples, key, seq):
107
+ built = [build_tensors(tok, ex.get("persona", "analyst"), ex["prompt"], ex[key], seq) for ex in examples]
108
+ T = max(x.shape[0] for x, _, _ in built)
109
+ B = len(built)
110
+ xs = torch.zeros(B, T, dtype=torch.long)
111
+ ys = torch.zeros(B, T, dtype=torch.long)
112
+ ms = torch.zeros(B, T, dtype=torch.bool)
113
+ for i, (x, y, m) in enumerate(built):
114
+ xs[i, : x.shape[0]] = x
115
+ ys[i, : y.shape[0]] = y
116
+ ms[i, : m.shape[0]] = m
117
+ return xs, ys, ms
118
+
119
+
120
+ def batch_seq_logprob(model, xs, ys, masks, persona_ids):
121
+ logits = model(xs, persona_ids=persona_ids)
122
+ logp = F.log_softmax(logits.float(), dim=-1)
123
+ per_tok = logp.gather(-1, ys.unsqueeze(-1)).squeeze(-1)
124
+ return (per_tok * masks).sum(dim=-1) / masks.sum(dim=-1).clamp(min=1.0)
125
+
126
+
127
+ def lfm2_loss(reward_w, reward_r, beta, margin=0.1, dpo_weight=1.0, apo_weight=0.2):
128
+ """LFM2 length-normalized joint objective (arXiv 2511.23404, section 4.3).
129
+
130
+ L = -E[ w*f(Delta - m) + lambda*g(delta) ]
131
+ Delta = beta*rew_w - beta*rew_r (length-normalized relative)
132
+ delta = sigmoid(beta*rew_w) - sigmoid(beta*rew_r) (absolute)
133
+ f = log_sigmoid (DPO), g = identity (APO-zero)
134
+ The minus sits outside the expectation, so the APO contribution is
135
+ -lambda*(sigmoid(scaled_w) - sigmoid(scaled_r)) and can be negative
136
+ when the chosen response is favored (gradient still widens the gap).
137
+ Plain DPO is the special case margin=0, apo_weight=0.
138
+ """
139
+ scaled_w = beta * reward_w
140
+ scaled_r = beta * reward_r
141
+ dpo_term = -F.logsigmoid(scaled_w - scaled_r - margin)
142
+ apo_term = torch.sigmoid(scaled_r) - torch.sigmoid(scaled_w)
143
+ return (dpo_weight * dpo_term + apo_weight * apo_term).mean()
144
+
145
+
146
+ def main():
147
+ args = parse_args()
148
+ torch.set_num_threads(args.threads)
149
+ torch.manual_seed(args.seed)
150
+ random.seed(args.seed)
151
+
152
+ tok = load_tokenizer(args.tok)
153
+ base_path = Path(args.base)
154
+ base_ckpt = base_path if base_path.is_file() else latest_ckpt(args.base)
155
+ sd = torch.load(base_ckpt, map_location="cpu")
156
+ config = sd.get("config") or CONFIGS["tiny10m"]
157
+ cfg = TinyLiquidConfig(vocab_size=tok.get_vocab_size(),
158
+ **{k: v for k, v in config.items() if k != "vocab_size"})
159
+ cfg.mtp_heads = 0 # MTP is pretrain-only; post-training has no MTP heads
160
+ model = TinyLiquid(cfg)
161
+ resume = torch.load(args.resume, map_location="cpu") if args.resume else None
162
+ ref = TinyLiquid(cfg)
163
+ ref.load_state_dict(sd["model"], strict=False)
164
+ for p in ref.parameters():
165
+ p.requires_grad_(False)
166
+ ref.eval()
167
+ wrapped = []
168
+ if resume is not None:
169
+ if args.lora:
170
+ wrapped = wrap_lora(model, args.r, args.alpha, args.dropout)
171
+ model.load_state_dict(resume["model"], strict=False)
172
+ else:
173
+ model.load_state_dict(sd["model"], strict=False)
174
+ if args.lora:
175
+ wrapped = wrap_lora(model, args.r, args.alpha, args.dropout)
176
+ if args.lora:
177
+ for p in model.parameters():
178
+ p.requires_grad = False
179
+ for p in model.persona_emb.parameters():
180
+ p.requires_grad = True
181
+ for _, lora in wrapped:
182
+ lora.lora_a.requires_grad = True
183
+ lora.lora_b.requires_grad = True
184
+ print(f"adapter-only DPO: {len(wrapped)} lora adapters, "
185
+ f"trainable {sum(p.numel() for p in model.parameters() if p.requires_grad):,}", flush=True)
186
+ model.train()
187
+ opt = torch.optim.AdamW([p for p in model.parameters() if p.requires_grad],
188
+ lr=args.lr, betas=(0.9, 0.95), weight_decay=0.02)
189
+ start_step = 0
190
+ if resume is not None and not args.reset_optimizer:
191
+ opt.load_state_dict(resume["opt"])
192
+ start_step = resume.get("step", 0)
193
+ if resume is not None:
194
+ if "best_ppl" in resume and resume["best_ppl"] is not None: best_ppl = float(resume["best_ppl"])
195
+ elif args.resume_best_ppl is not None: best_ppl = args.resume_best_ppl
196
+ else: raise ValueError("resume checkpoint lacks best_ppl; pass --resume-best-ppl from the recorded evaluation")
197
+ if "torch_rng" in resume: torch.set_rng_state(resume["torch_rng"])
198
+ if "python_rng" in resume: random.setstate(resume["python_rng"])
199
+ else: best_ppl = float("inf")
200
+
201
+ examples = [json.loads(l) for l in open(args.data, encoding="utf-8") if l.strip()]
202
+ assert examples, "no preference data"
203
+ print(f"loaded {len(examples)} preference pairs; base {base_ckpt}", flush=True)
204
+
205
+ out_dir = Path(args.ckpt)
206
+ out_dir.mkdir(parents=True, exist_ok=True)
207
+ steps_per_epoch = max(1, len(examples) // args.batch)
208
+ total = steps_per_epoch * args.epochs
209
+ step = 0
210
+ if start_step:
211
+ print(f"resuming from {args.resume} at step {start_step}/{total}", flush=True)
212
+ for ep in range(args.epochs):
213
+ # Rebuild the same epoch order after resume; global RNG state alone is
214
+ # insufficient because shuffle happens before skipped steps.
215
+ epoch_rng = random.Random(args.seed + ep)
216
+ epoch_rng.shuffle(examples)
217
+ usable = len(examples) - len(examples) % args.batch
218
+ for i in range(0, usable, args.batch):
219
+ step += 1
220
+ if step <= start_step:
221
+ continue
222
+ batch = examples[i : i + args.batch]
223
+ persona_ids = torch.tensor([2 if ex.get("persona") == "skeptic" else 1 for ex in batch])
224
+ xw, yw, mw = build_batch(tok, batch, "chosen", args.seq)
225
+ xr, yr, mr = build_batch(tok, batch, "rejected", args.seq)
226
+ opt.zero_grad(set_to_none=True)
227
+ lw = batch_seq_logprob(model, xw, yw, mw, persona_ids)
228
+ lr = batch_seq_logprob(model, xr, yr, mr, persona_ids)
229
+ with torch.no_grad():
230
+ rw = batch_seq_logprob(ref, xw, yw, mw, persona_ids)
231
+ rr = batch_seq_logprob(ref, xr, yr, mr, persona_ids)
232
+ reward_w = lw - rw
233
+ reward_r = lr - rr
234
+ loss = lfm2_loss(reward_w, reward_r, args.beta,
235
+ margin=args.margin,
236
+ dpo_weight=args.dpo_weight,
237
+ apo_weight=args.apo_weight)
238
+ loss.backward()
239
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
240
+ opt.param_groups[0]["lr"] = lr_at(step, total, args.lr, args.lr_min,
241
+ args.warmup_frac, args.lr_schedule)
242
+ opt.step()
243
+ if step % 10 == 0 or step == total:
244
+ print(f"step {step}/{total} dpo_loss {loss.item():.4f}", flush=True)
245
+ def save_ck(tag):
246
+ if wrapped:
247
+ torch.save({"model": model.state_dict(), "opt": opt.state_dict(), "step": step,
248
+ "config": cfg.__dict__, "best_ppl": best_ppl,
249
+ "torch_rng": torch.get_rng_state(), "python_rng": random.getstate()}, str(out_dir / "raw_latest.pt"))
250
+ sd_save = fold_state_dict(model.state_dict(), wrapped) if wrapped else model.state_dict()
251
+ torch.save({"model": sd_save, "opt": opt.state_dict(), "step": step,
252
+ "config": cfg.__dict__, "best_ppl": best_ppl,
253
+ "torch_rng": torch.get_rng_state(), "python_rng": random.getstate()}, str(out_dir / tag))
254
+ if step % args.eval_every == 0:
255
+ ppl = val_ppl(model, args.val_bin, batch=2, seq=64, n_batches=1, seed=args.seed + step)
256
+ mark = ""
257
+ if ppl > args.ppl_guard:
258
+ failure = out_dir / "dpo_failed.json"
259
+ temp = failure.with_suffix(".partial")
260
+ temp.write_text(json.dumps({"status": "ppl-guard-breach", "step": step,
261
+ "val_ppl": ppl, "ppl_guard": args.ppl_guard}, indent=2) + "\n")
262
+ temp.replace(failure)
263
+ print(f" [eval {step}] val_ppl {ppl:.2f} guard {args.ppl_guard} [BREACH]", flush=True)
264
+ print("PPL GUARD BREACH — aborting before any checkpoint is marked best", flush=True)
265
+ sys.exit(3)
266
+ if ppl < best_ppl:
267
+ best_ppl = ppl
268
+ save_ck("best_ppl.pt")
269
+ mark = " [best ppl]"
270
+ if ppl <= args.ppl_guard:
271
+ save_ck(f"model_{step}.pt")
272
+ print(f" [eval {step}] val_ppl {ppl:.2f} guard {args.ppl_guard}{mark}", flush=True)
273
+ if step % args.save_every == 0:
274
+ save_ck(f"model_{step}.pt")
275
+ print(f" saved model_{step}.pt", flush=True)
276
+ save_ck("model_final.pt")
277
+ completion = out_dir / "dpo_complete.json"
278
+ temp = completion.with_suffix(".partial")
279
+ temp.write_text(json.dumps({"status": "complete", "step": step,
280
+ "best_ppl": best_ppl,
281
+ "checkpoint": "model_final.pt"}, indent=2) + "\n")
282
+ temp.replace(completion)
283
+ (out_dir / "dpo_failed.json").unlink(missing_ok=True)
284
+ print(f"done -> {out_dir}", flush=True)
285
+
286
+
287
+ if __name__ == "__main__":
288
+ main()
train/train_judge.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Claim-vs-evidence judge: verdict classification from claim+evidence text.
2
+
3
+ Trains the last blocks + a linear head on TinyLiquid's mean-pooled hidden
4
+ state. Class-weighted loss; per-epoch checkpoints; resumable.
5
+ Usage:
6
+ .venv/bin/python train/train_judge.py --base ckpt/v8_lora/best.pt \
7
+ --data data/evidence_judge.jsonl --ckpt ckpt/judge --epochs 20
8
+ """
9
+ import argparse, json, random, time
10
+ from pathlib import Path
11
+ import numpy as np
12
+ import torch
13
+ import torch.nn as nn
14
+ import torch.nn.functional as F
15
+
16
+ from model.config import TinyLiquidConfig
17
+ from model.tiny_liquid import TinyLiquid
18
+ from data.tokenizer import load_tokenizer
19
+
20
+ VALS = ["true statement", "false statement", "supports", "refutes", "not_enough_info"]
21
+
22
+ def encode_batch(model, items, tok, max_len=192, grad=True):
23
+ xs, ps = [], []
24
+ for it in items:
25
+ xs.append(tok.encode(it["user"]).ids[:max_len])
26
+ ps.append(1 if it.get("persona", "analyst") != "skeptic" else 2)
27
+ L = max(len(x) for x in xs)
28
+ buf = torch.zeros(len(xs), L, dtype=torch.long)
29
+ for j, x in enumerate(xs):
30
+ buf[j, :len(x)] = torch.tensor(x, dtype=torch.long)
31
+ if grad:
32
+ h = model.encode(buf, persona_ids=torch.tensor(ps))
33
+ else:
34
+ with torch.no_grad():
35
+ h = model.encode(buf, persona_ids=torch.tensor(ps))
36
+ mask = torch.arange(L).unsqueeze(0) < torch.tensor([len(x) for x in xs]).unsqueeze(1)
37
+ h = h * mask.unsqueeze(-1)
38
+ return h.sum(1) / mask.sum(1, keepdim=True)
39
+
40
+ def main():
41
+ ap = argparse.ArgumentParser()
42
+ ap.add_argument("--base", default="ckpt/v8_lora/best.pt")
43
+ ap.add_argument("--data", default="data/evidence_judge.jsonl")
44
+ ap.add_argument("--tok", default="data/tokenizer.json")
45
+ ap.add_argument("--ckpt", default="ckpt/judge")
46
+ ap.add_argument("--epochs", type=int, default=20)
47
+ ap.add_argument("--batch", type=int, default=32)
48
+ ap.add_argument("--lr", type=float, default=1e-4)
49
+ ap.add_argument("--threads", type=int, default=4)
50
+ ap.add_argument("--seed", type=int, default=17)
51
+ args = ap.parse_args()
52
+ torch.set_num_threads(args.threads)
53
+ rng = random.Random(args.seed)
54
+ tok = load_tokenizer(args.tok)
55
+
56
+ rows = [json.loads(l) for l in open(args.data, encoding="utf-8") if l.strip()]
57
+ rows = [r for r in rows if r.get("verdict") in VALS]
58
+ # stratified split
59
+ by_v = {v: [r for r in rows if r["verdict"] == v] for v in VALS}
60
+ tr, va = [], []
61
+ for v, lst in by_v.items():
62
+ rng.shuffle(lst)
63
+ n_va = max(2, int(len(lst) * 0.15))
64
+ va += lst[:n_va]; tr += lst[n_va:]
65
+ rng.shuffle(tr); rng.shuffle(va)
66
+ counts = {v: sum(1 for r in tr if r["verdict"] == v) for v in VALS}
67
+ w = torch.tensor([sum(counts.values()) / max(1, counts[v]) for v in VALS], dtype=torch.float32)
68
+ print(f"train {len(tr)} val {len(va)} weights {w.tolist()}", flush=True)
69
+
70
+ sd = torch.load(args.base, map_location="cpu")
71
+ cfg = TinyLiquidConfig(vocab_size=tok.get_vocab_size(), **{k: v for k, v in sd["config"].items() if k != "vocab_size"})
72
+ model = TinyLiquid(cfg)
73
+ model.load_state_dict(sd["model"])
74
+ for p in model.parameters():
75
+ p.requires_grad = False
76
+ for p in model.blocks[-2:].parameters():
77
+ p.requires_grad = True
78
+ for p in model.norm_out.parameters():
79
+ p.requires_grad = True
80
+ head = nn.Linear(cfg.d_model, len(VALS))
81
+ model.train()
82
+
83
+ out = Path(args.ckpt); out.mkdir(parents=True, exist_ok=True)
84
+ start_ep, best_acc = 0, 0.0
85
+ ck = out / "judge.pt"
86
+ if ck.exists() and args.epochs > 0:
87
+ st = torch.load(ck, map_location="cpu")
88
+ model.load_state_dict(st["model"])
89
+ head.load_state_dict(st["head"])
90
+ start_ep, best_acc = st["epoch"] + 1, st.get("best_acc", 0.0)
91
+ print(f"resumed at epoch {start_ep} (best_acc {best_acc:.3f})", flush=True)
92
+
93
+ params = [p for p in model.parameters() if p.requires_grad] + list(head.parameters())
94
+ opt = torch.optim.AdamW(params, lr=args.lr, weight_decay=0.01)
95
+ t0 = time.time()
96
+ for ep in range(start_ep, args.epochs):
97
+ rng.shuffle(tr)
98
+ for i in range(0, len(tr), args.batch):
99
+ batch = tr[i:i + args.batch]
100
+ h = encode_batch(model, batch, tok, grad=True)
101
+ logits = head(h)
102
+ target = torch.tensor([VALS.index(b["verdict"]) for b in batch])
103
+ loss = F.cross_entropy(logits, target, weight=w)
104
+ opt.zero_grad(); loss.backward()
105
+ torch.nn.utils.clip_grad_norm_(params, 1.0)
106
+ opt.step()
107
+ # eval
108
+ model.eval(); head.eval()
109
+ with torch.no_grad():
110
+ h = encode_batch(model, va, tok, grad=False)
111
+ preds = head(h).argmax(-1).numpy()
112
+ targets = np.array([VALS.index(b["verdict"]) for b in va])
113
+ acc = (preds == targets).mean()
114
+ per = {VALS[c]: round(float((preds == targets)[targets == c].mean()), 3) if (targets == c).any() else 0.0 for c in range(len(VALS))}
115
+ print(f"epoch {ep+1}/{args.epochs} val_acc {acc:.3f} per-class {per} ({time.time()-t0:.0f}s)", flush=True)
116
+ t0 = time.time()
117
+ if acc > best_acc:
118
+ best_acc = acc
119
+ torch.save({"model": model.state_dict(), "head": head.state_dict(), "epoch": ep,
120
+ "best_acc": best_acc, "config": cfg.__dict__, "vals": VALS, "base": args.base},
121
+ out / "judge.pt")
122
+ model.train(); head.train()
123
+ print(f"done -> {out}/judge.pt best_acc {best_acc:.3f}", flush=True)
124
+
125
+ if __name__ == "__main__":
126
+ main()
train/train_lm.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Causal LM pretraining for TinyLiquid on packed uint16 token files.
2
+
3
+ Usage:
4
+ .venv/bin/python train/train_lm.py --data data/train.bin --val data/valid.bin \
5
+ --config tiny10m --ckpt ckpt/nlp --steps 12000
6
+ Resume:
7
+ .venv/bin/python train/train_lm.py ... --resume ckpt/nlp
8
+ """
9
+
10
+ import argparse
11
+ import json
12
+ import math
13
+ import os
14
+ import random
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+
19
+ import numpy as np
20
+ import torch
21
+ import torch.nn.functional as F
22
+
23
+ from model.config import TinyLiquidConfig, CONFIGS
24
+ from model.utils import latest_ckpt
25
+ from model.tiny_liquid import TinyLiquid
26
+ from data.tokenizer import load_tokenizer, PERSONA_TOKENS
27
+
28
+ SAVE_PREFIX = "model"
29
+
30
+
31
+ def parse_args():
32
+ ap = argparse.ArgumentParser()
33
+ ap.add_argument("--data", default="data/train.bin")
34
+ ap.add_argument("--val", default="data/valid.bin")
35
+ ap.add_argument("--tok", default="data/tokenizer.json")
36
+ ap.add_argument("--config", default="tiny10m")
37
+ ap.add_argument("--ckpt", default="ckpt/nlp")
38
+ ap.add_argument("--resume", default=None)
39
+ ap.add_argument("--init-from", default=None,
40
+ help="load model weights only (fresh optimizer + LR schedule)")
41
+ ap.add_argument("--batch", type=int, default=16)
42
+ ap.add_argument("--seq", type=int, default=256)
43
+ ap.add_argument("--lr", type=float, default=3e-4)
44
+ ap.add_argument("--min-lr", type=float, default=1e-5)
45
+ ap.add_argument("--warmup", type=int, default=200)
46
+ ap.add_argument("--steps", type=int, default=12000)
47
+ ap.add_argument("--total-steps", type=int, default=None,
48
+ help="absolute final step when resuming; overrides additive --steps")
49
+ ap.add_argument("--log-every", type=int, default=25)
50
+ ap.add_argument("--eval-every", type=int, default=500)
51
+ ap.add_argument("--save-every", type=int, default=1000)
52
+ ap.add_argument("--val-batches", type=int, default=40)
53
+ ap.add_argument("--sample-max-new", type=int, default=80)
54
+ ap.add_argument("--seed", type=int, default=42)
55
+ ap.add_argument("--threads", type=int, default=8)
56
+ ap.add_argument("--bf16", action="store_true", help="CPU autocast bf16 (SVE2/BF16 ARMv9)")
57
+ ap.add_argument("--nan-rollback", type=int, default=50,
58
+ help="auto-resume from last checkpoint after N consecutive non-finite steps (0=off)")
59
+ ap.add_argument("--mtp", type=int, default=0,
60
+ help="multi-token prediction aux heads (Meta MTP; 0=off)")
61
+ return ap.parse_args()
62
+
63
+
64
+ def load_bin(path, memmap_threshold=300 * 1024 * 1024):
65
+ size = Path(path).stat().st_size
66
+ if size >= memmap_threshold:
67
+ # memory-map large corpora; sample_batch converts slices on the fly
68
+ return np.memmap(path, dtype=np.uint16, mode="r")
69
+ arr = np.fromfile(path, dtype=np.uint16)
70
+ return torch.from_numpy(arr.astype(np.int64))
71
+
72
+
73
+ def make_batches(tokens: torch.Tensor, batch: int, seq: int, rng: random.Random):
74
+ # non-overlapping windows; cycle through them in order
75
+ n = (len(tokens) - 1) // seq
76
+ if n <= 0:
77
+ raise ValueError("corpus too small for seq")
78
+ return torch.arange(0, n * seq, seq, dtype=torch.long)
79
+
80
+
81
+ def sample_batch(tokens: torch.Tensor, offsets, step: int, batch: int, seq: int):
82
+ pos = (step * batch) % len(offsets)
83
+ idx = offsets[pos : pos + batch]
84
+ if len(idx) < batch:
85
+ idx = torch.cat([idx, offsets[: batch - len(idx)]])
86
+ if isinstance(tokens, np.memmap):
87
+ buf = torch.stack([torch.from_numpy(tokens[int(s): int(s) + seq].astype(np.int64)) for s in idx])
88
+ else:
89
+ buf = torch.stack([tokens[int(s): int(s) + seq] for s in idx])
90
+ return buf[:, :-1], buf[:, 1:]
91
+
92
+
93
+ @torch.no_grad()
94
+ def evaluate(model, val_tokens, batch, seq, n_batches, rng, persona=0, bf16=False):
95
+ model.eval()
96
+ total, count = 0.0, 0
97
+ offsets = make_batches(val_tokens, batch, seq, rng)
98
+ for i in range(n_batches):
99
+ x, y = sample_batch(val_tokens, offsets, i, batch, seq)
100
+ p = torch.full((batch,), persona, dtype=torch.long) if persona else None
101
+ if bf16:
102
+ with torch.autocast("cpu", dtype=torch.bfloat16):
103
+ logits = model(x, persona_ids=p)
104
+ loss = F.cross_entropy(logits.reshape(-1, logits.size(-1)), y.reshape(-1))
105
+ else:
106
+ logits = model(x, persona_ids=p)
107
+ loss = F.cross_entropy(logits.reshape(-1, logits.size(-1)), y.reshape(-1))
108
+ total += loss.item() * y.numel()
109
+ count += y.numel()
110
+ model.train()
111
+ return total / count
112
+
113
+
114
+ @torch.no_grad()
115
+ def sample(model, tok, prompt, max_new, persona=0, temp=0.8, top_k=40):
116
+ ids = tok.encode(prompt).ids
117
+ out = model.generate(tok, ids, persona_id=persona, max_new=max_new,
118
+ temperature=temp, top_k=top_k)
119
+ return tok.decode(out[len(ids):])
120
+
121
+
122
+ def main():
123
+ args = parse_args()
124
+ torch.set_num_threads(args.threads)
125
+ torch.manual_seed(args.seed)
126
+ random.seed(args.seed)
127
+ rng = random.Random(args.seed)
128
+
129
+ ckpt_dir = Path(args.ckpt)
130
+ ckpt_dir.mkdir(parents=True, exist_ok=True)
131
+
132
+ tok = load_tokenizer(args.tok)
133
+ cfg = TinyLiquidConfig(vocab_size=tok.get_vocab_size(), mtp_heads=args.mtp,
134
+ **CONFIGS[args.config])
135
+
136
+ model = TinyLiquid(cfg)
137
+ opt = torch.optim.AdamW(model.parameters(), lr=args.lr, betas=(0.9, 0.95), weight_decay=0.1)
138
+ step, best_val = 0, float("inf")
139
+ grad_accum = 1 # keep simple on CPU
140
+ resume_from = 0
141
+
142
+ if args.init_from:
143
+ path = latest_ckpt(args.init_from)
144
+ if path:
145
+ sd = torch.load(path, map_location="cpu")
146
+ if args.mtp and set(sd["model"]) != set(model.state_dict()):
147
+ model.load_state_dict(sd["model"], strict=False)
148
+ print(f"init-from {path} (weights only, mtp heads fresh)", flush=True)
149
+ else:
150
+ model.load_state_dict(sd["model"])
151
+ print(f"init-from {path} (weights only, fresh schedule)", flush=True)
152
+
153
+ if args.resume:
154
+ path = latest_ckpt(args.resume)
155
+ if path:
156
+ sd = torch.load(path, map_location="cpu")
157
+ model.load_state_dict(sd["model"])
158
+ opt.load_state_dict(sd["opt"])
159
+ step, best_val = sd["step"], sd.get("best_val", float("inf"))
160
+ resume_from = step
161
+ print(f"resumed {path} at step {step}", flush=True)
162
+
163
+ train_tokens = load_bin(args.data)
164
+ val_tokens = load_bin(args.val)
165
+ offsets = make_batches(train_tokens, args.batch, args.seq, rng)
166
+ # shuffle windows (seed-fixed): every batch is a random domain mix, so a
167
+ # hard slice of the corpus can never dominate a whole batch
168
+ offsets = offsets[torch.randperm(len(offsets), generator=torch.Generator().manual_seed(args.seed))]
169
+
170
+ model.train()
171
+ total_params = model.num_params()
172
+ print(f"params: {total_params/1e6:.2f}M train_tokens: {len(train_tokens):,} "
173
+ f"steps_per_epoch: {len(offsets)//args.batch}", flush=True)
174
+
175
+ def save(path, tag=""):
176
+ payload = {
177
+ "model": model.state_dict(),
178
+ "opt": opt.state_dict(),
179
+ "step": step,
180
+ "best_val": best_val,
181
+ "config": cfg.__dict__,
182
+ "args": vars(args),
183
+ "tag": tag,
184
+ }
185
+ # Never expose a partially written checkpoint to resume logic.
186
+ tmp = path.with_suffix(path.suffix + ".tmp")
187
+ torch.save(payload, str(tmp))
188
+ os.replace(tmp, path)
189
+
190
+ prompts = ["Once upon a time,", "The little girl wanted to", "In the dark forest,"]
191
+ target_steps = args.total_steps if args.total_steps is not None else resume_from + args.steps
192
+ if target_steps < resume_from:
193
+ raise ValueError(f"total target step {target_steps} is before resume step {resume_from}")
194
+ t0 = time.time()
195
+ nan_streak = 0
196
+ while step < target_steps:
197
+ step += 1
198
+ x, y = sample_batch(train_tokens, offsets, step - 1, args.batch, args.seq)
199
+ opt.zero_grad(set_to_none=True)
200
+ def _fwd():
201
+ if args.mtp:
202
+ logits, aux = model.forward_mtp(x)
203
+ loss = F.cross_entropy(logits.reshape(-1, logits.size(-1)), y.reshape(-1))
204
+ for k, a in enumerate(aux):
205
+ off = k + 2
206
+ if args.seq > off:
207
+ loss = loss + 0.1 * F.cross_entropy(
208
+ a[:, :-off].reshape(-1, a.size(-1)), x[:, off:].reshape(-1))
209
+ return loss
210
+ logits = model(x)
211
+ return F.cross_entropy(logits.reshape(-1, logits.size(-1)), y.reshape(-1))
212
+ if args.bf16:
213
+ with torch.autocast("cpu", dtype=torch.bfloat16):
214
+ loss = _fwd()
215
+ else:
216
+ loss = _fwd()
217
+ if not torch.isfinite(loss):
218
+ # never let a single bad batch poison the weights: skip the optimizer
219
+ # step entirely so neither weights nor LR schedule are mutated
220
+ nan_streak += 1
221
+ print(f" !! step {step} non-finite loss {loss.item():.3e}; skipping step", flush=True)
222
+ if args.nan_rollback > 0 and nan_streak >= args.nan_rollback:
223
+ rollback = latest_ckpt(ckpt_dir)
224
+ if rollback is not None:
225
+ sd = torch.load(rollback, map_location="cpu")
226
+ model.load_state_dict(sd["model"])
227
+ opt.load_state_dict(sd["opt"])
228
+ step, best_val = sd["step"], sd.get("best_val", float("inf"))
229
+ nan_streak = 0
230
+ print(f" !! {args.nan_rollback} consecutive non-finite steps; "
231
+ f"rolled back to {rollback} at step {step}", flush=True)
232
+ else:
233
+ nan_streak = 0
234
+ print(" !! no checkpoint to roll back to; continuing", flush=True)
235
+ continue
236
+ nan_streak = 0
237
+ loss.backward()
238
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
239
+ lr = args.lr if step <= args.warmup else args.min_lr + 0.5 * (args.lr - args.min_lr) * (
240
+ 1 + math.cos(math.pi * (step - args.warmup) / max(1, target_steps - args.warmup)))
241
+ for g in opt.param_groups:
242
+ g["lr"] = lr
243
+ opt.step()
244
+
245
+ if step % args.log_every == 0 or step == 1:
246
+ dt = time.time() - t0
247
+ toks = args.batch * args.seq
248
+ print(f"step {step}/{target_steps} loss {loss.item():.4f} lr {lr:.2e} "
249
+ f"{toks*args.log_every/dt:.0f} tok/s", flush=True)
250
+ t0 = time.time()
251
+
252
+ if step % args.eval_every == 0:
253
+ vl = evaluate(model, val_tokens, args.batch, args.seq, args.val_batches, rng, bf16=args.bf16)
254
+ if vl < best_val:
255
+ best_val = vl
256
+ save(ckpt_dir / "model_best.pt", tag="best")
257
+ save(ckpt_dir / f"{SAVE_PREFIX}_{step}.pt", tag=f"step{step}")
258
+ print(f" [eval step {step}] val_loss {vl:.4f} (best {best_val:.4f})", flush=True)
259
+ for pr in prompts:
260
+ txt = sample(model, tok, pr, args.sample_max_new)
261
+ print(f" [gen] {pr} {txt}", flush=True)
262
+ model.train()
263
+
264
+ if step % args.save_every == 0:
265
+ save(ckpt_dir / f"{SAVE_PREFIX}_{step}.pt", tag=f"step{step}")
266
+ print(f" saved {ckpt_dir}/{SAVE_PREFIX}_{step}.pt", flush=True)
267
+
268
+ save(ckpt_dir / f"{SAVE_PREFIX}_{step}.pt", tag="final")
269
+ print("done", flush=True)
270
+
271
+
272
+ if __name__ == "__main__":
273
+ main()
train/train_lora.py ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LoRA SFT for TinyLiquid, per the small-model adaptation recipe (LoRA paper).
2
+
3
+ Freezes the pretrained base, trains low-rank adapters on the gated-MLP linears
4
+ plus persona embeddings, with a KL anchor to the base and a TinyStories PPL
5
+ guard. Best checkpoint is selected by masked SFT holdout loss while PPL < guard.
6
+ Saved checkpoints are FOLDED back into standard model keys (no lora_* in the
7
+ state dict), so hf/export_hf.py works unchanged.
8
+
9
+ Usage:
10
+ .venv/bin/python train/train_lora.py --base ckpt/nlp --data data/sft_mix_v5.jsonl \
11
+ --ckpt ckpt/v5_lora --epochs 2 --lr 3e-4 --r 16 --kl 0.05
12
+ """
13
+ import argparse, json, math, random, time
14
+ from pathlib import Path
15
+ import numpy as np
16
+ import torch
17
+ import torch.nn as nn
18
+ import torch.nn.functional as F
19
+
20
+ from model.config import TinyLiquidConfig, CONFIGS
21
+ from model.utils import latest_ckpt
22
+ from model.tiny_liquid import TinyLiquid
23
+ from data.tokenizer import load_tokenizer
24
+
25
+
26
+ def resolve_ckpt(path):
27
+ p = Path(path)
28
+ if p.is_file():
29
+ return p
30
+ ck = latest_ckpt(p)
31
+ assert ck, f"no checkpoints in {path}"
32
+ return ck
33
+
34
+ USER_T, ASST_T, EOT_T = "<|user|>", "<|assistant|>", "<|endoftext|>"
35
+ PERSONA_T = {"analyst": "<|analyst|>", "skeptic": "<|skeptic|>", "spock": "<|analyst|>", "none": ""}
36
+ P_IDS = {"analyst": 1, "skeptic": 2, "spock": 1, "none": 0}
37
+
38
+
39
+ class LoRALinear(nn.Module):
40
+ def __init__(self, base: nn.Linear, r: int, alpha: float, dropout: float):
41
+ super().__init__()
42
+ self.base = base
43
+ for p in base.parameters():
44
+ p.requires_grad = False
45
+ out_f, in_f = base.weight.shape
46
+ self.lora_a = nn.Parameter(torch.empty(in_f, r))
47
+ self.lora_b = nn.Parameter(torch.zeros(r, out_f))
48
+ nn.init.kaiming_uniform_(self.lora_a, a=math.sqrt(5))
49
+ self.dropout = nn.Dropout(dropout)
50
+ self.scale = alpha / max(1, r)
51
+
52
+ def forward(self, x):
53
+ return self.base(x) + (self.dropout(x) @ self.lora_a @ self.lora_b) * self.scale
54
+
55
+
56
+ def wrap_lora(model: TinyLiquid, r: int, alpha: float, dropout: float):
57
+ wrapped = []
58
+ for name, mod in list(model.named_modules()):
59
+ if isinstance(mod, nn.Linear) and not name.endswith("lm_head"):
60
+ lora = LoRALinear(mod, r, alpha, dropout)
61
+ parts = name.split(".")
62
+ parent = model
63
+ for p in parts[:-1]:
64
+ parent = parent._modules[p] if isinstance(parent, nn.Module) else getattr(parent, p)
65
+ parent._modules[parts[-1]] = lora
66
+ wrapped.append((name, lora))
67
+ return wrapped
68
+
69
+
70
+ def fold_state_dict(sd, wrapped):
71
+ out = {}
72
+ for k, v in sd.items():
73
+ if any(k.startswith(n + ".") and not k.startswith(n + ".base.") for n, _ in wrapped):
74
+ continue # lora_a / lora_b
75
+ matched = False
76
+ for name, _ in wrapped:
77
+ if k.startswith(name + ".base."):
78
+ out[name + "." + k.split(".base.", 1)[1]] = v.clone()
79
+ matched = True
80
+ break
81
+ if not matched:
82
+ out[k] = v.clone()
83
+ for name, lora in wrapped:
84
+ delta = (lora.lora_a @ lora.lora_b).t() * lora.scale
85
+ out[name + ".weight"] = out[name + ".weight"] + delta.detach()
86
+ return out
87
+
88
+
89
+ def parse_args():
90
+ ap = argparse.ArgumentParser()
91
+ ap.add_argument("--base", default="ckpt/nlp")
92
+ ap.add_argument("--resume", default="", help="resume from latest ckpt in this dir")
93
+ ap.add_argument("--data", default="data/sft_mix_v5.jsonl")
94
+ ap.add_argument("--tok", default="data/tokenizer.json")
95
+ ap.add_argument("--ckpt", default="ckpt/v5_lora")
96
+ ap.add_argument("--val-bin", default="data/valid.bin")
97
+ ap.add_argument("--replay-bin", default="", help="tokenized bin to mix as fluency replay (raw full-loss items)")
98
+ ap.add_argument("--replay-ratio", type=float, default=0.5, help="fraction of replay items in the train mixture (0..1)")
99
+ ap.add_argument("--epochs", type=int, default=2)
100
+ ap.add_argument("--batch", type=int, default=8)
101
+ ap.add_argument("--seq", type=int, default=256)
102
+ ap.add_argument("--lr", type=float, default=3e-4)
103
+ ap.add_argument("--r", type=int, default=16)
104
+ ap.add_argument("--alpha", type=float, default=32.0)
105
+ ap.add_argument("--dropout", type=float, default=0.05)
106
+ ap.add_argument("--kl", type=float, default=0.05)
107
+ ap.add_argument("--eval-every", type=int, default=25)
108
+ ap.add_argument("--log-every", type=int, default=25)
109
+ ap.add_argument("--ppl-guard", type=float, default=60.0)
110
+ ap.add_argument("--resume-best-sft", type=float, default=None)
111
+ ap.add_argument("--resume-best-ppl", type=float, default=None)
112
+ ap.add_argument("--val-batches", type=int, default=2)
113
+ ap.add_argument("--seed", type=int, default=17)
114
+ ap.add_argument("--threads", type=int, default=4)
115
+ return ap.parse_args()
116
+
117
+
118
+ def tokenize_example(tok, ex, seq, u_id, a_id, eot_id):
119
+ if "raw" in ex:
120
+ ids = tok.encode(ex["raw"]).ids + [eot_id]
121
+ x = torch.tensor(ids[:-1], dtype=torch.long)
122
+ y = torch.tensor(ids[1:], dtype=torch.long)
123
+ return x[:seq], y[:seq], torch.ones_like(y[:seq], dtype=torch.bool), 0
124
+ persona_name = ex.get("persona", "analyst")
125
+ persona = PERSONA_T.get(persona_name, PERSONA_T["analyst"])
126
+ p_id = P_IDS.get(persona_name, 1)
127
+ p_ids = tok.encode(persona).ids if persona else []
128
+ ids = p_ids + [u_id] + tok.encode(ex["user"]).ids + [a_id] + tok.encode(ex["assistant"]).ids + [eot_id]
129
+ if len(ids) > seq:
130
+ return None
131
+ asst_start = len(p_ids) + 1 + len(tok.encode(ex["user"]).ids) + 1
132
+ x = torch.tensor(ids[:-1], dtype=torch.long)
133
+ y = torch.tensor(ids[1:], dtype=torch.long)
134
+ mask = torch.zeros_like(y, dtype=torch.bool)
135
+ mask[asst_start - 1:] = True
136
+ if int(mask.sum()) < 16:
137
+ return None
138
+ return x, y, mask, p_id
139
+
140
+
141
+ def collate(items, seq):
142
+ xs, ys, ms, ps = [], [], [], []
143
+ for x, y, m, p in items:
144
+ xs.append(F.pad(x, (0, seq - x.shape[0]), value=0))
145
+ ys.append(F.pad(y, (0, seq - y.shape[0]), value=0))
146
+ ms.append(F.pad(m, (0, seq - m.shape[0]), value=False))
147
+ ps.append(p)
148
+ return torch.stack(xs), torch.stack(ys), torch.stack(ms), torch.tensor(ps, dtype=torch.long)
149
+
150
+
151
+ @torch.no_grad()
152
+ def val_ppl(model, val_bin, batch=4, seq=64, n_batches=2, seed=0):
153
+ mm = np.memmap(val_bin, dtype=np.uint16, mode="r")
154
+ total, cnt = 0.0, 0
155
+ rng = np.random.RandomState(seed)
156
+ n = (len(mm) - 1) // seq
157
+ for _ in range(n_batches):
158
+ s = int(rng.randint(0, n - batch))
159
+ buf = torch.stack([torch.from_numpy(mm[s * seq + i * seq: s * seq + i * seq + seq].astype(np.int64)) for i in range(batch)])
160
+ x, y = buf[:, :-1], buf[:, 1:]
161
+ loss = F.cross_entropy(model(x).reshape(-1, model.cfg.vocab_size), y.reshape(-1))
162
+ total += loss.item() * y.numel(); cnt += y.numel()
163
+ return float(np.exp(total / cnt))
164
+
165
+
166
+ def main():
167
+ args = parse_args()
168
+ torch.set_num_threads(args.threads)
169
+ torch.manual_seed(args.seed); random.seed(args.seed)
170
+ rng = random.Random(args.seed)
171
+ tok = load_tokenizer(args.tok)
172
+ u_id, a_id, eot_id = tok.token_to_id(USER_T), tok.token_to_id(ASST_T), tok.token_to_id(EOT_T)
173
+ assert None not in (u_id, a_id, eot_id)
174
+
175
+ raw = [json.loads(l) for l in open(args.data, encoding="utf-8") if l.strip()]
176
+ teacher_path = resolve_ckpt(args.base)
177
+ model_path = resolve_ckpt(args.resume) if args.resume else teacher_path
178
+ resume_ck = torch.load(model_path, map_location="cpu") if args.resume else None
179
+ base = torch.load(model_path, map_location="cpu")
180
+ config = base.get("config") or CONFIGS["tiny10m"]
181
+ cfg = TinyLiquidConfig(vocab_size=tok.get_vocab_size(),
182
+ **{k: v for k, v in config.items() if k != "vocab_size"})
183
+ cfg.mtp_heads = 0 # MTP is pretrain-only; post-training has no MTP heads
184
+ model = TinyLiquid(cfg); model.load_state_dict(base["model"], strict=False)
185
+ teacher_sd = torch.load(teacher_path, map_location="cpu")["model"]
186
+
187
+ teacher = None
188
+ if args.kl > 0:
189
+ teacher = TinyLiquid(cfg); teacher.load_state_dict(teacher_sd, strict=False); teacher.eval()
190
+ for p in teacher.parameters(): p.requires_grad = False
191
+
192
+ wrapped = wrap_lora(model, args.r, args.alpha, args.dropout)
193
+ for p in model.parameters():
194
+ p.requires_grad = False
195
+ for p in model.persona_emb.parameters():
196
+ p.requires_grad = True
197
+ for _, lora in wrapped:
198
+ lora.base.weight.requires_grad = False
199
+ lora.lora_a.requires_grad = True
200
+ lora.lora_b.requires_grad = True
201
+ trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
202
+ opt = torch.optim.AdamW([p for p in model.parameters() if p.requires_grad],
203
+ lr=args.lr, betas=(0.9, 0.95), weight_decay=0.02)
204
+ print(f"base {model_path.name} | lora adapters {len(wrapped)} | trainable {trainable:,}", flush=True)
205
+
206
+ items_all = [tokenize_example(tok, e, args.seq, u_id, a_id, eot_id) for e in raw]
207
+ items_all = [i for i in items_all if i is not None]
208
+ rng.shuffle(items_all)
209
+
210
+ n_eval = min(128, max(16, len(items_all) // 12))
211
+ eval_items, train_items = items_all[:n_eval], items_all[n_eval:]
212
+ if args.replay_bin:
213
+ mm = np.memmap(args.replay_bin, dtype=np.uint16, mode="r")
214
+ n = (len(mm) - 1) // args.seq
215
+ gold_n = max(1, len(train_items))
216
+ replay_n = int(gold_n * args.replay_ratio / max(1e-9, 1.0 - args.replay_ratio))
217
+ rr = np.random.RandomState(args.seed + 1)
218
+ for _ in range(replay_n):
219
+ s = int(rr.randint(0, n))
220
+ w = torch.from_numpy(mm[s * args.seq: (s + 1) * args.seq].astype(np.int64))
221
+ x, y = w[:-1], w[1:]
222
+ train_items.append((x, y, torch.ones_like(y, dtype=torch.bool), 0))
223
+ print(f"replay: {replay_n} raw items from {args.replay_bin} (mixture ratio {args.replay_ratio:.2f})", flush=True)
224
+ print(f"train {len(train_items)} eval {len(eval_items)} filtered {len(raw) - len(items_all)}", flush=True)
225
+
226
+ out = Path(args.ckpt); out.mkdir(parents=True, exist_ok=True)
227
+ best_score = args.resume_best_sft if args.resume_best_sft is not None else float("inf")
228
+ best_ppl = args.resume_best_ppl if args.resume_best_ppl is not None else float("inf")
229
+ step = (resume_ck or {}).get("step", 0)
230
+ start_iter = (resume_ck or {}).get("iter", step)
231
+ t0 = time.time()
232
+ total_steps = (len(train_items) // args.batch) * args.epochs
233
+ if step:
234
+ print(f"resuming from {model_path} at step {step}/{total_steps} iter {start_iter}", flush=True)
235
+
236
+ def run_eval():
237
+ model.eval()
238
+ total, n = 0.0, 0
239
+ for i in range(0, len(eval_items), args.batch):
240
+ x, y, m, p = collate(eval_items[i:i + args.batch], args.seq)
241
+ with torch.no_grad():
242
+ logits = model(x, persona_ids=p).reshape(-1, model.cfg.vocab_size)
243
+ loss = F.cross_entropy(logits, y.reshape(-1), reduction="none")
244
+ loss = (loss * m.reshape(-1)).sum() / m.sum()
245
+ total += loss.item() * m.sum().item(); n += m.sum().item()
246
+ ppl = val_ppl(model, args.val_bin, n_batches=args.val_batches, seed=args.seed + step)
247
+ model.train()
248
+ return total / n, ppl
249
+
250
+ def save(path, tag=""):
251
+ sd = fold_state_dict(model.state_dict(), wrapped)
252
+ torch.save({"model": sd, "step": step, "iter": iter_no, "config": cfg.__dict__, "tag": tag}, str(path))
253
+
254
+ model.train()
255
+ iter_no = 0
256
+ for ep in range(args.epochs):
257
+ rng.shuffle(train_items)
258
+ usable = len(train_items) - len(train_items) % args.batch
259
+ for i in range(0, usable, args.batch):
260
+ iter_no += 1
261
+ if iter_no <= start_iter:
262
+ continue
263
+ step += 1
264
+ x, y, m, p = collate(train_items[i:i + args.batch], args.seq)
265
+ opt.zero_grad(set_to_none=True)
266
+ logits = model(x, persona_ids=p)
267
+ sft_loss = F.cross_entropy(logits.reshape(-1, model.cfg.vocab_size), y.reshape(-1), reduction="none")
268
+ sft_loss = (sft_loss * m.reshape(-1)).sum() / m.sum()
269
+ loss = sft_loss
270
+ if teacher is not None:
271
+ with torch.no_grad():
272
+ t_logits = teacher(x, persona_ids=p)
273
+ kl = F.kl_div(F.log_softmax(logits.float(), dim=-1), F.softmax(t_logits.float(), dim=-1),
274
+ reduction="none").sum(dim=-1)
275
+ loss = loss + args.kl * (kl * m).sum() / m.sum()
276
+ loss.backward()
277
+ torch.nn.utils.clip_grad_norm_([p for p in model.parameters() if p.requires_grad], 0.5)
278
+ opt.step()
279
+ if step % args.log_every == 0:
280
+ print(f"step {step}/{total_steps} loss {loss.item():.4f} sft {sft_loss.item():.4f} "
281
+ f"{args.batch * args.seq * args.log_every / max(1e-6, time.time() - t0):.0f} tok/s", flush=True)
282
+ t0 = time.time()
283
+ if step % args.eval_every == 0:
284
+ sft_vl, ppl = run_eval()
285
+ tag = ""
286
+ if ppl < best_ppl:
287
+ best_ppl = ppl; save(out / "best_ppl.pt", tag="best_ppl"); tag += " [best ppl]"
288
+ if ppl <= args.ppl_guard and sft_vl < best_score:
289
+ best_score = sft_vl; save(out / "best.pt", tag="best"); tag += " [new best]"
290
+ save(out / f"model_{step}.pt", tag=f"step{step}")
291
+ try:
292
+ sp = tok.encode("<|analyst|><|user|>Find discrepancies between: Account A: The meeting ended at 11am. Account B: The meeting ended at noon.<|assistant|>").ids
293
+ with torch.no_grad():
294
+ sout = tok.decode(model.generate(tok, sp, persona_id=1, max_new=50, temperature=0.35,
295
+ top_k=20, repetition_penalty=1.25, no_repeat_ngram_size=4)[len(sp):]).replace("\n", " ").strip()[:180]
296
+ print(f" [eval {step}] sft_val_loss {sft_vl:.4f} val_ppl {ppl:.2f}{tag}", flush=True)
297
+ print(f" sample: {sout}", flush=True)
298
+ except Exception:
299
+ print(f" [eval {step}] sft_val_loss {sft_vl:.4f} val_ppl {ppl:.2f}{tag}", flush=True)
300
+ sd = fold_state_dict(model.state_dict(), wrapped)
301
+ torch.save({"model": sd, "step": step, "config": cfg.__dict__, "tag": "final"}, out / "model_final.pt")
302
+ print(f"done -> {out} best_sft={best_score:.4f} best_ppl={best_ppl:.2f}", flush=True)
303
+
304
+
305
+ if __name__ == "__main__":
306
+ main()
train/train_sft.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Supervised fine-tune of TinyLiquid for forensic analysis (SOP + scratchpad).
2
+
3
+ Format per example:
4
+ <|persona|><|user|>USER<|assistant|>ASSISTANT<|endoftext|>
5
+ Loss is masked to the ASSISTANT segment (including scratchpad markers).
6
+
7
+ Usage:
8
+ .venv/bin/python train/train_sft.py --base ckpt/nlp --data data/sft_forensic.jsonl \
9
+ --ckpt ckpt/forensic --epochs 3
10
+ """
11
+
12
+ import argparse
13
+ import json
14
+ import math
15
+ import random
16
+ import time
17
+ from pathlib import Path
18
+
19
+ import torch
20
+ import torch.nn.functional as F
21
+
22
+ from model.config import TinyLiquidConfig, CONFIGS
23
+ from model.utils import latest_ckpt
24
+ from model.tiny_liquid import TinyLiquid
25
+ from data.tokenizer import load_tokenizer
26
+
27
+ USER_T = "<|user|>"
28
+ ASST_T = "<|assistant|>"
29
+ EOT_T = "<|endoftext|>"
30
+ PERSONA_T = {"analyst": "<|analyst|>", "skeptic": "<|skeptic|>", "spock": "<|analyst|>"}
31
+
32
+
33
+ def parse_args():
34
+ ap = argparse.ArgumentParser()
35
+ ap.add_argument("--base", default="ckpt/nlp", help="dir with pretrain checkpoints")
36
+ ap.add_argument("--data", default="data/sft_forensic.jsonl")
37
+ ap.add_argument("--tok", default="data/tokenizer.json")
38
+ ap.add_argument("--ckpt", default="ckpt/forensic")
39
+ ap.add_argument("--epochs", type=int, default=3)
40
+ ap.add_argument("--batch", type=int, default=8)
41
+ ap.add_argument("--seq", type=int, default=256)
42
+ ap.add_argument("--lr", type=float, default=5e-5)
43
+ ap.add_argument("--eval-every", type=int, default=200)
44
+ ap.add_argument("--log-every", type=int, default=25)
45
+ ap.add_argument("--seed", type=int, default=7)
46
+ ap.add_argument("--threads", type=int, default=8)
47
+ return ap.parse_args()
48
+
49
+
50
+ def tokenize_example(tok, ex, seq, u_id, a_id, eot_id, p_ids):
51
+ persona = PERSONA_T.get(ex["persona"], PERSONA_T["analyst"])
52
+ p_id = p_ids[ex["persona"]] if ex["persona"] in p_ids else p_ids["analyst"]
53
+ parts = [tok.encode(persona).ids, [u_id], tok.encode(ex["user"]).ids,
54
+ [a_id], tok.encode(ex["assistant"]).ids, [eot_id]]
55
+ ids = [i for part in parts for i in part]
56
+ if len(ids) > seq: # truncate assistant side
57
+ keep = seq - 1
58
+ ids = ids[:keep] + [eot_id]
59
+ asst_start = len(parts[0]) + 1 + len(parts[2]) + 1 # index of first assistant token
60
+ x = torch.tensor(ids[:-1], dtype=torch.long)
61
+ y = torch.tensor(ids[1:], dtype=torch.long)
62
+ mask = torch.zeros_like(y, dtype=torch.bool)
63
+ mask[asst_start - 1:] = True # y positions that predict assistant tokens
64
+ mask = mask[: x.shape[0]]
65
+ return x, y[: x.shape[0]], mask, p_id
66
+
67
+
68
+ def collate(items, seq):
69
+ xs, ys, ms, ps = [], [], [], []
70
+ for x, y, m, p in items:
71
+ xs.append(F.pad(x, (0, seq - x.shape[0]), value=0))
72
+ ys.append(F.pad(y, (0, seq - y.shape[0]), value=0))
73
+ ms.append(F.pad(m, (0, seq - m.shape[0]), value=False))
74
+ ps.append(p)
75
+ return (torch.stack(xs), torch.stack(ys), torch.stack(ms),
76
+ torch.tensor(ps, dtype=torch.long))
77
+
78
+
79
+ def main():
80
+ args = parse_args()
81
+ torch.set_num_threads(args.threads)
82
+ torch.manual_seed(args.seed)
83
+ random.seed(args.seed)
84
+ rng = random.Random(args.seed)
85
+
86
+ tok = load_tokenizer(args.tok)
87
+ u_id = tok.token_to_id(USER_T)
88
+ a_id = tok.token_to_id(ASST_T)
89
+ eot_id = tok.token_to_id(EOT_T)
90
+ # persona embedding indices (NOT tokenizer ids): 0=none, 1=analyst, 2=skeptic
91
+ p_ids = {"analyst": 1, "skeptic": 2}
92
+ assert None not in (u_id, a_id, eot_id), "special tokens missing from tokenizer"
93
+
94
+ examples = [json.loads(l) for l in open(args.data, encoding="utf-8") if l.strip()]
95
+ rng.shuffle(examples)
96
+ n_eval = min(128, len(examples) // 10)
97
+ eval_ex, train_ex = examples[:n_eval], examples[n_eval:]
98
+ print(f"train {len(train_ex)} eval {len(eval_ex)}", flush=True)
99
+
100
+ base_ckpt = latest_ckpt(args.base)
101
+ assert base_ckpt, f"no pretrain checkpoint in {args.base}"
102
+ base = torch.load(base_ckpt, map_location="cpu")
103
+ config = base.get("config") or CONFIGS["tiny10m"]
104
+ cfg = TinyLiquidConfig(vocab_size=tok.get_vocab_size(),
105
+ **{k: v for k, v in config.items() if k != "vocab_size"})
106
+ model = TinyLiquid(cfg)
107
+ model.load_state_dict(base["model"])
108
+ opt = torch.optim.AdamW(model.parameters(), lr=args.lr, betas=(0.9, 0.95), weight_decay=0.05)
109
+ print(f"loaded base {base_ckpt.name} (step {base.get('step', '?')})", flush=True)
110
+
111
+ out_dir = Path(args.ckpt)
112
+ out_dir.mkdir(parents=True, exist_ok=True)
113
+ steps_per_epoch = max(1, len(train_ex) // args.batch)
114
+ total_steps = steps_per_epoch * args.epochs
115
+ model.train()
116
+
117
+ def make_items(exs):
118
+ return [tokenize_example(tok, e, args.seq, u_id, a_id, eot_id, p_ids) for e in exs]
119
+
120
+ def run_eval():
121
+ model.eval()
122
+ items = make_items(eval_ex)
123
+ total, n = 0.0, 0
124
+ for i in range(0, len(items), args.batch):
125
+ x, y, m, p = collate(items[i:i + args.batch], args.seq)
126
+ with torch.no_grad():
127
+ logits = model(x, persona_ids=p)
128
+ logits = logits.reshape(-1, logits.size(-1))
129
+ loss = F.cross_entropy(logits, y.reshape(-1), reduction="none")
130
+ loss = (loss * m.reshape(-1)).sum() / m.sum()
131
+ total += loss.item() * m.sum().item()
132
+ n += m.sum().item()
133
+ model.train()
134
+ return total / n
135
+
136
+ t0 = time.time()
137
+ step = 0
138
+ for ep in range(args.epochs):
139
+ rng.shuffle(train_ex)
140
+ items = make_items(train_ex)
141
+ for i in range(0, len(items) - len(items) % args.batch, args.batch):
142
+ step += 1
143
+ x, y, m, p = collate(items[i:i + args.batch], args.seq)
144
+ opt.zero_grad(set_to_none=True)
145
+ logits = model(x, persona_ids=p)
146
+ logits = logits.reshape(-1, logits.size(-1))
147
+ loss = F.cross_entropy(logits, y.reshape(-1), reduction="none")
148
+ loss = (loss * m.reshape(-1)).sum() / m.sum()
149
+ loss.backward()
150
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
151
+ opt.step()
152
+
153
+ if step % args.log_every == 0:
154
+ dt = time.time() - t0
155
+ print(f"step {step}/{total_steps} loss {loss.item():.4f} "
156
+ f"{args.batch*args.seq*args.log_every/dt:.0f} tok/s", flush=True)
157
+ t0 = time.time()
158
+ if step % args.eval_every == 0:
159
+ vl = run_eval()
160
+ print(f" [eval {step}] sft_val_loss {vl:.4f}", flush=True)
161
+ torch.save({"model": model.state_dict(), "opt": opt.state_dict(),
162
+ "step": step, "config": cfg.__dict__},
163
+ str(out_dir / f"model_{step}.pt"))
164
+ torch.save({"model": model.state_dict(), "opt": opt.state_dict(),
165
+ "step": step, "config": cfg.__dict__}, str(out_dir / "model_final.pt"))
166
+ print(f"done -> {out_dir}", flush=True)
167
+
168
+
169
+ if __name__ == "__main__":
170
+ main()
train/train_sft2.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Guarded SFT for TinyLiquid: chat+SOP+forensic mix with TinyStories retention.
2
+
3
+ Differs from train_sft.py:
4
+ * supports raw full-loss retention examples ({"raw": text})
5
+ * evals BOTH masked SFT holdout loss AND TinyStories val PPL (coherence guard)
6
+ * keeps best.pt (min sft_val_loss while val_ppl < 90) and best_ppl.pt (min ppl)
7
+
8
+ Usage:
9
+ .venv/bin/python train/train_sft2.py --base ckpt/nlp --data data/sft_mix_v2.jsonl \
10
+ --ckpt ckpt/v2 --epochs 3 --lr 2e-5
11
+ """
12
+ import argparse, json, math, random, time
13
+ from pathlib import Path
14
+ import numpy as np
15
+ import torch
16
+ import torch.nn.functional as F
17
+
18
+ from model.config import TinyLiquidConfig, CONFIGS
19
+ from model.utils import latest_ckpt
20
+ from model.tiny_liquid import TinyLiquid
21
+ from data.tokenizer import load_tokenizer
22
+
23
+ USER_T, ASST_T, EOT_T = "<|user|>", "<|assistant|>", "<|endoftext|>"
24
+ PERSONA_T = {"analyst": "<|analyst|>", "skeptic": "<|skeptic|>", "spock": "<|analyst|>", "none": ""}
25
+ P_IDS = {"analyst": 1, "skeptic": 2, "spock": 1, "none": 0}
26
+
27
+ def parse_args():
28
+ ap = argparse.ArgumentParser()
29
+ ap.add_argument("--base", default="ckpt/nlp")
30
+ ap.add_argument("--resume", default="", help="resume from latest ckpt in this dir")
31
+ ap.add_argument("--data", default="data/sft_mix_v2.jsonl")
32
+ ap.add_argument("--tok", default="data/tokenizer.json")
33
+ ap.add_argument("--ckpt", default="ckpt/v2")
34
+ ap.add_argument("--val-bin", default="data/valid.bin")
35
+ ap.add_argument("--epochs", type=int, default=3)
36
+ ap.add_argument("--batch", type=int, default=8)
37
+ ap.add_argument("--seq", type=int, default=256)
38
+ ap.add_argument("--lr", type=float, default=2e-5)
39
+ ap.add_argument("--eval-every", type=int, default=25)
40
+ ap.add_argument("--log-every", type=int, default=25)
41
+ ap.add_argument("--ppl-guard", type=float, default=90.0)
42
+ ap.add_argument("--val-batches", type=int, default=2)
43
+ ap.add_argument("--seed", type=int, default=7)
44
+ ap.add_argument("--threads", type=int, default=8)
45
+ return ap.parse_args()
46
+
47
+ def tokenize_example(tok, ex, seq, u_id, a_id, eot_id):
48
+ if "raw" in ex:
49
+ ids = tok.encode(ex["raw"]).ids + [eot_id]
50
+ x = torch.tensor(ids[:-1], dtype=torch.long)
51
+ y = torch.tensor(ids[1:], dtype=torch.long)
52
+ mask = torch.ones_like(y, dtype=torch.bool)
53
+ return x[:seq], y[:seq], mask[:seq], 0
54
+ persona = PERSONA_T.get(ex.get("persona", "analyst"), PERSONA_T["analyst"])
55
+ p_id = P_IDS.get(ex.get("persona"), 1)
56
+ user_ids = tok.encode(ex["user"]).ids
57
+ asst_ids = tok.encode(ex["assistant"]).ids
58
+ if persona:
59
+ p_ids_ = tok.encode(persona).ids
60
+ ids = p_ids_ + [u_id] + user_ids + [a_id] + asst_ids + [eot_id]
61
+ asst_start = len(p_ids_) + 1 + len(user_ids) + 1
62
+ else:
63
+ ids = [u_id] + user_ids + [a_id] + asst_ids + [eot_id]
64
+ asst_start = 1 + len(user_ids) + 1
65
+ if len(ids) > seq:
66
+ ids = ids[:seq - 1] + [eot_id]
67
+ x = torch.tensor(ids[:-1], dtype=torch.long)
68
+ y = torch.tensor(ids[1:], dtype=torch.long)
69
+ mask = torch.zeros_like(y, dtype=torch.bool)
70
+ mask[asst_start - 1:] = True
71
+ return x[:seq], y[:seq], mask[:seq], p_id
72
+
73
+ def collate(items, seq):
74
+ xs, ys, ms, ps = [], [], [], []
75
+ for x, y, m, p in items:
76
+ xs.append(F.pad(x, (0, seq - x.shape[0]), value=0))
77
+ ys.append(F.pad(y, (0, seq - y.shape[0]), value=0))
78
+ ms.append(F.pad(m, (0, seq - m.shape[0]), value=False))
79
+ ps.append(p)
80
+ return torch.stack(xs), torch.stack(ys), torch.stack(ms), torch.tensor(ps, dtype=torch.long)
81
+
82
+ @torch.no_grad()
83
+ def val_ppl(model, val_bin, batch=4, seq=64, n_batches=2, seed=0):
84
+ mm = np.memmap(val_bin, dtype=np.uint16, mode="r")
85
+ total, cnt = 0.0, 0
86
+ rng = np.random.RandomState(seed)
87
+ n = (len(mm) - 1) // seq
88
+ for b in range(n_batches):
89
+ s = int(rng.randint(0, n - batch))
90
+ buf = torch.stack([torch.from_numpy(mm[s * seq + i * seq: s * seq + i * seq + seq].astype(np.int64))
91
+ for i in range(batch)])
92
+ x, y = buf[:, :-1], buf[:, 1:]
93
+ loss = F.cross_entropy(model(x).reshape(-1, 8192), y.reshape(-1))
94
+ total += loss.item() * y.numel(); cnt += y.numel()
95
+ return float(np.exp(total / cnt))
96
+
97
+ def main():
98
+ args = parse_args()
99
+ torch.set_num_threads(args.threads)
100
+ torch.manual_seed(args.seed); random.seed(args.seed)
101
+ rng = random.Random(args.seed)
102
+ tok = load_tokenizer(args.tok)
103
+ u_id, a_id, eot_id = tok.token_to_id(USER_T), tok.token_to_id(ASST_T), tok.token_to_id(EOT_T)
104
+ assert None not in (u_id, a_id, eot_id)
105
+
106
+ exs = [json.loads(l) for l in open(args.data, encoding="utf-8") if l.strip()]
107
+ rng.shuffle(exs)
108
+ n_eval = min(128, max(8, len(exs) // 12))
109
+ eval_ex, train_ex = exs[:n_eval], exs[n_eval:]
110
+ print(f"train {len(train_ex)} eval {len(eval_ex)}", flush=True)
111
+
112
+ base_path = latest_ckpt(args.resume or args.base)
113
+ base = torch.load(base_path, map_location="cpu")
114
+ base_cfg = base.get("config") or CONFIGS["tiny10m"]
115
+ cfg = TinyLiquidConfig(vocab_size=tok.get_vocab_size(),
116
+ **{k: v for k, v in base_cfg.items() if k != "vocab_size"})
117
+ model = TinyLiquid(cfg)
118
+ model.load_state_dict(base["model"])
119
+ opt = torch.optim.AdamW(model.parameters(), lr=args.lr, betas=(0.9, 0.95), weight_decay=0.05)
120
+ print(f"loaded base {base_path.name}", flush=True)
121
+
122
+ out = Path(args.ckpt); out.mkdir(parents=True, exist_ok=True)
123
+ steps_per_epoch = max(1, len(train_ex) // args.batch)
124
+ total_steps = steps_per_epoch * args.epochs
125
+
126
+ def make_items(exs_):
127
+ return [tokenize_example(tok, e, args.seq, u_id, a_id, eot_id) for e in exs_]
128
+
129
+ eval_items = make_items(eval_ex)
130
+
131
+ def run_eval():
132
+ model.eval()
133
+ total, n = 0.0, 0
134
+ for i in range(0, len(eval_items), args.batch):
135
+ x, y, m, p = collate(eval_items[i:i + args.batch], args.seq)
136
+ with torch.no_grad():
137
+ logits = model(x, persona_ids=p).reshape(-1, 8192)
138
+ loss = F.cross_entropy(logits, y.reshape(-1), reduction="none")
139
+ loss = (loss * m.reshape(-1)).sum() / m.sum()
140
+ total += loss.item() * m.sum().item(); n += m.sum().item()
141
+ sft_vl = total / n
142
+ ppl = val_ppl(model, args.val_bin, n_batches=args.val_batches)
143
+ model.train()
144
+ return sft_vl, ppl
145
+
146
+ best_guard, best_ppl = float("inf"), float("inf")
147
+ t0 = time.time(); step = 0
148
+ for ep in range(args.epochs):
149
+ rng.shuffle(train_ex)
150
+ items = make_items(train_ex)
151
+ for i in range(0, len(items) - len(items) % args.batch, args.batch):
152
+ step += 1
153
+ x, y, m, p = collate(items[i:i + args.batch], args.seq)
154
+ opt.zero_grad(set_to_none=True)
155
+ logits = model(x, persona_ids=p).reshape(-1, 8192)
156
+ loss = F.cross_entropy(logits, y.reshape(-1), reduction="none")
157
+ loss = (loss * m.reshape(-1)).sum() / m.sum()
158
+ loss.backward()
159
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
160
+ opt.step()
161
+ if step % args.log_every == 0:
162
+ print(f"step {step}/{total_steps} loss {loss.item():.4f} "
163
+ f"{args.batch*args.seq*args.log_every/(time.time()-t0):.0f} tok/s", flush=True)
164
+ t0 = time.time()
165
+ if step % args.eval_every == 0:
166
+ sft_vl, ppl = run_eval()
167
+ try:
168
+ sp = tok.encode("<|analyst|><|user|>Find discrepancies between: Account A: The meeting ended at 11am. Account B: The meeting ended at noon.<|assistant|>").ids
169
+ with torch.no_grad():
170
+ sout = tok.decode(model.generate(tok, sp, persona_id=1, max_new=50, temperature=0.35,
171
+ top_k=20, repetition_penalty=1.25,
172
+ no_repeat_ngram_size=4)[len(sp):]).replace("\n", " ").strip()[:180]
173
+ print(f" sample: {sout}", flush=True)
174
+ except Exception:
175
+ pass
176
+ tag = ""
177
+ if ppl < args.ppl_guard and sft_vl < best_guard:
178
+ best_guard = sft_vl
179
+ torch.save({"model": model.state_dict(), "step": step, "config": cfg.__dict__}, out / "best.pt")
180
+ tag += " [new best]"
181
+ if ppl < best_ppl:
182
+ best_ppl = ppl
183
+ torch.save({"model": model.state_dict(), "step": step, "config": cfg.__dict__}, out / "best_ppl.pt")
184
+ tag += " [best ppl]"
185
+ torch.save({"model": model.state_dict(), "step": step, "config": cfg.__dict__}, out / f"model_{step}.pt")
186
+ print(f" [eval {step}] sft_val_loss {sft_vl:.4f} val_ppl {ppl:.2f}{tag}", flush=True)
187
+ torch.save({"model": model.state_dict(), "step": step, "config": cfg.__dict__}, out / "model_final.pt")
188
+ print(f"done -> {out} best_guard_sft_loss={best_guard:.4f} best_ppl={best_ppl:.2f}", flush=True)
189
+
190
+ if __name__ == "__main__":
191
+ main()
train/train_sft_v4.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Conservative SFT for TinyLiquid v4.
2
+
3
+ Designed for the 7.8M TinyLiquid model where full SFT caused catastrophic
4
+ forgetting/model-collapse into broken forensic jargon. Defaults freeze most of
5
+ the model, train only persona embeddings + final liquid block + output norm,
6
+ and add a base-model KL anchor.
7
+ """
8
+ import argparse
9
+ import json
10
+ import random
11
+ import time
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+ import torch
16
+ import torch.nn.functional as F
17
+
18
+ from model.config import CONFIGS, TinyLiquidConfig
19
+ from model.tiny_liquid import TinyLiquid
20
+ from model.utils import latest_ckpt
21
+ from data.tokenizer import load_tokenizer
22
+
23
+ USER_T, ASST_T, EOT_T = '<|user|>', '<|assistant|>', '<|endoftext|>'
24
+ PERSONA_T = {'analyst': '<|analyst|>', 'skeptic': '<|skeptic|>', 'spock': '<|analyst|>', 'none': ''}
25
+ P_IDS = {'analyst': 1, 'skeptic': 2, 'spock': 1, 'none': 0}
26
+
27
+
28
+ def parse_args():
29
+ ap = argparse.ArgumentParser()
30
+ ap.add_argument('--base', default='ckpt/nlp')
31
+ ap.add_argument('--data', default='data/sft_mix_v4.jsonl')
32
+ ap.add_argument('--tok', default='data/tokenizer.json')
33
+ ap.add_argument('--ckpt', default='ckpt/v4')
34
+ ap.add_argument('--val-bin', default='data/valid.bin')
35
+ ap.add_argument('--epochs', type=int, default=2)
36
+ ap.add_argument('--batch', type=int, default=8)
37
+ ap.add_argument('--seq', type=int, default=256)
38
+ ap.add_argument('--lr', type=float, default=8e-6)
39
+ ap.add_argument('--kl', type=float, default=0.05)
40
+ ap.add_argument('--eval-every', type=int, default=25)
41
+ ap.add_argument('--log-every', type=int, default=25)
42
+ ap.add_argument('--ppl-guard', type=float, default=45.0)
43
+ ap.add_argument('--val-batches', type=int, default=2)
44
+ ap.add_argument('--resume', default=None, help='continue from this checkpoint (model weights + step); teacher/KL anchor stays --base')
45
+ ap.add_argument('--resume-best-sft', type=float, default=None)
46
+ ap.add_argument('--resume-best-ppl', type=float, default=None)
47
+ ap.add_argument('--resume-iter', type=int, default=None, help='iterations already trained (needed when saved step is a shifted label)')
48
+ ap.add_argument('--train-scope', choices=['head', 'last', 'last2', 'full'], default='last')
49
+ ap.add_argument('--seed', type=int, default=11)
50
+ ap.add_argument('--threads', type=int, default=8)
51
+ return ap.parse_args()
52
+
53
+
54
+ def resolve_ckpt(path):
55
+ p = Path(path)
56
+ if p.is_file():
57
+ return p
58
+ ck = latest_ckpt(p)
59
+ assert ck, f'no checkpoints in {path}'
60
+ return ck
61
+
62
+
63
+ def tokenize_example(tok, ex, seq, u_id, a_id, eot_id):
64
+ if 'raw' in ex:
65
+ ids = tok.encode(ex['raw']).ids + [eot_id]
66
+ x = torch.tensor(ids[:-1], dtype=torch.long)
67
+ y = torch.tensor(ids[1:], dtype=torch.long)
68
+ mask = torch.ones_like(y, dtype=torch.bool)
69
+ return x[:seq], y[:seq], mask[:seq], 0
70
+
71
+ persona_name = ex.get('persona', 'analyst')
72
+ persona = PERSONA_T.get(persona_name, PERSONA_T['analyst'])
73
+ p_id = P_IDS.get(persona_name, 1)
74
+ user_ids = tok.encode(ex['user']).ids
75
+ asst_ids = tok.encode(ex['assistant']).ids
76
+ p_ids = tok.encode(persona).ids if persona else []
77
+ ids = p_ids + [u_id] + user_ids + [a_id] + asst_ids + [eot_id]
78
+ asst_start = len(p_ids) + 1 + len(user_ids) + 1
79
+ if len(ids) > seq:
80
+ return None
81
+ x = torch.tensor(ids[:-1], dtype=torch.long)
82
+ y = torch.tensor(ids[1:], dtype=torch.long)
83
+ mask = torch.zeros_like(y, dtype=torch.bool)
84
+ mask[asst_start - 1:] = True
85
+ if int(mask.sum().item()) < 16:
86
+ return None
87
+ return x, y, mask, p_id
88
+
89
+
90
+ def collate(items, seq):
91
+ xs, ys, ms, ps = [], [], [], []
92
+ for x, y, m, p in items:
93
+ xs.append(F.pad(x, (0, seq - x.shape[0]), value=0))
94
+ ys.append(F.pad(y, (0, seq - y.shape[0]), value=0))
95
+ ms.append(F.pad(m, (0, seq - m.shape[0]), value=False))
96
+ ps.append(p)
97
+ return torch.stack(xs), torch.stack(ys), torch.stack(ms), torch.tensor(ps, dtype=torch.long)
98
+
99
+
100
+ def set_train_scope(model, scope):
101
+ for p in model.parameters():
102
+ p.requires_grad = False
103
+ for p in model.persona_emb.parameters():
104
+ p.requires_grad = True
105
+ if scope in {'last', 'last2', 'full'}:
106
+ for p in model.blocks[-1].parameters():
107
+ p.requires_grad = True
108
+ for p in model.norm_out.parameters():
109
+ p.requires_grad = True
110
+ if scope in {'last2', 'full'}:
111
+ for p in model.blocks[-2].parameters():
112
+ p.requires_grad = True
113
+ if scope == 'full':
114
+ for p in model.parameters():
115
+ p.requires_grad = True
116
+ return sum(p.numel() for p in model.parameters() if p.requires_grad)
117
+
118
+
119
+ @torch.no_grad()
120
+ def val_ppl(model, val_bin, batch=4, seq=64, n_batches=2, seed=0):
121
+ mm = np.memmap(val_bin, dtype=np.uint16, mode='r')
122
+ total, cnt = 0.0, 0
123
+ rng = np.random.RandomState(seed)
124
+ n = (len(mm) - 1) // seq
125
+ for _ in range(n_batches):
126
+ s = int(rng.randint(0, n - batch))
127
+ buf = torch.stack([torch.from_numpy(mm[s * seq + i * seq: s * seq + i * seq + seq].astype(np.int64)) for i in range(batch)])
128
+ x, y = buf[:, :-1], buf[:, 1:]
129
+ loss = F.cross_entropy(model(x).reshape(-1, model.cfg.vocab_size), y.reshape(-1))
130
+ total += loss.item() * y.numel()
131
+ cnt += y.numel()
132
+ return float(np.exp(total / cnt))
133
+
134
+
135
+ @torch.no_grad()
136
+ def sample_text(model, tok, prompt, persona_id):
137
+ ids = tok.encode(prompt).ids
138
+ out = model.generate(tok, ids, persona_id=persona_id, max_new=60, temperature=0.35, top_k=20, repetition_penalty=1.25, no_repeat_ngram_size=4)
139
+ return tok.decode(out[len(ids):]).replace('\n', ' ').strip()[:220]
140
+
141
+
142
+ def main():
143
+ args = parse_args()
144
+ torch.set_num_threads(args.threads)
145
+ torch.manual_seed(args.seed)
146
+ random.seed(args.seed)
147
+ rng = random.Random(args.seed)
148
+
149
+ tok = load_tokenizer(args.tok)
150
+ u_id, a_id, eot_id = tok.token_to_id(USER_T), tok.token_to_id(ASST_T), tok.token_to_id(EOT_T)
151
+ assert None not in (u_id, a_id, eot_id)
152
+
153
+ raw_exs = [json.loads(line) for line in open(args.data, encoding='utf-8') if line.strip()]
154
+ rng.shuffle(raw_exs)
155
+ base_path = resolve_ckpt(args.base)
156
+ base = torch.load(base_path, map_location='cpu')
157
+ resume_path = resolve_ckpt(args.resume) if args.resume else None
158
+ resume = torch.load(resume_path, map_location='cpu') if resume_path else None
159
+ config = (resume or base).get('config') or CONFIGS['tiny10m']
160
+ cfg = TinyLiquidConfig(vocab_size=tok.get_vocab_size(),
161
+ **{k: v for k, v in config.items() if k != 'vocab_size'})
162
+ model = TinyLiquid(cfg)
163
+ model.load_state_dict((resume or base)['model'])
164
+
165
+ teacher = None
166
+ if args.kl > 0:
167
+ teacher = TinyLiquid(cfg)
168
+ teacher.load_state_dict(base['model'])
169
+ teacher.eval()
170
+ for p in teacher.parameters():
171
+ p.requires_grad = False
172
+
173
+ trainable = set_train_scope(model, args.train_scope)
174
+ opt = torch.optim.AdamW([p for p in model.parameters() if p.requires_grad], lr=args.lr, betas=(0.9, 0.95), weight_decay=0.02)
175
+ print(f'loaded base {base_path} trainable {trainable}/{sum(p.numel() for p in model.parameters())} scope={args.train_scope}', flush=True)
176
+
177
+ items_all = [tokenize_example(tok, e, args.seq, u_id, a_id, eot_id) for e in raw_exs]
178
+ items_all = [item for item in items_all if item is not None]
179
+ rng.shuffle(items_all)
180
+ n_eval = min(128, max(16, len(items_all) // 12))
181
+ eval_items, train_items = items_all[:n_eval], items_all[n_eval:]
182
+ print(f'train {len(train_items)} eval {len(eval_items)} filtered {len(raw_exs) - len(items_all)}', flush=True)
183
+
184
+ out = Path(args.ckpt)
185
+ out.mkdir(parents=True, exist_ok=True)
186
+ best_score = args.resume_best_sft if args.resume_best_sft is not None else float('inf')
187
+ best_ppl = args.resume_best_ppl if args.resume_best_ppl is not None else float('inf')
188
+ step = resume.get('step', 0) if resume is not None else 0
189
+ start_iter = args.resume_iter if args.resume_iter is not None else (resume.get('iter', step) if resume is not None else 0)
190
+ start_step = step
191
+ t0 = time.time()
192
+ total_steps = (len(train_items) // args.batch) * args.epochs
193
+ if start_step:
194
+ print(f'resuming from {resume_path} at step {start_step}/{total_steps} iter {start_iter}', flush=True)
195
+
196
+ def run_eval():
197
+ model.eval()
198
+ total, n = 0.0, 0
199
+ for i in range(0, len(eval_items), args.batch):
200
+ x, y, m, p = collate(eval_items[i:i + args.batch], args.seq)
201
+ logits = model(x, persona_ids=p).reshape(-1, model.cfg.vocab_size)
202
+ loss = F.cross_entropy(logits, y.reshape(-1), reduction='none')
203
+ loss = (loss * m.reshape(-1)).sum() / m.sum()
204
+ total += loss.item() * m.sum().item()
205
+ n += m.sum().item()
206
+ ppl = val_ppl(model, args.val_bin, n_batches=args.val_batches, seed=args.seed + step)
207
+ model.train()
208
+ return total / n, ppl
209
+
210
+ model.train()
211
+ iter_no = 0
212
+ for ep in range(args.epochs):
213
+ rng.shuffle(train_items)
214
+ usable = len(train_items) - len(train_items) % args.batch
215
+ for i in range(0, usable, args.batch):
216
+ iter_no += 1
217
+ if iter_no <= start_iter:
218
+ continue
219
+ step += 1
220
+ x, y, m, p = collate(train_items[i:i + args.batch], args.seq)
221
+ opt.zero_grad(set_to_none=True)
222
+ logits = model(x, persona_ids=p)
223
+ sft_loss = F.cross_entropy(logits.reshape(-1, model.cfg.vocab_size), y.reshape(-1), reduction='none')
224
+ sft_loss = (sft_loss * m.reshape(-1)).sum() / m.sum()
225
+ loss = sft_loss
226
+ if teacher is not None:
227
+ with torch.no_grad():
228
+ t_logits = teacher(x, persona_ids=p)
229
+ kl = F.kl_div(
230
+ F.log_softmax(logits.float(), dim=-1),
231
+ F.softmax(t_logits.float(), dim=-1),
232
+ reduction='none',
233
+ ).sum(dim=-1)
234
+ kl = (kl * m).sum() / m.sum()
235
+ loss = loss + args.kl * kl
236
+ loss.backward()
237
+ torch.nn.utils.clip_grad_norm_([p for p in model.parameters() if p.requires_grad], 0.5)
238
+ opt.step()
239
+
240
+ if step % args.log_every == 0:
241
+ print(f'step {step}/{total_steps} loss {loss.item():.4f} sft {sft_loss.item():.4f} {args.batch * args.seq * args.log_every / max(1e-6, time.time() - t0):.0f} tok/s', flush=True)
242
+ t0 = time.time()
243
+ if step % args.eval_every == 0:
244
+ sft_vl, ppl = run_eval()
245
+ tag = ''
246
+ if ppl < best_ppl:
247
+ best_ppl = ppl
248
+ torch.save({'model': model.state_dict(), 'step': step, 'iter': iter_no, 'config': cfg.__dict__}, out / 'best_ppl.pt')
249
+ tag += ' [best ppl]'
250
+ if ppl <= args.ppl_guard and sft_vl < best_score:
251
+ best_score = sft_vl
252
+ torch.save({'model': model.state_dict(), 'step': step, 'iter': iter_no, 'config': cfg.__dict__}, out / 'best.pt')
253
+ tag += ' [new best]'
254
+ torch.save({'model': model.state_dict(), 'step': step, 'iter': iter_no, 'config': cfg.__dict__}, out / f'model_{step}.pt')
255
+ print(f' [eval {step}] sft_val_loss {sft_vl:.4f} val_ppl {ppl:.2f}{tag}', flush=True)
256
+ print(' sample:', sample_text(model, tok, '<|analyst|><|user|>Find discrepancies between: Account A: The meeting ended at 11am. Account B: The meeting ended at noon.<|assistant|>', 1), flush=True)
257
+ torch.save({'model': model.state_dict(), 'step': step, 'iter': iter_no, 'config': cfg.__dict__}, out / 'model_final.pt')
258
+ print(f'done -> {out} best_sft={best_score:.4f} best_ppl={best_ppl:.2f}', flush=True)
259
+
260
+
261
+ if __name__ == '__main__':
262
+ main()
train/watchdog_50m.sh ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Self-healing runner for the 50M continue-pretrain.
3
+ # Resume-safe (ckpt every 500) + auto-restart on any death (OOM/kill/reboot-loss).
4
+ # Stops when the latest checkpoint reaches TARGET_STEPS.
5
+ # Launch detached: setsid nohup ./train/watchdog_50m.sh > /dev/null 2>&1 < /dev/null &
6
+ set -u
7
+ cd "$(dirname "$0")/.."
8
+ export PYTHONPATH=$PWD MALLOC_ARENA_MAX=2
9
+
10
+ CKPT=ckpt/hybrid50m_pretrain
11
+ LOG=logs/pretrain50m.log
12
+ TARGET_STEPS=5000
13
+ MAX_STEPS_PER_ATTEMPT=4000 # cap per launch; remaining steps are computed from latest ckpt
14
+
15
+ last_step() {
16
+ .venv/bin/python - "$CKPT" << 'PYEOF' 2>/dev/null
17
+ import sys, torch
18
+ from pathlib import Path
19
+ from model.utils import latest_ckpt
20
+ p = latest_ckpt(sys.argv[1])
21
+ if p is None:
22
+ print(0)
23
+ else:
24
+ sd = torch.load(p, map_location="cpu", weights_only=False)
25
+ print(sd.get("step", 0))
26
+ PYEOF
27
+ }
28
+
29
+ for attempt in $(seq 1 999); do
30
+ step0=$(last_step)
31
+ if [ "$step0" -ge "$TARGET_STEPS" ]; then
32
+ echo "target $TARGET_STEPS reached (step $step0); stopping watchdog" >> "$LOG"
33
+ break
34
+ fi
35
+ need=$((TARGET_STEPS - step0))
36
+ if [ "$need" -gt "$MAX_STEPS_PER_ATTEMPT" ]; then
37
+ need=$MAX_STEPS_PER_ATTEMPT
38
+ fi
39
+ now=$(date "+%Y-%m-%d %H:%M:%S")
40
+ echo "=== attempt $attempt start $now (step $step0 -> $TARGET_STEPS, $need steps) ===" >> "$LOG"
41
+ .venv/bin/python -u train/train_lm.py \
42
+ --data data/train_phase2b.bin --val data/valid.bin \
43
+ --tok data/tokenizer.json --config hybrid50m \
44
+ --ckpt "$CKPT" --resume "$CKPT" \
45
+ --batch 8 --seq 512 --lr 1.5e-4 --warmup 300 --steps "$need" \
46
+ --eval-every 500 --save-every 500 --val-batches 40 \
47
+ --bf16 --threads 6 --nan-rollback 50 --log-every 50 \
48
+ >> "$LOG" 2>&1
49
+ code=$?
50
+ echo "=== attempt $attempt exit code $code $(date) ===" >> "$LOG"
51
+ step=$(last_step)
52
+ if [ "$step" -ge "$TARGET_STEPS" ]; then
53
+ echo "target $TARGET_STEPS reached (step $step); stopping watchdog" >> "$LOG"
54
+ break
55
+ fi
56
+ echo "died at step $step; restarting in 30s" >> "$LOG"
57
+ sleep 30
58
+ done
train/watchdog_dpo_v22.sh ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Self-healing LoRA-DPO v22 runner with checkpoint resume.
3
+ # Launch detached: setsid nohup ./train/watchdog_dpo_v22.sh >/dev/null 2>&1 </dev/null &
4
+ # to survive env session kills. Watchdog owns its own session (PPid=1) so it
5
+ # persists across interactive session ends and re-launches killed children.
6
+ set -u
7
+ cd "$(dirname "$0")/.."
8
+ export PYTHONPATH=$PWD MALLOC_ARENA_MAX=2
9
+ CKPT=ckpt/hybrid50m_v22_dpo_full_recovery
10
+ LOG=logs/dpo_v22_full_recovery.log
11
+ RESUME=ckpt/hybrid50m_v22_dpo/model_600_recovery.pt
12
+ RESUME_FLAGS=(--resume-best-ppl 25.76 --reset-optimizer)
13
+
14
+ # Continue the newest valid recovery checkpoint when the device kills the
15
+ # child. Recovery checkpoints carry best_ppl and RNG state; the folded source
16
+ # archive needs the explicit legacy metadata and fresh optimizer only once.
17
+ latest_step=-1
18
+ for candidate in "$CKPT"/model_*.pt; do
19
+ [ -f "$candidate" ] || continue
20
+ step_name=${candidate##*/model_}
21
+ step_name=${step_name%.pt}
22
+ case "$step_name" in ''|*[!0-9]*) continue ;; esac
23
+ if [ "$step_name" -gt "$latest_step" ]; then
24
+ latest_step=$step_name
25
+ RESUME="$candidate"
26
+ RESUME_FLAGS=()
27
+ fi
28
+ done
29
+
30
+ if [ -f "$CKPT/dpo_complete.json" ]; then
31
+ echo "DPO completion manifest already exists; not relaunching" >> "$LOG"
32
+ exit 0
33
+ fi
34
+ mkdir -p "$CKPT"
35
+
36
+ for attempt in $(seq 1 999); do
37
+ echo "=== dpo attempt $attempt $(date '+%Y-%m-%d %H:%M:%S') ===" >> "$LOG"
38
+ # Resume from model_600.pt if it exists; otherwise start from model_base
39
+ if [ -f "$RESUME" ]; then
40
+ echo "resuming from truthful step-600 recovery archive with fresh optimizer" >> "$LOG"
41
+ ARGS=(--base ckpt/hybrid50m_v22_lora/best.pt --data data/prefs_p_all.jsonl --tok data/tokenizer16k.json
42
+ --ckpt "$CKPT" --val-bin data/valid16k.bin --epochs 1 --batch 4 --seq 512
43
+ --lr 1e-7 --beta 0.05
44
+ --ppl-guard 60.0 --eval-every 100 --save-every 100 --threads 6
45
+ "${RESUME_FLAGS[@]}" --resume "$RESUME")
46
+ else
47
+ ARGS=(--base ckpt/hybrid50m_v22_lora/best.pt --data data/prefs_p_all.jsonl --tok data/tokenizer16k.json
48
+ --ckpt "$CKPT" --val-bin data/valid16k.bin --epochs 1 --batch 4 --seq 512
49
+ --lr 4e-6 --beta 0.05 --lora --r 16 --alpha 32.0 --dropout 0.05
50
+ --ppl-guard 60.0 --eval-every 100 --save-every 100 --threads 6)
51
+ fi
52
+ .venv/bin/python -u train/train_dpo.py "${ARGS[@]}" >> "$LOG" 2>&1
53
+ code=$?
54
+ echo "=== dpo attempt $attempt exit $code $(date '+%H:%M:%S') ===" >> "$LOG"
55
+ if [ -f "$CKPT/dpo_complete.json" ]; then
56
+ echo "DPO COMPLETE: $CKPT/model_final.pt" >> "$LOG"
57
+ exit 0
58
+ fi
59
+ if [ -f "$CKPT/dpo_failed.json" ]; then
60
+ echo "DPO failure manifest written; preserving checkpoint for triage" >> "$LOG"
61
+ exit 3
62
+ fi
63
+ if [ "$code" -eq 3 ]; then
64
+ echo "PPL guard breached; preserving last good checkpoint and stopping" >> "$LOG"
65
+ exit 3
66
+ fi
67
+ if [ "$code" -eq 1 ] || [ "$code" -eq 2 ]; then
68
+ echo "Configuration or runtime error; stopping instead of retrying blindly" >> "$LOG"
69
+ exit "$code"
70
+ fi
71
+ sleep 20
72
+ done
train/watchdog_dpo_v25.sh ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Self-healing LFM2 DPO v25 runner (beta=5.0, cosine LR, schema-matched prefs).
3
+ # Resume-safe; stops when model_final.pt exists.
4
+ # Launch detached: setsid nohup ./train/watchdog_dpo_v25.sh >/dev/null 2>&1 </dev/null & disown
5
+ set -u
6
+ cd "$(dirname "$0")/.."
7
+ export PYTHONPATH=$PWD MALLOC_ARENA_MAX=2
8
+
9
+ CKPT=ckpt/hybrid50m_v25_dpo
10
+ LOG=logs/dpo_v25.log
11
+ mkdir -p "$CKPT"
12
+
13
+ for attempt in $(seq 1 999); do
14
+ echo "=== dpo attempt $attempt $(date "+%Y-%m-%d %H:%M:%S") ===" >> "$LOG"
15
+ ARGS=(--base ckpt/hybrid50m_v25_lora/best.pt --data data/prefs_v23.jsonl --tok data/tokenizer16k.json
16
+ --ckpt "$CKPT" --val-bin data/valid16k.bin --epochs 1 --batch 4 --seq 512
17
+ --lr 8e-7 --lr-schedule cosine --lr-min 8e-8 --warmup-frac 0.01
18
+ --beta 5.0 --margin 0.1 --apo-weight 0.2 --threads 6
19
+ --ppl-guard 60.0 --eval-every 100 --save-every 100
20
+ --lora --r 16 --alpha 32.0 --dropout 0.05)
21
+ if [ -d "$CKPT" ] && ls "$CKPT"/*.pt >/dev/null 2>&1; then
22
+ ARGS+=(--resume "$CKPT")
23
+ fi
24
+ .venv/bin/python -u train/train_dpo.py "${ARGS[@]}" >> "$LOG" 2>&1
25
+ code=$?
26
+ echo "=== dpo attempt $attempt exit $code $(date "+%H:%M:%S") ===" >> "$LOG"
27
+ if [ -f "$CKPT/model_final.pt" ]; then
28
+ echo "DPO COMPLETE: $CKPT/model_final.pt" >> "$LOG"
29
+ exit 0
30
+ fi
31
+ echo "died; retrying in 20s" >> "$LOG"
32
+ sleep 20
33
+ done
train/watchdog_dpo_v26.sh ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Self-healing LFM2 DPO v26 runner (beta=5.0, cosine LR, 154 schema-matched prefs).
3
+ # Resume-safe; stops when model_final.pt exists.
4
+ # Launch detached: setsid nohup ./train/watchdog_dpo_v26.sh >/dev/null 2>&1 </dev/null & disown
5
+ set -u
6
+ cd "$(dirname "$0")/.."
7
+ export PYTHONPATH=$PWD MALLOC_ARENA_MAX=2
8
+
9
+ CKPT=ckpt/hybrid50m_v26_dpo
10
+ LOG=logs/dpo_v26.log
11
+ mkdir -p "$CKPT"
12
+
13
+ for attempt in $(seq 1 999); do
14
+ echo "=== dpo attempt $attempt $(date "+%Y-%m-%d %H:%M:%S") ===" >> "$LOG"
15
+ ARGS=(--base ckpt/hybrid50m_v26_lora/best.pt --data data/prefs_v26.jsonl --tok data/tokenizer16k.json
16
+ --ckpt "$CKPT" --val-bin data/valid16k.bin --epochs 1 --batch 4 --seq 512
17
+ --lr 8e-7 --lr-schedule cosine --lr-min 8e-8 --warmup-frac 0.01
18
+ --beta 5.0 --margin 0.1 --apo-weight 0.2 --threads 6
19
+ --ppl-guard 60.0 --eval-every 100 --save-every 100
20
+ --lora --r 16 --alpha 32.0 --dropout 0.05)
21
+ if [ -d "$CKPT" ] && ls "$CKPT"/*.pt >/dev/null 2>&1; then
22
+ ARGS+=(--resume "$CKPT")
23
+ fi
24
+ .venv/bin/python -u train/train_dpo.py "${ARGS[@]}" >> "$LOG" 2>&1
25
+ code=$?
26
+ echo "=== dpo attempt $attempt exit $code $(date "+%H:%M:%S") ===" >> "$LOG"
27
+ if [ -f "$CKPT/model_final.pt" ]; then
28
+ echo "DPO COMPLETE: $CKPT/model_final.pt" >> "$LOG"
29
+ exit 0
30
+ fi
31
+ echo "died; retrying in 20s" >> "$LOG"
32
+ sleep 20
33
+ done
train/watchdog_eval_candidates.sh ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Candidate battery evals (tiny-model-eval discipline): SFT best, then DPO
3
+ # best_ppl, each on the SAME fixed battery (main + researcher + red-team).
4
+ # Sequential (one heavy torch job at a time), resume-safe per battery:
5
+ # per-battery logs + eval.py --resume-from, so a device kill never re-runs
6
+ # completed probes. Launch detached:
7
+ # setsid nohup ./train/watchdog_eval_candidates.sh >/dev/null 2>&1 </dev/null & disown
8
+ set -u
9
+ cd "$(dirname "$0")/.."
10
+ export PYTHONPATH=$PWD MALLOC_ARENA_MAX=2
11
+ TOK=data/tokenizer16k.json
12
+ THREADS=6
13
+ mkdir -p logs
14
+
15
+ CANDIDATES=(
16
+ "sft_best|ckpt/hybrid50m_v22_lora/best.pt"
17
+ "dpo_best_ppl|ckpt/hybrid50m_v22_dpo_full_recovery/best_ppl.pt"
18
+ )
19
+ BATTERIES=( "main|data/eval_probes.jsonl" "researcher|data/probes_researcher.jsonl" "redteam|data/eval_redteam.jsonl" )
20
+
21
+ echo "=== candidate eval watchdog start $(date '+%Y-%m-%d %H:%M:%S') ===" >> logs/eval_v22_candidates.log
22
+
23
+ for cand in "${CANDIDATES[@]}"; do
24
+ name=${cand%%|*}
25
+ ckpt=${cand##*|}
26
+ for bat in "${BATTERIES[@]}"; do
27
+ label=${bat%%|*}
28
+ probes=${bat##*|}
29
+ marker="logs/eval_cand_${name}_${label}.done"
30
+ if [ -f "$marker" ]; then
31
+ echo "=== [$name/$label] already complete, skipping ===" >> logs/eval_v22_candidates.log
32
+ continue
33
+ fi
34
+ runlog="logs/eval_cand_${name}_${label}.log"
35
+ attempt=0
36
+ while [ "$attempt" -lt 8 ]; do
37
+ attempt=$((attempt+1))
38
+ echo "=== [$name/$label] attempt $attempt $(date '+%Y-%m-%d %H:%M:%S') ===" >> "$runlog"
39
+ .venv/bin/python -u research/eval.py --ckpt "$ckpt" --tok "$TOK" --threads "$THREADS" \
40
+ --probes "$probes" --resume-from "$runlog" >> "$runlog" 2>&1
41
+ code=$?
42
+ echo "=== [$name/$label] exit $code $(date '+%H:%M:%S') ===" >> "$runlog"
43
+ if [ "$code" -eq 0 ]; then
44
+ touch "$marker"
45
+ break
46
+ fi
47
+ sleep 30
48
+ done
49
+ done
50
+ done
51
+
52
+ echo "=== ALL CANDIDATE EVALS COMPLETE $(date '+%Y-%m-%d %H:%M:%S') ===" >> logs/eval_v22_candidates.log
train/watchdog_eval_merges_v25.sh ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Main-battery evals for v25 merge candidates (tiny-model-posttrain: parallel
3
+ # merge -> eval -> select). Sequential, resume-safe per candidate.
4
+ # Launch detached: setsid nohup ./train/watchdog_eval_merges_v25.sh >/dev/null 2>&1 </dev/null & disown
5
+ set -u
6
+ cd "$(dirname "$0")/.."
7
+ export PYTHONPATH=$PWD MALLOC_ARENA_MAX=2
8
+ TOK=data/tokenizer16k.json
9
+ THREADS=6
10
+ mkdir -p logs
11
+
12
+ CANDIDATES=(
13
+ "soup|ckpt/hybrid50m_v25_merges/soup.pt"
14
+ "taskarith|ckpt/hybrid50m_v25_merges/taskarith_l0p5.pt"
15
+ "ties|ckpt/hybrid50m_v25_merges/ties_k0p2.pt"
16
+ )
17
+ echo "=== merge candidate eval start $(date '+%Y-%m-%d %H:%M:%S') ===" >> logs/eval_v25_merge_candidates.log
18
+
19
+ for cand in "${CANDIDATES[@]}"; do
20
+ name=${cand%%|*}
21
+ ckpt=${cand##*|}
22
+ marker="logs/eval_cand_merge_${name}_main.done"
23
+ if [ -f "$marker" ]; then
24
+ echo "=== [$name/main] already complete, skipping ===" >> logs/eval_v25_merge_candidates.log
25
+ continue
26
+ fi
27
+ runlog="logs/eval_cand_merge_${name}_main.log"
28
+ attempt=0
29
+ while [ "$attempt" -lt 8 ]; do
30
+ attempt=$((attempt+1))
31
+ echo "=== [$name/main] attempt $attempt $(date '+%Y-%m-%d %H:%M:%S') ===" >> "$runlog"
32
+ .venv/bin/python -u research/eval.py --ckpt "$ckpt" --tok "$TOK" --threads "$THREADS" \
33
+ --probes data/eval_probes.jsonl --resume-from "$runlog" >> "$runlog" 2>&1
34
+ code=$?
35
+ echo "=== [$name/main] exit $code $(date '+%H:%M:%S') ===" >> "$runlog"
36
+ if [ "$code" -eq 0 ]; then
37
+ touch "$marker"
38
+ break
39
+ fi
40
+ sleep 30
41
+ done
42
+ done
43
+
44
+ echo "=== ALL MERGE CANDIDATE EVALS COMPLETE $(date '+%Y-%m-%d %H:%M:%S') ===" >> logs/eval_v25_merge_candidates.log
train/watchdog_eval_v22.sh ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -u
3
+ cd "$(dirname "$0")/.."
4
+ export PYTHONPATH=$PWD MALLOC_ARENA_MAX=2
5
+ CKPT=ckpt/hybrid50m_v22_dpo_full_recovery
6
+ LOG=logs/eval_v22_detached.log
7
+ if [ -f "$CKPT/eval_complete.json" ]; then exit 0; fi
8
+ echo "=== eval start $(date '+%Y-%m-%d %H:%M:%S') ===" >> "$LOG"
9
+ for pair in "main data/eval_probes.jsonl" "researcher data/probes_researcher.jsonl" "redteam data/eval_redteam.jsonl"; do
10
+ set -- $pair
11
+ label=$1; probes=$2
12
+ echo "== $label battery ==" >> "$LOG"
13
+ .venv/bin/python research/eval.py --ckpt "$CKPT" --tok data/tokenizer16k.json --threads 6 --probes "$probes" >> "$LOG" 2>&1
14
+ code=$?
15
+ if [ "$code" -ne 0 ]; then echo "EVAL FAILED label=$label code=$code" >> "$LOG"; exit "$code"; fi
16
+ done
17
+ printf '%s\n' '{"status":"complete","checkpoint":"ckpt/hybrid50m_v22_dpo_full_recovery/model_final.pt","tokenizer":"data/tokenizer16k.json"}' > "$CKPT/eval_complete.json"
18
+ echo "=== eval complete $(date '+%Y-%m-%d %H:%M:%S') ===" >> "$LOG"
train/watchdog_eval_v23.sh ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Candidate battery evals for v23 (tiny-model-eval discipline): SFT best,
3
+ # each on the SAME fixed battery (main + researcher + red-team). Sequential
4
+ # (one heavy torch job at a time), resume-safe per battery. Launch detached:
5
+ # setsid nohup ./train/watchdog_eval_v23.sh >/dev/null 2>&1 </dev/null & disown
6
+ set -u
7
+ cd "$(dirname "$0")/.."
8
+ export PYTHONPATH=$PWD MALLOC_ARENA_MAX=2
9
+ TOK=data/tokenizer16k.json
10
+ THREADS=6
11
+ mkdir -p logs
12
+
13
+ CANDIDATES=(
14
+ "sft_best|ckpt/hybrid50m_v23_lora/best.pt"
15
+ )
16
+ BATTERIES=( "main|data/eval_probes.jsonl" "researcher|data/probes_researcher.jsonl" "redteam|data/eval_redteam.jsonl" )
17
+
18
+ echo "=== candidate eval watchdog start $(date '+%Y-%m-%d %H:%M:%S') ===" >> logs/eval_v23_candidates.log
19
+
20
+ for cand in "${CANDIDATES[@]}"; do
21
+ name=${cand%%|*}
22
+ ckpt=${cand##*|}
23
+ for bat in "${BATTERIES[@]}"; do
24
+ label=${bat%%|*}
25
+ probes=${bat##*|}
26
+ marker="logs/eval_cand_${name}_${label}.done"
27
+ if [ -f "$marker" ]; then
28
+ echo "=== [$name/$label] already complete, skipping ===" >> logs/eval_v23_candidates.log
29
+ continue
30
+ fi
31
+ runlog="logs/eval_cand_${name}_${label}.log"
32
+ attempt=0
33
+ while [ "$attempt" -lt 8 ]; do
34
+ attempt=$((attempt+1))
35
+ echo "=== [$name/$label] attempt $attempt $(date '+%Y-%m-%d %H:%M:%S') ===" >> "$runlog"
36
+ .venv/bin/python -u research/eval.py --ckpt "$ckpt" --tok "$TOK" --threads "$THREADS" \
37
+ --probes "$probes" --resume-from "$runlog" >> "$runlog" 2>&1
38
+ code=$?
39
+ echo "=== [$name/$label] exit $code $(date '+%H:%M:%S') ===" >> "$runlog"
40
+ if [ "$code" -eq 0 ]; then
41
+ touch "$marker"
42
+ break
43
+ fi
44
+ sleep 30
45
+ done
46
+ done
47
+ done
48
+
49
+ echo "=== ALL CANDIDATE EVALS COMPLETE $(date '+%Y-%m-%d %H:%M:%S') ===" >> logs/eval_v23_candidates.log
train/watchdog_eval_v25_dpo.sh ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Battery evals for v25 DPO (tiny-model-eval discipline): DPO model_final on
3
+ # SAME fixed battery (main + researcher + red-team). Sequential, resume-safe.
4
+ # Launch detached: setsid nohup ./train/watchdog_eval_v25_dpo.sh >/dev/null 2>&1 </dev/null & disown
5
+ set -u
6
+ cd "$(dirname "$0")/.."
7
+ export PYTHONPATH=$PWD MALLOC_ARENA_MAX=2
8
+ TOK=data/tokenizer16k.json
9
+ THREADS=6
10
+ mkdir -p logs
11
+
12
+ CANDIDATES=(
13
+ "dpo_v25_final|ckpt/hybrid50m_v25_dpo/model_final.pt"
14
+ )
15
+ BATTERIES=( "main|data/eval_probes.jsonl" "researcher|data/probes_researcher.jsonl" "redteam|data/eval_redteam.jsonl" )
16
+
17
+ echo "=== v25 DPO candidate eval watchdog start $(date '+%Y-%m-%d %H:%M:%S') ===" >> logs/eval_v25_dpo_candidates.log
18
+
19
+ for cand in "${CANDIDATES[@]}"; do
20
+ name=${cand%%|*}
21
+ ckpt=${cand##*|}
22
+ for bat in "${BATTERIES[@]}"; do
23
+ label=${bat%%|*}
24
+ probes=${bat##*|}
25
+ marker="logs/eval_cand_${name}_${label}.done"
26
+ if [ -f "$marker" ]; then
27
+ echo "=== [$name/$label] already complete, skipping ===" >> logs/eval_v25_dpo_candidates.log
28
+ continue
29
+ fi
30
+ runlog="logs/eval_cand_${name}_${label}.log"
31
+ attempt=0
32
+ while [ "$attempt" -lt 8 ]; do
33
+ attempt=$((attempt+1))
34
+ echo "=== [$name/$label] attempt $attempt $(date '+%Y-%m-%d %H:%M:%S') ===" >> "$runlog"
35
+ .venv/bin/python -u research/eval.py --ckpt "$ckpt" --tok "$TOK" --threads "$THREADS" \
36
+ --probes "$probes" --resume-from "$runlog" >> "$runlog" 2>&1
37
+ code=$?
38
+ echo "=== [$name/$label] exit $code $(date '+%H:%M:%S') ===" >> "$runlog"
39
+ if [ "$code" -eq 0 ]; then
40
+ touch "$marker"
41
+ break
42
+ fi
43
+ sleep 30
44
+ done
45
+ done
46
+ done
47
+
48
+ echo "=== ALL v25 DPO CANDIDATE EVALS COMPLETE $(date '+%Y-%m-%d %H:%M:%S') ===" >> logs/eval_v25_dpo_candidates.log
train/watchdog_hf_backup.sh ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Self-healing HF backup runner: restarts hf_backup.py until it reports DONE.
3
+ # Launch detached: setsid nohup ./train/watchdog_hf_backup.sh >/dev/null 2>&1 </dev/null & disown
4
+ set -u
5
+ cd "$(dirname "$0")/.."
6
+ export PYTHONPATH=$PWD
7
+ LOG=logs/hf_backup_all.log
8
+ echo "=== backup watchdog start $(date '+%Y-%m-%d %H:%M:%S') ===" >> "$LOG"
9
+
10
+ for attempt in $(seq 1 999); do
11
+ TOKEN=$(cat ~/.cache/huggingface/token 2>/dev/null || echo "${HF_TOKEN:-}")
12
+ env HF_TOKEN="$TOKEN" .venv/bin/python -u hf_backup.py --stage all >> "$LOG" 2>&1
13
+ code=$?
14
+ echo "=== backup attempt $attempt exit $code $(date '+%H:%M:%S') ===" >> "$LOG"
15
+ if [ $code -eq 0 ]; then
16
+ echo "BACKUP COMPLETE" >> "$LOG"
17
+ exit 0
18
+ fi
19
+ echo "died; retrying in 20s" >> "$LOG"
20
+ sleep 20
21
+ done
train/watchdog_lora_v22.sh ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Self-healing LoRA SFT v22 runner. Resume-safe; stops when model_final.pt exists.
3
+ # Launch detached: setsid nohup ./train/watchdog_lora_v22.sh > /dev/null 2>&1 < /dev/null &
4
+ set -u
5
+ cd "$(dirname "$0")/.."
6
+ export PYTHONPATH=$PWD MALLOC_ARENA_MAX=2
7
+ CKPT=ckpt/hybrid50m_v22_lora
8
+ LOG=logs/sft_v22.log
9
+
10
+ for attempt in $(seq 1 999); do
11
+ echo "=== lora attempt $attempt $(date "+%Y-%m-%d %H:%M:%S") ===" >> "$LOG"
12
+ ARGS=(--base ckpt/hybrid50m_v16k_pretrain --data data/sft_v22.jsonl --tok data/tokenizer16k.json
13
+ --ckpt "$CKPT" --val-bin data/valid16k.bin --replay-bin data/train_phase2b16k.bin
14
+ --replay-ratio 0.5 --epochs 3 --batch 4 --seq 512 --lr 5e-5 --r 16 --alpha 32.0
15
+ --dropout 0.05 --kl 0.1 --ppl-guard 60.0 --eval-every 25 --log-every 25
16
+ --val-batches 4 --threads 6)
17
+ if [ -d "$CKPT" ] && ls "$CKPT"/*.pt >/dev/null 2>&1; then
18
+ ARGS+=(--resume "$CKPT")
19
+ fi
20
+ .venv/bin/python -u train/train_lora.py "${ARGS[@]}" >> "$LOG" 2>&1
21
+ code=$?
22
+ echo "=== lora attempt $attempt exit $code $(date "+%H:%M:%S") ===" >> "$LOG"
23
+ if [ -f "$CKPT/model_final.pt" ]; then
24
+ echo "SFT COMPLETE: $CKPT/model_final.pt" >> "$LOG"
25
+ exit 0
26
+ fi
27
+ echo "died; retrying in 20s" >> "$LOG"
28
+ sleep 20
29
+ done
train/watchdog_lora_v23.sh ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Self-healing LoRA SFT v23 runner (class-balanced gold, clean 16k base).
3
+ # Resume-safe; stops when model_final.pt exists.
4
+ # Launch detached: setsid nohup ./train/watchdog_lora_v23.sh >/dev/null 2>&1 </dev/null & disown
5
+ set -u
6
+ cd "$(dirname "$0")/.."
7
+ export PYTHONPATH=$PWD MALLOC_ARENA_MAX=2
8
+ CKPT=ckpt/hybrid50m_v23_lora
9
+ LOG=logs/sft_v23.log
10
+ mkdir -p "$CKPT"
11
+
12
+ for attempt in $(seq 1 999); do
13
+ echo "=== lora attempt $attempt $(date "+%Y-%m-%d %H:%M:%S") ===" >> "$LOG"
14
+ ARGS=(--base ckpt/hybrid50m_v16k_pretrain --data data/sft_v23.jsonl --tok data/tokenizer16k.json
15
+ --ckpt "$CKPT" --val-bin data/valid16k.bin --replay-bin data/train_phase2b16k.bin
16
+ --replay-ratio 0.5 --epochs 3 --batch 4 --seq 512 --lr 5e-5 --r 16 --alpha 32.0
17
+ --dropout 0.05 --kl 0.1 --ppl-guard 60.0 --eval-every 25 --log-every 25
18
+ --val-batches 4 --threads 6)
19
+ if [ -d "$CKPT" ] && ls "$CKPT"/*.pt >/dev/null 2>&1; then
20
+ ARGS+=(--resume "$CKPT")
21
+ fi
22
+ .venv/bin/python -u train/train_lora.py "${ARGS[@]}" >> "$LOG" 2>&1
23
+ code=$?
24
+ echo "=== lora attempt $attempt exit $code $(date "+%H:%M:%S") ===" >> "$LOG"
25
+ if [ -f "$CKPT/model_final.pt" ]; then
26
+ echo "SFT COMPLETE: $CKPT/model_final.pt" >> "$LOG"
27
+ exit 0
28
+ fi
29
+ echo "died; retrying in 20s" >> "$LOG"
30
+ sleep 20
31
+ done
train/watchdog_lora_v25.sh ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Self-healing LoRA SFT v25 runner (handcrafted gold 3000-row milestone).
3
+ # Resume-safe; stops when model_final.pt exists.
4
+ # Launch detached: setsid nohup ./train/watchdog_lora_v25.sh >/dev/null 2>&1 </dev/null & disown
5
+ set -u
6
+ cd "$(dirname "$0")/.."
7
+ export PYTHONPATH=$PWD MALLOC_ARENA_MAX=2
8
+
9
+ CKPT=ckpt/hybrid50m_v25_lora
10
+ LOG=logs/sft_v25.log
11
+ mkdir -p "$CKPT"
12
+
13
+ for attempt in $(seq 1 999); do
14
+ echo "=== lora attempt $attempt $(date "+%Y-%m-%d %H:%M:%S") ===" >> "$LOG"
15
+ ARGS=(--base ckpt/hybrid50m_v16k_pretrain --data data/sft_v25.jsonl --tok data/tokenizer16k.json
16
+ --ckpt "$CKPT" --val-bin data/valid16k.bin --replay-bin data/train_phase2b16k.bin
17
+ --replay-ratio 0.5 --epochs 3 --batch 4 --seq 512 --lr 5e-5 --r 16 --alpha 32.0
18
+ --dropout 0.05 --kl 0.1 --ppl-guard 60.0 --eval-every 25 --log-every 25
19
+ --val-batches 4 --threads 6)
20
+ if [ -d "$CKPT" ] && ls "$CKPT"/*.pt >/dev/null 2>&1; then
21
+ ARGS+=(--resume "$CKPT")
22
+ fi
23
+ .venv/bin/python -u train/train_lora.py "${ARGS[@]}" >> "$LOG" 2>&1
24
+ code=$?
25
+ echo "=== lora attempt $attempt exit $code $(date "+%H:%M:%S") ===" >> "$LOG"
26
+ if [ -f "$CKPT/model_final.pt" ]; then
27
+ echo "SFT COMPLETE: $CKPT/model_final.pt" >> "$LOG"
28
+ exit 0
29
+ fi
30
+ echo "died; retrying in 20s" >> "$LOG"
31
+ sleep 20
32
+ done
train/watchdog_lora_v26.sh ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Self-healing LoRA SFT v26 runner (handcrafted gold 3000-row milestone).
3
+ # Resume-safe; stops when model_final.pt exists.
4
+ # Launch detached: setsid nohup ./train/watchdog_lora_v26.sh >/dev/null 2>&1 </dev/null & disown
5
+ set -u
6
+ cd "$(dirname "$0")/.."
7
+ export PYTHONPATH=$PWD MALLOC_ARENA_MAX=2
8
+
9
+ CKPT=ckpt/hybrid50m_v26_lora
10
+ LOG=logs/sft_v26.log
11
+ mkdir -p "$CKPT"
12
+
13
+ for attempt in $(seq 1 999); do
14
+ echo "=== lora attempt $attempt $(date "+%Y-%m-%d %H:%M:%S") ===" >> "$LOG"
15
+ ARGS=(--base ckpt/hybrid50m_v16k_pretrain --data data/sft_v26.jsonl --tok data/tokenizer16k.json
16
+ --ckpt "$CKPT" --val-bin data/valid16k.bin --replay-bin data/train_phase2b16k.bin
17
+ --replay-ratio 0.5 --epochs 3 --batch 4 --seq 512 --lr 5e-5 --r 16 --alpha 32.0
18
+ --dropout 0.05 --kl 0.1 --ppl-guard 60.0 --eval-every 25 --log-every 25
19
+ --val-batches 4 --threads 6)
20
+ if [ -d "$CKPT" ] && ls "$CKPT"/*.pt >/dev/null 2>&1; then
21
+ ARGS+=(--resume "$CKPT")
22
+ fi
23
+ .venv/bin/python -u train/train_lora.py "${ARGS[@]}" >> "$LOG" 2>&1
24
+ code=$?
25
+ echo "=== lora attempt $attempt exit $code $(date "+%H:%M:%S") ===" >> "$LOG"
26
+ if [ -f "$CKPT/model_final.pt" ]; then
27
+ echo "SFT COMPLETE: $CKPT/model_final.pt" >> "$LOG"
28
+ exit 0
29
+ fi
30
+ echo "died; retrying in 20s" >> "$LOG"
31
+ sleep 20
32
+ done
train/watchdog_merge_v25.sh ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Parallel merge recipes on the 16k base (tiny-model-posttrain): soup,
3
+ # task arithmetic, TIES. Resume-safe with .done markers per recipe.
4
+ # Launch detached: setsid nohup ./train/watchdog_merge_v25.sh >/dev/null 2>&1 </dev/null & disown
5
+ set -u
6
+ cd "$(dirname "$0")/.."
7
+ export PYTHONPATH=$PWD MALLOC_ARENA_MAX=2
8
+ LOG=logs/merge_v25.log
9
+ OUT=ckpt/hybrid50m_v25_merges
10
+ mkdir -p "$OUT" logs
11
+ echo "=== merge watchdog start $(date '+%Y-%m-%d %H:%M:%S') ===" >> "$LOG"
12
+
13
+ BASE=ckpt/hybrid50m_v16k_pretrain/model_5000.pt
14
+ SFT=ckpt/hybrid50m_v25_lora/best.pt
15
+ DPO=ckpt/hybrid50m_v25_dpo/model_final.pt
16
+
17
+ run_recipe() {
18
+ local name=$1; shift
19
+ if [ -f "$OUT/$name.pt.done" ] || [ -f "$OUT/$name.pt" ]; then
20
+ echo "[$name] already merged, skipping" >> "$LOG"
21
+ return
22
+ fi
23
+ echo "[$name] starting $(date '+%H:%M:%S')" >> "$LOG"
24
+ .venv/bin/python -u train/parallel_merges.py --base "$BASE" --tasks "$SFT" "$DPO" \
25
+ --out-dir "$OUT" --lambda-ta 0.5 --threads 4 >> "$LOG" 2>&1
26
+ local code=$?
27
+ echo "[$name] exit $code $(date '+%H:%M:%S')" >> "$LOG"
28
+ if [ $code -eq 0 ]; then touch "$OUT/$name.pt.done"; fi
29
+ }
30
+
31
+ # parallel_merges.py writes soup.pt + taskarith_l0p5.pt in one call
32
+ run_recipe "soup"
33
+ # TIES separately (existing script)
34
+ if [ ! -f "$OUT/ties_k0p2.pt" ]; then
35
+ echo "[ties] starting $(date '+%H:%M:%S')" >> "$LOG"
36
+ .venv/bin/python -u train/ties_merge.py --base "$BASE" --tasks "$SFT" "$DPO" \
37
+ --keep 0.2 --out "$OUT/ties_k0p2.pt" --threads 4 >> "$LOG" 2>&1
38
+ echo "[ties] exit $? $(date '+%H:%M:%S')" >> "$LOG"
39
+ fi
40
+ echo "=== ALL MERGES DONE $(date '+%Y-%m-%d %H:%M:%S') ===" >> "$LOG"
train/watchdog_reencode_full.sh ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Self-healing runner for the 528M full-corpus 16k re-encode.
3
+ # Loops: rm partial -> re-encode -> verify against the deterministic anchor
4
+ # (2 complete runs produced 11,544,766 non-empty lines / 520,133,183 tokens;
5
+ # empty EOT segments are dropped by reencode.py, so the retrain's 11,545,267
6
+ # line count is NOT the right expectation).
7
+ # Stops when the output file matches BOTH anchors.
8
+ # Launch detached: setsid nohup ./train/watchdog_reencode_full.sh > /dev/null 2>&1 < /dev/null &
9
+ set -u
10
+ cd "$(dirname "$0")/.."
11
+ export PYTHONPATH=$PWD
12
+ LOG=logs/tokenizer16k.log
13
+ EXPECT=11544766
14
+ TOK_EXPECT=520133183
15
+
16
+ for attempt in $(seq 1 99); do
17
+ echo "=== full re-encode attempt $attempt $(date "+%Y-%m-%d %H:%M:%S") ===" >> "$LOG"
18
+ rm -f data/train_full16k.bin
19
+ .venv/bin/python data/reencode.py \
20
+ --in data/train_full.bin --old-tok data/tokenizer.json \
21
+ --new-tok data/tokenizer16k.json --out data/train_full16k.bin >> "$LOG" 2>&1
22
+ code=$?
23
+ echo "=== attempt $attempt exit $code $(date "+%H:%M:%S") ===" >> "$LOG"
24
+ lines=$(.venv/bin/python -c "
25
+ import numpy as np
26
+ from data.tokenizer import load_tokenizer
27
+ tok = load_tokenizer('data/tokenizer16k.json')
28
+ eot = tok.token_to_id('<|endoftext|>')
29
+ mm = np.memmap('data/train_full16k.bin', dtype='<u2', mode='r')
30
+ n = len(mm)
31
+ cnt = 0
32
+ for start in range(0, n, 4_000_000):
33
+ cnt += int((mm[start:start + 4_000_000] == eot).sum())
34
+ print(cnt)
35
+ ")
36
+ tokens=$(.venv/bin/python -c "import os; print(os.path.getsize('data/train_full16k.bin') // 2)")
37
+ echo "attempt $attempt lines=$lines expect=$EXPECT tokens=$tokens expect_tokens=$TOK_EXPECT" >> "$LOG"
38
+ if [ "$lines" -eq "$EXPECT" ] && [ "$tokens" -eq "$TOK_EXPECT" ]; then
39
+ echo "full re-encode COMPLETE ($lines lines, $tokens tokens)" >> "$LOG"
40
+ exit 0
41
+ fi
42
+ echo "incomplete (or killed); retrying in 10s" >> "$LOG"
43
+ sleep 10
44
+ done
45
+ echo "gave up after 99 attempts" >> "$LOG"
46
+ exit 1
tui/analyst.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TinyLiquid Analyst - neon terminal UI (stdlib curses, zero deps).
2
+
3
+ An OpenCode-style agent terminal themed like Parrot OS meets Matrix:
4
+ near-black background, matrix-green neon, pink/blue/purple accents.
5
+ Chat with the analyst/skeptic personas, run SOP-driven cases, search and
6
+ read the library, manage case files.
7
+
8
+ Usage:
9
+ .venv/bin/python tui/analyst.py --ckpt ckpt/dpo
10
+ .venv/bin/python tui/analyst.py --ckpt ckpt/dpo --case mycase.json
11
+ """
12
+
13
+ import argparse
14
+ import curses
15
+ import random
16
+ import textwrap
17
+
18
+ from tui.engine import AnalystEngine
19
+ from tui.cli import handle_cmd
20
+
21
+ WRAP = 78
22
+
23
+ # --- neon palette (256-color) ---
24
+ C_BG = 0 # terminal default (black)
25
+ C_GREEN = 82 # matrix green
26
+ C_PINK = 205
27
+ C_BLUE = 39
28
+ C_PURPLE = 141
29
+ C_CYAN = 51
30
+ C_WHITE = 255
31
+ C_DIM = 240
32
+
33
+ PAIR = {
34
+ "bg": 1, "green": 2, "pink": 3, "blue": 4, "purple": 5,
35
+ "cyan": 6, "white": 7, "dim": 8, "user": 9, "model": 10,
36
+ "skeptic": 11, "system": 12, "error": 13, "header": 14,
37
+ }
38
+
39
+
40
+ def setup_colors(stdscr):
41
+ curses.start_color()
42
+ curses.use_default_colors()
43
+ try:
44
+ if curses.COLORS >= 256:
45
+ curses.init_pair(PAIR["bg"], C_WHITE, C_BG)
46
+ curses.init_pair(PAIR["green"], C_GREEN, C_BG)
47
+ curses.init_pair(PAIR["pink"], C_PINK, C_BG)
48
+ curses.init_pair(PAIR["blue"], C_BLUE, C_BG)
49
+ curses.init_pair(PAIR["purple"], C_PURPLE, C_BG)
50
+ curses.init_pair(PAIR["cyan"], C_CYAN, C_BG)
51
+ curses.init_pair(PAIR["white"], C_WHITE, C_BG)
52
+ curses.init_pair(PAIR["dim"], C_DIM, C_BG)
53
+ curses.init_pair(PAIR["user"], C_CYAN, C_BG)
54
+ curses.init_pair(PAIR["model"], C_GREEN, C_BG)
55
+ curses.init_pair(PAIR["skeptic"], C_PINK, C_BG)
56
+ curses.init_pair(PAIR["system"], C_PURPLE, C_BG)
57
+ curses.init_pair(PAIR["error"], C_PINK, C_BG)
58
+ curses.init_pair(PAIR["header"], C_GREEN, C_BG)
59
+ else:
60
+ for k in PAIR:
61
+ curses.init_pair(PAIR[k], curses.COLOR_WHITE, curses.COLOR_BLACK)
62
+ curses.init_pair(PAIR["green"], curses.COLOR_GREEN, curses.COLOR_BLACK)
63
+ curses.init_pair(PAIR["pink"], curses.COLOR_RED, curses.COLOR_BLACK)
64
+ curses.init_pair(PAIR["blue"], curses.COLOR_CYAN, curses.COLOR_BLACK)
65
+ curses.init_pair(PAIR["purple"], curses.COLOR_MAGENTA, curses.COLOR_BLACK)
66
+ curses.init_pair(PAIR["cyan"], curses.COLOR_CYAN, curses.COLOR_BLACK)
67
+ curses.init_pair(PAIR["user"], curses.COLOR_CYAN, curses.COLOR_BLACK)
68
+ curses.init_pair(PAIR["model"], curses.COLOR_GREEN, curses.COLOR_BLACK)
69
+ curses.init_pair(PAIR["skeptic"], curses.COLOR_RED, curses.COLOR_BLACK)
70
+ curses.init_pair(PAIR["system"], curses.COLOR_MAGENTA, curses.COLOR_BLACK)
71
+ curses.init_pair(PAIR["error"], curses.COLOR_RED, curses.COLOR_BLACK)
72
+ curses.init_pair(PAIR["header"], curses.COLOR_GREEN, curses.COLOR_BLACK)
73
+ except curses.error:
74
+ pass
75
+
76
+
77
+ def wrap(text, width):
78
+ return "\n".join(textwrap.fill(p, width) for p in text.splitlines()) or " "
79
+
80
+
81
+ class Rain:
82
+ """Minimal matrix-rain strip for the header (lightweight, idle-animated)."""
83
+
84
+ def __init__(self, width, rows=1):
85
+ self.width = max(1, width)
86
+ self.rows = rows
87
+ self.cols = [random.randint(0, self.width - 1) for _ in range(self.rows)]
88
+
89
+ def frame(self):
90
+ chars = [" "] * self.width
91
+ for i, c in enumerate(self.cols):
92
+ if random.random() < 0.75:
93
+ chars[c] = random.choice("01$#&@!?ABCDEF")
94
+ self.cols[i] = (c + random.choice([-1, 0, 1])) % self.width
95
+ return "".join(chars)
96
+
97
+
98
+ class TerminalUI:
99
+ def __init__(self, stdscr, eng):
100
+ self.stdscr = stdscr
101
+ self.eng = eng
102
+ self.lines = [] # list of (kind, text)
103
+ self.history = []
104
+ self.hist_idx = -1
105
+ self.scroll = 0
106
+ self.input_text = ""
107
+ self.cursor = 0
108
+ self.rain = Rain(10)
109
+ self.status = f"persona: analyst | case: {eng.case.name}"
110
+
111
+ def add(self, kind, text):
112
+ for ln in wrap(text, WRAP).splitlines() or [""]:
113
+ self.lines.append((kind, ln))
114
+ self.scroll = 0
115
+
116
+ def header(self, wd):
117
+ title = " TINYLIQUID ANALYST "
118
+ left = (wd - len(title)) // 2
119
+ top = "═" * wd
120
+ line = " " * left + title
121
+ return top, line[:wd], self.rain.frame()[:wd]
122
+
123
+ def draw(self):
124
+ h, wd = self.stdscr.getmaxyx()
125
+ top, title, rain = self.header(wd)
126
+ body_h = max(1, h - 6)
127
+
128
+ self.stdscr.erase()
129
+ self.stdscr.addnstr(0, 0, top[:wd], wd, curses.color_pair(PAIR["purple"]))
130
+ self.stdscr.addnstr(1, 0, title[:wd], wd, curses.color_pair(PAIR["header"]))
131
+ self.stdscr.addnstr(2, 0, rain[:wd], wd, curses.color_pair(PAIR["green"]))
132
+
133
+ start = len(self.lines) - body_h - self.scroll
134
+ start = max(0, min(start, max(0, len(self.lines) - body_h)))
135
+ for i in range(body_h):
136
+ idx = start + i
137
+ if idx < len(self.lines):
138
+ kind, ln = self.lines[idx]
139
+ pair = {"user": PAIR["user"], "model": PAIR["model"],
140
+ "skeptic": PAIR["skeptic"], "system": PAIR["system"],
141
+ "error": PAIR["error"]}.get(kind, PAIR["white"])
142
+ self.stdscr.addnstr(3 + i, 0, ln[:wd], wd, curses.color_pair(pair))
143
+
144
+ self.stdscr.addnstr(h - 3, 0, "─" * wd, wd, curses.color_pair(PAIR["purple"]))
145
+ self.stdscr.addnstr(h - 2, 0, self.status[:wd], wd,
146
+ curses.color_pair(PAIR["system"]) | curses.A_REVERSE)
147
+ prompt = "❯ "
148
+ self.stdscr.addnstr(h - 1, 0, prompt + self.input_text[:wd - len(prompt) - 1], wd,
149
+ curses.color_pair(PAIR["green"]))
150
+ try:
151
+ self.stdscr.move(h - 1, min(self.cursor + len(prompt), wd - 1))
152
+ except curses.error:
153
+ pass
154
+ self.stdscr.refresh()
155
+
156
+ def run_cmd(self, cmd):
157
+ if cmd in ("/quit", "/exit", "/q"):
158
+ return False
159
+ if cmd == "/clear":
160
+ self.lines = []
161
+ self.add("system", "cleared.")
162
+ return True
163
+ self.add("user", cmd if cmd.startswith("/") else "you> " + cmd)
164
+ if not cmd.startswith("/"):
165
+ # streamed chat: redraw as tokens arrive
166
+ buf = []
167
+ self.lines.append(("model", "…"))
168
+ try:
169
+ def on_token(tok_id):
170
+ buf.append(tok_id)
171
+ self.lines[-1] = ("model", self.eng.tok.decode(buf))
172
+ self.draw()
173
+ self.eng.chat(cmd, on_token=on_token)
174
+ except Exception as e:
175
+ self.lines[-1] = ("error", f"error: {e}")
176
+ self.draw()
177
+ return True
178
+ self.lines[-1] = ("model", self.eng.tok.decode(buf))
179
+ self.draw()
180
+ return True
181
+ try:
182
+ out = handle_cmd(self.eng, cmd)
183
+ except Exception as e:
184
+ out = f"error: {e}"
185
+ self.add("error", out)
186
+ return True
187
+ kind = "skeptic" if cmd.startswith(("/skeptic",)) else "model"
188
+ # render JSON reports nicely
189
+ if out.strip().startswith("{") or out.strip().startswith("["):
190
+ import json as _json
191
+ try:
192
+ data = _json.loads(out)
193
+ out = _json.dumps(data, indent=2, ensure_ascii=False)
194
+ except Exception:
195
+ pass
196
+ for ln in out.splitlines():
197
+ self.add(kind, ln)
198
+ return True
199
+
200
+ def loop(self):
201
+ setup_colors(self.stdscr)
202
+ curses.curs_set(1)
203
+ self.stdscr.timeout(80)
204
+ self.add("system", "TinyLiquid Analyst — on-device forensic research terminal")
205
+ self.add("dim", "Authorized research/OSINT only. Outputs are decision support, never a verdict.")
206
+ self.add("dim", "type /help for commands | /quit to exit")
207
+ while True:
208
+ self.draw()
209
+ try:
210
+ ch = self.stdscr.get_wch()
211
+ except curses.error:
212
+ continue
213
+ if ch in ("\n", "\r", "KEY_ENTER"):
214
+ cmd = self.input_text.strip()
215
+ self.input_text, self.cursor = "", 0
216
+ self.history.append(cmd)
217
+ self.hist_idx = len(self.history)
218
+ if cmd and not self.run_cmd(cmd):
219
+ break
220
+ continue
221
+ if ch in ("\x7f", "KEY_BACKSPACE"):
222
+ if self.cursor > 0:
223
+ self.input_text = self.input_text[:self.cursor - 1] + self.input_text[self.cursor:]
224
+ self.cursor -= 1
225
+ continue
226
+ if ch == "KEY_DC":
227
+ self.input_text = self.input_text[:self.cursor] + self.input_text[self.cursor + 1:]
228
+ continue
229
+ if ch == "KEY_LEFT":
230
+ self.cursor = max(0, self.cursor - 1)
231
+ continue
232
+ if ch == "KEY_RIGHT":
233
+ self.cursor = min(len(self.input_text), self.cursor + 1)
234
+ continue
235
+ if ch == "KEY_HOME":
236
+ self.cursor = 0
237
+ continue
238
+ if ch == "KEY_END":
239
+ self.cursor = len(self.input_text)
240
+ continue
241
+ if ch in ("KEY_PPAGE",):
242
+ self.scroll = min(self.scroll + 5, max(0, len(self.lines) - 1))
243
+ continue
244
+ if ch in ("KEY_NPAGE",):
245
+ self.scroll = max(0, self.scroll - 5)
246
+ continue
247
+ if ch == "KEY_UP":
248
+ if self.history and self.hist_idx > 0:
249
+ self.hist_idx -= 1
250
+ self.input_text = self.history[self.hist_idx]
251
+ self.cursor = len(self.input_text)
252
+ continue
253
+ if ch == "KEY_DOWN":
254
+ if self.history and self.hist_idx < len(self.history) - 1:
255
+ self.hist_idx += 1
256
+ self.input_text = self.history[self.hist_idx]
257
+ self.cursor = len(self.input_text)
258
+ else:
259
+ self.input_text, self.hist_idx = "", len(self.history)
260
+ continue
261
+ if isinstance(ch, str) and ch.isprintable():
262
+ self.input_text = self.input_text[:self.cursor] + ch + self.input_text[self.cursor:]
263
+ self.cursor += 1
264
+
265
+
266
+ def main():
267
+ ap = argparse.ArgumentParser()
268
+ ap.add_argument("--ckpt", default="ckpt/v8_lora/best.pt")
269
+ ap.add_argument("--tok", default="data/tokenizer.json")
270
+ ap.add_argument("--case", default=None)
271
+ ap.add_argument("--threads", type=int, default=4)
272
+ args = ap.parse_args()
273
+
274
+ eng = AnalystEngine(ckpt=args.ckpt, tok_path=args.tok, threads=args.threads)
275
+ if args.case:
276
+ eng.load_case(args.case)
277
+
278
+ def go(stdscr):
279
+ ui = TerminalUI(stdscr, eng)
280
+ ui.status = f"persona: {eng.persona} | case: {eng.case.name} | {eng.ckpt}"
281
+ ui.loop()
282
+
283
+ try:
284
+ curses.wrapper(go)
285
+ except KeyboardInterrupt:
286
+ pass
287
+ print("session ended.")
288
+
289
+
290
+ if __name__ == "__main__":
291
+ main()
tui/cli.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TinyLiquid Analyst - headless terminal client (also scripts/SSH-friendly).
2
+
3
+ Usage:
4
+ .venv/bin/python tui/cli.py --ckpt ckpt/dpo # interactive REPL
5
+ .venv/bin/python tui/cli.py --ckpt ckpt/dpo --once "Verify: ..."
6
+ echo "Verify: ..." | .venv/bin/python tui/cli.py --ckpt ckpt/dpo
7
+ """
8
+
9
+ import argparse
10
+ import sys
11
+
12
+ from tui.engine import AnalystEngine, parse_cmd, BANNER
13
+
14
+ HELP = """commands:
15
+ <text> chat with the current persona
16
+ /case <sop> <text> SOP-driven analysis (verdict + confidence + skeptic)
17
+ /skeptic <text> skeptic attack on a claim
18
+ /search <query> search the library/corpus index
19
+ /read <key> read a document from the index
20
+ /web <topic> search the live web (news/archive/wiki)
21
+ /fetch <url> pull one URL into the library
22
+ /pull <topic> search + pull top docs into the library
23
+ /tor on|off|status route .onion fetches through local Tor SOCKS
24
+ /sop [name] show one or all procedures
25
+ /persona <analyst|skeptic|none>
26
+ /rag on|off retrieval-grounded chat (auto search + context)
27
+ /memory on|off multi-turn conversation memory
28
+ /mem on|off|stats|<claim> DNA-helix long-term memory (prior-case recall)
29
+ /agent <task> run the procedural agent loop (search/read/verdict)
30
+ /agents <task> parallel research swarm: 4 agents, merged report
31
+ /synth [title] render case ledger into saved research documents
32
+ /chart <t>|<l>:<v,..>|... series chart + saved artifact (cross-synthesis)
33
+ /opinion <claim> dual-mind fusion: the model's calibrated opinion
34
+ /research <question> research-partner loop: pull, verify, opine, reply
35
+ /journal [name] full journalism suite -> CaseFile notebook
36
+ /gaps list the case's open questions (gap ledger)
37
+ /save <name> save this case
38
+ /load <name> load a case
39
+ /status show state
40
+ /help this help
41
+ /quit exit"""
42
+
43
+
44
+ def run_once(eng, text):
45
+ if not text.strip():
46
+ return
47
+ if text.startswith("/case"):
48
+ rest = text[5:].strip()
49
+ sop = ""
50
+ if rest and " " in rest[:60]:
51
+ maybe, body = rest.split(maxsplit=1)
52
+ if maybe in eng.__class__.__module__: # noqa - simple guard
53
+ pass
54
+ sop = maybe
55
+ text = body
56
+ elif rest:
57
+ text = rest
58
+ rep = eng.analyze(text, sop or None)
59
+ print(json_dump(rep))
60
+ elif text.startswith("/"):
61
+ print(handle_cmd(eng, text))
62
+ else:
63
+ print(eng.chat(text))
64
+
65
+
66
+ def json_dump(rep):
67
+ import json
68
+ return json.dumps(rep, indent=2, ensure_ascii=False)
69
+
70
+
71
+ def handle_cmd(eng, line):
72
+ cmd, arg = parse_cmd(line) or ("chat", line)
73
+ if cmd == "chat":
74
+ return eng.chat(arg)
75
+ if cmd == "case":
76
+ sop = ""
77
+ body = arg
78
+ if " " in body:
79
+ maybe, rest = body.split(maxsplit=1)
80
+ if maybe in {"claim_verification", "cross_source_discrepancy", "pattern_finding",
81
+ "timeline_reconstruction", "historical_truth", "politics_analysis",
82
+ "dark_web_research", "terminal_control", "source_triage"}:
83
+ sop, body = maybe, rest
84
+ return json_dump(eng.analyze(body, sop or None))
85
+ if cmd == "skeptic":
86
+ return eng.generate(arg, persona="skeptic")
87
+ if cmd == "search":
88
+ return "\n".join(f"{k} (score {s:.2f})" for k, s in eng.search(arg)) or "(no hits)"
89
+ if cmd == "read":
90
+ return eng.read(arg)
91
+ if cmd == "sop":
92
+ return eng.sop_text(arg) if arg else "\n".join(list_sops_str())
93
+ if cmd == "persona":
94
+ if arg in eng.PERSONA_ID if hasattr(eng, "PERSONA_ID") else arg in ("analyst", "skeptic", "none"):
95
+ eng.persona = arg
96
+ return f"persona -> {arg}"
97
+ return "persona: analyst|skeptic|none"
98
+ if cmd == "save":
99
+ return "saved " + eng.save_case(arg or "default")
100
+ if cmd == "load":
101
+ return eng.load_case(arg or "default")
102
+ if cmd == "rag":
103
+ eng.rag = arg not in ("off", "0", "false")
104
+ return f"retrieval-grounded chat: {'on' if eng.rag else 'off'}"
105
+ if cmd == "memory":
106
+ eng.memory = arg not in ("off", "0", "false")
107
+ return f"multi-turn memory: {'on' if eng.memory else 'off'}"
108
+ if cmd == "mem":
109
+ if arg in ("on", "1", "true", "yes"):
110
+ eng.helix_on = True
111
+ return "long-term helix memory: on"
112
+ if arg in ("off", "0", "false", "no"):
113
+ eng.helix_on = False
114
+ return "long-term helix memory: off"
115
+ if arg == "stats":
116
+ return json_dump(eng.mem_stats())
117
+ return eng.recall(arg)
118
+ if cmd == "agent":
119
+ return json_dump(eng.agent(arg))
120
+ if cmd == "agents":
121
+ return json_dump(eng.agents(arg, n=4))
122
+ if cmd == "synth":
123
+ return eng.synthesize(arg or None)
124
+ if cmd == "chart":
125
+ return eng.chart(arg)
126
+ if cmd == "opinion":
127
+ return json_dump(eng.opinion(arg))
128
+ if cmd == "research":
129
+ return json_dump(eng.research(arg))
130
+ if cmd == "journal":
131
+ return eng.journal(arg or None)
132
+ if cmd == "gaps":
133
+ return "\n".join(f" {g}" for g in eng.gaps())
134
+ if cmd == "web":
135
+ hits = eng.web_search(arg, n=8)
136
+ if not hits:
137
+ return "(no web hits)"
138
+ return "\n".join("[{0}] {1}\n {2}\n {3}".format(
139
+ r["source"], r["title"], r["url"], r["snippet"][:100]) for r in hits)
140
+ if cmd == "fetch":
141
+ return json_dump(eng.web_fetch(arg))
142
+ if cmd == "pull":
143
+ return json_dump(eng.web_pull(arg, n=3))
144
+ if cmd == "tor":
145
+ if arg in ("on", "1", "true", "yes"):
146
+ eng.tor = True
147
+ return "tor: on"
148
+ if arg in ("off", "0", "false", "no"):
149
+ eng.tor = False
150
+ return "tor: off"
151
+ return "tor: {0} | {1}".format(eng.tor, eng.tor_status_msg())
152
+ if cmd == "status":
153
+ return (f"persona={eng.persona} memory={'on' if eng.memory else 'off'} "
154
+ f"helix={'on' if getattr(eng, 'helix_on', False) else 'off'} "
155
+ f"rag={'on' if eng.rag else 'off'} case={eng.case.name} "
156
+ f"msgs={len(eng.case.chat)} notes={len(eng.case.ledger)} ckpt={eng.ckpt}")
157
+ if cmd in ("help", "?"):
158
+ return HELP
159
+ if cmd in ("quit", "exit"):
160
+ raise SystemExit(0)
161
+ return f"unknown command /{cmd}; try /help"
162
+
163
+
164
+ def list_sops_str():
165
+ from research.agent import SOP_ALIASES
166
+ return [f" {k:28s} {', '.join(SOP_ALIASES[k][:3])}" for k in SOP_ALIASES]
167
+
168
+
169
+ def main():
170
+ ap = argparse.ArgumentParser()
171
+ ap.add_argument("--ckpt", default="ckpt/v8_lora/best.pt")
172
+ ap.add_argument("--tok", default="data/tokenizer.json")
173
+ ap.add_argument("--once", default=None)
174
+ ap.add_argument("--threads", type=int, default=4)
175
+ args = ap.parse_args()
176
+
177
+ eng = AnalystEngine(ckpt=args.ckpt, tok_path=args.tok, threads=args.threads)
178
+ print(BANNER, flush=True)
179
+ print(f"model: {eng.ckpt} | persona: analyst | type /help\n", flush=True)
180
+
181
+ if args.once:
182
+ run_once(eng, args.once)
183
+ return
184
+ if not sys.stdin.isatty():
185
+ for line in sys.stdin:
186
+ line = line.strip()
187
+ if not line:
188
+ continue
189
+ if line in ("/quit", "/exit"):
190
+ break
191
+ print(handle_cmd(eng, line), flush=True)
192
+ return
193
+ for line in sys.stdin:
194
+ line = line.strip()
195
+ if not line:
196
+ continue
197
+ try:
198
+ print(handle_cmd(eng, line), flush=True)
199
+ except SystemExit:
200
+ break
201
+
202
+
203
+ if __name__ == "__main__":
204
+ main()
tui/engine.py ADDED
@@ -0,0 +1,467 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TinyLiquid Analyst terminal engine.
2
+
3
+ Purpose-built core for the forensic/dark-web research assistant: chat in the
4
+ analyst/skeptic personas with multi-turn memory, optional retrieval-grounded
5
+ chat (RAG over the library), SOP-driven case analysis, an /agent task loop,
6
+ library search, document reading, and persistent case files. Both the TUI
7
+ (tui/analyst.py) and the headless CLI (tui/cli.py) run on this engine.
8
+
9
+ Guardrails: research/OSINT only. Dark-web actions require an explicit Tor
10
+ proxy and the crawler's stop rules; nothing illegal is ever in scope.
11
+ """
12
+
13
+ import json
14
+ import re
15
+ import threading
16
+ from pathlib import Path
17
+
18
+ import torch
19
+
20
+ from model.config import TinyLiquidConfig
21
+ from model.tiny_liquid import TinyLiquid
22
+ from model.utils import latest_ckpt
23
+ from data.tokenizer import load_tokenizer
24
+ from research.index import TinyIndex
25
+ from research.structured import analyst_report
26
+ from research.agent import load_sop, run_case, SOP_ALIASES
27
+ from research import orchestrator as orch
28
+ from research import websearch as ws
29
+ from research import workspace as wkspc
30
+ from research.helix import HelixMemory
31
+ from research.user_journal import UserJournal
32
+ import urllib.parse
33
+
34
+ ROOT = Path(__file__).resolve().parents[1]
35
+ CASE_DIR = ROOT / "cases"
36
+
37
+ PERSONA_TOK = {"analyst": "<|analyst|>", "skeptic": "<|skeptic|>", "none": ""}
38
+ PERSONA_ID = {"analyst": 1, "skeptic": 2, "none": 0}
39
+ MEMORY_TURNS = 6 # last N turns injected as conversation history
40
+ MAX_HIST_TOK = 768 # cap history tokens inside the context window
41
+
42
+ BANNER = (
43
+ "TinyLiquid Analyst - on-device forensic research terminal\n"
44
+ "Authorized research/OSINT only. Outputs are decision support, never a verdict.\n"
45
+ "Dark-web actions: only via an explicit Tor proxy, with rate limits and stop rules.\n"
46
+ )
47
+
48
+
49
+ class Case:
50
+ def __init__(self, name="default"):
51
+ self.name = name
52
+ self.ledger = []
53
+ self.chat = []
54
+
55
+ def add(self, role, text):
56
+ self.chat.append({"role": role, "text": text})
57
+
58
+ def note(self, text):
59
+ self.ledger.append(text)
60
+
61
+ def save(self, path: Path):
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ path.write_text(json.dumps({"name": self.name, "ledger": self.ledger,
64
+ "chat": self.chat}, indent=2), encoding="utf-8")
65
+
66
+ def load(self, path: Path):
67
+ d = json.loads(path.read_text(encoding="utf-8"))
68
+ self.name = d.get("name", self.name)
69
+ self.ledger = d.get("ledger", [])
70
+ self.chat = d.get("chat", [])
71
+
72
+
73
+ class AnalystEngine:
74
+ def __init__(self, ckpt="ckpt/v8_lora/best.pt", tok_path="data/tokenizer.json",
75
+ library="data/library", threads=8):
76
+ torch.set_num_threads(threads)
77
+ self.tok = load_tokenizer(tok_path)
78
+ self.ckpt = latest_ckpt(ckpt)
79
+ assert self.ckpt, f"no checkpoints in {ckpt}"
80
+ sd = torch.load(self.ckpt, map_location="cpu")
81
+ cfg = TinyLiquidConfig(vocab_size=self.tok.get_vocab_size(),
82
+ **{k: v for k, v in sd["config"].items() if k != "vocab_size"})
83
+ self.model = TinyLiquid(cfg)
84
+ self.model.load_state_dict(sd["model"])
85
+ self.model.eval()
86
+ self.persona = "analyst"
87
+ self.memory = True
88
+ self.rag = False
89
+ self.case = Case()
90
+ self.library = str(Path(library))
91
+ self.tor = False
92
+ self.helix_on = True
93
+ self.memdb = HelixMemory(str(ROOT / "data" / "helix_memory.jsonl"))
94
+ self.user_journal = UserJournal()
95
+ self.index = self._build_index(library)
96
+
97
+ def _build_index(self, library):
98
+ idx = TinyIndex()
99
+ for f in sorted(Path(library).glob("*.txt")):
100
+ idx.add(f.stem, str(f), f.read_text(encoding="utf-8", errors="ignore"))
101
+ for d in (ROOT / "corpus" / "raw",):
102
+ if d.exists():
103
+ for f in sorted(d.glob("*.txt")):
104
+ idx.add(f"{d.name}/{f.stem}", str(f),
105
+ f.read_text(encoding="utf-8", errors="ignore"))
106
+ idx.build()
107
+ return idx
108
+
109
+ # ---- prompt building ----
110
+ def _rag_context(self, text):
111
+ if not self.rag:
112
+ return ""
113
+ hits = self.index.query(text, k=2)
114
+ if not hits:
115
+ return ""
116
+ parts = ["Library context:"]
117
+ for key, score in hits:
118
+ for k, path, doc in self.index.docs:
119
+ if k == key:
120
+ parts.append(f"[{key}] " + doc[:400].replace("\n", " "))
121
+ break
122
+ return "\n".join(parts) + "\n"
123
+
124
+ def _history_prompt(self, text):
125
+ """Multi-turn memory: last N case turns -> a chat prefix."""
126
+ if not self.memory:
127
+ return ""
128
+ turns = self.case.chat[-(MEMORY_TURNS * 2):] if self.case.chat else []
129
+ parts = []
130
+ used = 0
131
+ for t in reversed(turns):
132
+ seg = t["text"]
133
+ if used + len(seg) > MAX_HIST_TOK:
134
+ break
135
+ parts.append((t["role"], seg))
136
+ used += len(seg)
137
+ parts.reverse()
138
+ out = []
139
+ for role, seg in parts:
140
+ if role == "user":
141
+ out.append("<|user|>")
142
+ else:
143
+ out.append("<|assistant|>")
144
+ out.append(seg)
145
+ return "".join(out)
146
+
147
+ def _build_prompt(self, text):
148
+ text2 = self.user_journal.context() + text
149
+ ctx = self._rag_context(text2)
150
+ hist = self._history_prompt(text2)
151
+ p = "<|user|>" + (ctx + text2 if ctx else text2) + "<|assistant|>"
152
+ return hist + p
153
+
154
+ # ---- chat / analysis ----
155
+ def generate(self, text, persona=None, max_new=180, temp=0.6, on_token=None):
156
+ persona = persona or self.persona
157
+ p_token = PERSONA_TOK[persona]
158
+ prompt = p_token + "<|user|>" + text + "<|assistant|>"
159
+ ids = self.tok.encode(prompt).ids
160
+ out = self.model.generate(self.tok, ids, persona_id=PERSONA_ID[persona],
161
+ max_new=max_new, temperature=temp, top_k=40,
162
+ repetition_penalty=1.4, no_repeat_ngram_size=4,
163
+ on_token=on_token)
164
+ return self.tok.decode(out[len(ids):]).strip()
165
+
166
+ def chat(self, text, on_token=None):
167
+ self.case.add("user", text)
168
+ self.user_journal.note_thread(text)
169
+ prompt = self._build_prompt(text)
170
+ ids = self.tok.encode(prompt).ids
171
+ out = self.model.generate(self.tok, ids, persona_id=PERSONA_ID[self.persona],
172
+ max_new=180, temperature=0.6, top_k=40,
173
+ repetition_penalty=1.4, no_repeat_ngram_size=4,
174
+ on_token=on_token)
175
+ reply = self.tok.decode(out[len(ids):]).strip()
176
+ self.case.add("assistant", reply)
177
+ return reply
178
+
179
+ def analyze(self, text, sop=None):
180
+ sop_text = load_sop(sop, text) if sop else load_sop(None, text)
181
+ prior = ""
182
+ if self.helix_on:
183
+ rec = self.memdb.recall(text, sop_text)
184
+ if rec:
185
+ prior = ("\nPRIOR ANALYSIS OF THIS CASE (recalled):\n" + rec["reasoning"]
186
+ + "\n-- use as a prior; do not assume it is still correct.\n\n")
187
+ user = self.user_journal.context() + prior + sop_text + "\n\nTASK: " + text
188
+ self.case.add("user", "ANALYSIS: " + text)
189
+ report = analyst_report(self.model, self.tok, user, persona_id=1,
190
+ max_scratch=120, max_reason=60)
191
+ skeptic = self.generate(
192
+ f"Act as the skeptic. Attack this conclusion:\nClaim: {text}\n"
193
+ f"Conclusion: {report.get('verdict', '')} {report.get('reasoning', '')}",
194
+ persona="skeptic", max_new=100)
195
+ report["skeptic"] = skeptic
196
+ report["sop"] = sop or "auto"
197
+ for q in self._open_questions(report.get("reasoning", "")):
198
+ note = "OPEN: " + q
199
+ if note not in self.case.ledger:
200
+ self.case.ledger.append(note)
201
+ if self.helix_on:
202
+ self.memdb.write(
203
+ claim=text,
204
+ evidence=sop_text if isinstance(sop_text, str) else "",
205
+ verdict=report.get("verdict", ""),
206
+ confidence=report.get("confidence", ""),
207
+ reasoning=report.get("reasoning", ""),
208
+ )
209
+ self.case.add("assistant", json.dumps(report, ensure_ascii=False))
210
+ self.user_journal.note_thread(text)
211
+ return report
212
+
213
+ def recall(self, text):
214
+ if not self.helix_on:
215
+ return "long-term memory is off (/mem on)"
216
+ rec = self.memdb.recall(text)
217
+ if not rec:
218
+ return "(no prior record for this case in long-term memory)"
219
+ return ("prior verdict: {0} ({1})\nreasoning: {2}\nsource text: {3}".format(
220
+ rec["verdict"], rec["confidence"], rec["reasoning"],
221
+ (rec.get("claim", "") or "")[:200]))
222
+
223
+ def mem_stats(self):
224
+ return {"helix": self.helix_on, **self.memdb.stats()}
225
+
226
+ # ---- gap ledger: my special touch ----
227
+ _OPEN_KW = ("missing", "what would settle", "what would change", "what is needed",
228
+ "what is required", "unsupported detail", "not in the record",
229
+ "no record of", "no document", "outstanding")
230
+
231
+ def _open_questions(self, text):
232
+ """Pull the open threads out of a verdict's reasoning (pure suit logic)."""
233
+ if not text:
234
+ return []
235
+ out = []
236
+ sents = re.split(r"(?<=[.!?])\s+", text.replace("\n", " ").strip())
237
+ for s in sents:
238
+ low = s.lower()
239
+ if any(k in low for k in self._OPEN_KW):
240
+ q = s.strip()
241
+ if q and q not in out:
242
+ out.append(q)
243
+ return out
244
+
245
+ def gaps(self):
246
+ opens = [l for l in self.case.ledger if l.startswith("OPEN:")]
247
+ return opens or ["(no open questions on this case yet - run /case analyses)"]
248
+
249
+ # ---- dual mind: two cognitive minds fused into one opinion ----
250
+ def _run_minds(self, doc):
251
+ """Two minds, one model: analyst + skeptic passes with own memory pools."""
252
+ from research.fusion import run_two_pass
253
+ return run_two_pass(self.model, self.tok, doc,
254
+ memory=self.memdb if self.helix_on else None)
255
+
256
+ def _voice(self, instruction, max_new=150):
257
+ return self.generate(instruction, persona="analyst", max_new=max_new, temp=0.6)
258
+
259
+ def opinion(self, text, evidence=None):
260
+ from research.verify import deterministic_verdict
261
+ from research.fusion import fuse, opinion_text
262
+ doc = text if not evidence else "{0}\nEvidence: {1}".format(text, evidence)
263
+ rv = deterministic_verdict(doc)
264
+ rule = rv if rv["verdict"] in ("supports", "refutes", "not enough information") else None
265
+ a, s = self._run_minds(doc)
266
+ op = fuse(a, s, rule=rule, sources=getattr(self, "_last_sources", ()))
267
+ op["spoken"] = opinion_text(op)
268
+ self.case.add("user", "OPINION: " + text)
269
+ for q in op["open_questions"]:
270
+ note = "OPEN: " + q
271
+ if note not in self.case.ledger:
272
+ self.case.ledger.append(note)
273
+ self.case.add("assistant", op["spoken"])
274
+ return op
275
+
276
+ def research(self, question, tor=None, pulls=2):
277
+ """Research-partner loop: search+pull docs -> verify -> two minds -> reply."""
278
+ from research.verify import deterministic_verdict
279
+ from research.fusion import fuse, opinion_text
280
+ tor = self.tor if tor is None else bool(tor)
281
+
282
+ if self.helix_on:
283
+ rec = self.memdb.recall(question, "")
284
+ if rec:
285
+ reply = self._voice(
286
+ "A prior record exists for this exact case. Restate your "
287
+ "assessment of {0} using: verdict {1} ({2}); reasoning {3}".format(
288
+ question, rec["verdict"], rec.get("confidence", ""),
289
+ (rec.get("reasoning") or "")[:220]))
290
+ return {"source": "memory", "question": question, "reply": reply,
291
+ "opinion": (rec.get("reasoning") or "")[:300],
292
+ "gaps": [], "sources": [], "artifact": None}
293
+
294
+ pulled = []
295
+ try:
296
+ pulled = ws.pull(question, n=pulls, tor=tor, library_dir=self.library)
297
+ self._reindex()
298
+ except Exception:
299
+ pulled = []
300
+ self._last_sources = [p.get("file", "") for p in pulled if p.get("file")]
301
+
302
+ evidence = ""
303
+ for key, score in self.index.query(question, k=2):
304
+ for k, path, doc in self.index.docs:
305
+ if k == key:
306
+ evidence += doc[:600].replace("\n", " ") + " "
307
+ break
308
+ doc = question if not evidence.strip() else question + "\nEvidence: " + evidence[:1200]
309
+
310
+ rv = deterministic_verdict(doc)
311
+ rule = rv if rv["verdict"] in ("supports", "refutes", "not enough information") else None
312
+ a, s = self._run_minds(doc)
313
+ op = fuse(a, s, rule=rule, sources=self._last_sources)
314
+ op["spoken"] = opinion_text(op)
315
+
316
+ self.case.add("user", "RESEARCH: " + question)
317
+ for q in op["open_questions"]:
318
+ note = "OPEN: " + q
319
+ if note not in self.case.ledger:
320
+ self.case.ledger.append(note)
321
+ art = None
322
+ try:
323
+ res = wkspc.synthesize(self.case.ledger, title="research_" + question[:40],
324
+ lines=self.case.ledger)
325
+ art = res[0] if res else None
326
+ except Exception:
327
+ art = None
328
+
329
+ srcs = ", ".join(self._last_sources[:4]) or "library index"
330
+ reply = self._voice(
331
+ "Research outcome for: {0}\n{1}\nSources: {2}\n"
332
+ "Reply to the user in your own voice: what the record shows, the "
333
+ "discrepancy you found, your assessment, and what would change it.".format(
334
+ question, op["spoken"], srcs))
335
+ self.case.add("assistant", reply)
336
+ return {"question": question, "reply": reply, "opinion": op["spoken"],
337
+ "verdict": op["verdict"], "confidence": op["confidence"],
338
+ "sources": self._last_sources[:6], "gaps": op["open_questions"],
339
+ "artifact": art}
340
+
341
+ def agent(self, task, max_steps=5):
342
+ """Procedural agent loop: model issues SEARCH/READ/NOTE/VERDICT actions."""
343
+ sop_text = load_sop(None, task)
344
+ plan, ledger = run_case(self.model, self.tok, task, self.index,
345
+ sop_text, max_steps=max_steps)
346
+ report = analyst_report(self.model, self.tok, task, persona_id=1,
347
+ max_scratch=90, max_reason=50)
348
+ report["steps"] = plan
349
+ self.case.add("user", "AGENT TASK: " + task)
350
+ self.case.add("assistant", json.dumps(report, ensure_ascii=False))
351
+ self.user_journal.note_thread(text)
352
+ return report
353
+
354
+ def agents(self, task, n=4, max_steps=5):
355
+ """Parallel research swarm: n agents under distinct angles, merged."""
356
+ sop_text = load_sop(None, task)
357
+ lock = threading.Lock()
358
+ results = orch.run_parallel(self.model, self.tok, task, self.index,
359
+ sop_text, n=n, lock=lock,
360
+ max_steps=max_steps, library=self.library)
361
+ merged = orch.synthesize(task, results, self._build_index(self.library))
362
+ merged["analyst"] = analyst_report(self.model, self.tok, task, persona_id=1,
363
+ max_scratch=90, max_reason=50)
364
+ self.case.add("user", "AGENTS TASK (" + str(n) + " parallel): " + task)
365
+ self.case.add("assistant", json.dumps(merged, ensure_ascii=False))
366
+ return merged
367
+
368
+ # ---- research tools ----
369
+ def search(self, query, k=5):
370
+ return self.index.query(query, k)
371
+
372
+ def read(self, key):
373
+ for k, path, text in self.index.docs:
374
+ if k == key:
375
+ return f"<{k} ({path})>\n" + text[:2500]
376
+ return "(document not found)"
377
+
378
+ def sop_text(self, sop):
379
+ if sop:
380
+ return load_sop(sop, "")
381
+ files = sorted((ROOT / "research" / "sop_library").glob("*.md"))
382
+ return "\n\n".join(f.read_text(encoding="utf-8") for f in files)
383
+
384
+ # ---- case files ----
385
+ def save_case(self, name):
386
+ self.case.name = name
387
+ self.case.save(CASE_DIR / f"{name}.json")
388
+ return str(CASE_DIR / f"{name}.json")
389
+
390
+ def load_case(self, name):
391
+ path = CASE_DIR / f"{name}.json"
392
+ if not path.exists():
393
+ return f"no case file: {path}"
394
+ self.case = Case(name)
395
+ self.case.load(path)
396
+ return f"loaded {path} ({len(self.case.chat)} msgs, {len(self.case.ledger)} notes)"
397
+
398
+
399
+ # ---- live web / dark-web retrieval (client hands) ----
400
+ def _reindex(self):
401
+ self.index = self._build_index(self.library)
402
+
403
+ def tor_status_msg(self):
404
+ return ws.tor_status()[1]
405
+
406
+ def web_search(self, query, n=8):
407
+ return ws.search_web(query, limit=n)
408
+
409
+ def web_fetch(self, url):
410
+ doc = ws.fetch(url, tor=self.tor)
411
+ slug = urllib.parse.urlparse(url).path.rstrip("/").rsplit("/", 1)[-1] or "doc"
412
+ path = ws.save_doc(self.library, slug, doc["title"], doc["content"])
413
+ self._reindex()
414
+ doc["file"] = str(path)
415
+ return doc
416
+
417
+ def web_pull(self, query, n=3):
418
+ res = ws.pull(query, n=n, tor=self.tor, library_dir=self.library)
419
+ self._reindex()
420
+ return res
421
+
422
+ # ---- research workspace (analytic artifacts: the sandbox) ----
423
+ def synthesize(self, title=None, theme=None):
424
+ """Render the case ledger as saved documents (timeline/table/chart/crossref)."""
425
+ title = (title or self.case.name or "case").strip()
426
+ series = getattr(self, "_series", None)
427
+ res = wkspc.synthesize(self.case.ledger, title, series=series,
428
+ theme=theme, lines=self.case.ledger)
429
+ if not res:
430
+ return ("no dated artifacts yet - run /case analyses, /agent tasks, "
431
+ "or /chart data first")
432
+ path, doc = res
433
+ return f"saved {path}\n\n" + doc
434
+
435
+ def journal(self, arg=None):
436
+ """Run the full journalism suite over the case + library (CaseFile)."""
437
+ from research.journalism import (suite_report, docs_from_library,
438
+ claims_from_ledger)
439
+ name = (arg or self.case.name or "case").strip()
440
+ docs = docs_from_library(self.library)
441
+ claims = claims_from_ledger(self.case.ledger)
442
+ events = []
443
+ for line in self.case.ledger:
444
+ m = re.match(r"^(NOTE|EVENT):\s*(\d{4}-\d{1,2}-\d{1,2})\s+(.+)$",
445
+ line.strip())
446
+ if m:
447
+ events.append({"when": m.group(2), "what": m.group(3),
448
+ "source_id": "-"})
449
+ md = suite_report(name, docs, claims, events=events)
450
+ return md
451
+
452
+ def chart(self, arg):
453
+ """Parse 'title | label:a,b,c | label:x,y,z' and render/save a series chart."""
454
+ title, series = wkspc.parse_series_arg(arg)
455
+ if len(series) < 2:
456
+ return "need at least two series: / chart <title> | <label>:<v1,v2,..> | <label>:<v2,v3,..>"
457
+ self._series = series
458
+ ch = wkspc.render_series(series, title=title)
459
+ path = wkspc.save_md(title, ch)
460
+ return f"saved {path}\n\n" + ch
461
+
462
+
463
+ def parse_cmd(line: str):
464
+ if line.startswith("/"):
465
+ parts = line[1:].split(maxsplit=1)
466
+ return (parts[0].lower(), parts[1].strip() if len(parts) > 1 else "")
467
+ return None