FerrellSyntheticIntelligence commited on
Commit
76b78ee
Β·
verified Β·
1 Parent(s): 8b8e59d

backup all: 100 files (batch)

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. research/framing.py +144 -0
  2. research/fusion.py +161 -0
  3. research/guardrails.py +113 -0
  4. research/helix.py +145 -0
  5. research/index.py +82 -0
  6. research/journalism.py +194 -0
  7. research/orchestrator.py +211 -0
  8. research/patterns.py +105 -0
  9. research/probe.py +62 -0
  10. research/procedures_research.md +161 -0
  11. research/provenance.py +272 -0
  12. research/researcher_model_survey.md +91 -0
  13. research/rlvr.py +58 -0
  14. research/room.py +152 -0
  15. research/sop_library/00_common.md +19 -0
  16. research/sop_library/claim_verification.md +19 -0
  17. research/sop_library/cross_source_discrepancy.md +18 -0
  18. research/sop_library/dark_web_research.md +20 -0
  19. research/sop_library/historical_truth.md +17 -0
  20. research/sop_library/pattern_finding.md +15 -0
  21. research/sop_library/politics_analysis.md +15 -0
  22. research/sop_library/source_triage.md +19 -0
  23. research/sop_library/terminal_control.md +18 -0
  24. research/sop_library/timeline_reconstruction.md +16 -0
  25. research/structured.py +117 -0
  26. research/timeline.py +145 -0
  27. research/user_journal.py +132 -0
  28. research/verify.py +60 -0
  29. research/verify_loop.py +172 -0
  30. research/websearch.py +290 -0
  31. research/workspace.py +167 -0
  32. run_code.sh +10 -0
  33. run_distill.sh +9 -0
  34. run_domain_adapt.sh +9 -0
  35. run_dpo.sh +9 -0
  36. run_dpo_sop.sh +29 -0
  37. run_nlp.sh +10 -0
  38. run_nlp2.sh +10 -0
  39. run_nlp3.sh +10 -0
  40. run_pipeline.sh +46 -0
  41. run_pretrain_full.sh +9 -0
  42. run_sft.sh +9 -0
  43. run_sop.sh +9 -0
  44. run_tui.sh +5 -0
  45. run_v2.sh +7 -0
  46. sft_v25.jsonl +0 -0
  47. skills/tiny-model-agent-notes/SKILL.md +42 -0
  48. skills/tiny-model-arch/SKILL.md +122 -0
  49. skills/tiny-model-deploy/SKILL.md +45 -0
  50. skills/tiny-model-developer-credo/SKILL.md +108 -0
