Instructions to use FerrellSyntheticIntelligence/fsi-anomaly with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- llama.cpp
How to use FerrellSyntheticIntelligence/fsi-anomaly with llama.cpp:
Install (macOS, Linux)
curl -LsSf https://llama.app/install.sh | sh # Start a local OpenAI-compatible server with a web UI: llama serve -hf FerrellSyntheticIntelligence/fsi-anomaly # Run inference directly in the terminal: llama cli -hf FerrellSyntheticIntelligence/fsi-anomaly
Install from WinGet (Windows)
winget install llama.cpp # Start a local OpenAI-compatible server with a web UI: llama serve -hf FerrellSyntheticIntelligence/fsi-anomaly # Run inference directly in the terminal: llama cli -hf FerrellSyntheticIntelligence/fsi-anomaly
Use pre-built binary
# Download pre-built binary from: # https://github.com/ggerganov/llama.cpp/releases # Start a local OpenAI-compatible server with a web UI: ./llama-server -hf FerrellSyntheticIntelligence/fsi-anomaly # Run inference directly in the terminal: ./llama-cli -hf FerrellSyntheticIntelligence/fsi-anomaly
Build from source code
git clone https://github.com/ggerganov/llama.cpp.git cd llama.cpp cmake -B build cmake --build build -j --target llama-server llama-cli # Start a local OpenAI-compatible server with a web UI: ./build/bin/llama-server -hf FerrellSyntheticIntelligence/fsi-anomaly # Run inference directly in the terminal: ./build/bin/llama-cli -hf FerrellSyntheticIntelligence/fsi-anomaly
Use Docker
docker model run hf.co/FerrellSyntheticIntelligence/fsi-anomaly
- LM Studio
- Jan
- Ollama
How to use FerrellSyntheticIntelligence/fsi-anomaly with Ollama:
ollama run hf.co/FerrellSyntheticIntelligence/fsi-anomaly
- Unsloth Desktop
- Docker Model Runner
How to use FerrellSyntheticIntelligence/fsi-anomaly with Docker Model Runner:
docker model run hf.co/FerrellSyntheticIntelligence/fsi-anomaly
- Lemonade
How to use FerrellSyntheticIntelligence/fsi-anomaly with Lemonade:
Pull the model
# Download Lemonade from https://lemonade-server.ai/ lemonade pull FerrellSyntheticIntelligence/fsi-anomaly
Run and chat with the model
lemonade run user.fsi-anomaly-{{QUANT_TAG}}List all available models
lemonade list
- Atomic Chat
backup all: 100 files (batch)
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- research/framing.py +144 -0
- research/fusion.py +161 -0
- research/guardrails.py +113 -0
- research/helix.py +145 -0
- research/index.py +82 -0
- research/journalism.py +194 -0
- research/orchestrator.py +211 -0
- research/patterns.py +105 -0
- research/probe.py +62 -0
- research/procedures_research.md +161 -0
- research/provenance.py +272 -0
- research/researcher_model_survey.md +91 -0
- research/rlvr.py +58 -0
- research/room.py +152 -0
- research/sop_library/00_common.md +19 -0
- research/sop_library/claim_verification.md +19 -0
- research/sop_library/cross_source_discrepancy.md +18 -0
- research/sop_library/dark_web_research.md +20 -0
- research/sop_library/historical_truth.md +17 -0
- research/sop_library/pattern_finding.md +15 -0
- research/sop_library/politics_analysis.md +15 -0
- research/sop_library/source_triage.md +19 -0
- research/sop_library/terminal_control.md +18 -0
- research/sop_library/timeline_reconstruction.md +16 -0
- research/structured.py +117 -0
- research/timeline.py +145 -0
- research/user_journal.py +132 -0
- research/verify.py +60 -0
- research/verify_loop.py +172 -0
- research/websearch.py +290 -0
- research/workspace.py +167 -0
- run_code.sh +10 -0
- run_distill.sh +9 -0
- run_domain_adapt.sh +9 -0
- run_dpo.sh +9 -0
- run_dpo_sop.sh +29 -0
- run_nlp.sh +10 -0
- run_nlp2.sh +10 -0
- run_nlp3.sh +10 -0
- run_pipeline.sh +46 -0
- run_pretrain_full.sh +9 -0
- run_sft.sh +9 -0
- run_sop.sh +9 -0
- run_tui.sh +5 -0
- run_v2.sh +7 -0
- sft_v25.jsonl +0 -0
- skills/tiny-model-agent-notes/SKILL.md +42 -0
- skills/tiny-model-arch/SKILL.md +122 -0
- skills/tiny-model-deploy/SKILL.md +45 -0
- 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.
|