Instructions to use FerrellSyntheticIntelligence/fsi-anomaly with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- llama.cpp
How to use FerrellSyntheticIntelligence/fsi-anomaly with llama.cpp:
Install (macOS, Linux)
curl -LsSf https://llama.app/install.sh | sh # Start a local OpenAI-compatible server with a web UI: llama serve -hf FerrellSyntheticIntelligence/fsi-anomaly # Run inference directly in the terminal: llama cli -hf FerrellSyntheticIntelligence/fsi-anomaly
Install from WinGet (Windows)
winget install llama.cpp # Start a local OpenAI-compatible server with a web UI: llama serve -hf FerrellSyntheticIntelligence/fsi-anomaly # Run inference directly in the terminal: llama cli -hf FerrellSyntheticIntelligence/fsi-anomaly
Use pre-built binary
# Download pre-built binary from: # https://github.com/ggerganov/llama.cpp/releases # Start a local OpenAI-compatible server with a web UI: ./llama-server -hf FerrellSyntheticIntelligence/fsi-anomaly # Run inference directly in the terminal: ./llama-cli -hf FerrellSyntheticIntelligence/fsi-anomaly
Build from source code
git clone https://github.com/ggerganov/llama.cpp.git cd llama.cpp cmake -B build cmake --build build -j --target llama-server llama-cli # Start a local OpenAI-compatible server with a web UI: ./build/bin/llama-server -hf FerrellSyntheticIntelligence/fsi-anomaly # Run inference directly in the terminal: ./build/bin/llama-cli -hf FerrellSyntheticIntelligence/fsi-anomaly
Use Docker
docker model run hf.co/FerrellSyntheticIntelligence/fsi-anomaly
- LM Studio
- Jan
- Ollama
How to use FerrellSyntheticIntelligence/fsi-anomaly with Ollama:
ollama run hf.co/FerrellSyntheticIntelligence/fsi-anomaly
- Unsloth Desktop
- Docker Model Runner
How to use FerrellSyntheticIntelligence/fsi-anomaly with Docker Model Runner:
docker model run hf.co/FerrellSyntheticIntelligence/fsi-anomaly
- Lemonade
How to use FerrellSyntheticIntelligence/fsi-anomaly with Lemonade:
Pull the model
# Download Lemonade from https://lemonade-server.ai/ lemonade pull FerrellSyntheticIntelligence/fsi-anomaly
Run and chat with the model
lemonade run user.fsi-anomaly-{{QUANT_TAG}}List all available models
lemonade list
- Atomic Chat
backup all: 37 files (final)
Browse files- tests/test_posttrain.py +71 -0
- tests/test_verify_loop.py +131 -0
- train/chain_dpo_v26_after_sft.sh +20 -0
- train/chain_post_sft_v22.sh +27 -0
- train/chain_sft_v26_after_evals.sh +27 -0
- train/grow_weights.py +213 -0
- train/grow_weights.py.bak +190 -0
- train/map_vocab.py +177 -0
- train/parallel_merges.py +101 -0
- train/ties_merge.py +105 -0
- train/train_classifier.py +182 -0
- train/train_dpo.py +288 -0
- train/train_judge.py +126 -0
- train/train_lm.py +273 -0
- train/train_lora.py +306 -0
- train/train_sft.py +170 -0
- train/train_sft2.py +191 -0
- train/train_sft_v4.py +262 -0
- train/watchdog_50m.sh +58 -0
- train/watchdog_dpo_v22.sh +72 -0
- train/watchdog_dpo_v25.sh +33 -0
- train/watchdog_dpo_v26.sh +33 -0
- train/watchdog_eval_candidates.sh +52 -0
- train/watchdog_eval_merges_v25.sh +44 -0
- train/watchdog_eval_v22.sh +18 -0
- train/watchdog_eval_v23.sh +49 -0
- train/watchdog_eval_v25_dpo.sh +48 -0
- train/watchdog_hf_backup.sh +21 -0
- train/watchdog_lora_v22.sh +29 -0
- train/watchdog_lora_v23.sh +31 -0
- train/watchdog_lora_v25.sh +32 -0
- train/watchdog_lora_v26.sh +32 -0
- train/watchdog_merge_v25.sh +40 -0
- train/watchdog_reencode_full.sh +46 -0
- tui/analyst.py +291 -0
- tui/cli.py +204 -0
- tui/engine.py +467 -0
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
|