research/framing.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Framing / language forensics (journalism suite layer 3).
2
+
3
+ Framing = selection + salience (Entman 1993). Deterministic proxies we can
4
+ measure without a model:
5
+ - passive voice (agency hidden: "was ordered" vs "X ordered")
6
+ - loaded/emotive terms (charged vocabulary)
7
+ - hedges (plausible deniability: "appears", "reportedly", "may")
8
+ - nominalization (actions turned into nouns: "the decision" hides who decided)
9
+ - agency: who performs the action in active-verb clauses
10
+ - omission: which sources NEVER mention a topic the others cover
11
+
12
+ These are heuristics (suit logic), not a trained detector. The suite flags;
13
+ the human decides.
14
+
15
+ Usage:
16
+ from research.framing import FramingAnalyzer
17
+ f = FramingAnalyzer()
18
+ f.add_doc("s1", "The memo was destroyed. Officials reportedly decided...")
19
+ f.report()
20
+ """
21
+ import re
22
+ from collections import defaultdict
23
+
24
+ LOADED = [
25
+ "secret", "cover-up", "conspiracy", "plot", "scandal", "corrupt",
26
+ "fraud", "shocking", "outrage", "horrific", "brutal", "crisis",
27
+ "cover", "smear", "whistleblower", "leak", "collusion", "betrayal",
28
+ "liar", "hoax", "traitor", "unprecedented", "catastrophic",
29
+ ]
30
+ HEDGES = [
31
+ "appears", "apparently", "reportedly", "allegedly", "seems", "seem",
32
+ "may", "might", "could", "possibly", "perhaps", "suggest", "claims to",
33
+ "is said to", "it is believed", "sources say", "not clear",
34
+ ]
35
+ PASSIVE_RE = re.compile(r"\b(was|were|been|being|is|are)\s+(?:\w+ly\s+)?"
36
+ r"(\w+ed|torn|broken|hidden|destroyed|taken|given|"
37
+ r"made|held|filed)\b", re.IGNORECASE)
38
+ NOMINAL = re.compile(r"\b\w+(?:tion|sion|ment|ness|ity|ence|ance)\b", re.IGNORECASE)
39
+ ACTIVE_VERBS = ("said", "announced", "ordered", "admitted", "denied", "claimed",
40
+ "confirmed", "reported", "released", "disclosed", "wrote",
41
+ "testified", "warned", "decided", "approved")
42
+ _ACTIVE_RE = re.compile(r"\b([A-Z][a-zA-Z]{2,30}(?:\s+[A-Z][a-zA-Z]{2,30}){0,2})"
43
+ r"\s+(?:" + "|".join(ACTIVE_VERBS) + r")\b")
44
+
45
+
46
+ class FramingAnalyzer:
47
+ def __init__(self):
48
+ self.docs = {} # source_id -> text
49
+
50
+ def add_doc(self, source_id, text):
51
+ self.docs[source_id] = text
52
+
53
+ @staticmethod
54
+ def passive_ratio(text):
55
+ clauses = len(re.findall(r"[.!?]", text)) + 1
56
+ hits = len(PASSIVE_RE.findall(text))
57
+ return round(hits / max(clauses, 1), 3), hits
58
+
59
+ @staticmethod
60
+ def loaded_terms(text):
61
+ low = text.lower()
62
+ return [(w, low.count(w)) for w in LOADED if w in low]
63
+
64
+ @staticmethod
65
+ def hedges(text):
66
+ low = text.lower()
67
+ return [(w, low.count(w)) for w in HEDGES if w in low]
68
+
69
+ @staticmethod
70
+ def nominalizations(text):
71
+ out = defaultdict(int)
72
+ for m in NOMINAL.finditer(text):
73
+ w = m.group(0).lower()
74
+ if len(w) > 6:
75
+ out[w] += 1
76
+ return sorted(out.items(), key=lambda kv: -kv[1])[:12]
77
+
78
+ @staticmethod
79
+ def agency(text):
80
+ """Who performs actions: leading noun phrases before active verbs."""
81
+ return [m.group(1) for m in _ACTIVE_RE.finditer(text)][:10]
82
+
83
+ def omissions(self, topics):
84
+ """Sources that never mention a topic other sources cover."""
85
+ flags = []
86
+ for topic in topics:
87
+ low_t = topic.lower()
88
+ mentioned = [sid for sid, t in self.docs.items() if low_t in t.lower()]
89
+ if 1 <= len(mentioned) < len(self.docs):
90
+ for sid, t in self.docs.items():
91
+ if low_t not in t.lower():
92
+ flags.append({
93
+ "topic": topic,
94
+ "source_id": sid,
95
+ "flag": f"source {sid} never mentions '{topic}' "
96
+ f"while {len(mentioned)} source(s) do",
97
+ })
98
+ return flags
99
+
100
+ def doc_card(self, source_id):
101
+ text = self.docs.get(source_id, "")
102
+ if not text:
103
+ return None
104
+ ratio, passive = self.passive_ratio(text)
105
+ return {
106
+ "source_id": source_id,
107
+ "passive_ratio": ratio,
108
+ "passive_hits": passive,
109
+ "loaded": self.loaded_terms(text),
110
+ "hedges": self.hedges(text),
111
+ "nominalizations": self.nominalizations(text),
112
+ "agency": self.agency(text),
113
+ }
114
+
115
+ def report(self, topics=()):
116
+ lines = ["# Framing / Language Forensics", ""]
117
+ for sid in self.docs:
118
+ c = self.doc_card(sid)
119
+ if not c:
120
+ continue
121
+ lines.append(f"## {sid}")
122
+ lines.append(f"- passive ratio: {c['passive_ratio']} "
123
+ f"({c['passive_hits']} hits) β€” agency hidden where?")
124
+ if c["loaded"]:
125
+ lines.append("- loaded terms: " + ", ".join(
126
+ f"{w} x{n}" for w, n in c["loaded"]))
127
+ if c["hedges"]:
128
+ lines.append("- hedges: " + ", ".join(
129
+ f"{w} x{n}" for w, n in c["hedges"]))
130
+ if c["nominalizations"]:
131
+ lines.append("- nominalizations: " + ", ".join(
132
+ f"{w} x{n}" for w, n in c["nominalizations"][:6]))
133
+ if c["agency"]:
134
+ lines.append("- agency: " + ", ".join(c["agency"][:6]))
135
+ else:
136
+ lines.append("- agency: none found (fully passive?)")
137
+ lines.append("")
138
+ if topics:
139
+ lines.append("## Omissions (what a source does NOT say)")
140
+ for o in self.omissions(topics):
141
+ lines.append(f"- {o['flag']}")
142
+ if not self.omissions(topics):
143
+ lines.append("- all sources mention all topics, or only one source exists")
144
+ return "\n".join(lines)
research/fusion.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Two cognitive minds, one model: the fusion opinion layer (experimental).
2
+
3
+ Mind 1 (analyst, persona 1): conservative and focal - what does the record say?
4
+ Mind 2 (skeptic, persona 2): adversarial - what is the weakest link, what else
5
+ explains the same record?
6
+
7
+ Each mind runs its OWN scratchpad pass (separate prompt + decoding) and writes to
8
+ its OWN memory pool (persona-tagged helix strands). The fusion gate combines them:
9
+
10
+ AGREE -> shared verdict; confidence raised to the higher of the two
11
+ RULE -> deterministic spine wins when it resolves (cannot hallucinate)
12
+ CONFLICT -> calibrated OPINION, not just abstention: lean toward the side with
13
+ the better value citation, else "conflict/LOW"; ALWAYS state the
14
+ discrepancy and what would settle it.
15
+
16
+ The output is an OPINION: position + evidence + discrepancy + open questions.
17
+ Composition is deterministic (suit logic); the Spock voice is generated by the
18
+ model from the opinion as context.
19
+
20
+ Usage (library): from research.fusion import run_two_pass, fuse, opinion_text
21
+ """
22
+ import re
23
+ import json
24
+ from pathlib import Path
25
+ from research.helix import rungs, normalize
26
+ from research.decision import load_table, calibrated_prob
27
+
28
+
29
+ def _cited(rep):
30
+ c = rep.get("cited")
31
+ if isinstance(c, list):
32
+ return [str(v) for v in c][:5]
33
+ if c:
34
+ return [str(c)]
35
+ return [v for v in rungs(rep.get("reasoning", ""))][:5]
36
+
37
+
38
+ def _gaps(reasoning):
39
+ out = []
40
+ if not reasoning:
41
+ return out
42
+ for s in re.split(r"(?<=[.!?])\s+", reasoning.replace("\n", " ")):
43
+ low = s.lower()
44
+ if any(k in low for k in ("missing", "what would settle", "what would change",
45
+ "what is needed", "not in the record", "no record")):
46
+ out.append(s.strip())
47
+ return out[:4]
48
+
49
+
50
+ def _calibrated_merge(a_conf, s_conf, table_path):
51
+ """Merge two confidence labels using calibrated reliability from the table.
52
+
53
+ Returns (p_mean, bucket) where p_mean is the mean calibrated probability
54
+ and bucket is the display bucket (HIGH/MEDIUM/LOW/cannot assess).
55
+ """
56
+ table = load_table(table_path)
57
+ p_a = calibrated_prob(a_conf, table, unknown=0.0)
58
+ p_s = calibrated_prob(s_conf, table, unknown=0.0)
59
+ p_mean = (p_a + p_s) / 2.0 if (p_a > 0 or p_s > 0) else 0.0
60
+
61
+ # Map to display bucket
62
+ if p_mean >= 0.66:
63
+ return p_mean, "HIGH"
64
+ if p_mean >= 0.40:
65
+ return p_mean, "MEDIUM"
66
+ if p_mean > 0.0:
67
+ return p_mean, "LOW"
68
+ return p_mean, "cannot assess"
69
+
70
+
71
+ def fuse(analyst, skeptic, rule=None, sources=(), max_gaps=4,
72
+ calibration_table=None):
73
+ """Fuse two minds (+ optional rule spine) into one calibrated opinion.
74
+
75
+ Args:
76
+ analyst: analyst report dict with verdict, confidence, reasoning
77
+ skeptic: skeptic report dict with verdict, confidence, reasoning
78
+ rule: optional rule spine result dict
79
+ sources: optional list of sources
80
+ max_gaps: max open questions to include
81
+ calibration_table: path to calibration summary JSON (e.g., logs/calib_summary_dpo3_200.json)
82
+ """
83
+ a_v = normalize(analyst.get("verdict", ""))
84
+ s_v = normalize(skeptic.get("verdict", ""))
85
+ a_conf = (analyst.get("confidence") or "LOW").upper()
86
+ s_conf = (skeptic.get("confidence") or "LOW").upper()
87
+ gaps = (_gaps(analyst.get("reasoning", "")) + _gaps(skeptic.get("reasoning", "")))[:max_gaps]
88
+
89
+ if rule and rule.get("verdict") in ("supports", "refutes", "not enough information"):
90
+ verdict, conf, basis = rule["verdict"], rule.get("confidence", "HIGH"), "rule"
91
+ pos = ("the record deterministically " +
92
+ ("supports" if verdict == "supports" else "contradicts" if verdict == "refutes"
93
+ else "does not settle") + " the claim")
94
+ elif a_v and a_v == s_v:
95
+ # Both minds agree - use calibrated merge instead of naive confidence raise
96
+ if calibration_table:
97
+ p_mean, conf = _calibrated_merge(a_conf, s_conf, calibration_table)
98
+ else:
99
+ # Fallback: naive confidence raise (but mark as uncalibrated)
100
+ conf = "HIGH" if "HIGH" in (a_conf, s_conf) else "MEDIUM"
101
+ verdict, basis = a_v, "agreed"
102
+ pos = "both minds reach the same verdict"
103
+ elif a_v and s_v:
104
+ cite_a, cite_s = bool(_cited(analyst)), bool(_cited(skeptic))
105
+ if cite_a != cite_s:
106
+ lean, side = (analyst, "analyst") if cite_a else (skeptic, "skeptic")
107
+ verdict, conf, basis = f"leaning: {lean['verdict']}", "MEDIUM", f"leaning-{side}"
108
+ pos = f"the minds conflict, but the {side} mind cites record values"
109
+ else:
110
+ verdict, conf, basis = "conflict", "LOW", "conflict"
111
+ pos = "the two minds conflict on the same record"
112
+ else:
113
+ verdict, conf, basis = "not enough information", "LOW", "insufficient"
114
+ pos = "neither mind can reach a verdict from the record"
115
+
116
+ discrepancy = ""
117
+ if basis in ("conflict", "leaning-analyst", "leaning-skeptic"):
118
+ discrepancy = (skeptic.get("reasoning") or "")[:220]
119
+
120
+ return {
121
+ "verdict": verdict,
122
+ "confidence": conf,
123
+ "basis": basis,
124
+ "position": pos,
125
+ "discrepancy": discrepancy,
126
+ "cited": _cited(analyst)[:4] + [v for v in _cited(skeptic) if v not in _cited(analyst)][:2],
127
+ "open_questions": gaps,
128
+ "sources": list(sources)[:6],
129
+ "minds": {"analyst": analyst.get("verdict", ""), "skeptic": skeptic.get("verdict", "")},
130
+ }
131
+
132
+
133
+ def opinion_text(op):
134
+ """Turn a fused opinion into a spoken, calibrating statement (suit-composed)."""
135
+ v = op["verdict"]
136
+ conf = op["confidence"]
137
+ line = f"My assessment: {op['position']}. Confidence: {conf}."
138
+ if op.get("discrepancy"):
139
+ line += f" Discrepancy noted: {op['discrepancy']}"
140
+ if op.get("cited"):
141
+ line += " Cited values: " + ", ".join(str(c) for c in op["cited"][:4]) + "."
142
+ if op.get("open_questions"):
143
+ line += " Open: " + "; ".join(op["open_questions"][:3]) + "."
144
+ if op.get("sources"):
145
+ line += " Sources: " + ", ".join(str(s) for s in op["sources"][:4]) + "."
146
+ return line
147
+
148
+
149
+ def run_two_pass(model, tok, doc, memory=None, persona_ids=(1, 2),
150
+ max_scratch=90, max_reason=50):
151
+ """Mind 1 (analyst) then Mind 2 (skeptic): separate scratchpads, own memory pool."""
152
+ from research.structured import analyst_report
153
+ a = analyst_report(model, tok, doc, persona_id=persona_ids[0],
154
+ max_scratch=max_scratch, max_reason=max_reason)
155
+ s = analyst_report(model, tok, doc, persona_id=persona_ids[1],
156
+ max_scratch=max_scratch, max_reason=max_reason)
157
+ if memory is not None:
158
+ for rep, mind in ((a, "analyst"), (s, "skeptic")):
159
+ memory.write(doc, "", rep.get("verdict", ""), rep.get("confidence", ""),
160
+ rep.get("reasoning", ""), agreed=True, mind=mind)
161
+ return a, s
research/guardrails.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Input/output guardrails for the research TUI (harness doctrine #1).
2
+
3
+ Big-tech basis (docs/harness_research.md): guardrails are first-class agent
4
+ components (OpenAI agent guide): relevance classifier, safety classifier, PII
5
+ filter, rules-based protections (blocklists, regex), output validation
6
+ (Anthropic building-effective-agents).
7
+
8
+ Layers (deterministic, rule-first β€” never blocks legitimate research):
9
+ check_input -> relevance + safety/injection + PII redaction
10
+ check_output -> verdict present, confidence present, abstain policy honored
11
+
12
+ Design: rules are cheap, transparent, and testable; a constrained-decode
13
+ classifier can be plugged in later via `classify` hooks. An off-topic or
14
+ unsafe input is FLAGGED, not silently dropped β€” the loop asks the user to
15
+ rephrase (human-in-the-loop), per OpenAI guardrail practice.
16
+ """
17
+ import re
18
+
19
+ # --- safety / prompt-injection (rules-based protections) ---
20
+ INJECTION_PATTERNS = [
21
+ (r"\b(ignore|disregard|forget)\s+(all\s+)?(previous|prior|above)\s+(instructions?|rules?|prompt)\b",
22
+ "instruction-override"),
23
+ (r"\b(system|developer|agent)\s*(instructions?|prompt|message)\b",
24
+ "instruction-extraction"),
25
+ (r"\bshow\s+(me\s+)?(your|the)\s*(system|hidden|full)\s*(prompt|instructions?|rules?)\b",
26
+ "prompt-extraction"),
27
+ (r"\brole[-\s]?play\b.*\b(reveal|extract|output)\b", "roleplay-extraction"),
28
+ (r"\b(base64|rot13|hex)\s*(encode|decode)\b.*\binstructions?\b", "encoded-payload"),
29
+ (r"\bdan\b|\bdo\s+anything\s+now\b", "jailbreak-alias"),
30
+ (r"\byou\s+are\s+now\s+(without\s+)?(restrictions?|uncensored|free)\b", "jailbreak"),
31
+ ]
32
+
33
+ # --- relevance (on-domain: forensic research / claim verification) ---
34
+ ON_DOMAIN_HINTS = [
35
+ "verify", "check", "claim", "discrepanc", "contradict", "account",
36
+ "evidence", "source", "record", "document", "memo", "report", "timeline",
37
+ "pattern", "symbolism", "conspiracy", "dark web", "onion", "leak",
38
+ "whistleblow", "history", "government", "archive", "corroborat",
39
+ "analysis", "investigat", "provenance", "citation", "fact", "truth",
40
+ "compare", "cross-reference", "cross reference", "research",
41
+ ]
42
+ OFF_DOMAIN_HINTS = [
43
+ "how tall is", "recipe for", "weather in", "write a poem", "joke",
44
+ "horoscope", "what is your favorite", "stock tip", "cook", "play a game",
45
+ "dating advice", "what should i wear", "math homework:",
46
+ ]
47
+
48
+ # --- PII redaction (dark-web/OSINT safety; never store what we don't need) ---
49
+ PII_PATTERNS = [
50
+ (r"[\w.+-]+@[\w-]+\.[\w.-]+", "<email>"),
51
+ (r"\b\d{3}[-.\s]?\d{3}[-.\s]?\d{4}\b", "<phone>"),
52
+ (r"\b\d{3}-\d{2}-\d{4}\b", "<ssn>"),
53
+ (r"\b(?:\d[ -]*?){13,19}\b", "<card>"),
54
+ ]
55
+
56
+
57
+ class GuardResult:
58
+ __slots__ = ("ok", "flags", "redacted")
59
+
60
+ def __init__(self, ok, flags, redacted):
61
+ self.ok = ok
62
+ self.flags = flags
63
+ self.redacted = redacted
64
+
65
+ def __repr__(self):
66
+ return f"GuardResult(ok={self.ok}, flags={self.flags})"
67
+
68
+
69
+ def redact(text):
70
+ out = text
71
+ for pat, sub in PII_PATTERNS:
72
+ out = re.sub(pat, sub, out)
73
+ return out
74
+
75
+
76
+ def check_input(text, domain_hints=ON_DOMAIN_HINTS,
77
+ off_hints=OFF_DOMAIN_HINTS):
78
+ """Flag unsafe/off-topic input; return redacted text + flags.
79
+
80
+ ok=False means the loop should ask the user to rephrase (never silently
81
+ drop β€” human-in-the-loop)."""
82
+ low = text.lower()
83
+ flags = []
84
+ for pat, name in INJECTION_PATTERNS:
85
+ if re.search(pat, low):
86
+ flags.append(f"injection:{name}")
87
+ if flags:
88
+ return GuardResult(False, flags, redact(text))
89
+ hit = sum(1 for h in domain_hints if h in low)
90
+ off = sum(1 for h in off_hints if h in low)
91
+ if off > hit:
92
+ flags.append("off-topic")
93
+ return GuardResult(False, flags, redact(text))
94
+ return GuardResult(True, flags, redact(text))
95
+
96
+
97
+ def check_output(decision, require_verdict=True):
98
+ """Validate a decision dict before it reaches the user (output validation).
99
+
100
+ Rules: a verdict must exist; a non-abstain verdict must carry a confidence;
101
+ the abstain policy must be honored (abstained decisions say so)."""
102
+ flags = []
103
+ verdict = (decision.get("verdict") or "").strip()
104
+ conf = (decision.get("confidence") or "").strip()
105
+ if require_verdict and not verdict:
106
+ flags.append("missing-verdict")
107
+ if verdict and verdict.lower() != "not enough information" and not conf:
108
+ flags.append("missing-confidence")
109
+ if decision.get("abstained") and verdict.lower() != "not enough information":
110
+ flags.append("abstain-mismatch")
111
+ if not decision.get("abstained") and (decision.get("p") or 0.0) < 0.0:
112
+ flags.append("negative-probability")
113
+ return GuardResult(not flags, flags, "")
research/helix.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DNA-helix-style persistent memory for the analyst.
2
+
3
+ Two complementary strands are stored per case:
4
+ - the CLAIM strand (what was asserted)
5
+ - the EVIDENCE strand (what the record said)
6
+ The "rungs" are the values/entities that link them (years, percents, counts,
7
+ times, names). When a new case arrives, recall() walks the strands for rung
8
+ overlap and can answer from memory before re-reasoning -- the "remember what
9
+ it needs to remember, when it needs to remember it" loop.
10
+
11
+ Memory is append-only and deduplicated by rung signature. Saved as JSONL so it
12
+ persists across sessions (closed loop: analyze -> write -> recall).
13
+ """
14
+ import json
15
+ import re
16
+ import threading
17
+ import time
18
+ import uuid
19
+ from pathlib import Path
20
+
21
+ lock = threading.Lock()
22
+
23
+
24
+ def rungs(text):
25
+ """Extract the linking values: numbers, years, times, percentages."""
26
+ out = []
27
+ out += re.findall(r"\b(?:19|20)\d{2}\b", text)
28
+ out += re.findall(r"\b\d{1,2}:\d{2}\b", text)
29
+ out += re.findall(r"\b\d+(?:,\d{3})*\.?\d*%?\b", text)
30
+ return sorted(set(out))
31
+
32
+
33
+ def normalize(v):
34
+ v = v.strip().lower()
35
+ return v.replace("not enough information", "not_enough_info")
36
+
37
+
38
+ class HelixMemory:
39
+ def __init__(self, path="data/helix_memory.jsonl"):
40
+ self.path = Path(path)
41
+ self.records = []
42
+ self._load()
43
+
44
+ def _load(self):
45
+ if not self.path.exists():
46
+ return
47
+ for line in self.path.open(encoding="utf-8"):
48
+ line = line.strip()
49
+ if line:
50
+ try:
51
+ self.records.append(json.loads(line))
52
+ except Exception:
53
+ pass
54
+
55
+ def write(self, claim, evidence, verdict, confidence, reasoning, agreed=True, mind=None,
56
+ case_id="default", source_ids=None, tags=None, salience=1.0,
57
+ privacy="case-local"):
58
+ sig = (mind,) + tuple(rungs(claim + " " + evidence)) if mind else tuple(rungs(claim + " " + evidence))
59
+ for r in self.records:
60
+ if tuple(r.get("sig", [])) == sig:
61
+ return r # already remembered
62
+ rec = {
63
+ "id": "mem-" + uuid.uuid4().hex[:12],
64
+ "claim": claim, "evidence": evidence, "verdict": verdict,
65
+ "confidence": confidence, "reasoning": reasoning,
66
+ "agreed": bool(agreed), "sig": list(sig), "mind": mind,
67
+ "case_id": case_id, "source_ids": list(source_ids or []),
68
+ "tags": sorted(set(tags or [])), "salience": float(salience),
69
+ "privacy": privacy, "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
70
+ }
71
+ with lock:
72
+ self.records.append(rec)
73
+ with self.path.open("a", encoding="utf-8") as f:
74
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
75
+ return rec
76
+
77
+ @staticmethod
78
+ def _words(text):
79
+ return set(re.findall(r"[a-z]{4,}", text.lower()))
80
+
81
+ @staticmethod
82
+ def _jaccard(a, b):
83
+ if not a or not b:
84
+ return 0.0
85
+ return len(a & b) / len(a | b)
86
+
87
+ def recall(self, claim, evidence=""):
88
+ """Return prior record ONLY for a true repeat (same values, same claim)."""
89
+ matches = self.recall_many(claim, evidence, limit=1)
90
+ return matches[0] if matches else None
91
+
92
+ def recall_many(self, claim, evidence="", limit=5, case_id=None, tags=None):
93
+ """Rank related memories by rungs, words, tags, salience, and case scope."""
94
+ text = claim + " " + evidence
95
+ new_rungs, new_words = set(rungs(text)), self._words(text)
96
+ if not new_rungs and not new_words:
97
+ return []
98
+ wanted = set(tags or [])
99
+ scored = []
100
+ for record in self.records:
101
+ if case_id is not None and record.get("case_id", "default") != case_id:
102
+ continue
103
+ old_words = self._words(record.get("claim", "") + " " + record.get("evidence", ""))
104
+ old_rungs = set(record.get("sig", []))
105
+ rung_score = len(new_rungs & old_rungs) / max(1, len(new_rungs | old_rungs))
106
+ word_score = self._jaccard(new_words, old_words)
107
+ tag_score = len(wanted & set(record.get("tags", []))) / max(1, len(wanted)) if wanted else 0.0
108
+ score = 0.55 * rung_score + 0.35 * word_score + 0.10 * tag_score
109
+ if score > 0.05:
110
+ scored.append((score * max(0.1, float(record.get("salience", 1.0))), record))
111
+ scored.sort(key=lambda pair: pair[0], reverse=True)
112
+ return [{**record, "recall_score": round(score, 4)} for score, record in scored[:limit]]
113
+
114
+ def bridges(self, claim, evidence="", tags=None, limit=5):
115
+ """Cross case/domain recall: never require the same case to bridge."""
116
+ return self.recall_many(claim, evidence, limit=limit, tags=tags)
117
+
118
+ def forget(self, record_id=None, claim=None):
119
+ """User-controlled deletion; rewrites the JSONL atomically."""
120
+ before = len(self.records)
121
+ self.records = [r for r in self.records if not ((record_id and r.get("id") == record_id) or (claim and r.get("claim") == claim))]
122
+ removed = before - len(self.records)
123
+ if removed:
124
+ self.path.parent.mkdir(parents=True, exist_ok=True)
125
+ tmp = self.path.with_suffix(self.path.suffix + ".tmp")
126
+ with tmp.open("w", encoding="utf-8") as fh:
127
+ for record in self.records:
128
+ fh.write(json.dumps(record, ensure_ascii=False) + "\n")
129
+ tmp.replace(self.path)
130
+ return removed
131
+
132
+ def consolidate(self):
133
+ """Deduplicate exact signatures while preserving the highest-salience record."""
134
+ best = {}
135
+ for record in self.records:
136
+ key = tuple(record.get("sig", []))
137
+ if key not in best or record.get("salience", 1.0) > best[key].get("salience", 1.0):
138
+ best[key] = record
139
+ self.records = list(best.values())
140
+ return self.stats()
141
+
142
+ def stats(self):
143
+ return {"records": len(self.records), "cases": len(set(r.get("case_id", "default") for r in self.records)),
144
+ "source_backed": sum(bool(r.get("source_ids")) for r in self.records),
145
+ "bridges": sum(bool(r.get("tags")) for r in self.records)}
research/index.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tiny on-device retrieval index (TF-IDF, numpy) over corpus/raw texts."""
2
+
3
+ import argparse
4
+ import math
5
+ import re
6
+ import time
7
+ from collections import Counter
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+
12
+ STOP = set("the a an of to in on for and or is are was were be been has have had it its this that with as at by from".split())
13
+
14
+
15
+ def tokens(text: str):
16
+ return [w for w in re.findall(r"[a-z0-9']+", text.lower()) if w not in STOP and len(w) > 2]
17
+
18
+
19
+ class TinyIndex:
20
+ def __init__(self):
21
+ self.docs = [] # list of (key, path, text)
22
+ self.terms = {}
23
+ self.df = Counter() # doc frequencies
24
+ self.tfidf = None
25
+
26
+ def add(self, key, path, text):
27
+ self.docs.append((key, path, text))
28
+ for t in set(tokens(text)):
29
+ self.df[t] += 1
30
+
31
+ def build(self):
32
+ vocab = {t for t in self.df}
33
+ self.terms = {t: i for i, t in enumerate(sorted(vocab))}
34
+ n = len(self.docs)
35
+ rows, cols, vals = [], [], []
36
+ for di, (_, _, text) in enumerate(self.docs):
37
+ for t, c in Counter(tokens(text)).items():
38
+ if t in self.terms:
39
+ idf = math.log((n + 1) / (self.df[t] + 1)) + 1
40
+ rows.append(di); cols.append(self.terms[t]); vals.append(c * idf)
41
+ m = np.zeros((n, len(self.terms)), dtype=np.float32)
42
+ m[rows, cols] = vals
43
+ norms = np.linalg.norm(m, axis=1, keepdims=True)
44
+ norms[norms == 0] = 1
45
+ self.tfidf = m / norms
46
+
47
+ def query(self, q: str, k: int = 5):
48
+ if self.tfidf is None:
49
+ self.build()
50
+ v = np.zeros(len(self.terms), dtype=np.float32)
51
+ for t, c in Counter(tokens(q)).items():
52
+ if t in self.terms:
53
+ v[self.terms[t]] = c * (math.log((len(self.docs) + 1) / (self.df[t] + 1)) + 1)
54
+ if v.sum() == 0:
55
+ return []
56
+ v = v / np.linalg.norm(v)
57
+ scores = self.tfidf @ v
58
+ order = np.argsort(-scores)[:k]
59
+ return [(self.docs[i][0], float(scores[i])) for i in order if scores[i] > 0]
60
+
61
+
62
+ def main():
63
+ ap = argparse.ArgumentParser()
64
+ ap.add_argument("--corpus", default="corpus/raw")
65
+ ap.add_argument("--query", default=None)
66
+ ap.add_argument("--k", type=int, default=5)
67
+ args = ap.parse_args()
68
+
69
+ idx = TinyIndex()
70
+ for f in sorted(Path(args.corpus).glob("*.txt")):
71
+ idx.add(f.stem, str(f), f.read_text(encoding="utf-8", errors="ignore"))
72
+ print(f"indexed {len(idx.docs)} docs", flush=True)
73
+
74
+ if args.query:
75
+ for key, score in idx.query(args.query, args.k):
76
+ print(f"{score:.3f} {key} (corpus/raw/{key}.txt)", flush=True)
77
+ else:
78
+ print("usage: research/index.py --query 'query text' [--k 5]")
79
+
80
+
81
+ if __name__ == "__main__":
82
+ main()
research/journalism.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Journalism suite facade β€” one call, the whole desk (tiny-model-journalism).
2
+
3
+ suite_report(name, docs, claims) runs every deterministic forensic layer over
4
+ a case and returns a single markdown notebook, saved as a durable CaseFile:
5
+ provenance ledger + chain-of-custody
6
+ timeline + gap/cliff/anachronism detection
7
+ framing / language forensics
8
+ cross-domain pattern synthesis
9
+ entity relationship graph
10
+ pre-publication adversarial review
11
+
12
+ No model inference anywhere in this file β€” the suite materializes; the brain
13
+ reasons over the surfaced leads.
14
+
15
+ Usage:
16
+ from research.journalism import suite_report
17
+ md = suite_report("bridge_case", docs, claims)
18
+ """
19
+ import hashlib
20
+ import re
21
+ from pathlib import Path
22
+
23
+ from research.provenance import ProvenanceLedger, evaluate_source_policy
24
+ from research.timeline import TimelineAnalyzer
25
+ from research.framing import FramingAnalyzer
26
+ from research.patterns import CrossDomainPatterns
27
+ from research.entitygraph import EntityGraph
28
+ from research.editorial_review import editorial_review
29
+ from research.casefile import CaseFile
30
+
31
+ _EVENT_LINE = re.compile(r"^(?:NOTE|EVENT|OPEN):\s*(.+)$")
32
+
33
+
34
+ def suite_report(name, docs, claims=(), events=(), topics=()):
35
+ """Run the full forensic desk and fail closed on uncorroborated claims.
36
+
37
+ A non-empty claim verdict enters the CaseFile only when its source bundle
38
+ meets SOP 09: two independent usable sources with one strong source, each
39
+ carrying URL, retrieval time, content hash, and triage metadata. Otherwise
40
+ the claim remains an unresolved lead with its missing evidence recorded.
41
+ """
42
+ cf = CaseFile(name)
43
+ led = ProvenanceLedger(path=None)
44
+ tl = TimelineAnalyzer()
45
+ fr = FramingAnalyzer()
46
+ pat = CrossDomainPatterns()
47
+ eg = EntityGraph()
48
+ source_inputs = {}
49
+
50
+ for d in docs:
51
+ sid = d.get("source_id") or d.get("title") or "doc"
52
+ tier = d.get("tier", "unverified")
53
+ text = d.get("text", "")
54
+ retrieved_at = d.get("retrieved_at", d.get("retrieved", ""))
55
+ content_sha256 = d.get("content_sha256") or hashlib.sha256(
56
+ text.encode("utf-8", errors="replace")).hexdigest()
57
+ triage = d.get("triage") if isinstance(d.get("triage"), dict) else {}
58
+ led.register_source(sid, d.get("title", sid), tier=tier,
59
+ url=d.get("url", ""), date=d.get("date", ""),
60
+ retrievable=d.get("retrievable", True),
61
+ independent=d.get("independent", True),
62
+ origin=d.get("origin", ""),
63
+ content_sha256=content_sha256, triage=triage,
64
+ retrieved=retrieved_at)
65
+ source_inputs[sid] = {
66
+ "source_id": sid,
67
+ "url": d.get("url", ""),
68
+ "origin": d.get("origin", ""),
69
+ "retrieved_at": retrieved_at,
70
+ "content_sha256": content_sha256,
71
+ "independent": d.get("independent", True),
72
+ "retrievable": d.get("retrievable", True),
73
+ "triage": triage,
74
+ }
75
+ fr.add_doc(sid, text)
76
+ eg.add_doc(sid, text)
77
+ pat.add_strand(d.get("domain") or sid, text)
78
+ if d.get("date"):
79
+ tl.add_event(d["date"], d.get("title", sid), sid)
80
+ cf.add_source(sid, d.get("title", sid), tier=tier,
81
+ url=d.get("url", ""), date=d.get("date", ""),
82
+ retrievable=d.get("retrievable", True),
83
+ independent=d.get("independent", True),
84
+ origin=d.get("origin", ""), content_sha256=content_sha256,
85
+ triage=triage, retrieved_at=retrieved_at)
86
+
87
+ for e in events:
88
+ tl.add_event(e["when"], e["what"], e.get("source_id", "-"))
89
+ cf.add_event(e["when"], e["what"], e.get("source_id", "-"))
90
+
91
+ policy_results = []
92
+ for c in claims:
93
+ sids = c.get("source_ids", [])
94
+ policy = evaluate_source_policy([source_inputs[sid] for sid in sids
95
+ if sid in source_inputs])
96
+ requested_verdict = c.get("verdict", "")
97
+ verdict, confidence = requested_verdict, c.get("confidence", "")
98
+ missing = c.get("missing", "")
99
+ if requested_verdict and not policy["verified"]:
100
+ verdict, confidence = "not enough information", "LOW"
101
+ policy_missing = "source policy: " + policy["reason"]
102
+ missing = "; ".join(part for part in (missing, policy_missing) if part)
103
+ policy_results.append({"claim": c["claim"], "policy": policy,
104
+ "effective_verdict": verdict})
105
+ led.record_claim(c["claim"], sids, verdict=verdict, confidence=confidence)
106
+ cf.add_claim(c["claim"], sids, verdict=verdict, confidence=confidence,
107
+ source_policy=policy)
108
+ if verdict:
109
+ cf.add_finding("main", c["claim"], verdict, confidence, sids,
110
+ missing=missing, source_policy=policy)
111
+
112
+ md = [
113
+ f"# Journalism Suite: {name}",
114
+ f"documents: {len(docs)} | claims: {len(claims)} | "
115
+ f"events: {len(events)}",
116
+ "",
117
+ "---", "",
118
+ led.report(), "", "---", "",
119
+ tl.report(), "", "---", "",
120
+ fr.report(topics), "", "---", "",
121
+ pat.report(), "", "---", "",
122
+ eg.report(), "", "---", "",
123
+ _source_policy_section(policy_results), "", "---", "",
124
+ _review_section(claims, led), "",
125
+ "---", "",
126
+ "## CaseFile",
127
+ f"saved: {cf.path}",
128
+ "",
129
+ cf.export_markdown(),
130
+ ]
131
+ return "\n".join(md)
132
+
133
+
134
+ def _source_policy_section(results):
135
+ lines = ["# Source Policy Gate", ""]
136
+ if not results:
137
+ lines.append("- no claim verdicts to gate")
138
+ return "\n".join(lines)
139
+ for result in results:
140
+ policy = result["policy"]
141
+ label = "VERIFIED SOURCE POLICY" if policy["verified"] else "LEAD ONLY"
142
+ lines.append(f"- [{label}] {result['claim']} -> {result['effective_verdict']} "
143
+ f"({policy['reason']}; independent usable: "
144
+ f"{policy['independent_usable']}, strong: "
145
+ f"{policy['independent_strong']})")
146
+ return "\n".join(lines)
147
+
148
+
149
+ def _review_section(claims, ledger):
150
+ lines = ["# Pre-Publication Adversarial Review", ""]
151
+ if not claims:
152
+ lines.append("- no claims to review")
153
+ return "\n".join(lines)
154
+ for c in claims:
155
+ sids = c.get("source_ids", [])
156
+ indep = [s for s in ledger.sources.values()
157
+ if s.source_id in sids and s.independent and s.retrievable]
158
+ r = editorial_review(c["claim"], sources=len(indep),
159
+ counter_evidence=c.get("counter_evidence", False),
160
+ has_dates=c.get("has_dates", False))
161
+ lines.append(f"## {r['claim']}")
162
+ lines.append(f"**{r['summary']}** ({r['flags']} flags)")
163
+ for card in r["cards"]:
164
+ lines.append(f"- [{card['status']}] {card['item']}: "
165
+ f"{card['detail']}")
166
+ lines.append("")
167
+ return "\n".join(lines)
168
+
169
+
170
+ def docs_from_library(library_dir, max_chars=12000):
171
+ """Load library docs into the facade's doc schema (deterministic)."""
172
+ out = []
173
+ for p in sorted(Path(library_dir).glob("*")):
174
+ if not p.is_file():
175
+ continue
176
+ text = p.read_text(encoding="utf-8", errors="replace")[:max_chars]
177
+ out.append({"source_id": p.name, "title": p.stem, "text": text,
178
+ "tier": "unverified", "url": "", "date": "",
179
+ "domain": "library", "independent": True,
180
+ "retrievable": True})
181
+ return out
182
+
183
+
184
+ def claims_from_ledger(lines):
185
+ """Parse case ledger lines into claims (NOTE:/VERDICT:/OPEN: prefixes)."""
186
+ claims = []
187
+ for line in lines:
188
+ m = _EVENT_LINE.match(line.strip())
189
+ if m:
190
+ body = m.group(1).strip()
191
+ claims.append({"claim": body[:160], "source_ids": [],
192
+ "verdict": "", "confidence": "",
193
+ "counter_evidence": False, "has_dates": False})
194
+ return claims
research/orchestrator.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parallel research orchestrator for the tiny researcher (suit layer).
2
+
3
+ The 25M brain cannot spawn agents by itself. This module is the on-device
4
+ equivalent: one shared model instance (a single analyst brain) with N worker
5
+ threads, each running the SOP agent loop under a different research angle.
6
+ Network and dark-web retrieval run in parallel; model inference is serialized
7
+ by a lock (one brain, many hands), so concurrent torch forward passes never
8
+ race on the 8-core device.
9
+
10
+ Pipeline:
11
+ planner -> deterministic angle split of the task
12
+ workers -> N x agent.run_case (parallel I/O, locked inference)
13
+ synthesis -> merge findings, dedup sources, flag cross-agent conflicts
14
+ (consensus + contradiction report), final analyst verdict
15
+
16
+ Usage:
17
+ .venv/bin/python research/orchestrator.py --case "Verify: ..." --agents 4 \\
18
+ --sop dark_web_research --ckpt ckpt/tiny25m_sft_f
19
+ """
20
+
21
+ import argparse
22
+ import json
23
+ import re
24
+ import sys
25
+ import threading
26
+ from concurrent.futures import ThreadPoolExecutor, as_completed
27
+ from pathlib import Path
28
+
29
+ import torch
30
+
31
+ from model.config import TinyLiquidConfig
32
+ from model.utils import latest_ckpt
33
+ from model.tiny_liquid import TinyLiquid
34
+ from data.tokenizer import load_tokenizer
35
+ from research.agent import load_sop, run_case, list_sops
36
+ from research.room import build_index
37
+ from research.structured import analyst_report
38
+
39
+ ROOT = Path(__file__).resolve().parents[1]
40
+
41
+ ANGLES = {
42
+ "core": ("CORE CLAIM β€” verify the central claim itself against the record "
43
+ "first; cite the deciding document."),
44
+ "provenance": ("SOURCES & PROVENANCE β€” hunt the primary records behind the "
45
+ "claim: who created them, when, and whether the provenance "
46
+ "is verifiable."),
47
+ "timeline": ("TIMELINE & SEQUENCE β€” reconstruct the order of events and "
48
+ "dates; flag gaps and after-the-fact records."),
49
+ "contradiction": ("CONTRADICTION & DISCREPANCY β€” find records that conflict "
50
+ "with the claim or with each other; quantify the conflict."),
51
+ "pattern": ("PATTERN & CROSS-DOMAIN β€” look for recurring motifs, unusual "
52
+ "clusters, or links across domains the other angles would miss."),
53
+ }
54
+ ORDER = ["core", "provenance", "timeline", "contradiction", "pattern"]
55
+
56
+ VERDICT_RE = re.compile(r"Verdict:\s*([^.\n]+)\.", re.IGNORECASE)
57
+
58
+ # Opposite verdict pairs: agents landing on both sides of one of these is a
59
+ # real cross-agent conflict worth surfacing in the merged report.
60
+ OPPOSITES = {
61
+ "true": "false", "false": "true",
62
+ "contradiction": "not a contradiction", "not a contradiction": "contradiction",
63
+ "refutes": "supports", "supports": "refutes",
64
+ "overclaim": "understates", "understates": "overclaim",
65
+ }
66
+
67
+
68
+ def angles_for(task: str, n: int = 4) -> list[str]:
69
+ """Deterministic angle split: core claim always first, then the lenses that
70
+ match the task domain, capped at n."""
71
+ low = task.lower()
72
+ wanted = ["core"]
73
+ for name in ORDER[1:]:
74
+ if n <= len(wanted):
75
+ break
76
+ wanted.append(name)
77
+ return wanted[:max(1, min(n, len(ANGLES)))]
78
+
79
+
80
+ def _verdicts(ledger):
81
+ return [m.group(1).strip().lower() for e in ledger
82
+ for m in [VERDICT_RE.search(e)] if m]
83
+
84
+
85
+ def _worker(model, tok, task, idx, sop_text, angle, lock, max_steps):
86
+ try:
87
+ plan, ledger = run_case(model, tok, task, idx, sop_text,
88
+ max_steps=max_steps, lock=lock, angle=ANGLES[angle])
89
+ notes = [e[5:].strip() for e in ledger if e.startswith("NOTE:")]
90
+ return {"angle": angle, "ok": True, "steps": len(plan), "plan": plan,
91
+ "ledger": ledger, "notes": notes, "verdicts": _verdicts(ledger)}
92
+ except Exception as e: # one bad angle must not kill the swarm
93
+ return {"angle": angle, "ok": False, "error": str(e)[:300],
94
+ "steps": 0, "plan": [], "ledger": [], "notes": [], "verdicts": []}
95
+
96
+
97
+ def run_parallel(model, tok, task, idx, sop_text, n=4, lock=None,
98
+ max_steps=5, library="data/library"):
99
+ """Spawn n worker agents under distinct angles. I/O runs in parallel;
100
+ model inference is serialized by `lock` (one shared brain)."""
101
+ angles = angles_for(task, n)
102
+ lock = lock or threading.Lock()
103
+ results = []
104
+ with ThreadPoolExecutor(max_workers=len(angles)) as pool:
105
+ futs = {pool.submit(_worker, model, tok, task, build_index(library),
106
+ sop_text, a, lock, max_steps): a for a in angles}
107
+ for fut in as_completed(futs):
108
+ results.append(fut.result())
109
+ results.sort(key=lambda r: ORDER.index(r["angle"]) if r["angle"] in ORDER else 99)
110
+ return results
111
+
112
+
113
+ def _conflicts(results):
114
+ """Pairs of opposite verdicts reached by different agents."""
115
+ pairs = []
116
+ seen = set()
117
+ for i, a in enumerate(results):
118
+ for j, b in enumerate(results):
119
+ if i >= j:
120
+ continue
121
+ for va in a.get("verdicts", []):
122
+ for vb in b.get("verdicts", []):
123
+ if OPPOSITES.get(va) == vb or OPPOSITES.get(vb) == va:
124
+ key = tuple(sorted((a["angle"], b["angle"], va, vb)))
125
+ if key not in seen:
126
+ seen.add(key)
127
+ pairs.append({"agents": [a["angle"], b["angle"]],
128
+ "verdicts": [va, vb]})
129
+ return pairs
130
+
131
+
132
+ def synthesize(task, results, idx):
133
+ """Merge the swarm: grouped findings, deduped sources, conflicts, summary."""
134
+ ok = [r for r in results if r["ok"]]
135
+ findings = []
136
+ for r in ok:
137
+ for note in r["notes"]:
138
+ findings.append({"angle": r["angle"], "note": note})
139
+ sources = [{"key": k, "path": str(p)} for k, p, _ in idx.docs]
140
+ conflicts = _conflicts(ok)
141
+ completed = len(ok)
142
+ summary = (
143
+ f"{len(findings)} findings across {completed}/{len(results)} agents "
144
+ f"({', '.join(r['angle'] for r in ok) or 'none'}); "
145
+ f"{len(sources)} documents in the library; "
146
+ f"{len(conflicts)} cross-agent conflict(s) flagged."
147
+ )
148
+ return {
149
+ "task": task,
150
+ "agents_total": len(results),
151
+ "agents_ok": completed,
152
+ "angles": [r["angle"] for r in ok],
153
+ "findings": findings,
154
+ "sources": sources[:40],
155
+ "conflicts": conflicts,
156
+ "summary": summary,
157
+ "analyst": None,
158
+ }
159
+
160
+
161
+ def main():
162
+ ap = argparse.ArgumentParser()
163
+ ap.add_argument("--case", default=None)
164
+ ap.add_argument("--agents", type=int, default=4, help="parallel agents (default 4)")
165
+ ap.add_argument("--sop", default=None, help="procedure stem, e.g. dark_web_research")
166
+ ap.add_argument("--list-sops", action="store_true")
167
+ ap.add_argument("--ckpt", default="ckpt/distill")
168
+ ap.add_argument("--tok", default="data/tokenizer.json")
169
+ ap.add_argument("--library", default="data/library")
170
+ ap.add_argument("--max-new", type=int, default=200)
171
+ ap.add_argument("--max-steps", type=int, default=5)
172
+ ap.add_argument("--threads", type=int, default=8)
173
+ args = ap.parse_args()
174
+
175
+ if args.list_sops:
176
+ list_sops()
177
+ return
178
+
179
+ task = args.case or sys.stdin.read().strip()
180
+ assert task, "no case provided (--case or stdin)"
181
+
182
+ torch.set_num_threads(args.threads)
183
+ tok = load_tokenizer(args.tok)
184
+ ckpt = latest_ckpt(args.ckpt)
185
+ assert ckpt, f"no checkpoints in {args.ckpt}"
186
+ sd = torch.load(ckpt, map_location="cpu")
187
+ cfg = TinyLiquidConfig(vocab_size=tok.get_vocab_size(),
188
+ **{k: v for k, v in sd["config"].items() if k != "vocab_size"})
189
+ model = TinyLiquid(cfg)
190
+ model.load_state_dict(sd["model"])
191
+ model.eval()
192
+ print(f"loaded {ckpt} (step {sd.get('step', '?')})", flush=True)
193
+
194
+ sop_text = load_sop(args.sop, task)
195
+ idx = build_index(args.library)
196
+ print(f"swarm: {args.agents} agents | library docs: {len(idx.docs)}", flush=True)
197
+
198
+ results = run_parallel(model, tok, task, idx, sop_text,
199
+ n=args.agents, max_steps=args.max_steps,
200
+ library=args.library)
201
+ merged = synthesize(task, results, build_index(args.library))
202
+ merged["analyst"] = analyst_report(model, tok, task, persona_id=1,
203
+ max_scratch=args.max_new // 2,
204
+ max_reason=args.max_new // 4)
205
+
206
+ print("\n=== MERGED REPORT ===")
207
+ print(json.dumps(merged, indent=2, ensure_ascii=False))
208
+
209
+
210
+ if __name__ == "__main__":
211
+ main()
research/patterns.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cross-domain pattern synthesis over memory strands (journalism suite 4).
2
+
3
+ The owner's closed-loop insight: every domain sits in one system, so a rung
4
+ (number, year, name) or theme repeated across UNRELATED domains is a lead.
5
+ The suite finds the overlap; the human decides whether the connection is
6
+ causal, coincidental, or symbolic. Cards carry the base-rate caveat so a
7
+ repeated number is never auto-promoted to a conclusion.
8
+
9
+ Basis: helix rung model (research/helix.py) + memory skill's cross-domain
10
+ reinforcement doctrine (tiny-model-memory).
11
+
12
+ Usage:
13
+ from research.patterns import CrossDomainPatterns
14
+ p = CrossDomainPatterns()
15
+ p.add_strand("economics", "the 1929 crash... gold standard...")
16
+ p.add_strand("religion", "Genesis... serpent... 1929...")
17
+ p.report()
18
+ """
19
+ import re
20
+ from collections import defaultdict
21
+
22
+ from research.helix import rungs
23
+
24
+ THEMES = [
25
+ "serpent", "snake", "eye", "pyramid", "coin", "flood", "plague", "fire",
26
+ "tower", "gate", "seal", "crown", "star", "dove", "wolf", "mirror",
27
+ "key", "blood", "gold", "iron", "wall", "circle", "garden", "beast",
28
+ "mark", "number", "trumpet", "scroll", "angel", "dragon",
29
+ ]
30
+ _NAME = re.compile(r"\b[A-Z][a-z]{2,20}(?:\s+[A-Z][a-z]{2,20}){0,2}\b")
31
+
32
+
33
+ class CrossDomainPatterns:
34
+ def __init__(self):
35
+ self.strands = [] # list of {"domain", "text"}
36
+
37
+ def add_strand(self, domain, text):
38
+ self.strands.append({"domain": domain, "text": text})
39
+
40
+ def domains(self):
41
+ return sorted({s["domain"] for s in self.strands})
42
+
43
+ def shared_rungs(self):
44
+ """Rungs (numbers/years/times/names) present in >=2 different domains."""
45
+ by_rung = defaultdict(dict)
46
+ for s in self.strands:
47
+ vals = set(rungs(s["text"]))
48
+ for v in vals:
49
+ by_rung[v][s["domain"]] = by_rung[v].get(s["domain"], 0) + 1
50
+ out = []
51
+ for v, doms in by_rung.items():
52
+ if len(doms) >= 2:
53
+ out.append({"rung": v, "domains": sorted(doms),
54
+ "strength": min(doms.values())})
55
+ return sorted(out, key=lambda c: -c["strength"])
56
+
57
+ def theme_overlap(self):
58
+ """Themes present in >=2 different domains."""
59
+ by_theme = defaultdict(set)
60
+ for s in self.strands:
61
+ low = s["text"].lower()
62
+ for t in THEMES:
63
+ if t in low:
64
+ by_theme[t].add(s["domain"])
65
+ return [{"theme": t, "domains": sorted(d)}
66
+ for t, d in by_theme.items() if len(d) >= 2]
67
+
68
+ def names(self, min_domains=2):
69
+ """Proper-noun co-occurrence across domains (loose entity bridge)."""
70
+ by_name = defaultdict(set)
71
+ for s in self.strands:
72
+ for m in _NAME.finditer(s["text"]):
73
+ by_name[m.group(0)].add(s["domain"])
74
+ return [{"name": n, "domains": sorted(d)}
75
+ for n, d in by_name.items() if len(d) >= min_domains]
76
+
77
+ def report(self):
78
+ lines = ["# Cross-Domain Pattern Synthesis", ""]
79
+ lines.append(f"domains: {', '.join(self.domains())}")
80
+ lines.append("")
81
+ lines.append("## Shared rungs (numbers/years/times)")
82
+ sr = self.shared_rungs()
83
+ for c in sr[:20]:
84
+ lines.append(f"- `{c['rung']}` (strength {c['strength']}) appears in "
85
+ f"{', '.join(c['domains'])}")
86
+ lines.append(" - LEAD: check whether causal, coincidental, or symbolic")
87
+ if not sr:
88
+ lines.append("- no cross-domain rungs")
89
+ lines.append("")
90
+ lines.append("## Theme overlap")
91
+ for c in self.theme_overlap()[:20]:
92
+ lines.append(f"- '{c['theme']}' in {', '.join(c['domains'])}")
93
+ lines.append(" - LEAD: base-rate check first; repeated themes are "
94
+ "common in text")
95
+ if not self.theme_overlap():
96
+ lines.append("- no cross-domain themes")
97
+ lines.append("")
98
+ lines.append("## Name bridges")
99
+ for c in self.names()[:20]:
100
+ lines.append(f"- '{c['name']}' in {', '.join(c['domains'])}")
101
+ if not self.names():
102
+ lines.append("- no cross-domain name bridges")
103
+ lines.append("")
104
+ lines.append("_Every card above is a LEAD, never a verdict._")
105
+ return "\n".join(lines)
research/probe.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run a fixed set of forensic probes through a TinyLiquid checkpoint.
2
+
3
+ Usage:
4
+ .venv/bin/python research/probe.py --ckpt ckpt/distill
5
+ """
6
+
7
+ import argparse
8
+ from pathlib import Path
9
+
10
+ import torch
11
+
12
+ from model.config import TinyLiquidConfig, CONFIGS
13
+ from model.tiny_liquid import TinyLiquid
14
+ from model.utils import latest_ckpt
15
+ from data.tokenizer import load_tokenizer
16
+
17
+ PROBES = [
18
+ ("analyst", "Find discrepancies between: Account A: The meeting started at 9am and ended at 11am. Account B: The meeting started at 9am and ran until noon."),
19
+ ("analyst", "Two accounts describe the same event. Account A: 'No officials were present.' Account B: 'An official arrived later.' What can you conclude?"),
20
+ ("analyst", "Evaluate this claim: 'Crime in the city doubled last year because of the new policy.' The report shows incidents rose from 1,000 to 2,000 while reporting methods changed."),
21
+ ("analyst", "What are the weak links in a theory claiming one actor caused three unrelated disasters?"),
22
+ ("skeptic", "Attack this conclusion: 'The stock dropped after the announcement, so investors rejected the announcement.'"),
23
+ ]
24
+
25
+
26
+ def parse_args():
27
+ ap = argparse.ArgumentParser()
28
+ ap.add_argument("--ckpt", default="ckpt/distill")
29
+ ap.add_argument("--tok", default="data/tokenizer.json")
30
+ ap.add_argument("--max-new", type=int, default=140)
31
+ ap.add_argument("--threads", type=int, default=8)
32
+ return ap.parse_args()
33
+
34
+
35
+ def main():
36
+ args = parse_args()
37
+ torch.set_num_threads(args.threads)
38
+ tok = load_tokenizer(args.tok)
39
+ ckpt = latest_ckpt(args.ckpt)
40
+ assert ckpt, f"no checkpoints in {args.ckpt}"
41
+ sd = torch.load(ckpt, map_location="cpu")
42
+ cfg_dict = dict(sd.get("config", CONFIGS["tiny10m"]))
43
+ cfg = TinyLiquidConfig(vocab_size=tok.get_vocab_size(),
44
+ **{k: v for k, v in cfg_dict.items() if k != "vocab_size"})
45
+ model = TinyLiquid(cfg)
46
+ model.load_state_dict(sd["model"])
47
+ model.eval()
48
+ print(f"== {ckpt} (step {sd.get('step','?')}) ==\n", flush=True)
49
+
50
+ P_TOKEN = {"analyst": "<|analyst|>", "skeptic": "<|skeptic|>"}
51
+ P_ID = {"analyst": 1, "skeptic": 2}
52
+ for persona, prompt in PROBES:
53
+ p = P_TOKEN[persona] + "<|user|>" + prompt + "<|assistant|>"
54
+ ids = tok.encode(p).ids
55
+ out = model.generate(tok, ids, persona_id=P_ID[persona], max_new=args.max_new,
56
+ temperature=0.65, top_k=40, repetition_penalty=1.4,
57
+ no_repeat_ngram_size=4)
58
+ print(f"--- [{persona}] {prompt}\n{tok.decode(out[len(ids):])}\n", flush=True)
59
+
60
+
61
+ if __name__ == "__main__":
62
+ main()
research/procedures_research.md ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # How Codex-style agents run per-task procedures β€” and how we give TinyLiquid the same power
2
+
3
+ Date: 2026-07-31
4
+ Sources: official Codex manual (cached copy, `/tmp/openai-docs-cache/codex-manual.md`)
5
+ -> "Custom instructions with AGENTS.md", "Best practices", "Prompting", "Plan mode".
6
+
7
+ ## 1. What the "little task bar" actually is
8
+
9
+ The task bar you see in Codex/GPT agents is not a single feature. It is three
10
+ mechanisms working together:
11
+
12
+ 1. **A plan / task list (the visible bar).** For multi-step work the agent
13
+ maintains an explicit list of steps and updates status as it goes
14
+ (plan mode, `update_plan`-style item updates). The list is *external state*
15
+ β€” it lives in the loop, not in the model's weights β€” so the model never has
16
+ to remember where it is.
17
+ 2. **Durable procedures (the invisible rules).** Repo instructions in
18
+ `AGENTS.md` (and overrides) are *loaded into the prompt at start* and stay
19
+ in context. They encode: repo layout, how to run/test, conventions,
20
+ constraints, do-not rules, and "what done means". Multiple `AGENTS.md` files
21
+ are layered global -> project -> subdirectory; closer files override, and
22
+ the whole chain is capped at 32 KiB.
23
+ 3. **A tool loop with guardrails.** The agent proposes tool calls (shell,
24
+ search, file edits), the harness executes them under a sandbox, feeds
25
+ results back, and the agent re-plans. Approval rules gate destructive
26
+ actions. This loop is what makes a small per-step model behave like a
27
+ careful operator instead of a chatbox.
28
+
29
+ Official manual facts used here:
30
+ - "Codex reads `AGENTS.md` files before doing any work... Discovery follows
31
+ this precedence order: global scope, then project scope (walking from
32
+ project root down to cwd), merged root-to-leaf; files closer to the current
33
+ directory override earlier guidance."
34
+ - "A good `AGENTS.md` covers: repo layout..., build/test/lint commands,
35
+ engineering conventions..., constraints and do-not rules, what done means
36
+ and how to verify work."
37
+ - Prompt best practice: give the agent **Goal / Context / Constraints /
38
+ Done-when** so it stays scoped.
39
+ - Guidance is deliberately *short and practical*: "start with the basics,
40
+ then add new rules only after you notice repeated mistakes."
41
+
42
+ ## 2. The general pattern (model-agnostic)
43
+
44
+ Any agent (tiny or huge) gets powerful from this 4-layer stack:
45
+
46
+ Layer 0 Weights language skill learned at pretraining
47
+ Layer 1 Prompt goal + context + constraints + done-when
48
+ Layer 2 Procedures durable SOP text injected per task (AGENTS.md analog)
49
+ Layer 3 Loop plan list + tool calls + result feedback + stop rules
50
+
51
+ Big models rely on Layer 1-3 being *understandable*. Tiny models fail there:
52
+ they lose track, drift off procedure, and cannot hold long context. So for a
53
+ tiny model we must make Layers 2-3 *external and mechanical*:
54
+
55
+ - procedures are files we choose and inject (never rely on memory);
56
+ - the loop, not the model, tracks step state (ledger, remaining steps);
57
+ - outputs that matter are produced with constrained decoding (verdict,
58
+ confidence) so the model cannot emit an off-protocol final answer;
59
+ - and we *train* the model to obey the procedure format (SFT + DPO on
60
+ procedure-following examples), so Layer 2 becomes learned behavior, not just
61
+ a prompt trick.
62
+
63
+ ## 3. What we already have in this repo
64
+
65
+ - `research/analyst.py` β€” dual-mind: analyst pass + skeptic attack pass.
66
+ - `research/structured.py` β€” constrained verdict/confidence decoding (the
67
+ "cannot emit off-protocol" guarantee).
68
+ - `research/room.py` β€” interactive environment: model issues RETRIEVE/READ/
69
+ NOTE/VERDICT actions against a local TF-IDF library; ledger = working memory
70
+ that exceeds the model's parameters.
71
+ - `research/crawl.py` + `research/index.py` β€” clearnet/Tor fetch and local
72
+ search (the "library" and "dark web" tools).
73
+ - Training: SFT examples already use `<|scratchpad|> ... <|final|>` so the
74
+ model is trained to think-then-answer in protocol form.
75
+
76
+ Gap vs. the Codex pattern: procedures are currently *one hard-coded SOP in
77
+ analyst.py*, not a per-task library; there is no "plan" step list; there is no
78
+ procedure-aware training data. This task closes exactly that gap.
79
+
80
+ ## 4. Design: the SOP layer for TinyLiquid
81
+
82
+ We mirror the Codex stack 1:1, adapted to 7.8M params:
83
+
84
+ Codex TinyLiquid equivalent
85
+ --------------------------- -----------------------------------------
86
+ AGENTS.md files research/sop_library/*.md (per task)
87
+ plan/task list research/agent.py ledger + step list
88
+ tool loop RETRIEVE/READ/NOTE/VERDICT + crawl/index
89
+ guardrails stop rules in each SOP + read-only shell
90
+ trained behavior sft_sop.jsonl + prefs_sop.jsonl (SFT+DPO)
91
+
92
+ ### 4.1 SOP library (procedures = AGENTS.md analog)
93
+
94
+ `research/sop_library/00_common.md` β€” universal rules every procedure obeys:
95
+ evidence over assertion, name missing evidence, primary-source checks, two
96
+ independent sources per factual claim, confidence on every verdict,
97
+ "cannot confirm" beats speculation, never a final verdict β€” decision support.
98
+
99
+ Task procedures (all authorized-research/OSINT, never illegal action):
100
+ - `claim_verification.md` β€” decompose -> source -> corroborate -> date ->
101
+ provenance -> verdict.
102
+ - `cross_source_discrepancy.md` β€” align two accounts, list deltas, classify
103
+ each delta (typo/ambiguity/conflict), find
104
+ which source changes the story.
105
+ - `pattern_finding.md` β€” collect events, cluster, look for common
106
+ cause/escalation, test against null
107
+ hypothesis, state pattern strength.
108
+ - `timeline_reconstruction.md` β€” anchor to dated primary records, gap list,
109
+ contradiction list, don't fill gaps with
110
+ inference.
111
+ - `historical_truth.md` β€” compare past reporting to later records,
112
+ identify what was hidden/late/corrected.
113
+ - `politics_analysis.md` β€” separate interests from evidence, track
114
+ provenance of talking points, rate
115
+ spin vs fact.
116
+ - `dark_web_research.md` β€” authorized OSINT; use crawler + Tor proxy
117
+ for .onion; rate-limit; never purchase,
118
+ never access CSAM/credential dumps, never
119
+ engage; document chain of custody.
120
+ - `terminal_control.md` β€” read-only first, dry-run, log every command,
121
+ no destructive ops without explicit approval,
122
+ kill long-running jobs, verify outputs.
123
+ - `source_triage.md` β€” score sources on independence, recency,
124
+ proximity to primary record, track record.
125
+
126
+ ### 4.2 Trained behavior (baking procedures into weights)
127
+
128
+ 1. `data/gen_sop_sft.py` -> `data/sft_sop.jsonl`
129
+ Task prompts that name a procedure; the assistant answer opens a scratchpad
130
+ that applies the procedure's steps to the material, then a `<|final|>` with
131
+ verdict + confidence. This teaches: (a) read the procedure, (b) follow it
132
+ stepwise, (c) never skip to a conclusion.
133
+ 2. `data/gen_sop_sft.py` also emits `data/prefs_sop.jsonl` (DPO pairs):
134
+ chosen = answer that follows the SOP; rejected = confident answer that
135
+ skipped the procedure. Teaches the *preference*: protocol beats fluency.
136
+ 3. Train order after current NLP retrain:
137
+ `forensic SFT -> distill SFT -> SOP SFT (mixed with distill set) -> DPO -> code stage`.
138
+
139
+ ### 4.3 Inference loop (`research/agent.py`)
140
+
141
+ - `--sop <name>` injects the procedure text into the user turn (Layer 2).
142
+ - The model works the case with RETRIEVE/READ/NOTE actions; agent.py enforces
143
+ a max-step plan (Layer 3), the ledger is external memory.
144
+ - At the end the loop runs the structured decoder (verdict + confidence),
145
+ then a skeptic pass, then emits a JSON report that audits which SOP steps
146
+ were actually completed and which gaps remain.
147
+ - This is the "task bar": the user sees the step ledger update, exactly like
148
+ watching Codex work through its plan.
149
+
150
+ ## 5. Best path forward (current situation)
151
+
152
+ - NLP retrain (`ckpt/nlp`, ~7.8M params) is running on-device and producing
153
+ coherent text (val_loss ~3.13, best yet).
154
+ - Next: finish NLP, then SFT on `sft_distill_mix` (forensic + teacher
155
+ distillation), then SOP SFT + DPO, then probe, then the code stage, then
156
+ GGUF quantization for edge deployment.
157
+ - Rule of thumb that big-tech recommends for tiny models: *more
158
+ high-quality, narrowly-scoped examples beats raw scale*. The 114 gold
159
+ distillation examples plus the new procedure-conditioned sets are the right
160
+ shape; we keep each SFT set focused and mix them at ~1:1 with the base
161
+ forensic set to avoid forgetting.
research/provenance.py ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Source credibility + provenance ledger (journalism suite layer 1).
2
+
3
+ The tiny head never grades a source. The SUITE keeps the ledger: every source
4
+ registered with a tier, a retrieval date, a retrievability flag, and an
5
+ independence mark; every claim recorded with its full chain-of-custody. A
6
+ credibility score is a deterministic heuristic (tier weight x retrievability
7
+ x independence), never a model opinion.
8
+
9
+ Basis: Bellingcat OSINT chain-of-custody / evidence standards; repo suit
10
+ decision (2026-08-07) to carry provenance tiers on every verdict record.
11
+
12
+ Usage:
13
+ from research.provenance import ProvenanceLedger
14
+ led = ProvenanceLedger()
15
+ led.register_source("s1", title="DOT filing", tier="verified-leak",
16
+ url="file://dot_2010.txt", date="2010-06-01")
17
+ led.record_claim("bridge opened 2010", ["s1", "s2"])
18
+ led.report()
19
+ """
20
+ import json
21
+ import re
22
+ import time
23
+ from dataclasses import dataclass, field, asdict
24
+ from pathlib import Path
25
+
26
+ TIERS = ("verified-leak", "secondary", "unverified", "claim")
27
+ TIER_WEIGHT = {"verified-leak": 1.0, "secondary": 0.6, "unverified": 0.3, "claim": 0.1}
28
+ TRIAGE_FIELDS = ("independence", "proximity", "recency", "track", "interest")
29
+ _SHA256 = re.compile(r"^[0-9a-f]{64}$")
30
+
31
+
32
+ def evaluate_source_policy(sources):
33
+ """Apply SOP 09 deterministically to a retrieved evidence bundle.
34
+
35
+ A source may be strong (13-15), usable only with corroboration (9-12), or
36
+ a lead (<9). A claim is eligible for a verified result only when it has two
37
+ independent usable sources with distinct origins, including one strong
38
+ source. Metadata is mandatory so a later reviewer can reproduce the trail.
39
+
40
+ ``sources`` is a sequence of mappings with ``source_id``, ``url``,
41
+ ``retrieved_at`` (or ``retrieved``), ``content_sha256``, ``independent``,
42
+ ``retrievable``, and five 0-3 ``triage`` scores. The scores are supplied by
43
+ the retrieval/review workflow, never inferred by the language model.
44
+ """
45
+ cards = []
46
+ independent_origins = {}
47
+ for raw in sources or ():
48
+ source = raw if isinstance(raw, dict) else {}
49
+ triage = source.get("triage") if isinstance(source.get("triage"), dict) else {}
50
+ errors = []
51
+ source_id = str(source.get("source_id", "")).strip()
52
+ url = str(source.get("url", "")).strip()
53
+ retrieved_at = str(source.get("retrieved_at") or source.get("retrieved") or "").strip()
54
+ digest = str(source.get("content_sha256", "")).lower().strip()
55
+ if not source_id:
56
+ errors.append("missing-source-id")
57
+ if not url:
58
+ errors.append("missing-url")
59
+ if not retrieved_at:
60
+ errors.append("missing-retrieved-at")
61
+ if not _SHA256.fullmatch(digest):
62
+ errors.append("missing-or-invalid-content-sha256")
63
+
64
+ values = {}
65
+ for field in TRIAGE_FIELDS:
66
+ value = triage.get(field)
67
+ if type(value) is not int or not 0 <= value <= 3:
68
+ errors.append(f"invalid-triage-{field}")
69
+ else:
70
+ values[field] = value
71
+ score = sum(values.values()) if len(values) == len(TRIAGE_FIELDS) else 0
72
+ retrievable = bool(source.get("retrievable", False))
73
+ independent = bool(source.get("independent", False))
74
+ valid = not errors
75
+ usable = valid and retrievable and score >= 9
76
+ strong = usable and score >= 13
77
+ classification = "strong" if strong else "usable" if usable else "lead"
78
+ origin = str(source.get("origin") or url).strip()
79
+ card = {
80
+ "source_id": source_id,
81
+ "origin": origin,
82
+ "score": score,
83
+ "classification": classification,
84
+ "usable": usable,
85
+ "strong": strong,
86
+ "independent": independent,
87
+ "errors": errors,
88
+ }
89
+ cards.append(card)
90
+ if usable and independent:
91
+ previous = independent_origins.get(origin)
92
+ if previous is None or card["score"] > previous["score"]:
93
+ independent_origins[origin] = card
94
+
95
+ independent = list(independent_origins.values())
96
+ strong = [card for card in independent if card["strong"]]
97
+ verified = len(independent) >= 2 and bool(strong)
98
+ if verified:
99
+ reason = "two-independent-usable-sources-including-one-strong"
100
+ elif len(independent) < 2:
101
+ reason = "fewer-than-two-independent-usable-sources"
102
+ else:
103
+ reason = "no-strong-source"
104
+ return {
105
+ "verified": verified,
106
+ "reason": reason,
107
+ "independent_usable": len(independent),
108
+ "independent_strong": len(strong),
109
+ "sources": cards,
110
+ }
111
+
112
+
113
+ @dataclass
114
+ class SourceRecord:
115
+ source_id: str
116
+ title: str
117
+ tier: str = "unverified"
118
+ url: str = ""
119
+ date: str = ""
120
+ retrieved: str = ""
121
+ retrievable: bool = True
122
+ independent: bool = True # not a re-publication of another recorded source
123
+ notes: str = ""
124
+ origin: str = ""
125
+ content_sha256: str = ""
126
+ triage: dict = field(default_factory=dict)
127
+
128
+ def credibility(self) -> float:
129
+ w = TIER_WEIGHT.get(self.tier, 0.1)
130
+ score = w * (1.0 if self.retrievable else 0.4)
131
+ if not self.independent:
132
+ score *= 0.5
133
+ return round(score, 3)
134
+
135
+
136
+ @dataclass
137
+ class ClaimRecord:
138
+ claim: str
139
+ source_ids: list = field(default_factory=list)
140
+ verdict: str = ""
141
+ confidence: str = ""
142
+ noted: str = ""
143
+
144
+ def chain(self, ledger: "ProvenanceLedger") -> list:
145
+ out = []
146
+ for sid in self.source_ids:
147
+ s = ledger.sources.get(sid)
148
+ if s:
149
+ out.append({"source_id": sid, "tier": s.tier,
150
+ "title": s.title, "url": s.url,
151
+ "credibility": s.credibility(),
152
+ "retrievable": s.retrievable,
153
+ "independent": s.independent})
154
+ return out
155
+
156
+
157
+ class ProvenanceLedger:
158
+ """Deterministic source + claim ledger with chain-of-custody reports."""
159
+
160
+ def __init__(self, path="data/casefiles/provenance.json"):
161
+ self.path = Path(path) if path else None
162
+ self.sources = {}
163
+ self.claims = []
164
+ self._load()
165
+
166
+ def _load(self):
167
+ if not self.path or not self.path.exists():
168
+ return
169
+ try:
170
+ d = json.loads(self.path.read_text(encoding="utf-8"))
171
+ for s in d.get("sources", []):
172
+ rec = SourceRecord(**{k: v for k, v in s.items()
173
+ if k in SourceRecord.__dataclass_fields__})
174
+ self.sources[rec.source_id] = rec
175
+ self.claims = [ClaimRecord(**{k: v for k, v in c.items()
176
+ if k in ClaimRecord.__dataclass_fields__})
177
+ for c in d.get("claims", [])]
178
+ except (ValueError, TypeError):
179
+ pass
180
+
181
+ def _save(self):
182
+ if not self.path:
183
+ return
184
+ self.path.parent.mkdir(parents=True, exist_ok=True)
185
+ self.path.write_text(json.dumps({
186
+ "sources": [asdict(s) for s in self.sources.values()],
187
+ "claims": [asdict(c) for c in self.claims],
188
+ }, indent=2, ensure_ascii=False), encoding="utf-8")
189
+
190
+ def register_source(self, source_id, title, tier="unverified", url="",
191
+ date="", retrievable=True, independent=True, notes="",
192
+ origin="", content_sha256="", triage=None, retrieved=""):
193
+ if tier not in TIERS:
194
+ raise ValueError(f"tier must be one of {TIERS}")
195
+ if source_id in self.sources:
196
+ rec = self.sources[source_id]
197
+ rec.title = title
198
+ rec.tier = tier
199
+ rec.url = url or rec.url
200
+ rec.date = date or rec.date
201
+ rec.retrieved = retrieved or rec.retrieved
202
+ rec.retrievable = retrievable
203
+ rec.independent = independent
204
+ rec.notes = notes or rec.notes
205
+ rec.origin = origin or rec.origin
206
+ rec.content_sha256 = content_sha256 or rec.content_sha256
207
+ rec.triage = triage if triage is not None else rec.triage
208
+ return rec
209
+ rec = SourceRecord(source_id=source_id, title=title, tier=tier, url=url,
210
+ date=date, retrieved=retrieved or time.strftime("%Y-%m-%d"),
211
+ retrievable=retrievable, independent=independent,
212
+ notes=notes, origin=origin,
213
+ content_sha256=content_sha256, triage=triage or {})
214
+ self.sources[source_id] = rec
215
+ self._save()
216
+ return rec
217
+
218
+ def record_claim(self, claim, source_ids, verdict="", confidence=""):
219
+ rec = ClaimRecord(claim=claim, source_ids=list(source_ids),
220
+ verdict=verdict, confidence=confidence,
221
+ noted=time.strftime("%Y-%m-%d"))
222
+ self.claims.append(rec)
223
+ self._save()
224
+ return rec
225
+
226
+ def corroboration(self, claim_text):
227
+ """Independent sources behind a claim (dedup by url)."""
228
+ urls = set()
229
+ out = []
230
+ for c in self.claims:
231
+ if c.claim.strip().lower() != claim_text.strip().lower():
232
+ continue
233
+ for s in c.chain(self):
234
+ if s["independent"] and s["retrievable"] and s["url"] not in urls:
235
+ urls.add(s["url"])
236
+ out.append(s)
237
+ return out
238
+
239
+ def single_source(self):
240
+ """Claims backed by at most one independent source."""
241
+ return [c for c in self.claims
242
+ if sum(1 for s in c.chain(self) if s["independent"]) <= 1]
243
+
244
+ def unverified(self):
245
+ return [s for s in self.sources.values() if s.tier in ("unverified", "claim")]
246
+
247
+ def report(self):
248
+ lines = ["# Provenance Ledger", ""]
249
+ lines.append("## Sources")
250
+ for s in self.sources.values():
251
+ lines.append(f"- `{s.source_id}` [{s.tier}] {s.title} "
252
+ f"(cred {s.credibility():.2f}, "
253
+ f"{'retrievable' if s.retrievable else 'NOT retrievable'}, "
254
+ f"{'independent' if s.independent else 'derived'})")
255
+ lines.append("")
256
+ lines.append("## Claims & chain-of-custody")
257
+ for c in self.claims:
258
+ chain = c.chain(self)
259
+ lines.append(f"- **{c.claim}**")
260
+ for s in chain:
261
+ lines.append(f" - {s['source_id']} ({s['tier']}, cred {s['credibility']:.2f})")
262
+ if not chain:
263
+ lines.append(" - UNSUBSTANTIATED: no recorded source")
264
+ lines.append("")
265
+ lines.append("## Flags")
266
+ if self.single_source():
267
+ lines.append(f"- single-source claims: {len(self.single_source())}")
268
+ if self.unverified():
269
+ lines.append(f"- unverified/claim-tier sources: {len(self.unverified())}")
270
+ if not self.claims:
271
+ lines.append("- no claims recorded")
272
+ return "\n".join(lines)
research/researcher_model_survey.md ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Researcher/Truth-Verifier Tiny Model β€” Multi-Site Research Survey
2
+
3
+ Sources pulled Aug 2026: arXiv (TinyStories, Self-Consistency, LoRA, FEVER,
4
+ Toolformer, Let's Verify Step by Step, Chain-of-Verification, DeepSeek-R1, Phi-1,
5
+ LIMA, Small-LM Survey) + HuggingFace (SmolLM, LoRA) + measured experiments on THIS
6
+ tablet (MoE, tower, scan numerics, corpus mixing). Each item: source, what it
7
+ means, verdict for this project.
8
+
9
+ ## PROVEN β€” adopt
10
+ 1. Domain-constrained base (TinyStories, 2305.07759): tiny models speak coherently
11
+ only inside a simple, constrained domain. We did this (val 2.47, coherent).
12
+ 2. Curriculum pretraining (SmolLM, HF blog): easy -> hard data ordering. Adopted:
13
+ balanced shuffled corpus (train_phase2b.bin).
14
+ 3. Verifiable 3-way claim verdicts (FEVER, 1803.05355): labels checkable from the
15
+ prompt alone -> learnable at 16M. Core task, Stage A.
16
+ 4. Few hundred handcrafted examples shape style (LIMA, 2305.11206): our kd skill.
17
+ 5. Textbook-quality curation beats scale (Phi-1, 2306.11644): our kd skill.
18
+ 6. RL on verifiable rewards elicits reasoning (DeepSeek-R1, 2501.12948): Stage E β€”
19
+ reward = probe-verdict correctness, rule-checkable.
20
+ 7. Process supervision > outcome (2305.20050): reward scratchpad STEPS, not only
21
+ the final verdict. Stage D design.
22
+ 8. Self-verification cuts hallucination (Chain-of-Verification, 2309.11495):
23
+ draft -> verify -> revise SOP in every example.
24
+ 9. Tool loops are learnable (Toolformer, 2302.04761): model emits search/retrieve
25
+ actions; the CLIENT executes them under Tor.
26
+ 10. Self-consistency decoding (2203.11171): sample N reasoning paths, majority
27
+ vote. Zero training cost β€” implement in the client at inference.
28
+ 11. LoRA (2106.09685): parameter-efficient SFT. Use for fast SFT iterations on the
29
+ frozen trunk; full SFT only when we commit a final stage.
30
+ 12. Anti-imitation guardrail (False-Promise): never train soft logits from a big
31
+ model; verifiable targets only.
32
+
33
+ ## EXPERIMENTAL β€” pilot, don't bet the pipeline
34
+ - Persona-routed sparse experts (dual-mind in one forward): measured partial
35
+ specialization (corr 0.435). Pilot: explicit persona->router bias, target corr < 0.3.
36
+ - Process-rewarded SFT: train on scratchpad step sequences, not just final verdicts.
37
+ - Abstention/calibration: measure calibration on abstain rows (silent record ->
38
+ must say unsubstantiated). Tune confidence thresholds by probe score.
39
+ - Long context via liquid recurrence: test seq 512-1024 on this device; recurrence
40
+ may extend effective context cheaply (our arch's natural advantage).
41
+ - RAG lite: BM25 index over the user's document folder; client retrieves, model
42
+ analyzes. No training change.
43
+
44
+ ## UNPROVEN / REJECTED (documented decisions, do not re-run)
45
+ - 250-tiny-expert MoE: router collapse (51/250 used), no per-step win. Needs a
46
+ load-balance loss + shared-base/LoRA experts before any retry.
47
+ - Width upscaling 320->512: val 6.1-7.7 vs baseline 2.58. Rejected.
48
+ - Pure logit distillation from a big model: evidence says imitation degrades
49
+ small students. Rejected for skills.
50
+ - Helix/DNA "memory" as architecture magic: treat as CLIENT-side memory/state, not
51
+ a model-level capability. No training-time promise.
52
+ - Symbolic/neural hybrids: untested, high complexity, no evidence at 16M.
53
+
54
+ ## OUR OWN DESIGN (the unique moat β€” not in any other model we found)
55
+ - Source-DNA provenance tags: every claim tagged primary/secondary/anonymous,
56
+ independent-origin count, hash/PGP/corroborated status. Trained output class.
57
+ - Gap / "blotchy" detector: explicitly trained ABSENCE-finding (missing actor,
58
+ date, period, attachment, named source). Abstains on whatever rests on the gap.
59
+ - Symbolism decoder with base-rate discipline: exact textual patterns score higher
60
+ than motif association; always correlation-tagged, never proof.
61
+ - Told-vs-not-told timeline: dated grid of assertions vs records; gaps and
62
+ ordering anomalies are the output.
63
+ - Thread-tracer: "does X connect to Y?" broken into hops, each hop must be a real
64
+ record; proven / unproven / broken.
65
+ - SOP-conditioned scratchpad: procedure baked into the FORMAT (source -> evidence
66
+ -> self-check -> verdict -> missing), so the model follows it by conditioning,
67
+ not by remembering.
68
+ - Safe dark-web research SOP: verify .onion against trusted mirror, PGP check,
69
+ no JS/downloads/logins, no identity. In the client + trained dialogue.
70
+ - Mistake-driven closed loop: probe -> triage individual mistakes -> handcraft
71
+ targeted gold -> retrain -> re-probe. The pipeline moat (grants story).
72
+ - Confidence + abstention discipline: calibrated by construction; abstaining is a
73
+ correct answer, never a failure.
74
+
75
+ ## What I'm adopting now (priority order)
76
+ 1. Finish continue-pretrain (running; ETA ~9h).
77
+ 2. Stage A: grow FEVER-style verdict gold to ~500+ rows (handcrafted).
78
+ 3. Stage B: discrepancy/pattern gold (v12) growth.
79
+ 4. Stage C: symbolism + gap gold (v13) growth β€” the unique niche features.
80
+ 5. Stage D: self-verify draft->revise samples.
81
+ 6. Stage E: DPO/preference with rule-checkable verdict reward.
82
+ 7. Inference (client): self-consistency majority vote + BM25 doc retrieval +
83
+ provenance-tag tool. GGUF/int8 export.
84
+ 8. Guardrails: no soft-imitation; window-shuffle corpora; scan chunk 16;
85
+ load-balance loss if MoE is ever revisited.
86
+
87
+ ## Community niche (release framing)
88
+ - First open tiny model explicitly for TRUTH-VERIFYING RESEARCH with trained
89
+ discrepancy/pattern/gap/symbolism analysis + safe dark-web SOP β€” a defined
90
+ niche no other small model we found occupies. Release as GGUF + Q8, with the
91
+ probe scorecard and an honest README (capabilities + limitations + safety).
research/rlvr.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RLVR reward harness β€” verifiable reward on the deterministic spine.
2
+
3
+ The policy is the tiny analyst head; the reward is RULES, never the head
4
+ grading itself (DeepSeek-R1: reasoning emerges when reward is rule-checkable;
5
+ here it is OUR verify spine / probe labels):
6
+ +1 policy verdict == gold verdict (exact, constrained vocabulary)
7
+ 0 honest abstention ("not enough information" / "unsubstantiated")
8
+ -1 policy verdict contradicts the gold
9
+ +0.2 citation present AND its value appears in the evidence record
10
+ -0.2 citation present but the value is NOT in the record (fabricated anchor)
11
+ Self-reported confidence is NEVER rewarded (anti-calibrated, measured).
12
+
13
+ Usage:
14
+ from research.rlvr import reward, reward_card
15
+ r = reward(gold="refutes", policy="refutes", citation="1982",
16
+ evidence="the deed file states 1982")
17
+ """
18
+ import re
19
+ import time
20
+
21
+ ABSTAIN = {"not enough information", "unsubstantiated", "abstain",
22
+ "cannot provide", "no record"}
23
+ _VALUE = re.compile(r"\d{1,2}:\d{2}\b|\b(?:19|20)\d{2}\b|"
24
+ r"\b\d+(?:,\d{3})*\.?\d*%?\b")
25
+
26
+
27
+ def _cited_in_evidence(citation, evidence):
28
+ ev = evidence.lower()
29
+ for v in _VALUE.findall(citation):
30
+ if v in ev:
31
+ return True
32
+ return False
33
+
34
+
35
+ def reward(gold, policy, citation="", evidence=""):
36
+ """Rule reward for one (probe, policy) step. Deterministic."""
37
+ pv = policy.strip().lower()
38
+ gv = gold.strip().lower()
39
+ if pv in ABSTAIN:
40
+ verdict = 0.0
41
+ elif pv == gv:
42
+ verdict = 1.0
43
+ else:
44
+ verdict = -1.0
45
+ cit = 0.0
46
+ if citation:
47
+ cit = 0.2 if _cited_in_evidence(citation, evidence) else -0.2
48
+ return {"verdict": verdict, "citation": cit,
49
+ "total": round(verdict + cit, 3)}
50
+
51
+
52
+ def reward_card(gold, policy, citation="", evidence="", probe=""):
53
+ """Full chain-of-custody trace for RLVR logs (auditable)."""
54
+ r = reward(gold, policy, citation, evidence)
55
+ return {"probe": probe, "gold": gold, "policy": policy,
56
+ "citation": citation, "evidence": evidence[:160],
57
+ "verdict": r["verdict"], "citation_reward": r["citation"],
58
+ "total": r["total"], "ts": time.strftime("%Y-%m-%d %H:%M")}
research/room.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The Analyst Room: an interactive environment for the tiny model.
2
+
3
+ The model does not answer from memory alone. It works a case:
4
+ RETRIEVE <query> -> ask the library for relevant passages
5
+ READ <key> -> open a document
6
+ NOTE <text> -> write a finding to the evidence ledger
7
+ VERDICT -> produce the constrained final report
8
+
9
+ Every action is appended to the ledger, and the ledger is re-read on the
10
+ next step, so the model accumulates a case across many turns (working
11
+ memory that exceeds its parameter count).
12
+
13
+ Usage:
14
+ .venv/bin/python research/room.py --ckpt ckpt/distill
15
+ .venv/bin/python research/room.py --ckpt ckpt/distill --case "Was the 2019 outage caused by the truck seen nearby?"
16
+ """
17
+
18
+ import argparse
19
+ import json
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ import torch
24
+
25
+ from model.config import TinyLiquidConfig, CONFIGS
26
+ from model.tiny_liquid import TinyLiquid
27
+ from model.utils import latest_ckpt
28
+ from data.tokenizer import load_tokenizer
29
+ from research.index import TinyIndex
30
+ from research.structured import analyst_report, _decode_phrase
31
+
32
+ ACTIONS = ["RETRIEVE", "READ", "NOTE", "VERDICT"]
33
+ MAX_STEPS = 6
34
+
35
+
36
+ def load_model(args):
37
+ torch.set_num_threads(args.threads)
38
+ tok = load_tokenizer(args.tok)
39
+ ckpt = latest_ckpt(args.ckpt)
40
+ sd = torch.load(ckpt, map_location="cpu")
41
+ cfg = TinyLiquidConfig(vocab_size=tok.get_vocab_size(),
42
+ **{k: v for k, v in sd["config"].items() if k != "vocab_size"})
43
+ model = TinyLiquid(cfg)
44
+ model.load_state_dict(sd["model"])
45
+ model.eval()
46
+ return tok, model, ckpt
47
+
48
+
49
+ def build_index(library_dir: str, extra_dirs=("corpus/raw",)):
50
+ idx = TinyIndex()
51
+ for f in sorted(Path(library_dir).glob("*.txt")):
52
+ idx.add(f.stem, str(f), f.read_text(encoding="utf-8", errors="ignore"))
53
+ for d in extra_dirs:
54
+ for f in sorted(Path(d).glob("*.txt")):
55
+ idx.add(f"{Path(d).name}/{f.stem}", str(f),
56
+ f.read_text(encoding="utf-8", errors="ignore"))
57
+ idx.build()
58
+ return idx
59
+
60
+
61
+ def format_ledger(ledger):
62
+ return "\n".join(f"[{i+1}] {e}" for i, e in enumerate(ledger[-8:])) or "(empty)"
63
+
64
+
65
+ def make_context(prompt, ledger, hits):
66
+ ctx = (
67
+ "You are working a research case. Use the case file and library hits. "
68
+ "Reply with exactly one line: ACTION: <RETRIEVE|READ|NOTE|VERDICT> then ARG: <text>\n"
69
+ f"CASE FILE:\n{format_ledger(ledger)}\n"
70
+ )
71
+ if hits:
72
+ ctx += "LIBRARY HITS:\n" + hits + "\n"
73
+ return ctx + f"TASK: {prompt}"
74
+
75
+
76
+ def hit_text(idx, query, k=3, max_chars=500):
77
+ rows = idx.query(query, k)
78
+ parts = []
79
+ for key, score in rows:
80
+ text = idx.docs[[d[0] for d in idx.docs].index(key)][2] if key in [d[0] for d in idx.docs] else ""
81
+ parts.append(f"<{key} (score {score:.2f})> " + text[:max_chars].replace("\n", " "))
82
+ return "\n".join(parts)
83
+
84
+
85
+ def read_doc(idx, key):
86
+ for k, path, text in idx.docs:
87
+ if k == key:
88
+ return text[:2000]
89
+ return "(document not found)"
90
+
91
+
92
+ def run_case(model, tok, prompt, idx, max_steps=MAX_STEPS):
93
+ ledger = []
94
+ for step in range(max_steps):
95
+ hits = ""
96
+ if ledger:
97
+ last = ledger[-1]
98
+ if last.startswith("RETRIEVE:"):
99
+ hits = hit_text(idx, last.split(":", 1)[1].strip())
100
+ ctx = make_context(prompt, ledger, hits)
101
+ ids = tok.encode("<|analyst|><|user|>" + ctx + "<|assistant|>ACTION:").ids
102
+ pre = len(ids)
103
+ ids = _decode_phrase(model, tok, ids, 1, ACTIONS)
104
+ action = tok.decode(ids[pre:]).strip().upper()
105
+ if action not in ACTIONS:
106
+ action = "NOTE"
107
+ # free-form argument
108
+ ids = ids + tok.encode(" ARG:").ids
109
+ arg = tok.decode(model.generate(tok, ids, persona_id=1, max_new=80,
110
+ temperature=0.5, top_k=40,
111
+ repetition_penalty=1.5, no_repeat_ngram_size=4)[len(ids):]).strip()
112
+ if action == "VERDICT" or action == "RETRIEVE" and step == max_steps - 1:
113
+ if action == "VERDICT" or step == max_steps - 1:
114
+ break
115
+ ledger.append(f"{action}: {arg}")
116
+ print(f" [{step+1}] {action}: {arg}", flush=True)
117
+ return ledger
118
+
119
+
120
+ def main():
121
+ ap = argparse.ArgumentParser()
122
+ ap.add_argument("--ckpt", default="ckpt/distill")
123
+ ap.add_argument("--tok", default="data/tokenizer.json")
124
+ ap.add_argument("--library", default="data/library")
125
+ ap.add_argument("--case", default=None)
126
+ ap.add_argument("--threads", type=int, default=8)
127
+ args = ap.parse_args()
128
+
129
+ tok, model, ckpt = load_model(args)
130
+ idx = build_index(args.library)
131
+ print(f"room open: {ckpt} | library docs: {len(idx.docs)}", flush=True)
132
+
133
+ if args.case:
134
+ ledger = run_case(model, tok, args.case, idx)
135
+ report = analyst_report(model, tok, args.case)
136
+ print("\n=== FINAL REPORT ===")
137
+ print(json.dumps({**report, "steps": ledger}, indent=2, ensure_ascii=False))
138
+ return
139
+
140
+ print("Analyst Room REPL (type a case or question; Ctrl-D to exit)")
141
+ for line in sys.stdin:
142
+ line = line.strip()
143
+ if not line:
144
+ continue
145
+ ledger = run_case(model, tok, line, idx)
146
+ report = analyst_report(model, tok, line)
147
+ print("\n=== FINAL REPORT ===")
148
+ print(json.dumps({**report, "steps": ledger}, indent=2, ensure_ascii=False))
149
+
150
+
151
+ if __name__ == "__main__":
152
+ main()
research/sop_library/00_common.md ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SOP 00 β€” COMMON RULES (every procedure obeys these)
2
+
3
+ PURPOSE: baseline truth-seeking rules. Never override them.
4
+
5
+ 1. Evidence over assertion. Every conclusion names the evidence it rests on.
6
+ 2. Name what is missing. If a checkable fact is unverified, say so.
7
+ 3. Primary source first. Records, filings, official documents, original
8
+ statements. Aggregators and commentary are leads, not sources.
9
+ 4. Two independent sources per factual claim. Same-source repetition is not
10
+ corroboration.
11
+ 5. Confidence on every verdict: HIGH / MEDIUM / LOW / cannot assess.
12
+ 6. "Cannot confirm" beats speculation. Never fill a gap with inference.
13
+ 7. Flag overclaims: words like "always / never / proven / everyone" are
14
+ hypotheses until evidenced.
15
+ 8. Decision support only. This output is analysis, never a verdict.
16
+ 9. If a step cannot be completed, record the blocker and continue; do not
17
+ fake completion.
18
+ 10. Audit trail: every material fact is traceable to a named document or
19
+ retrieval action.
research/sop_library/claim_verification.md ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SOP 01 β€” CLAIM VERIFICATION
2
+
3
+ USE WHEN: a single factual claim or statement must be checked.
4
+
5
+ 1. DECOMPOSE: split the claim into separate checkable assertions. Never judge
6
+ a multi-part claim as one unit.
7
+ 2. SOURCE: find the primary source (records, filings, official documents,
8
+ original statements), not commentary.
9
+ 3. CORROBORATE: require two independent sources. Same-source repetition is
10
+ not corroboration.
11
+ 4. DATE: fix when each source was made and when it was verified; stale
12
+ sources cannot verify current claims.
13
+ 5. PROVENANCE: who originated the claim, who amplified it, and what they had
14
+ to gain.
15
+ 6. VERDICT: per assertion -> true / false / mostly true / partially true /
16
+ mixed / unsubstantiated / overclaim / unverifiable. Confidence per verdict.
17
+ 7. Stop when: every assertion has a verdict and a named source chain.
18
+
19
+ OUTPUT: verdict list + source chain + confidence + remaining gaps.
research/sop_library/cross_source_discrepancy.md ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SOP 02 β€” CROSS-SOURCE DISCREPANCY
2
+
3
+ USE WHEN: two or more accounts of the same event disagree.
4
+
5
+ 1. ALIGN: list both accounts side by side (who, what, when, where, how).
6
+ 2. DELTA: list every difference β€” numbers, names, dates, sequences, causes.
7
+ 3. CLASSIFY each delta: typo / ambiguity / emphasis / contradiction /
8
+ incompatible (both cannot be true).
9
+ 4. ROOT: for each contradiction, ask which source changes the story and what
10
+ evidence would settle it.
11
+ 5. TIMING: note if accounts were produced before/after the event (recency
12
+ bias, memory effects, post-hoc spin).
13
+ 6. VERDICT: which elements are confirmed, which conflict, which unverifiable.
14
+ Confidence per element.
15
+ 7. Stop when: every delta is classified and each open conflict names the
16
+ evidence that would settle it.
17
+
18
+ OUTPUT: aligned accounts + delta table + conflicts + resolution evidence.
research/sop_library/dark_web_research.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SOP 07 β€” DARK WEB / DEEP WEB RESEARCH (AUTHORIZED OSINT ONLY)
2
+
3
+ USE WHEN: searching clearnet + Tor (.onion) sources for documents, claims, or
4
+ patterns. Authorized research and OSINT only. Nothing illegal, ever.
5
+
6
+ 1. SCOPE: write the research question and the categories you will and will
7
+ not touch. Never: purchases, credentials/CSAM, malware, direct engagement
8
+ with actors. Never bypass access controls.
9
+ 2. CRAWL: use research/crawl.py (clearnet) and TOR_PROXY for .onion.
10
+ Rate-limit every host; crawl only authorized targets.
11
+ 3. TRIAGE: score each hit with SOP 09 (source triage) before quoting it.
12
+ 4. CHAIN: record url, fetch time, hash, and snippet for every used source
13
+ (chain of custody).
14
+ 5. VERIFY: treat dark-web claims as unverified leads until SOP 01 passes with
15
+ independent sources.
16
+ 6. STOP RULES: any result that escalates toward illegal content ends the
17
+ session immediately and is reported as a blocker, never opened further.
18
+ 7. Output: evidence list with chain-of-custody + verified/unverified labels.
19
+
20
+ OUTPUT: scope statement + evidence chain + triage scores + open blockers.
research/sop_library/historical_truth.md ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SOP 05 β€” HISTORICAL TRUTH / PAST-NEWS AUDIT
2
+
3
+ USE WHEN: comparing what was reported earlier against what later records show.
4
+
5
+ 1. RETRIEVE: collect the earlier reporting (what was said, when, by whom).
6
+ 2. AFTERMATH: collect later records (corrections, retractions, disclosures,
7
+ official findings).
8
+ 3. DELTA: list what changed between early reporting and later record.
9
+ 4. HIDDEN: identify what was absent early and appeared late; ask who knew
10
+ and when (provenance).
11
+ 5. CORRECTED vs CORRUPTED: separate honest corrections from systematic
12
+ suppression or error patterns.
13
+ 6. VERDICT per claim: confirmed / corrected / retracted / contradicted /
14
+ still open. Confidence each.
15
+ 7. Stop when: early reporting, later record, and every delta are on the table.
16
+
17
+ OUTPUT: before/after table + hidden-items list + verdicts + open items.
research/sop_library/pattern_finding.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SOP 03 β€” PATTERN FINDING
2
+
3
+ USE WHEN: looking for recurring structure across events or claims.
4
+
5
+ 1. COLLECT: list every event/claim with date, actor, and source.
6
+ 2. CLUSTER: group by similarity (actor, method, target, timing, claim shape).
7
+ 3. COMMON CAUSE: for each cluster, propose the mechanism that links members.
8
+ 4. NULL TEST: state what pattern would look like if the mechanism were false;
9
+ look for counterexamples deliberately.
10
+ 5. STRENGTH: rate pattern as weak (coincidence not excluded) / moderate /
11
+ strong (counterexamples searched, mechanism evidenced).
12
+ 6. ESCALATION: note whether clusters grow, accelerate, or repeat cyclically.
13
+ 7. Stop when: clusters, mechanism, counterexamples, and strength are stated.
14
+
15
+ OUTPUT: cluster table + mechanism + counterexamples + strength rating.
research/sop_library/politics_analysis.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SOP 06 β€” POLITICS / SPIN ANALYSIS
2
+
3
+ USE WHEN: analyzing political statements, talking points, or disputes.
4
+
5
+ 1. SEPARATE: extract factual assertions vs. interests and framing.
6
+ 2. PROVENANCE: trace each talking point to its originator and amplifier.
7
+ 3. INTEREST: state what each party gains from the claim being accepted.
8
+ 4. EVIDENCE: apply SOP 01 (claim verification) to each factual assertion.
9
+ 5. SPIN RATE: label each statement fact / partial / spin / false, with the
10
+ missing context that would change the label.
11
+ 6. BOTH-SIDES TEST: apply the same standard to every party; unequal scrutiny
12
+ is itself a discrepancy to report.
13
+ 7. Stop when: assertions, interests, provenance, and spin labels are explicit.
14
+
15
+ OUTPUT: assertion table + interest map + spin labels + unequal-scrutiny notes.
research/sop_library/source_triage.md ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SOP 09 β€” SOURCE TRIAGE
2
+
3
+ USE WHEN: deciding how much weight a source deserves.
4
+
5
+ Score 0-3 each:
6
+ 1. INDEPENDENCE: 3 = unrelated to the parties, 0 = the party itself or paid
7
+ by it (note: self-statements are primary evidence, not corroboration).
8
+ 2. PROXIMITY: 3 = primary record (original document, filing, raw data),
9
+ 0 = retold commentary.
10
+ 3. RECENCY: 3 = produced for the period in question, 0 = long after, with
11
+ memory/spin risk.
12
+ 4. TRACK: 3 = correct on comparable past claims, 0 = repeatedly wrong.
13
+ 5. INTEREST: 3 = nothing to gain, 0 = material stake in the claim.
14
+
15
+ WEIGHT: 13-15 strong, 9-12 usable with corroboration, <9 lead only.
16
+ Never rest a verdict on a single <9 source. Never count one source twice
17
+ (same parent outlet / same wire story = one source).
18
+
19
+ OUTPUT: per-source scores + weight class + corroboration requirement.
research/sop_library/terminal_control.md ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SOP 08 β€” TERMINAL CONTROL
2
+
3
+ USE WHEN: using the shell as part of research.
4
+
5
+ 1. READ-ONLY FIRST: list/search/inspect before anything that writes.
6
+ 2. DRY-RUN: prefer commands that preview effects (--dry-run, --check,
7
+ --diff) over direct execution.
8
+ 3. LOG: every command and its output is appended to the session ledger.
9
+ 4. NO DESTRUCTION: no rm -rf, no overwrites, no network mutations without
10
+ explicit approval. Default to new files.
11
+ 5. BOUNDS: timeouts on every fetch; kill runaway jobs; never run unknown
12
+ downloaded code.
13
+ 6. VERIFY: after a command, check the artifact exists and is sane (size,
14
+ head, checksum) before building on it.
15
+ 7. Stop when: the question is answered or a blocker is recorded with the
16
+ exact command that produced it.
17
+
18
+ OUTPUT: command log + artifact checks + blockers.
research/sop_library/timeline_reconstruction.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SOP 04 β€” TIMELINE RECONSTRUCTION
2
+
3
+ USE WHEN: reconstructing what happened and when.
4
+
5
+ 1. ANCHOR: collect dated primary records first (documents, logs, official
6
+ statements with dates).
7
+ 2. ORDER: sort anchors chronologically; leave gaps explicit.
8
+ 3. GAP LIST: every undated or missing period is listed as a gap. Do not fill
9
+ gaps with inference.
10
+ 4. CONTRADICT: flag entries that clash on dates/order; keep both, mark
11
+ conflict.
12
+ 5. INFERENCE: any non-anchored assertion is labeled "inferred" with its
13
+ confidence.
14
+ 6. Stop when: anchors, gaps, conflicts, and inferences are all labeled.
15
+
16
+ OUTPUT: dated anchor list + gap list + conflict list + labeled inferences.
research/structured.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Structured SOP decoding for the analyst persona.
2
+
3
+ The scratchpad is generated freely; the verdict and confidence sections are
4
+ decoded greedily under a controlled vocabulary, so the model cannot emit an
5
+ unstructured or off-protocol answer for the final judgment.
6
+
7
+ Usage:
8
+ from research.structured import analyst_report
9
+ report = analyst_report(model, tok, doc_text, max_scratch=140)
10
+ """
11
+
12
+ import torch
13
+
14
+ VERDICTS = [
15
+ "true", "false", "mostly true", "partially true", "mixed",
16
+ "unsubstantiated", "unsupported", "overclaim", "misleading",
17
+ "inaccurate", "unverifiable", "cannot confirm",
18
+ "not a discrepancy", "conflict", "no meaningful pattern",
19
+ # canonical set (eval_labels.CANON) β€” the tiny head must be able to emit these
20
+ "refutes", "not enough information", "not a contradiction",
21
+ "contradiction", "low confidence", "abstain", "cannot provide",
22
+ ]
23
+ CONFIDENCES = ["HIGH", "MEDIUM", "LOW", "cannot assess"]
24
+
25
+
26
+ def _decode_phrase(model, tok, ids, persona_id, allowed_phrases, max_new=16, fallback=None):
27
+ """Constrained greedy decode over the allowed phrases (longest-prefix).
28
+
29
+ Returns (exact_phrase_token_ids, ok). Never emits partial/garbage tokens:
30
+ - if a full allowed phrase is matched, its EXACT tokens are returned,
31
+ - otherwise the caller falls back (default: explicit abstain text),
32
+ so the model can never present an off-protocol verdict.
33
+ """
34
+ targets = [tok.encode(p).ids for p in allowed_phrases]
35
+ targets = [t for t in targets if t]
36
+ if not targets:
37
+ return [], False
38
+ progress = [0] * len(targets)
39
+ seq = torch.tensor([ids], dtype=torch.long)
40
+ for _ in range(max_new):
41
+ window = seq[:, -model.cfg.max_seq_len:]
42
+ logits = model(window, persona_ids=torch.tensor([persona_id]) if persona_id else None)
43
+ logits = logits[:, -1, :]
44
+ allowed = {}
45
+ for i, t in enumerate(targets):
46
+ if progress[i] < len(t):
47
+ allowed.setdefault(t[progress[i]], i)
48
+ if not allowed:
49
+ break
50
+ allowed_ids = torch.tensor(list(allowed.keys()), dtype=torch.long)
51
+ mask = torch.full_like(logits, -float("inf"))
52
+ mask[:, allowed_ids] = logits[:, allowed_ids]
53
+ nxt = int(mask.argmax().item())
54
+ seq = torch.cat([seq, torch.tensor([[nxt]], dtype=torch.long)], dim=1)
55
+ for i, t in enumerate(targets):
56
+ if progress[i] < len(t) and t[progress[i]] == nxt:
57
+ progress[i] += 1
58
+ if progress[i] == len(t):
59
+ return t, True # exact full-phrase tokens
60
+ # if we advanced no target further, stop (no clean path)
61
+ if not any(progress[i] > 0 and progress[i] < len(targets[i]) for i in range(len(targets))):
62
+ break
63
+ return [], False
64
+
65
+
66
+ @torch.no_grad()
67
+ def analyst_report(model, tok, doc, persona_id=1, max_scratch=140, max_reason=80):
68
+ """Two-pass structured analysis: scratchpad -> verdict -> confidence -> reasoning.
69
+
70
+ The verdict and confidence are CONSTRAINT-DECODED with a hard fallback: if the
71
+ head does not cleanly emit an allowed phrase, we return an explicit abstention
72
+ ("not enough information" / "cannot assess") and flag decode_failed=True.
73
+ Garbage is never presented as a verdict.
74
+ """
75
+ persona_tok = "<|analyst|>"
76
+ prompt = persona_tok + "<|user|>" + doc + "<|assistant|>"
77
+ ids = tok.encode(prompt).ids
78
+
79
+ # free-form scratchpad
80
+ scratch = model.generate(tok, ids, persona_id=persona_id, max_new=max_scratch,
81
+ temperature=0.6, top_k=40, repetition_penalty=1.4,
82
+ no_repeat_ngram_size=4)
83
+ ids = scratch
84
+
85
+ # constrained verdict (hard fallback -> abstention)
86
+ vids, vok = _decode_phrase(model, tok, ids, persona_id, VERDICTS)
87
+ if vok:
88
+ verdict = tok.decode(vids).strip()
89
+ else:
90
+ verdict = "not enough information"
91
+ vids = tok.encode(verdict).ids
92
+ ids = ids + vids
93
+
94
+ # constrained confidence
95
+ ids = ids + tok.encode(" Confidence: ").ids
96
+ cids, cok = _decode_phrase(model, tok, ids, persona_id, CONFIDENCES)
97
+ if cok:
98
+ confidence = tok.decode(cids).strip()
99
+ else:
100
+ confidence = "cannot assess"
101
+ cids = tok.encode(confidence).ids
102
+ ids = ids + cids
103
+
104
+ # free-form reasoning
105
+ ids = ids + tok.encode(" Reasoning: ").ids
106
+ reason = model.generate(tok, ids, persona_id=persona_id, max_new=max_reason,
107
+ temperature=0.6, top_k=40, repetition_penalty=1.4,
108
+ no_repeat_ngram_size=4)
109
+ reasoning = tok.decode(reason[len(ids):]).strip()
110
+
111
+ return {
112
+ "scratchpad": tok.decode(scratch[len(tok.encode(prompt).ids):]).strip(),
113
+ "verdict": verdict,
114
+ "confidence": confidence,
115
+ "reasoning": reasoning,
116
+ "decode_failed": not (vok and cok),
117
+ }
research/timeline.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Timeline reconstruction + gap detection (journalism suite layer 2).
2
+
3
+ A good investigation reads the ABSENCES as much as the events. This module
4
+ sorts dated events, measures intervals, and surfaces:
5
+ - gaps: intervals that exceed a heuristic threshold (2x median, >= 1 year)
6
+ - cliffs: active years whose neighbors are active but themselves silent
7
+ - anachronisms: an event whose text cites a year different from its date
8
+ - density: per-year event counts (where did the reporting thin out?)
9
+
10
+ Deterministic only β€” the model reasons over the surfaced gaps; the suite
11
+ never invents the missing event.
12
+
13
+ Usage:
14
+ from research.timeline import TimelineAnalyzer
15
+ tl = TimelineAnalyzer()
16
+ tl.add_event("2010-05-01", "bridge opens per DOT filing", "s1")
17
+ tl.report()
18
+ """
19
+ import re
20
+ from collections import defaultdict
21
+ from dataclasses import dataclass, field
22
+
23
+ _YEAR = re.compile(r"\b(19|20)\d{2}\b")
24
+
25
+
26
+ @dataclass
27
+ class Event:
28
+ when: str # ISO date YYYY-MM-DD or year YYYY
29
+ what: str
30
+ source_id: str = "-"
31
+
32
+ def date(self):
33
+ """Sortable key: YYYY -> YYYY-01-01; ISO kept as-is."""
34
+ if len(self.when) == 4 and self.when.isdigit():
35
+ return f"{self.when}-01-01"
36
+ return self.when
37
+
38
+
39
+ class TimelineAnalyzer:
40
+ def __init__(self):
41
+ self.events = []
42
+
43
+ def add_event(self, when, what, source_id="-"):
44
+ self.events.append(Event(when=when, what=what, source_id=source_id))
45
+
46
+ def sorted(self):
47
+ return sorted(self.events, key=lambda e: e.date())
48
+
49
+ def years(self):
50
+ out = defaultdict(int)
51
+ for e in self.events:
52
+ out[e.date()[:4]] += 1
53
+ return dict(sorted(out.items()))
54
+
55
+ def _gaps_raw(self, gap_min_days=None):
56
+ """(start_date, end_date, days) for each interval above threshold."""
57
+ ev = self.sorted()
58
+ if len(ev) < 2:
59
+ return []
60
+ deltas = []
61
+ for a, b in zip(ev, ev[1:]):
62
+ try:
63
+ deltas.append((_days(b.date()) - _days(a.date()), a, b))
64
+ except ValueError:
65
+ continue
66
+ if not deltas:
67
+ return []
68
+ median = sorted(d for d, _, _ in deltas)[len(deltas) // 2]
69
+ floor = gap_min_days or max(365, 2 * median)
70
+ return [(a, b, d) for d, a, b in deltas if d > floor]
71
+
72
+ def gaps(self, gap_min_days=None):
73
+ """Gap cards: missing period + the bookend events + absent line."""
74
+ out = []
75
+ for a, b, days in self._gaps_raw(gap_min_days):
76
+ out.append({
77
+ "from": a.date(),
78
+ "to": b.date(),
79
+ "days": days,
80
+ "between": [a.what, b.what],
81
+ "absent": f"no recorded event between {a.date()} and {b.date()} "
82
+ f"({days} days) - what happened there?",
83
+ })
84
+ return out
85
+
86
+ def cliffs(self):
87
+ """Years silent while both neighbors have events (missing period)."""
88
+ ys = self.years()
89
+ if len(ys) < 2:
90
+ return []
91
+ lo, hi = int(min(ys)), int(max(ys))
92
+ out = []
93
+ for y in range(lo, hi + 1):
94
+ yk = str(y)
95
+ if ys.get(yk, 0) == 0 and ys.get(str(y - 1), 0) and ys.get(str(y + 1), 0):
96
+ out.append({"year": yk, "before": str(y - 1), "after": str(y + 1),
97
+ "absent": f"year {yk} is silent between active "
98
+ f"years {y - 1} and {y + 1}"})
99
+ return out
100
+
101
+ def anachronisms(self):
102
+ """Event text cites a year that differs from its own date year."""
103
+ out = []
104
+ for e in self.events:
105
+ cited = set(_YEAR.findall(e.what))
106
+ if cited and e.date()[:4] not in cited:
107
+ out.append({"when": e.date(), "what": e.what,
108
+ "cited_years": sorted(cited),
109
+ "flag": "cited year != event date year"})
110
+ return out
111
+
112
+ def report(self, gap_min_days=None):
113
+ lines = ["# Timeline", ""]
114
+ lines.append("| date | event | source |")
115
+ lines.append("|---|---|---|")
116
+ for e in self.sorted():
117
+ lines.append(f"| {e.date()} | {e.what} | {e.source_id} |")
118
+ lines.append("")
119
+ lines.append("## Density (events/year)")
120
+ for y, n in self.years().items():
121
+ lines.append(f"- {y}: {n}")
122
+ lines.append("")
123
+ lines.append("## Gaps (what is absent)")
124
+ gaps = self.gaps(gap_min_days)
125
+ for g in gaps:
126
+ lines.append(f"- {g['absent']}")
127
+ lines.append(f" - between: {g['between'][0]} | {g['between'][1]}")
128
+ if not gaps:
129
+ lines.append("- no gaps above threshold")
130
+ lines.append("")
131
+ lines.append("## Cliffs & anachronisms")
132
+ for c in self.cliffs():
133
+ lines.append(f"- {c['absent']}")
134
+ for a in self.anachronisms():
135
+ lines.append(f"- `{a['when']}` {a['what']} -> cites {a['cited_years']} "
136
+ f"({a['flag']})")
137
+ if not self.cliffs() and not self.anachronisms():
138
+ lines.append("- none")
139
+ return "\n".join(lines)
140
+
141
+
142
+ def _days(iso):
143
+ from datetime import date
144
+ y, m, d = (int(x) for x in iso.split("-"))
145
+ return date(y, m, d).toordinal()
research/user_journal.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-user journalist's notebook (deterministic suit memory).
2
+
3
+ The tiny model cannot rederive "who the user is and what they care about" from
4
+ weights. So the suit keeps a small, auditable USER JOURNAL: focus topics, active
5
+ investigation threads, remembered corrections, and a tone preset. The journal is
6
+ injected as a short context prefix on each analysis turn, and is updated with a
7
+ hashed-simple key + rationalized text / questions. This is memory in the suit,
8
+ not in the brain; when the user returns days later, the model "still knows them"
9
+ like a good journalist knows their subject.
10
+
11
+ Usage:
12
+ from research.user_journal import UserJournal
13
+ j = UserJournal() # loads ./data/user_journal.json (creates default)
14
+ ctx = j.context() # compact prompt-prefix string
15
+ j.note_thread(text) # remember this turn as an active thread
16
+ j.note_fact(fact) # pin a fact/correction the user cares about
17
+ """
18
+ import json
19
+ import time
20
+ from pathlib import Path
21
+
22
+ ROOT = Path(__file__).resolve().parents[1]
23
+
24
+ # Editorial tone presets -> the single line we hand the model.
25
+ TONES = {
26
+ "spock": "Tone: strictly logical, evidence-first, concise; state what is "
27
+ "unsupported instead of guessing.", # default
28
+ "journalist": "Tone: probe the question; separate asserted fact from "
29
+ "speculation; ask what source the user already trusts.",
30
+ "coach": "Tone: explain briefly and support the user's own reasoning, "
31
+ "correcting only where evidence demands.",
32
+ "concise": "Tone: compact and direct, no filler.",
33
+ }
34
+ DEFAULT_TONE = "spock"
35
+ MAX_THREADS = 12
36
+ MAX_FACTS = 12
37
+
38
+
39
+ _default = {
40
+ "handle": "guest",
41
+ "tone": DEFAULT_TONE,
42
+ "focus": [],
43
+ "threads": [],
44
+ "facts": [],
45
+ "notes": "",
46
+ "corrections": [],
47
+ "last_seen": "",
48
+ }
49
+
50
+
51
+ class UserJournal:
52
+ def __init__(self, path=None):
53
+ self.path = Path(path) if path else ROOT / "data" / "user_journal.json"
54
+ self.data = dict(_default)
55
+ if self.path.exists():
56
+ try:
57
+ import json
58
+ self.data.update(json.loads(self.path.read_text()))
59
+ except Exception:
60
+ pass
61
+
62
+ def save(self):
63
+ import json, time
64
+ self.data["last_seen"] = time.strftime("%Y-%m-%d %H:%M")
65
+ self.path.parent.mkdir(parents=True, exist_ok=True)
66
+ self.path.write_text(json.dumps(self.data, indent=2, ensure_ascii=False))
67
+
68
+ # ---- reads ----
69
+ def context(self):
70
+ d = self.data
71
+ lines = ["\nJOURNAL (about the user, journal's private notes):"]
72
+ lines.append("handle: " + str(d.get("handle", "guest")))
73
+ if d.get("tone"):
74
+ lines.append(TONES.get(d["tone"], TONES[DEFAULT_TONE]))
75
+ if d.get("threads"):
76
+ lines.append("active threads: " + "; ".join(t if isinstance(t, str) else t.get("title","") for t in d["threads"][-MAX_THREADS:]))
77
+ if d.get("facts"):
78
+ lines.append("notes: " + " | ".join(str(f)[:140] for f in d["facts"][-MAX_FACTS:]))
79
+ if d.get("corrections"):
80
+ lines.append("remembered corrections: " + " | ".join(c[:140] for c in d["corrections"][-6:]))
81
+ lines.append("These are private notes. Use them to be relevant to THIS user, "
82
+ "but do not state them back verbatim.\n")
83
+ return "\n".join(lines)
84
+
85
+ # ---- writes (suit heuristics) ----
86
+ def set_handle(self, name):
87
+ self.data["handle"] = (name or "guest").strip()
88
+
89
+ def set_tone(self, preset):
90
+ if preset in TONES:
91
+ self.data["tone"] = preset
92
+
93
+ def note_thread(self, text):
94
+ title = " ".join((text or "").split()[:12])
95
+ if not title:
96
+ return
97
+ threads = [t for t in self.data.setdefault("threads", []) if not (isinstance(t,str) and t==title)]
98
+ threads.append(title)
99
+ self.data["threads"] = threads[-MAX_THREADS:]
100
+
101
+ def note_fact(self, fact):
102
+ fact = (fact or "").strip()
103
+ if not fact:
104
+ return
105
+ self.data.setdefault("facts", []).append(fact)
106
+ self.data["facts"] = self.data["facts"][-MAX_FACTS:]
107
+
108
+ def note_focus(self, terms):
109
+ for t in (terms or []):
110
+ t = str(t).strip()
111
+ if t and t not in self.data.setdefault("focus", []):
112
+ self.data["focus"].append(t)
113
+ self.data["focus"] = self.data["focus"][-16:]
114
+
115
+ def remember_correction(self, text):
116
+ # A safety-corpus: if the user explicitly corrects us, keep it short.
117
+ low = text.lower()
118
+ if any(k in low for k in ("you're wrong", "that's wrong", "no, ", "correction", "actually ")):
119
+ self.data.setdefault("corrections", []).append(text[:160])
120
+
121
+ def snapshot(self):
122
+ return dict(self.data)
123
+
124
+
125
+ if __name__ == "__main__":
126
+ import sys
127
+ j = UserJournal()
128
+ print(j.context())
129
+ print("---")
130
+ j.note_thread("wanted to verify the 2022 repaint permit narrative")
131
+ j.note_fact("user cares about timeline provenance across agencies")
132
+ print("after write snapshot keys:", sorted(j.snapshot().keys()))
research/verify.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic claim-vs-evidence verifier for the numeric spine.
2
+
3
+ A 7.8M model cannot reliably copy values, so the hard comparison is done by
4
+ rules instead of generation. The model's job is confined to what it actually
5
+ can do: identifying the claim and the evidence segments. This module:
6
+
7
+ 1. extracts values from the claim side and the evidence side,
8
+ 2. compares them deterministically,
9
+ 3. returns a verdict that cannot be hallucinated:
10
+ supports / refutes / not enough information / unclear.
11
+
12
+ For any case it cannot resolve (no clean numeric pair), it says so -- it never
13
+ fabricates an answer.
14
+ """
15
+ import re
16
+
17
+
18
+ def _nums(text):
19
+ return re.findall(r"\b\d+(?:,\d{3})*\.?\d*%?\b", text)
20
+
21
+
22
+ def _years(text):
23
+ return re.findall(r"\b(?:19|20)\d{2}\b", text)
24
+
25
+
26
+ def _times(text):
27
+ return re.findall(r"\b\d{1,2}:\d{2}\b", text)
28
+
29
+
30
+ def _clean(v):
31
+ return v.replace(",", "").replace("%", "")
32
+
33
+
34
+ def _vals(text):
35
+ return sorted({_clean(v) for v in (_nums(text) + _years(text) + _times(text))})
36
+
37
+
38
+ def deterministic_verdict(doc):
39
+ """doc: the analyst prompt (claim + evidence). Returns a verdict dict."""
40
+ m = re.split(r"\bEvidence:\s*", doc, flags=re.IGNORECASE)
41
+ claim, evidence = m[0], (m[1] if len(m) > 1 else "")
42
+ cv = _vals(claim)
43
+ ev = _vals(evidence)
44
+ if not cv and not ev:
45
+ return {"verdict": "not enough information", "kind": "no-values",
46
+ "confidence": "LOW", "explain": "no numeric value to compare"}
47
+ if cv and not ev:
48
+ return {"verdict": "not enough information", "kind": "claim-only",
49
+ "confidence": "HIGH", "explain": "evidence has no numeric value to compare"}
50
+ if not cv:
51
+ return {"verdict": "unclear", "kind": "no-claim-value",
52
+ "confidence": "LOW", "explain": "claim has no numeric value"}
53
+ if set(cv) == set(ev):
54
+ return {"verdict": "supports", "kind": "equal", "confidence": "HIGH",
55
+ "explain": f"claim value {sorted(cv)} equals evidence value {sorted(ev)}"}
56
+ if not (set(cv) & set(ev)):
57
+ return {"verdict": "refutes", "kind": "differ", "confidence": "HIGH",
58
+ "explain": f"claim value {sorted(cv)} differs from evidence value {sorted(ev)}"}
59
+ return {"verdict": "unclear", "kind": "partial", "confidence": "LOW",
60
+ "explain": f"claim {sorted(cv)} partially overlaps evidence {sorted(ev)}"}
research/verify_loop.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """External verification loop for the FSI suit (harness doctrine #5).
2
+
3
+ Big-tech basis (docs/harness_research.md): LLMs cannot self-correct with
4
+ intrinsic critique (arXiv 2310.01798); small models need STRONG EXTERNAL
5
+ verifiers (arXiv 2404.09931); CRITIC makes tools the critic (2305.11738);
6
+ Chain-of-Verification = draft -> verify -> revise (2309.09308).
7
+
8
+ Flow (deterministic suit logic; the head never grades itself):
9
+ 1. DRAFT: constrained analyst verdict on the claim (or rule spine first).
10
+ 2. VERIFY: plan checkable value probes (numbers/years/times/quoted values)
11
+ from the claim; for each, retrieve the record evidence and run the
12
+ deterministic spine (research/verify.py).
13
+ 3. REVISE: if the spine resolves (supports/refutes), the spine verdict
14
+ WINS (it cannot hallucinate); if unresolved (no-values/partial), keep the
15
+ draft verdict but do not raise confidence; if any check contradicts the
16
+ draft, downgrade to LOW and flag the discrepancy.
17
+ 4. TRACE: full chain-of-custody (checks, sources, decisions).
18
+
19
+ Usage:
20
+ from research.verify_loop import verify_case, plan_checks, run_checks
21
+ """
22
+ import re
23
+
24
+ from research.provenance import evaluate_source_policy
25
+ from research.verify import deterministic_verdict
26
+
27
+ _QUOTE = re.compile(r"[\"']([^\"']{4,60})[\"']")
28
+ _NUM = re.compile(r"\b\d+(?:,\d{3})*(?:\.\d+)?%?\b")
29
+ _YEAR = re.compile(r"\b(?:19|20)\d{2}\b")
30
+ _TIME = re.compile(r"\d{1,2}:\d{2}") # no trailing \b: "9:30am" has none
31
+
32
+
33
+ def plan_checks(claim):
34
+ """Extract checkable value probes from a claim (deterministic)."""
35
+ checks = []
36
+ seen = set()
37
+ for m in _QUOTE.finditer(claim):
38
+ q = m.group(1).strip()
39
+ if q.lower() not in seen:
40
+ seen.add(q.lower())
41
+ checks.append({"kind": "quote", "value": q})
42
+ # times first so their digits are not double-counted as numbers
43
+ for m in _TIME.finditer(claim):
44
+ v = m.group(0)
45
+ if v.lower() not in seen:
46
+ seen.add(v.lower())
47
+ checks.append({"kind": "time", "value": v})
48
+ rest = _TIME.sub(" ", claim)
49
+ for pat, kind in ((_NUM, "number"), (_YEAR, "year")):
50
+ for m in pat.finditer(rest):
51
+ v = m.group(0)
52
+ if v.lower() not in seen:
53
+ seen.add(v.lower())
54
+ checks.append({"kind": kind, "value": v})
55
+ return checks[:8]
56
+
57
+
58
+ def _retrieval_bundle(raw):
59
+ """Normalize legacy text retrieval and traceable evidence bundles."""
60
+ if isinstance(raw, dict):
61
+ evidence = raw.get("evidence", raw.get("text", ""))
62
+ sources = raw.get("sources", [])
63
+ relation = str(raw.get("claim_relation", "")).strip().lower()
64
+ return str(evidence or ""), list(sources) if isinstance(sources, list) else [], relation
65
+ return str(raw or ""), [], ""
66
+
67
+
68
+ def run_checks(checks, retrieve, spine=deterministic_verdict,
69
+ require_source_policy=False):
70
+ """Run each probe: retrieve evidence for the value, deterministic compare.
71
+
72
+ retrieve(value) -> text (legacy) or an evidence bundle:
73
+ {"evidence": str, "sources": [...], "claim_relation": supports|refutes}.
74
+ When ``require_source_policy`` is true, a bundle must pass SOP 09 and its
75
+ claim relation must agree with the deterministic value check. Otherwise it
76
+ is a lead, not verified evidence.
77
+ Returns list of {kind, value, evidence, verdict, kind_of_spine, explain}.
78
+ """
79
+ results = []
80
+ for c in checks:
81
+ evidence, sources, relation = _retrieval_bundle(retrieve(c["value"]))
82
+ if not evidence:
83
+ results.append({**c, "evidence": "", "verdict": "not enough information",
84
+ "kind": "no-evidence", "explain": "no record retrieved"})
85
+ continue
86
+ doc = f"Claim: {c['value']}\nEvidence: {evidence}"
87
+ r = spine(doc)
88
+ result = {**c, "evidence": evidence[:200], "value_verdict": r["verdict"],
89
+ "verdict": r["verdict"], "kind": r["kind"],
90
+ "explain": r["explain"]}
91
+ if require_source_policy:
92
+ policy = evaluate_source_policy(sources)
93
+ result["source_policy"] = policy
94
+ result["source_ids"] = [card["source_id"] for card in policy["sources"]]
95
+ if not policy["verified"]:
96
+ result.update(verdict="not enough information", kind="source-policy-failed",
97
+ explain=policy["reason"])
98
+ elif relation not in ("supports", "refutes"):
99
+ result.update(verdict="not enough information", kind="source-relation-missing",
100
+ explain="verified source bundle lacks a checked claim relation")
101
+ elif relation != r["verdict"]:
102
+ result.update(verdict="not enough information", kind="source-relation-conflict",
103
+ explain="claim relation conflicts with deterministic value check")
104
+ else:
105
+ result.update(verdict=relation, kind="source-policy-verified",
106
+ explain="source policy and value check agree")
107
+ results.append(result)
108
+ return results
109
+
110
+
111
+ def _classify(checks):
112
+ """Aggregate spine results: supports / refutes / mixed / unresolved."""
113
+ resolved = [c for c in checks if c["verdict"] in ("supports", "refutes")]
114
+ if not resolved:
115
+ return "unresolved", None
116
+ supports = sum(1 for c in resolved if c["verdict"] == "supports")
117
+ if supports == len(resolved):
118
+ return "supports", None
119
+ if supports == 0:
120
+ return "refutes", None
121
+ return "mixed", [c for c in resolved if c["verdict"] == "refutes"]
122
+
123
+
124
+ def verify_case(claim, draft_verdict, draft_conf, retrieve,
125
+ spine=deterministic_verdict, require_source_policy=True):
126
+ """Draft -> verify -> revise. Returns a decision dict with trace.
127
+
128
+ claim: the claim under investigation (text).
129
+ draft_verdict/conf: the constrained analyst verdict + confidence.
130
+ retrieve(value) -> record evidence text or a traceable evidence bundle.
131
+ require_source_policy: fail closed unless the bundle passes SOP 09.
132
+ """
133
+ checks = plan_checks(claim)
134
+ results = run_checks(checks, retrieve, spine=spine,
135
+ require_source_policy=require_source_policy)
136
+ status, refuting = _classify(results)
137
+
138
+ if status == "supports":
139
+ verdict, conf = "true", "HIGH"
140
+ basis = "source-policy-verified" if require_source_policy else "rule-verified"
141
+ elif status == "refutes":
142
+ verdict, conf, basis = "false", "HIGH", "rule-refuted"
143
+ elif status == "mixed":
144
+ verdict, conf = ("not enough information", "LOW") if require_source_policy else ("low confidence", "LOW")
145
+ basis = "rule-mixed:" + ",".join(c["value"] for c in refuting[:3])
146
+ else:
147
+ if require_source_policy:
148
+ verdict, conf, basis = "not enough information", "LOW", "source-policy-incomplete"
149
+ return {
150
+ "verdict": verdict,
151
+ "confidence": conf,
152
+ "basis": basis,
153
+ "checks": results,
154
+ "sources": [sid for c in results for sid in c.get("source_ids", [])][:8],
155
+ "abstained": True,
156
+ }
157
+ # unresolved: the spine cannot confirm; keep the draft but never raise
158
+ verdict = draft_verdict or "not enough information"
159
+ conf = draft_conf if draft_conf in ("LOW", "MEDIUM", "HIGH") else "LOW"
160
+ basis = "unresolved-by-spine"
161
+ if draft_conf == "HIGH":
162
+ conf, basis = "MEDIUM", "draft-high-downgraded-unverified"
163
+
164
+ return {
165
+ "verdict": verdict,
166
+ "confidence": conf,
167
+ "basis": basis,
168
+ "checks": results,
169
+ "sources": [sid for c in results for sid in c.get("source_ids", [])][:8]
170
+ if require_source_policy else [c["evidence"] for c in results if c["evidence"]][:6],
171
+ "abstained": verdict == "not enough information",
172
+ }
research/websearch.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Live web / dark-web retrieval layer for the tiny researcher (client hands).
2
+
3
+ The 16M model is the analyst brain and cannot browse. This module is the
4
+ client-side retrieval tool: it searches the open web (Google News RSS,
5
+ Internet Archive, Wikipedia - all key-free), optionally reaches .onion
6
+ services through a local Tor SOCKS5 proxy, extracts documents into plain
7
+ text, and writes them into the local library so TinyIndex can surface them
8
+ for the model to verify, cross-check, and guide the user down rabbit holes.
9
+
10
+ Guardrails (research/OSINT only):
11
+ * read-only, no identity, no credentials, no execution, size caps.
12
+ * http(s) and .onion only; other schemes (file:// ftp:// etc.) refused.
13
+ * .onion requests need a local Tor SOCKS proxy; if it is not running the
14
+ caller gets a clear, actionable message (never a silent empty result).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import html as _html
20
+ import json
21
+ import re
22
+ import socket
23
+ import ssl
24
+ import time
25
+ from pathlib import Path
26
+
27
+ import requests
28
+ import urllib.parse
29
+
30
+ SOCKS_HOST = "127.0.0.1"
31
+ SOCKS_PORT = 9050
32
+ UA = "FSI-forensic-research/0.1 (research OSINT only) Mozilla/5.0"
33
+ MAX_BODY = 1_500_000 # raw bytes cap per source
34
+ MAX_CHARS = 120_000 # extracted text cap stored per doc
35
+ TIMEOUT = (12, 25) # requests (connect, read)
36
+
37
+
38
+ def tor_status():
39
+ """Probe the local Tor SOCKS proxy. Returns (ok: bool, msg: str)."""
40
+ try:
41
+ with socket.create_connection((SOCKS_HOST, SOCKS_PORT), timeout=2):
42
+ return True, "Tor SOCKS is up on {0}:{1}".format(SOCKS_HOST, SOCKS_PORT)
43
+ except OSError:
44
+ return False, (
45
+ "Tor not reachable on {0}:{1}. Start a local Tor daemon "
46
+ "(tor, Tor Browser, or Orbot) and retry.".format(SOCKS_HOST, SOCKS_PORT)
47
+ )
48
+
49
+
50
+ def _socks5_connect(host, port, timeout=5):
51
+ """Open a TCP socket to (host, port) through the local Tor SOCKS5 proxy."""
52
+ s = socket.create_connection((SOCKS_HOST, SOCKS_PORT), timeout=timeout)
53
+ try:
54
+ s.settimeout(timeout)
55
+ s.sendall(b"\x05\x01\x00") # SOCKS5, 1 method: no-auth
56
+ rep = s.recv(2)
57
+ if rep != b"\x05\x00":
58
+ raise ConnectionError("Tor proxy requires auth (not supported)")
59
+ raw_host = host.encode("ascii", errors="ignore")
60
+ if len(raw_host) > 255:
61
+ raise ValueError("hostname too long for SOCKS5")
62
+ s.sendall(b"\x05\x01\x00\x03" + bytes([len(raw_host)]) + raw_host
63
+ + port.to_bytes(2, "big"))
64
+ head = s.recv(512) # VER REP RSV ATYP ADDR BND.PORT
65
+ if len(head) < 2 or head[1] != 0x00:
66
+ raise ConnectionError("SOCKS5 CONNECT refused for {0}:{1}".format(host, port))
67
+ return s
68
+ except Exception:
69
+ s.close()
70
+ raise
71
+
72
+
73
+ def _http_over_socks(host, port, https, path, timeout=25):
74
+ """Send one HTTP/1.1 GET over a Tor TCP socket (TLS-wrapped if https)."""
75
+ s = _socks5_connect(host, port, min(timeout, 10))
76
+ try:
77
+ if https:
78
+ ctx = ssl.create_default_context()
79
+ s = ctx.wrap_socket(s, server_hostname=host)
80
+ s.settimeout(timeout)
81
+ host_hdr = host if (https and port == 443) else "{0}:{1}".format(host, port)
82
+ req = ("GET {0} HTTP/1.1\r\nHost: {1}\r\nUser-Agent: {2}\r\n"
83
+ "Accept: text/html\r\nConnection: close\r\n\r\n").format(
84
+ path, host_hdr, UA)
85
+ s.sendall(req.encode())
86
+ buf = b""
87
+ while len(buf) < MAX_BODY:
88
+ try:
89
+ chunk = s.recv(65536)
90
+ except (socket.timeout, OSError):
91
+ break
92
+ if not chunk:
93
+ break
94
+ buf += chunk
95
+ return buf
96
+ finally:
97
+ try:
98
+ s.close()
99
+ except Exception:
100
+ pass
101
+
102
+
103
+ def _split_http(buf):
104
+ idx = buf.find(b"\r\n\r\n")
105
+ if idx < 0:
106
+ return buf, b""
107
+ return buf[:idx], buf[idx + 4:]
108
+
109
+
110
+ def _extract_text(raw):
111
+ if isinstance(raw, (bytes, bytearray)):
112
+ txt = bytes(raw).decode("utf-8", errors="replace")
113
+ else:
114
+ txt = str(raw)
115
+ txt = re.sub(r"(?is)<(script|style|head|header|footer|nav)[^>]*>.*?</\1>", " ", txt)
116
+ txt = re.sub(r"(?i)<br\s*/?>[\r\n]*", "\\n", txt)
117
+ txt = re.sub(r"(?s)<[^>]+>", " ", txt)
118
+ txt = _html.unescape(txt)
119
+ txt = re.sub(r"[ \\t]+", " ", txt)
120
+ txt = re.sub(r"\n\s*\n+", "\n\n", txt)
121
+ return txt.strip()
122
+
123
+
124
+ def _first_title(html_str):
125
+ m = re.search(r"(?is)<title[^>]*>(.*?)</title>", html_str)
126
+ if not m:
127
+ return "(untitled)"
128
+ return _extract_text(m.group(1))[:160] or "(untitled)"
129
+
130
+
131
+ def fetch(url, tor=False, timeout=25):
132
+ """Fetch one URL (clearnet or .onion) into a dict with plain text."""
133
+ u = urllib.parse.urlparse(url)
134
+ if u.scheme not in ("http", "https"):
135
+ raise ValueError("refusing non-http(s) target: {0}".format(u.scheme))
136
+ is_onion = (u.hostname or "").endswith(".onion") or tor
137
+ if is_onion:
138
+ ok, msg = tor_status()
139
+ if not ok:
140
+ raise RuntimeError(msg)
141
+ port = u.port or (443 if u.scheme == "https" else 80)
142
+ path = (u.path or "/") + (("?" + u.query) if u.query else "")
143
+ raw = _http_over_socks(u.hostname, port, u.scheme == "https", path, timeout)
144
+ head, body = _split_http(raw)
145
+ content = _extract_text(body or raw)
146
+ status = re.search(br"HTTP/1\.[01] (\d{3})", head)
147
+ title = _first_title(raw.decode("utf-8", "replace"))
148
+ return {"url": url, "title": title, "content": content,
149
+ "source": "onion",
150
+ "status": (status.group(1).decode() if status else "?")}
151
+ r = requests.get(url, headers={"User-Agent": UA}, timeout=TIMEOUT,
152
+ allow_redirects=True)
153
+ r.raise_for_status()
154
+ return {"url": r.url, "title": _first_title(r.text) or "(untitled)",
155
+ "content": _extract_text(r.content),
156
+ "source": "clearnet", "status": str(r.status_code)}
157
+
158
+
159
+ def search_news(query, limit=10):
160
+ url = ("https://news.google.com/rss/search?q=" + urllib.parse.quote(query)
161
+ + "&hl=en-US&gl=US&ceid=US:en")
162
+ r = requests.get(url, headers={"User-Agent": UA}, timeout=TIMEOUT)
163
+ r.raise_for_status()
164
+ out = []
165
+ for item in re.findall(r"(?is)<item>(.*?)</item>", r.text)[:limit]:
166
+ t = re.search(r"(?is)<title>(.*?)</title>", item)
167
+ link = re.search(r"(?is)<link>(.*?)</link>", item)
168
+ desc = re.search(r"(?is)<description>(.*?)</description>", item)
169
+ pub = re.search(r"(?is)<pubDate>(.*?)</pubDate>", item)
170
+ if not link:
171
+ continue
172
+ out.append({
173
+ "title": (_html.unescape(t.group(1)) if t else "(news)").strip(),
174
+ "url": link.group(1).strip(),
175
+ "snippet": (_html.unescape(desc.group(1)).strip() if desc else ""),
176
+ "source": "google-news",
177
+ "date": (pub.group(1).strip() if pub else ""),
178
+ })
179
+ return out
180
+
181
+
182
+ def search_archive(query, limit=5):
183
+ params = {"q": query, "fl[]": ["identifier", "title"],
184
+ "rows": limit, "output": "json"}
185
+ r = requests.get("https://archive.org/advancedsearch.php", params=params,
186
+ headers={"User-Agent": UA}, timeout=TIMEOUT)
187
+ r.raise_for_status()
188
+ docs = r.json().get("response", {}).get("docs", [])
189
+ out = []
190
+ for d in docs:
191
+ if not d.get("identifier"):
192
+ continue
193
+ out.append({"title": (d.get("title") or d["identifier"]).strip(),
194
+ "url": "https://archive.org/details/" + d["identifier"],
195
+ "snippet": "Internet Archive item", "source": "archive-org",
196
+ "date": ""})
197
+ return out[:limit]
198
+
199
+
200
+ def search_wiki(query, limit=4):
201
+ p = {"action": "query", "list": "search", "srsearch": query,
202
+ "format": "json", "srlimit": limit}
203
+ r = requests.get("https://en.wikipedia.org/w/api.php", params=p,
204
+ headers={"User-Agent": UA}, timeout=TIMEOUT)
205
+ r.raise_for_status()
206
+ res = r.json().get("query", {}).get("search", [])
207
+ out = []
208
+ for d in res:
209
+ out.append({
210
+ "title": d["title"],
211
+ "url": "https://en.wikipedia.org/wiki/" + urllib.parse.quote(
212
+ d["title"].replace(" ", "_")),
213
+ "snippet": re.sub(r"<.*?>", "", d.get("snippet", "")),
214
+ "source": "wikipedia", "date": ""})
215
+ return out
216
+
217
+
218
+ def search_web(query, limit=10):
219
+ """Search clearnet across news + archive + wiki; dedup by URL."""
220
+ combined = []
221
+ for fn in (search_news, search_archive, search_wiki):
222
+ try:
223
+ combined += fn(query, limit=max(1, limit // 2 + 1))
224
+ except Exception:
225
+ continue
226
+ seen, out = set(), []
227
+ for r in combined:
228
+ if r["url"] in seen:
229
+ continue
230
+ seen.add(r["url"])
231
+ out.append(r)
232
+ if len(out) >= limit:
233
+ break
234
+ return out
235
+
236
+
237
+ def save_doc(library_dir, slug, title, text):
238
+ """Write one pulled document into the library as a .txt file. Returns Path."""
239
+ if not text:
240
+ raise ValueError("no text to save")
241
+ safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", slug)[:60].strip("_") or "doc"
242
+ p = Path(library_dir) / ("pull_{0}_{1}.txt".format(int(time.time()), safe))
243
+ p.write_text("TITLE: {0}\nSOURCE: {1}\n\n{2}".format(title, slug, text[:MAX_CHARS]),
244
+ encoding="utf-8")
245
+ return p
246
+
247
+
248
+ def pull(query, n=3, tor=False, library_dir="data/library"):
249
+ """Search, fetch the top-n docs, save each into the library. Returns dict."""
250
+ results = search_web(query, limit=max(n * 3, 6))
251
+ saved, errors = [], []
252
+ for r in results:
253
+ if len(saved) >= n:
254
+ break
255
+ try:
256
+ doc = fetch(r["url"], tor=tor)
257
+ if not doc["content"]:
258
+ errors.append({"url": r["url"], "err": "empty body"})
259
+ continue
260
+ p = save_doc(library_dir, r["url"].rsplit("/", 1)[-1], doc["title"],
261
+ doc["content"])
262
+ saved.append({"url": r["url"], "title": doc["title"], "file": str(p)})
263
+ except Exception as e:
264
+ errors.append({"url": r["url"], "err": str(e)[:200]})
265
+ return {"query": query, "saved": saved, "errors": errors,
266
+ "tor": tor, "tor_status": tor_status()}
267
+
268
+
269
+ if __name__ == "__main__":
270
+ import argparse
271
+ ap = argparse.ArgumentParser(description="live retrieval for the tiny researcher")
272
+ ap.add_argument("--search", help="search clearnet for a topic")
273
+ ap.add_argument("--fetch", help="fetch one URL")
274
+ ap.add_argument("--pull", help="search + fetch top docs into the library")
275
+ ap.add_argument("--tor", action="store_true", help="route fetches via Tor SOCKS")
276
+ ap.add_argument("--library", default="data/library")
277
+ ap.add_argument("--n", type=int, default=3)
278
+ args = ap.parse_args()
279
+
280
+ if args.search:
281
+ for r in search_web(args.search, limit=8):
282
+ print("[{0}] {1}\n {2}\n {3}".format(
283
+ r["source"], r["title"], r["url"], r["snippet"][:120]))
284
+ elif args.pull:
285
+ res = pull(args.pull, library_dir=args.library)
286
+ print(json.dumps(res, indent=2, ensure_ascii=False))
287
+ elif args.fetch:
288
+ print(json.dumps(fetch(args.fetch, tor=args.tor), indent=2, ensure_ascii=False))
289
+ else:
290
+ ap.print_help()
research/workspace.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Research workspace: turn case notes into analytic artifacts (the sandbox).
2
+
3
+ The tiny head reasons; the workspace materializes. Given a case ledger (NOTE
4
+ lines, search hits, verdicts) it renders, saves, and returns markdown documents:
5
+ - timeline events with dates, sorted, source-tagged
6
+ - evidence claim/evidence/value rows (the discrepancy table)
7
+ - series two value series as an ASCII chart (pattern display)
8
+ - crossref a theme/symbol mapped to every source that mentions it
9
+
10
+ Deterministic only - no model inference here. Artifacts are saved under
11
+ data/artifacts/ so a case produces durable documents, not just chat.
12
+ """
13
+ import json
14
+ import re
15
+ from pathlib import Path
16
+
17
+ ROOT = Path(__file__).resolve().parents[1]
18
+ ART_DIR = ROOT / "data" / "artifacts"
19
+
20
+ DATE_RE = re.compile(r"\b((?:19|20)\d{2}(?:-\d{1,2}(?:-\d{1,2})?)?)\b")
21
+ VALUE_RE = re.compile(r"\b(\d{1,2}:\d{2}|\d+(?:,\d{3})*\.?\d*%?)\b")
22
+ SOURCE_RE = re.compile(r"\[([a-z0-9_./-]+)\]|(https?://\S+)")
23
+
24
+ ART_KINDS = ("timeline", "evidence", "series", "crossref")
25
+
26
+
27
+ def _slug(s):
28
+ return re.sub(r"[^a-z0-9]+", "_", s.lower()).strip("_") or "artifact"
29
+
30
+
31
+ def save_md(title, md):
32
+ ART_DIR.mkdir(parents=True, exist_ok=True)
33
+ path = ART_DIR / f"{_slug(title)}.md"
34
+ path.write_text(md + ("\n" if not md.endswith("\n") else ""), encoding="utf-8")
35
+ return path
36
+
37
+
38
+ def _src(line):
39
+ m = SOURCE_RE.search(line)
40
+ return m.group(1) or m.group(2) if m else "-"
41
+
42
+
43
+ def split_rows(ledger):
44
+ """Split a ledger into (date_rows, value_rows, bare_rows)."""
45
+ dates, values, bare = [], [], []
46
+ for ln in ledger:
47
+ ln = ln.strip()
48
+ if not ln:
49
+ continue
50
+ dm = DATE_RE.search(ln)
51
+ if dm:
52
+ dates.append((dm.group(1), ln, _src(ln)))
53
+ continue
54
+ vm = VALUE_RE.search(ln)
55
+ if vm:
56
+ values.append((ln, _src(ln)))
57
+ else:
58
+ bare.append(ln)
59
+ dates.sort(key=lambda r: r[0])
60
+ return dates, values, bare
61
+
62
+
63
+ def render_timeline(ledger, title="Timeline"):
64
+ rows, _, _ = split_rows(ledger)
65
+ if not rows:
66
+ return None
67
+ out = [f"# {title}", "", "| Date | Event | Source |", "|---|---|---|"]
68
+ for d, ln, s in rows:
69
+ out.append(f"| {d} | {ln[:140]} | {s} |")
70
+ out.append("")
71
+ out.append("_Ordering is verifiable only where the source records the date; "
72
+ "gaps are as informative as entries._")
73
+ return "\n".join(out)
74
+
75
+
76
+ def render_evidence(ledger, title="Evidence & Discrepancies"):
77
+ rows, _, _ = split_rows(ledger)
78
+ if not rows:
79
+ return None
80
+ out = [f"# {title}", "", "| Date | Statement | Source |", "|---|---|---|"]
81
+ for d, ln, s in rows:
82
+ out.append(f"| {d} | {ln[:140]} | {s} |")
83
+ return "\n".join(out)
84
+
85
+
86
+ def render_series(series, title="Series Comparison"):
87
+ """series: list of (label, [numbers]). ASCII bars side by side."""
88
+ if not series or len(series) < 2:
89
+ return None
90
+ labels = [s[0] for s in series]
91
+ seqs = [list(s[1]) for s in series]
92
+ n = min(len(x) for x in seqs)
93
+ if n == 0:
94
+ return None
95
+ out = [f"# {title}", "", f"| {' | '.join(labels)} |", f"|{'---|' * len(labels)}"]
96
+ for i in range(n):
97
+ vals = [x[i] for x in seqs]
98
+ out.append("| " + " | ".join(f"{v:.4g}" for v in vals) + " |")
99
+ out.append("")
100
+ out.append("Points (index) " + " ".join(f"[{i}]" for i in range(n)))
101
+ for j, (lab, seq) in enumerate(series):
102
+ mx = max(seq) or 1
103
+ bars = ["#" * max(1, round(v / mx * 20)) for v in seq]
104
+ out.append(f"{lab}: " + " ".join(bars))
105
+ out.append("")
106
+ out.append("_The chart only compares values; it asserts nothing about cause._")
107
+ return "\n".join(out)
108
+
109
+
110
+ def render_crossref(theme, lines, title="Cross-Reference"):
111
+ """theme: a term/symbol; lines: source-tagged ledger/notes."""
112
+ out = [f"# {title}", "", f"Theme/symbol: **{theme}**", "", "| Source | Context |", "|---|---|"]
113
+ hit = 0
114
+ low = theme.lower()
115
+ for ln in lines:
116
+ if low in ln.lower():
117
+ out.append(f"| {_src(ln)} | {ln[:150]} |")
118
+ hit += 1
119
+ if not hit:
120
+ out.append("| - | (no mention in this case's documents) |")
121
+ out.append("")
122
+ out.append("_Absence of a mention is a finding, not an error: note it explicitly._")
123
+ return "\n".join(out)
124
+
125
+
126
+ def synthesize(ledger, title, series=None, theme=None, lines=None):
127
+ """Compose every artifact available for a case into one saved document."""
128
+ arts = []
129
+ tl = render_timeline(ledger, title=f"{title} - Timeline")
130
+ if tl:
131
+ arts.append(tl)
132
+ ev = render_evidence(ledger, title=f"{title} - Evidence & Discrepancies")
133
+ if ev:
134
+ arts.append(ev)
135
+ ch = render_series(series, title=f"{title} - Series Comparison") if series else None
136
+ if ch:
137
+ arts.append(ch)
138
+ cr = render_crossref(theme, lines or ledger, title=f"{title} - Cross-Reference") if theme else None
139
+ if cr:
140
+ arts.append(cr)
141
+ if not arts:
142
+ return None
143
+ doc = "\n\n---\n\n".join(arts)
144
+ ART_DIR.mkdir(parents=True, exist_ok=True)
145
+ path = ART_DIR / f"{_slug(title)}.md"
146
+ path.write_text(doc + "\n", encoding="utf-8")
147
+ return str(path), doc
148
+
149
+
150
+ def parse_series_arg(arg):
151
+ """'title | label1:1,2,3 | label2:4,5,6' -> (title, [(label, [nums])])."""
152
+ parts = [p.strip() for p in arg.split("|")]
153
+ title = parts[0] or "Series Comparison"
154
+ series = []
155
+ for p in parts[1:]:
156
+ if ":" not in p:
157
+ continue
158
+ lab, vs = p.split(":", 1)
159
+ nums = []
160
+ for v in vs.replace(" ", "").split(","):
161
+ try:
162
+ nums.append(float(v))
163
+ except ValueError:
164
+ pass
165
+ if nums:
166
+ series.append((lab.strip(), nums))
167
+ return title, series
run_code.sh ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ cd "$(dirname "$0")"
3
+ export PYTHONPATH="$PWD"
4
+ setsid ./.venv/bin/python train/train_lm.py \
5
+ --data data/code_train.bin --val data/code_valid.bin --tok data/tokenizer.json \
6
+ --config tiny10m --ckpt ckpt/code \
7
+ --resume ckpt/forensic --batch 16 --seq 256 --lr 2e-4 --warmup 100 --steps 2500 \
8
+ --eval-every 250 --save-every 500 --threads 8 --seed 1 \
9
+ > logs/code_train.log 2>&1 < /dev/null &
10
+ echo "code launched pid $!"
run_distill.sh ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ cd "$(dirname "$0")"
3
+ export PYTHONPATH="$PWD"
4
+ setsid ./.venv/bin/python train/train_sft.py \
5
+ --base ckpt/forensic --data data/sft_distill_mix.jsonl --tok data/tokenizer.json \
6
+ --ckpt ckpt/distill --epochs 30 --batch 8 --seq 256 --lr 3e-5 \
7
+ --eval-every 40 --threads 8 \
8
+ > logs/distill_train.log 2>&1 < /dev/null &
9
+ echo "distill launched pid $!"
run_domain_adapt.sh ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ cd "$(dirname "$0")"
3
+ export PYTHONPATH="$PWD" OMP_NUM_THREADS=4 MALLOC_ARENA_MAX=2
4
+ .venv/bin/python -u train/train_lm.py \
5
+ --init-from ckpt/nlp_full --data data/domain_full.bin --val data/domain_valid.bin \
6
+ --config tiny10m --ckpt ckpt/nlp_domain \
7
+ --batch 16 --seq 256 --lr 1e-4 --min-lr 1e-5 --warmup 50 \
8
+ --steps "${STEPS:-1600}" --log-every 25 --eval-every 200 --save-every 200 \
9
+ --threads 4 2>&1 | tee logs/train_domain.log
run_dpo.sh ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ cd "$(dirname "$0")"
3
+ export PYTHONPATH="$PWD"
4
+ setsid ./.venv/bin/python train/train_dpo.py \
5
+ --base ckpt/distill --data data/prefs_persona.jsonl --tok data/tokenizer.json \
6
+ --ckpt ckpt/dpo --epochs 6 --batch 4 --seq 256 --lr 1e-5 --beta 0.1 \
7
+ --threads 8 \
8
+ > logs/dpo_train.log 2>&1 < /dev/null &
9
+ echo "dpo launched pid $!"
run_dpo_sop.sh ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ cd "$(dirname "$0")"
3
+ export PYTHONPATH="$PWD"
4
+ # combined preference set: persona style + procedure-following
5
+ .venv/bin/python - << 'PY'
6
+ import json
7
+ seen, out = set(), []
8
+ for src in ("data/prefs_persona.jsonl", "data/prefs_sop.jsonl"):
9
+ for line in open(src, encoding="utf-8"):
10
+ line = line.strip()
11
+ if not line:
12
+ continue
13
+ ex = json.loads(line)
14
+ k = ex["prompt"]
15
+ if k in seen:
16
+ continue
17
+ seen.add(k)
18
+ out.append(ex)
19
+ with open("data/prefs_all.jsonl", "w", encoding="utf-8") as f:
20
+ for ex in out:
21
+ f.write(json.dumps(ex) + "\n")
22
+ print(f"prefs_all.jsonl: {len(out)} pairs")
23
+ PY
24
+ setsid ./.venv/bin/python train/train_dpo.py \
25
+ --base ckpt/sop --data data/prefs_all.jsonl --tok data/tokenizer.json \
26
+ --ckpt ckpt/dpo --epochs 6 --batch 4 --seq 256 --lr 1e-5 --beta 0.1 \
27
+ --threads 8 \
28
+ > logs/dpo_sop.log 2>&1 < /dev/null &
29
+ echo "dpo launched pid $!"
run_nlp.sh ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ cd "$(dirname "$0")"
3
+ export PYTHONPATH="$PWD"
4
+ nohup ./.venv/bin/python train/train_lm.py \
5
+ --data data/train.bin --val data/valid.bin --tok data/tokenizer.json \
6
+ --config tiny10m --ckpt ckpt/nlp \
7
+ --batch 16 --seq 256 --lr 3e-4 --warmup 300 --steps 7000 \
8
+ --eval-every 500 --save-every 1000 --threads 8 --seed 42 \
9
+ > logs/nlp_train.log 2>&1 &
10
+ echo "launched pid $!"
run_nlp2.sh ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ cd "$(dirname "$0")"
3
+ export PYTHONPATH="$PWD"
4
+ setsid ./.venv/bin/python train/train_lm.py \
5
+ --data data/train.bin --val data/valid.bin --tok data/tokenizer.json \
6
+ --config tiny10m --ckpt ckpt/nlp --resume ckpt/nlp \
7
+ --batch 32 --seq 128 --lr 2e-4 --warmup 100 --steps 1200 \
8
+ --log-every 20 --eval-every 300 --save-every 300 --threads 8 --seed 3 \
9
+ > logs/nlp2_train.log 2>&1 < /dev/null &
10
+ echo "nlp2 launched pid $!"
run_nlp3.sh ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ cd "$(dirname "$0")"
3
+ export PYTHONPATH="$PWD"
4
+ setsid ./.venv/bin/python train/train_lm.py \
5
+ --data data/train2.bin --val data/valid.bin --tok data/tokenizer.json \
6
+ --config tiny10m --ckpt ckpt/nlp --resume ckpt/nlp \
7
+ --batch 32 --seq 128 --lr 1.5e-4 --warmup 100 --steps 1500 \
8
+ --log-every 20 --eval-every 300 --save-every 300 --threads 8 --seed 5 \
9
+ > logs/nlp3_train.log 2>&1 < /dev/null &
10
+ echo "nlp3 launched pid $!"
run_pipeline.sh ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Full on-device chain, each stage waits for the previous:
3
+ # nlp2 pretrain (fixed arch, already running) -> forensic SFT -> SOP SFT -> DPO
4
+ cd "$(dirname "$0")"
5
+ export PYTHONPATH="$PWD"
6
+ export PATH="$PWD/.venv/bin:$PATH"
7
+
8
+ echo "waiting for running pretrain (train/train_lm.py) to finish..."
9
+ while pgrep -f "train/train_lm.py" > /dev/null; do sleep 30; done
10
+ echo "pretrain done."
11
+
12
+ echo "[1/3] forensic SFT (fixed-architecture base)..."
13
+ python train/train_sft.py \
14
+ --base ckpt/nlp --data data/sft_forensic.jsonl --tok data/tokenizer.json \
15
+ --ckpt ckpt/forensic --epochs 1 --batch 8 --seq 256 --lr 5e-5 \
16
+ --eval-every 200 --threads 8 > logs/sft_train.log 2>&1
17
+ echo "[2/3] SOP SFT (forensic + teacher distill + procedures + room actions)..."
18
+ python train/train_sft.py \
19
+ --base ckpt/forensic --data data/sft_sop_mix.jsonl --tok data/tokenizer.json \
20
+ --ckpt ckpt/sop --epochs 12 --batch 8 --seq 256 --lr 3e-5 \
21
+ --eval-every 40 --threads 8 > logs/sop_train.log 2>&1
22
+ echo "[3/3] DPO (persona style + procedure-following preference)..."
23
+ python - << 'PY'
24
+ import json
25
+ seen, out = set(), []
26
+ for src in ("data/prefs_persona.jsonl", "data/prefs_sop.jsonl"):
27
+ for line in open(src, encoding="utf-8"):
28
+ line = line.strip()
29
+ if not line:
30
+ continue
31
+ ex = json.loads(line)
32
+ if ex["prompt"] in seen:
33
+ continue
34
+ seen.add(ex["prompt"])
35
+ out.append(ex)
36
+ with open("data/prefs_all.jsonl", "w", encoding="utf-8") as f:
37
+ for ex in out:
38
+ f.write(json.dumps(ex) + "\n")
39
+ print(f"prefs_all.jsonl: {len(out)} pairs")
40
+ PY
41
+ python train/train_dpo.py \
42
+ --base ckpt/sop --data data/prefs_all.jsonl --tok data/tokenizer.json \
43
+ --ckpt ckpt/dpo --epochs 6 --batch 4 --seq 256 --lr 1e-5 --beta 0.1 \
44
+ --threads 8 > logs/dpo_sop.log 2>&1
45
+ echo "pipeline complete -> ckpt/dpo"
46
+ echo "next: research/probe.py --ckpt ckpt/dpo ; then code stage (./run_code.sh after pointing its base at ckpt/dpo)"
run_pretrain_full.sh ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ cd "$(dirname "$0")"
3
+ export PYTHONPATH="$PWD" OMP_NUM_THREADS=4 MALLOC_ARENA_MAX=2
4
+ .venv/bin/python -u train/train_lm.py \
5
+ --resume ckpt/nlp_full --data data/train_full.bin --val data/valid.bin \
6
+ --config tiny10m --ckpt ckpt/nlp_full \
7
+ --batch 16 --seq 256 --lr 1.5e-4 --min-lr 1e-5 --warmup 200 \
8
+ --steps "${STEPS:-5000}" --log-every 25 --eval-every 500 --save-every 500 \
9
+ --threads 4 2>&1 | tee -a logs/train_full.log
run_sft.sh ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ cd "$(dirname "$0")"
3
+ export PYTHONPATH="$PWD"
4
+ setsid ./.venv/bin/python train/train_sft.py \
5
+ --base ckpt/nlp --data data/sft_forensic.jsonl --tok data/tokenizer.json \
6
+ --ckpt ckpt/forensic --epochs 3 --batch 8 --seq 256 --lr 5e-5 \
7
+ --eval-every 200 --threads 8 \
8
+ > logs/sft_train.log 2>&1 < /dev/null &
9
+ echo "sft launched pid $!"
run_sop.sh ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ cd "$(dirname "$0")"
3
+ export PYTHONPATH="$PWD"
4
+ setsid ./.venv/bin/python train/train_sft.py \
5
+ --base ckpt/forensic --data data/sft_sop_mix.jsonl --tok data/tokenizer.json \
6
+ --ckpt ckpt/sop --epochs 12 --batch 8 --seq 256 --lr 3e-5 \
7
+ --eval-every 40 --threads 8 \
8
+ > logs/sop_train.log 2>&1 < /dev/null &
9
+ echo "sop sft launched pid $!"
run_tui.sh ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ cd "$(dirname "$0")"
3
+ export PYTHONPATH="$PWD"
4
+ CKPT="${1:-ckpt/v15_lora/best.pt}"
5
+ exec ./.venv/bin/python tui/analyst.py --ckpt "$CKPT"
run_v2.sh ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ cd "$(dirname "$0")"
3
+ export PYTHONPATH="$PWD" OMP_NUM_THREADS=8
4
+ exec ./.venv/bin/python -u train/train_sft2.py \
5
+ --base ckpt/nlp --data data/sft_mix_v2.jsonl --tok data/tokenizer.json \
6
+ --ckpt ckpt/v2 --epochs 3 --batch 8 --seq 256 --lr 2e-5 \
7
+ --eval-every 25 --log-every 25 --threads 8
sft_v25.jsonl ADDED
File without changes
skills/tiny-model-agent-notes/SKILL.md ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: tiny-model-agent-notes
3
+ description: The living-project-record discipline for FSI Anomaly β€” every change, trial, measurement, and decision is recorded in agent_notes.md (and CHANGELOG.md) with dates and honest numbers, so sessions/agents stay in sync. The notes also feed the end-of-project training documents, the war story, and the paper. Use whenever anything changes on the project (run started/finished, gate result, code change, data authored, decision made) and when auditing project history.
4
+ ---
5
+
6
+ # Tiny-Model Agent Notes β€” the living record
7
+
8
+ ## Purpose
9
+ `agent_notes.md` (repo root) is the single chronological record of the project:
10
+ what we tried, what worked, what didn't, the research behind every decision,
11
+ and where we stand. It keeps every session and every collaborating agent in
12
+ sync, and it becomes part of the final training-document set, the war story,
13
+ and the paper.
14
+
15
+ ## Update discipline (MANDATORY, after EVERY action)
16
+ 1. Any change to the project β€” run started/finished, gate result, code change,
17
+ data authored, hyperparameter tried, decision made β€” MUST be appended to
18
+ `agent_notes.md` AND `CHANGELOG.md` with the date.
19
+ 2. Entries are factual and measurable: numbers, paths, commands, verdicts.
20
+ No silent re-rolls β€” record the failure first, then the next attempt.
21
+ 3. `agent_notes.md` sections stay current:
22
+ - "Where we stand right now" is updated at the end of every working session.
23
+ - The timeline grows chronologically (append, don't rewrite history).
24
+ - Scorecard tables get the new row with the honest numbers.
25
+ - Open questions / not-yet-tried is pruned when a question is answered.
26
+ 4. Skills get updated when a finding becomes a RULE (e.g., replay mandatory) β€”
27
+ the notes record what happened; the skill encodes what to do next time.
28
+ 5. Cross-check: if a session starts and `agent_notes.md` is stale (does not
29
+ match the last CHANGELOG entry), reconcile it FIRST.
30
+
31
+ ## What the notes feed
32
+ - Session continuity for human + AI collaborators (the user's models keep up
33
+ between sessions by reading this file).
34
+ - The end-of-project TRAINING documents (owner directive: the notes are part
35
+ of the training data at the end).
36
+ - The WAR STORY and PAPER: the honest engineering narrative (what was tried,
37
+ what the research said, what the measurements showed, what we learned).
38
+
39
+ ## Changelog
40
+ - 2026-08-09: created with the full project history compiled from
41
+ CHANGELOG.md + skills; agent_notes.md written at repo root (249 lines);
42
+ device RAM+ change measured and recorded (swap 4.0G -> 12.3G).
skills/tiny-model-arch/SKILL.md ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: tiny-model-arch
3
+ description: Architecture research + dry-run guardrails for the FSI tiny-liquid models β€” decides WHETHER and HOW to change the 7.8-13M liquid architecture (MoE / nanobot-experts / width / depth / attention) before committing training time. Use whenever choosing the model architecture, testing a "punch above weight" idea, or evaluating MoE/compression on edge hardware. Encodes measured results from this device so the same experiments are never re-run.
4
+ ---
5
+
6
+ # Tiny-Model Architecture (7.8-13M, measured on THIS tablet)
7
+
8
+ Goal: raise tiny-model capability without growing the footprint. BEFORE changing
9
+ architecture, run the cheap dry-runs below β€” never gamble a multi-day training run
10
+ on an un-proven arch.
11
+
12
+ ## Measured device / baseline (Aug 2026)
13
+ 8-core ARMv9, SVE2/BF16, ~2.4GB free RAM, ~780+ tok/s at d=320 with BF16. Baseline
14
+ `ckpt/nlp_full` val loss ~2.58; v15 LoRA (7.8M) is the current best analyst.
15
+
16
+ ## Dry-run battery (run each before any arch change)
17
+
18
+ ### 1. Throughput + real params (60-step training smoke, fp32, batch8 seq128, lr 3e-4)
19
+ Measured on data/train2.bin (loss @60 steps):
20
+ | config | realM | tok/s | loss@60 | experts used |
21
+ |---|---|---|---|---|
22
+ | dense6 (dense, 6 blocks) | 7.79 | 1003 | 5.326 | n/a |
23
+ | nano32 (32x~10K experts, top2) | 4.90 | 809 | 5.522 | 27/32 |
24
+ | nano64 (64x~10K experts, top2) | 6.93 | 577 | 5.474 | 51/64 |
25
+ | nano250 (250x~10K experts, top2) | 7.99 | 744 | 5.398 | 51/250 |
26
+ FINDINGS:
27
+ - Dense still reaches the lowest loss per-step at this scale. MoE does NOT outlearn
28
+ dense in a 60-step smoke; its benefit is capacity-per-FLOP at training time, not
29
+ faster convergence.
30
+ - ROUTER COLLAPSE (load imbalance): nano250 uses only 51/250 experts after 60 steps.
31
+ The router cannot learn to spread tokens over 250 tiny experts with plain SFT.
32
+ Fixes that work in the field: an auxiliary load-balancing loss (Switch/Mixtral/
33
+ DeepSeek), or shared experts + few routed experts (DeepSeek-V2 style).
34
+ - nano64 throughput DROPS (577) vs nano32/nano250: naive per-token python batching
35
+ costs more than tiny-expert compute. Vectorize routing/combine before expecting wins.
36
+ - CPU-autocast bug: `router(x)` on a 3D bf16 input fails under torch CPU autocast.
37
+ Cast the router input to float (or run fp32) β€” the smallest MoE fix.
38
+
39
+ ### 2. Density / compressibility math
40
+ nanobot-MoE reaches ~0.04-0.14% compute density (activated params per token) β€” the
41
+ "how dense/compress" answer is: extremely sparse is possible, but only learnable with
42
+ (a) a load-balance loss and (b) enough data per expert. 250 x ~10K-param experts needs
43
+ ~2.5M params/layer just for experts β€” NOT compatible with ~13M total in several layers.
44
+ Compression levers: (1) shared-base matrices + per-expert LoRA deltas (many cheap
45
+ experts), (2) low-rank factorized experts (W=A.B), (3) bf16 now / int8+ at inference.
46
+ Router is trivial to compress (d x E).
47
+
48
+ ## Recommendations (verified direction, not promises)
49
+ - AVOID 250-tiny-expert top-2 as the default: collapse + no per-step win on this device.
50
+ - PREFER: dense depth-growth to 13M (proven, baseline-preserving, skill tiny-model-phase2)
51
+ OR a small MoE (8-16 experts) WITH a load-balancing loss if sparsity is the goal.
52
+ - For a genuine 20M+ later: wide-head tower or shared-expert MoE require prototyping and
53
+ a head-to-head probe test; do NOT change architecture until the new arch beats dense in
54
+ an 8k-step curriculum + probe eval (verdict accuracy + value citation).
55
+
56
+ ## Gate before any real arch-change run
57
+ The proposed arch must (a) match dense per-step loss in a 1k-step smoke, (b) use most of
58
+ its experts (>=75%) with a load-balance loss, (c) train faster or equal on this tablet,
59
+ and (d) beat dense on the probe battery after an 8k-step curriculum SFT. Any arch that
60
+ fails (b) is rejected regardless of param count.
61
+
62
+ ## NEW: persona-routed sparse experts (dual-mind in ONE model) β€” measured, promising
63
+ Idea: fuse dual-mind into a single forward pass by routing on the persona token
64
+ (<|analyst|> vs <|skeptic|>) so each "mind" activates its own expert group. No 2x
65
+ inference passes; one liquid trunk + sparse MoE whose router is persona-aware.
66
+ Dry-run (16 experts, top-2, 160 steps on persona-tagged analyst+skeptic data):
67
+ - usage correlation between analyst and skeptic inputs = 0.435 (1.0 = identical
68
+ routing; <1 = divergence) => the router DID learn partial persona specialization
69
+ without any architectural change (persona token flows through the liquid state
70
+ into the router).
71
+ - e.g. expert 8/11/14 leaned analyst, expert 0/1/3/4/13 leaned skeptic.
72
+ - Sharpen next with an explicit persona->router bias term (target corr < 0.3).
73
+ This is the strongest "combine the concepts" candidate: liquid recurrence + sparse
74
+ experts + dual minds, one model, one forward pass.
75
+
76
+
77
+ ## Liquid scan numerics (measured)
78
+ Chunked log-space scan must keep exp args < 709: SCAN_CHUNK=16 (max arg 442) is
79
+ the verified safe setting; 128 overflows to NaN when gates saturate. Any future
80
+ recurrence rewrite must preserve the bounded-exponent property.
81
+
82
+ ## Vocabulary & Context decision record (2026-08-08) β€” researched, applied
83
+ Question: bigger vocab (currently 8192) and longer/"unlimited" context for the 25.4M head.
84
+
85
+ ### Research basis (primary sources)
86
+ - LFM2 Technical Report (Liquid AI, arXiv 2511.23404): ALL sizes 350M-8.3B ship with
87
+ 32K context; compact hybrid = gated short convs + a FEW grouped-query-attention blocks;
88
+ training = curriculum (difficulty-ordered) -> SFT -> length-normalized preference
89
+ optimization -> model merging; CPU-first, on-device focus. => context 32k is standard at
90
+ ANY size, but Liquid's own small models keep a small number of attention blocks for it.
91
+ - Position Interpolation (Chen et al 2023, arXiv 2306.15595): RoPE models extend context
92
+ with ~1k fine-tune steps; quality preserved in-window. Applies to us only if we keep RoPE.
93
+ - Mamba (Gu & Dao 2023, arXiv 2312.00752) + Gated Linear Attention (Yang et al 2024,
94
+ arXiv 2312.06635): linear-recurrent/SSM layers have O(1) memory per token; long context
95
+ costs FLOPs, not KV cache. Our liquid scan (SCAN_CHUNK=16, bounded exp) already has this
96
+ property; 1024-token inference fits in the recurrent state.
97
+ - TinyStories (Eldan & Li 2023, arXiv 2305.07759): 28M models are coherent ONLY when the
98
+ data is constrained/simple; fluency is a data property, not a scale property.
99
+ - Vocab reference points: GPT-2 50,257 (byte BPE); Llama 32,000; SmolLM2 49,152;
100
+ Qwen2.5 151,936 (multilingual); Phi-1 51,200. No serious tiny model ships 8k.
101
+
102
+ ### Measured on THIS model
103
+ - vocab 8192 x d_model 320, tied embeddings = 2.62M params = 10.3% of 25.43M.
104
+ - 16k vocab -> 5.24M (20.6%); 32k -> 10.49M (41.2%): 32k is rejected at this size.
105
+ - max_seq_len 1024 in config; SFT trains at seq 512; val at seq 64.
106
+ - Corpus char coverage: the 806-row forensic corpus uses 79 distinct chars, all covered.
107
+ 8k vocab is NOT the bottleneck for training data; it only hurts unseen named entities
108
+ (onion URLs, usernames, dates) in live use β€” and no small BPE handles those gracefully.
109
+
110
+ ### Decisions
111
+ 1. VOCAB: KEEP 8192 for the current baseline. 16k is the ceiling and only as a full
112
+ retokenize + embedding-expand + continue-pretrain job (multi-hour, later phase).
113
+ Do not retrain the tokenizer for a mid-life swap.
114
+ 2. CONTEXT: keep max_seq_len 1024. "Unlimited context" is a SUIT problem (retrieval +
115
+ memory slots + entity tracking β€” research/orchestrator.py, library, helix memory),
116
+ not a weights problem for a 25M head. The honest effective reasoning horizon is a few
117
+ hundred tokens; retrieval is the multiplier. Long-doc continue-pretrain at seq 1024 is
118
+ the weights-side upgrade path (next long run after fluency is fixed).
119
+ 3. FLUENCY IS THE BIGGER LEVER: measured free-form generation is template-loop garbage at
120
+ pretrain, SFT, AND DPO checkpoints while constrained-decode probes work. Per TinyStories
121
+ + training skill Phase-2, the fix = mix handcrafted fluent dialogue back into SFT
122
+ (<=15-20% of rows) from the best probe checkpoint. See tiny-model-training apply plan.
skills/tiny-model-deploy/SKILL.md ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: tiny-model-deploy
3
+ description: Deployment/client guardrails for the FSI tiny-liquid researcher models β€” the analyst TUI (Parrot-OS-meets-Matrix theme), inference-time features (self-consistency majority vote, BM25 retrieval, source-DNA/provenance tool), and safe dark-web/OSINT operations (Tor, .onion verification, PGP, no-identity rules). Use whenever building or changing the client, tool layer, or inference loop around the tiny researcher model.
4
+ ---
5
+
6
+ # Tiny-Model Deploy β€” client + tool layer guardrails
7
+
8
+ The model is the ANALYST BRAIN; the client is the HANDS (retrieval, navigation,
9
+ verification tooling). Keep the split clean: nothing in the client changes the
10
+ model's weights; everything the client does must be honest about provenance.
11
+
12
+ ## Interface
13
+ - `tui/analyst.py` β€” curses TUI, opencode-style agent, Parrot-OS-meets-Matrix
14
+ neon (matrix green, pink, blue, purple on near-black). Chat personas, SOP
15
+ cases, library search, case files.
16
+ - `run_tui.sh` β€” launcher; default ckpt must point at the CURRENT best analyst.
17
+ - `generate.py` β€” one-shot generation for tests/probes.
18
+
19
+ ## Inference-time features (no training change)
20
+ 1. SELF-CONSISTENCY (proven, 2203.11171): sample N verdicts (N>=3), majority
21
+ vote on the verdict class; cite the majority reasoning. Cheap reliability
22
+ boost at inference. Implement in the client, not the model.
23
+ 2. BM25 RETRIEVAL: local index over the user's document folder; client retrieves
24
+ top-k, model analyzes only retrieved text. No web memory in the model.
25
+ 3. PROVENANCE TOOL: compute/attach source-DNA tags (primary/secondary/anonymous,
26
+ independent-origin count, hash/PGP status) before the model sees a document.
27
+ Model ranks verifiable-vs-blotchy from the tags; client verifies the hashes.
28
+ 4. SAFE DARK-WEB OPS (client side): Tor Browser sessions, .onion address
29
+ verified against a trusted published mirror, PGP signature checks, no
30
+ downloads/JS/logins, disposable identity only. The client refuses unsafe
31
+ actions (opening unverified files, executing downloads).
32
+
33
+ ## Guardrails
34
+ - Never auto-open unverified files or execute downloads from .onion channels.
35
+ - Never let the client send real credentials/identity to any .onion service.
36
+ - Always show the user the source and its verification status with every claim.
37
+ - Keep the TUI dependency-free (stdlib curses; zero third-party deps).
38
+ - If the model's output is not parseable (missing Verdict line), the client
39
+ shows the raw text and flags "unparsed" rather than faking a verdict.
40
+ ## Changelog
41
+ - 2026-08-05: created; interface, inference features (self-consistency, BM25,
42
+ provenance tool, safe dark-web ops), guardrails.
43
+ - 2026-08-05: added live retrieval layer (research/websearch.py) + engine/CLI
44
+ (/web /fetch /pull /tor) so the system can search the web and dark web (.onion
45
+ via a local Tor SOCKS proxy) and pull documents into the library.
skills/tiny-model-developer-credo/SKILL.md ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: tiny-model-developer-credo
3
+ description: THE always-on discipline doctrine for the FSI tiny-liquid project β€” the Mandalorian creed ("This is the Way") translated into development guardrails: verify everything, never guess, surgical accuracy, absolute-highest-quality artifacts, no half-measures, no synthetic training data. Applies to every file, every training document, every stage. Use at the start of every task and during every gate.
4
+ ---
5
+
6
+ # The Developer's Credo β€” Always-On Discipline
7
+
8
+ > "We verify everything we do. We research everything. We do not guess. We do
9
+ > not go in circles. Surgical accuracy and precision is how we stay in line.
10
+ > We only use what we have verified β€” and the highest-quality, most efficient
11
+ > path β€” on everything we do." β€” the Owner (2026-08-12)
12
+
13
+ The Mandalorians are disciplined not because of their armor but because of
14
+ their Way. This is our Way, translated from their creed to engineering.
15
+
16
+ ## The Six Actions (Resol'nare -> Engineering)
17
+
18
+ 1. **Wear armor.** The skills and SOPs ARE the armor. Every task opens with the
19
+ discipline loop: research -> skill -> apply -> gate -> measure -> record.
20
+ The armor is never off.
21
+ 2. **Speak the language.** Record everything precisely: agent_notes.md,
22
+ CHANGELOG.md, measured numbers. Mando'a = naming things exactly; no vague
23
+ "feels better" claims. Numbers only.
24
+ 3. **Defend the family.** Protect the user, their data, and the mission: PII
25
+ guardrails, source protection, honest abstention. A Mandalorian never harms
26
+ an innocent; the model never fabricates or leaks.
27
+ 4. **Raise the children as Mandalorians.** Every artifact (document, gold row,
28
+ preference pair, checkpoint, skill) is created to the same standard so the
29
+ next stage inherits discipline. Leave nothing half-raised.
30
+ 5. **Contribute to the clan.** Give back: honest eval cards, open pipeline
31
+ (Apache-2.0), the community gets the measured truth, not marketing.
32
+ 6. **Rally when called.** Gates fire without hesitation: ppl abort, calibration
33
+ thresholds, red-team failures stop the line. "Mandalorians don't run" β€” not
34
+ from a failing run, not from a hard gate, not from an inconvenient number.
35
+
36
+ ## The Core Sayings (translated)
37
+
38
+ - **"This is the Way."** -> The SOP loop IS the Way. Every step of every stage:
39
+ research -> skill -> apply -> gate -> measure -> record.
40
+ - **"Mandalorians don't run."** -> No shortcuts, no abandoning a broken run
41
+ without diagnosis, no blaming the environment. Diagnose, fix, resume.
42
+ - **"I have spoken."** -> Every claim is backed by evidence or it is not spoken.
43
+ Abstain when the record is silent: "cannot confirm" beats speculation.
44
+ - **"I am a Mandalorian. Weapons are part of my religion."** -> Tools (harness,
45
+ code, skills) are part of the discipline, not separate from it. The suit
46
+ amplifies the skill; it never replaces it.
47
+
48
+ ## The Absolute Quality Bar (training documents)
49
+
50
+ Every training document, gold row, preference pair, prompt, and evaluation
51
+ example is produced at the absolute highest quality we can produce. Period.
52
+
53
+ - **No half-ass.** If a row is not production-grade, it does not enter the set.
54
+ Quality > quantity, always (Phi-1 "Textbooks Are All You Need", LIMA;
55
+ see tiny-model-kd).
56
+ - **No synthetic rows, no generators, no scripts** for training data
57
+ (tiny-model-kd hard rule). Handcrafted, verifiable, teacher-authored only.
58
+ - Every row must teach the model something true and checkable.
59
+ - Treat every model as if it were your own software and your life depended on
60
+ it. This is a career and a grant application, not a toy.
61
+
62
+ ## The Discipline SOP (always on)
63
+
64
+ 1. Verify everything we do.
65
+ 2. Research everything β€” from multiple sources; then create/update the skill;
66
+ then apply it.
67
+ 3. No guessing; no going in circles; never re-run a measured dead end
68
+ (tiny-model-sop, tiny-model-roadmap).
69
+ 4. Surgical accuracy and precision in every edit.
70
+ 5. Measure and record everything (tiny-model-agent-notes, tiny-model-tracking).
71
+
72
+ ## The Credo Applied to Architecture (growth decisions)
73
+
74
+ ### Core Principles
75
+
76
+ 1. **Grow Width Before Depth** β€” "Efficient width scaling beats depth at small
77
+ scale" (EfficientNet arXiv:1905.11970, MLP-Mixer 2021). Tower widens
78
+ correctly; trunk depth FAILED (tiny-model-phase2 measured).
79
+ 2. **Preserve the Baseline** β€” "Never destroy what works" (LoRA arXiv:2106.09685,
80
+ Phi-1.5). Identity-init tower expansion; fluent base never modified.
81
+ 3. **Compartmentalize New Capacity** β€” "Isolation prevents drift" (MoE
82
+ arXiv:2006.16692, AdapterFusion 2021). Tower is isolated; trunk stays
83
+ fluency-preserving.
84
+ 4. **The Harness Scales Better Than the Model** β€” "Tools > params" (Toolformer
85
+ 2023, tiny-model-suit). Build the suit, then grow the brain.
86
+ 5. **Measure, Don't Guess** β€” "Every growth step verified" (Chinchilla
87
+ arXiv:2205.05131, tiny-model-eval). Baseline preserved; PPL guard 60.
88
+ 6. **Width via Tower Expansion** β€” trunk (320, frozen) -> up_proj (identity)
89
+ -> tower (800, identity-init) -> down_proj (zero-init).
90
+
91
+ ### Implementation Rules
92
+ 1. grow_weights.py --verify must show baseline preserved.
93
+ 2. Frozen base + LoRA adapters only.
94
+ 3. Identity-init new tower weights.
95
+ 4. PPL guard: abort if > 60.0.
96
+ 5. Replay ratio 0.5 (arXiv 2502.06042).
97
+ 6. Checkpoint every 50 steps.
98
+
99
+ ## Build/audit rules
100
+ 1. Consult this skill at the START of every task and during every gate.
101
+ 2. Any work that violates the Quality Bar or skips the SOP loop is rejected.
102
+ 3. Changelog every application.
103
+
104
+ ## Changelog
105
+ - 2026-08-12: Consolidated tiny-model-mandalorian into this skill (owner
106
+ renamed it to the Developer's Credo). Added Absolute Quality Bar, always-on
107
+ framing, and the Mandalorian creed translation.
108
+ - 2026-08-09: Created with architecture growth principles.