Reizxn commited on
Commit
803b5e8
·
verified ·
1 Parent(s): 18b5b5d

Upload folder using huggingface_hub

Browse files
src/add_special_tokens.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Add special tokens to the tokenizer for the search agent format.
3
+
4
+ New tokens:
5
+ Chat format:
6
+ <|system|> <|end|>
7
+ <|user|> <|end|>
8
+ <|assistant|> <|end|>
9
+
10
+ Search agent actions:
11
+ <|search|> — marks a search query
12
+ <|result|> — marks a search result
13
+ <|evidence|> — marks curated evidence
14
+ <|reasoning|> — marks internal reasoning
15
+ <|finish|> — marks end of search (return evidence to caller)
16
+
17
+ The model learns to produce these tokens during SFT. The agent harness
18
+ at inference time parses them to drive the search loop.
19
+ """
20
+
21
+ import json
22
+ import os
23
+
24
+ from tokenizers import Tokenizer
25
+ from tokenizers.models import BPE
26
+
27
+ PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
28
+ TOKENIZER_DIR = os.path.join(PROJECT_DIR, "tokenizer")
29
+ TOKENIZER_PATH = os.path.join(TOKENIZER_DIR, "tokenizer.json")
30
+ AGENT_TOKENIZER_PATH = os.path.join(TOKENIZER_DIR, "tokenizer_agent.json")
31
+
32
+ SPECIAL_TOKENS = [
33
+ "<|system|>",
34
+ "<|user|>",
35
+ "<|assistant|>",
36
+ "<|search|>",
37
+ "<|result|>",
38
+ "<|evidence|>",
39
+ "<|reasoning|>",
40
+ "<|finish|>",
41
+ "<|end|>",
42
+ ]
43
+
44
+
45
+ def main():
46
+ print(f"Loading tokenizer from {TOKENIZER_PATH}...")
47
+ tokenizer = Tokenizer.from_file(TOKENIZER_PATH)
48
+ original_vocab_size = tokenizer.get_vocab_size()
49
+ print(f" Original vocab size: {original_vocab_size}")
50
+
51
+ # Add special tokens
52
+ print(f"\nAdding {len(SPECIAL_TOKENS)} special tokens...")
53
+ for token in SPECIAL_TOKENS:
54
+ tokenizer.add_tokens([token])
55
+ print(f" Added: {token}")
56
+
57
+ new_vocab_size = tokenizer.get_vocab_size()
58
+ print(f"\nNew vocab size: {new_vocab_size} (added {new_vocab_size - original_vocab_size})")
59
+
60
+ # Save
61
+ tokenizer.save(AGENT_TOKENIZER_PATH)
62
+ print(f"\nAgent tokenizer saved to {AGENT_TOKENIZER_PATH}")
63
+
64
+ # Verify the tokens encode correctly
65
+ print("\nVerification:")
66
+ test_text = "<|system|>You are a search agent.<|end|><|user|>Where is auth defined?<|end|><|assistant|><|search|>auth middleware<|end|>"
67
+ encoded = tokenizer.encode(test_text)
68
+ print(f" Input: {test_text[:80]}...")
69
+ print(f" IDs: {encoded.ids[:30]}")
70
+
71
+ # Verify each special token has a unique ID
72
+ vocab = tokenizer.get_vocab()
73
+ print("\nSpecial token IDs:")
74
+ for token in SPECIAL_TOKENS:
75
+ tid = vocab.get(token)
76
+ print(f" {token:20s} -> {tid}")
77
+
78
+ # Also save a mapping file for the agent harness
79
+ mapping_path = os.path.join(TOKENIZER_DIR, "special_tokens.json")
80
+ token_ids = {token: vocab[token] for token in SPECIAL_TOKENS}
81
+ with open(mapping_path, "w") as f:
82
+ json.dump({"special_tokens": SPECIAL_TOKENS, "token_ids": token_ids}, f, indent=2)
83
+ print(f"\nToken mapping saved to {mapping_path}")
84
+
85
+
86
+ if __name__ == "__main__":
87
+ main()
src/chunk_corpus.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chunk the curated corpus into searchable code units.
3
+
4
+ Splits code into semantically meaningful chunks:
5
+ - Python: functions, classes, top-level blocks
6
+ - JS/TS: functions, classes, export blocks
7
+ - Rust: fn, impl, struct, enum, trait blocks
8
+ - Go: func, type, struct blocks
9
+ - C/C++: function definitions, struct/typedef blocks
10
+
11
+ Each chunk gets:
12
+ - id: unique identifier
13
+ - language: detected language
14
+ - name: extracted name (function/class name)
15
+ - type: function/class/struct/etc
16
+ - code: the raw code text
17
+ - filepath: synthetic path (derived from doc index)
18
+ - start_line, end_line: line range within the doc
19
+
20
+ Output: data/chunks.jsonl
21
+ """
22
+
23
+ import json
24
+ import os
25
+ import re
26
+
27
+ PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
28
+ CURATED_PATH = os.path.join(PROJECT_DIR, "data", "corpus_curated.txt")
29
+ CHUNKS_PATH = os.path.join(PROJECT_DIR, "data", "chunks.jsonl")
30
+
31
+ # ─── Language detection ──────────────────────────────────────────────────────
32
+ LANG_PATTERNS = {
33
+ "python": re.compile(r"^(def |class |import |from \S+ import |if __name__|@)", re.M),
34
+ "js_ts": re.compile(r"^(function |const |let |class |export |import |async function|interface |type \w+ =)", re.M),
35
+ "rust": re.compile(r"^(fn |pub fn |impl |struct |enum |trait |mod |use |pub struct|pub enum|macro_rules!)", re.M),
36
+ "go": re.compile(r"^(func |package |type \w+ struct|import )", re.M),
37
+ "c_cpp": re.compile(r"^(#include|#define|#ifndef|#if |#endif|typedef |struct \w+|class \w+|void |int |char |size_t |ngx_)", re.M),
38
+ }
39
+
40
+ # ─── Chunk patterns per language ─────────────────────────────────────────────
41
+ # Each pattern matches the START of a chunk. We split on these.
42
+ CHUNK_STARTS = {
43
+ "python": re.compile(r"^(def |class |@|if __name__)", re.M),
44
+ "js_ts": re.compile(r"^(function |const |let |class |export |async function|interface |type \w+ =)", re.M),
45
+ "rust": re.compile(r"^(fn |pub fn |impl |struct |enum |trait |mod |macro_rules!|pub struct|pub enum)", re.M),
46
+ "go": re.compile(r"^(func |type \w+ struct|type \w+ interface)", re.M),
47
+ "c_cpp": re.compile(r"^(static |void |int |char |size_t |ngx_|typedef |struct \w+ \{|#define|#if|#ifndef)", re.M),
48
+ }
49
+
50
+ # ─── Name extraction patterns ────────────────────────────────────────────────
51
+ NAME_PATTERNS = [
52
+ (re.compile(r"def (\w+)"), "function"),
53
+ (re.compile(r"class (\w+)"), "class"),
54
+ (re.compile(r"fn (\w+)"), "function"),
55
+ (re.compile(r"pub fn (\w+)"), "function"),
56
+ (re.compile(r"struct (\w+)"), "struct"),
57
+ (re.compile(r"enum (\w+)"), "enum"),
58
+ (re.compile(r"trait (\w+)"), "trait"),
59
+ (re.compile(r"impl (\w+)"), "impl"),
60
+ (re.compile(r"func (\w+)"), "function"),
61
+ (re.compile(r"type (\w+) struct"), "struct"),
62
+ (re.compile(r"function (\w+)"), "function"),
63
+ (re.compile(r"typedef struct (\w+)"), "typedef"),
64
+ (re.compile(r"#define (\w+)"), "macro"),
65
+ (re.compile(r"(ngx_\w+)\s*\("), "function"),
66
+ ]
67
+
68
+
69
+ def detect_language(doc: str) -> str | None:
70
+ for lang, pat in LANG_PATTERNS.items():
71
+ if len(pat.findall(doc)) >= 2:
72
+ return lang
73
+ return None
74
+
75
+
76
+ def extract_name(code: str) -> tuple[str, str]:
77
+ """Extract the name and type from a code chunk."""
78
+ for pat, typ in NAME_PATTERNS:
79
+ m = pat.search(code)
80
+ if m:
81
+ return m.group(1), typ
82
+ return "unknown", "block"
83
+
84
+
85
+ def chunk_document(doc: str, lang: str, doc_idx: int) -> list[dict]:
86
+ """Split a document into chunks based on language-specific patterns."""
87
+ lines = doc.split("\n")
88
+ n_lines = len(lines)
89
+
90
+ # Find all chunk start positions
91
+ start_pat = CHUNK_STARTS.get(lang)
92
+ if start_pat is None:
93
+ # Fallback: treat whole doc as one chunk
94
+ return [{
95
+ "id": f"doc_{doc_idx}_chunk_0",
96
+ "language": lang,
97
+ "name": "block",
98
+ "type": "block",
99
+ "code": doc,
100
+ "filepath": f"src/doc_{doc_idx}.txt",
101
+ "start_line": 1,
102
+ "end_line": n_lines,
103
+ }]
104
+
105
+ starts = [(m.start(), m.group()) for m in start_pat.finditer(doc)]
106
+
107
+ if not starts:
108
+ # No pattern matches — treat whole doc as one chunk
109
+ return [{
110
+ "id": f"doc_{doc_idx}_chunk_0",
111
+ "language": lang,
112
+ "name": "block",
113
+ "type": "block",
114
+ "code": doc,
115
+ "filepath": f"src/doc_{doc_idx}.txt",
116
+ "start_line": 1,
117
+ "end_line": n_lines,
118
+ }]
119
+
120
+ # Add doc start if first match isn't at position 0
121
+ if starts[0][0] > 0:
122
+ starts.insert(0, (0, ""))
123
+
124
+ chunks = []
125
+ for i, (start_pos, _) in enumerate(starts):
126
+ end_pos = starts[i + 1][0] if i + 1 < len(starts) else len(doc)
127
+ chunk_code = doc[start_pos:end_pos].strip()
128
+
129
+ # Skip tiny chunks (< 30 chars)
130
+ if len(chunk_code) < 30:
131
+ continue
132
+
133
+ # Skip huge chunks (> 8000 chars — split them)
134
+ if len(chunk_code) > 8000:
135
+ # Split on blank lines
136
+ sub_parts = re.split(r"\n\n+", chunk_code)
137
+ for j, part in enumerate(sub_parts):
138
+ if len(part.strip()) < 30:
139
+ continue
140
+ name, typ = extract_name(part)
141
+ start_line = doc[:start_pos].count("\n") + 1 + sum(p.count("\n") + 2 for p in sub_parts[:j])
142
+ chunks.append({
143
+ "id": f"doc_{doc_idx}_chunk_{i}_{j}",
144
+ "language": lang,
145
+ "name": name,
146
+ "type": typ,
147
+ "code": part.strip(),
148
+ "filepath": f"src/doc_{doc_idx}.txt",
149
+ "start_line": start_line,
150
+ "end_line": start_line + part.count("\n"),
151
+ })
152
+ continue
153
+
154
+ name, typ = extract_name(chunk_code)
155
+ start_line = doc[:start_pos].count("\n") + 1
156
+
157
+ chunks.append({
158
+ "id": f"doc_{doc_idx}_chunk_{i}",
159
+ "language": lang,
160
+ "name": name,
161
+ "type": typ,
162
+ "code": chunk_code,
163
+ "filepath": f"src/doc_{doc_idx}.txt",
164
+ "start_line": start_line,
165
+ "end_line": start_line + chunk_code.count("\n"),
166
+ })
167
+
168
+ return chunks
169
+
170
+
171
+ def main():
172
+ print(f"Loading curated corpus from {CURATED_PATH}...")
173
+ with open(CURATED_PATH, "r", encoding="utf-8") as f:
174
+ text = f.read()
175
+ print(f" Corpus size: {len(text) / 1e6:.2f} MB")
176
+
177
+ # Split into documents
178
+ docs = re.split(r"\n{3,}", text)
179
+ print(f" Documents: {len(docs):,}")
180
+
181
+ # Chunk each document
182
+ print("Chunking documents...")
183
+ all_chunks = []
184
+ lang_counts = {}
185
+ type_counts = {}
186
+
187
+ for i, doc in enumerate(docs):
188
+ doc = doc.strip()
189
+ if len(doc) < 50:
190
+ continue
191
+ lang = detect_language(doc)
192
+ if lang is None:
193
+ continue
194
+
195
+ chunks = chunk_document(doc, lang, i)
196
+ for chunk in chunks:
197
+ all_chunks.append(chunk)
198
+ lang_counts[lang] = lang_counts.get(lang, 0) + 1
199
+ type_counts[chunk["type"]] = type_counts.get(chunk["type"], 0) + 1
200
+
201
+ print(f"\nTotal chunks: {len(all_chunks):,}")
202
+ print(f" By language: {lang_counts}")
203
+ print(f" By type: {type_counts}")
204
+
205
+ # Size distribution
206
+ sizes = [len(c["code"]) for c in all_chunks]
207
+ sizes.sort()
208
+ print(f" Chunk size: min={sizes[0]}, median={sizes[len(sizes)//2]}, max={sizes[-1]}, mean={sum(sizes)//len(sizes)}")
209
+
210
+ # Write chunks
211
+ with open(CHUNKS_PATH, "w", encoding="utf-8") as f:
212
+ for chunk in all_chunks:
213
+ f.write(json.dumps(chunk) + "\n")
214
+
215
+ print(f"\nChunks written to {CHUNKS_PATH}")
216
+
217
+ # Show a few samples
218
+ print("\n" + "=" * 60)
219
+ print("SAMPLE CHUNKS")
220
+ print("=" * 60)
221
+ for i in [0, len(all_chunks) // 4, len(all_chunks) // 2, len(all_chunks) - 1]:
222
+ c = all_chunks[i]
223
+ print(f"\n--- {c['id']} | {c['language']} | {c['type']} | {c['name']} ---")
224
+ print(f" File: {c['filepath']}:{c['start_line']}-{c['end_line']}")
225
+ print(f" Size: {len(c['code'])} chars")
226
+ print(c["code"][:300])
227
+ if len(c["code"]) > 300:
228
+ print("...")
229
+
230
+
231
+ if __name__ == "__main__":
232
+ main()
src/chunk_documents.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chunk quality-filtered documents into:
3
+ 1. A pre-training corpus (data/pretrain/corpus_pretrain.txt)
4
+ - Concatenated source code with document separators
5
+ - Used for base LM pre-training
6
+ 2. Retrieval chunks (data/chunks/chunks.jsonl)
7
+ - Semantically meaningful code units (functions, classes, structs)
8
+ - Used by the search agent and for SFT trace generation
9
+
10
+ Input: data/quality/documents_quality.jsonl
11
+ Output: data/pretrain/corpus_pretrain.txt
12
+ data/chunks/chunks.jsonl
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import re
18
+
19
+ PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
20
+ DATA_DIR = os.path.join(PROJECT_DIR, "data")
21
+ QUALITY_DOCS_PATH = os.path.join(DATA_DIR, "quality", "documents_quality.jsonl")
22
+ PRETRAIN_DIR = os.path.join(DATA_DIR, "pretrain")
23
+ CORPUS_PRETRAIN_PATH = os.path.join(PRETRAIN_DIR, "corpus_pretrain.txt")
24
+ CHUNKS_DIR = os.path.join(DATA_DIR, "chunks")
25
+ CHUNKS_PATH = os.path.join(CHUNKS_DIR, "chunks.jsonl")
26
+
27
+ # ─── Chunk start patterns per language ───────────────────────────────────────
28
+ CHUNK_STARTS = {
29
+ "python": re.compile(r"^(def |class |@|async def )", re.M),
30
+ "javascript": re.compile(r"^(function |const |let |class |export |async function|interface |type \w+ =)", re.M),
31
+ "typescript": re.compile(r"^(function |const |let |class |export |async function|interface |type \w+ =|enum )", re.M),
32
+ "rust": re.compile(r"^(fn |pub fn |impl |struct |enum |trait |mod |macro_rules!|pub struct|pub enum|pub trait)", re.M),
33
+ "go": re.compile(r"^(func |type \w+ struct|type \w+ interface)", re.M),
34
+ "c": re.compile(r"^(static |void |int |char |size_t |typedef |struct \w+ \{|#define|#if|#ifndef|ngx_)", re.M),
35
+ "cpp": re.compile(r"^(static |void |int |char |size_t |template |class \w+|namespace |struct \w+|#define|#if|#ifndef)", re.M),
36
+ "java": re.compile(r"^(public |private |protected |class \w+|interface |enum )", re.M),
37
+ "csharp": re.compile(r"^(public |private |protected |internal |class \w+|interface |enum )", re.M),
38
+ "ruby": re.compile(r"^(def |class |module )", re.M),
39
+ }
40
+
41
+ NAME_PATTERNS = [
42
+ (re.compile(r"def (\w+)"), "function"),
43
+ (re.compile(r"class (\w+)"), "class"),
44
+ (re.compile(r"fn (\w+)"), "function"),
45
+ (re.compile(r"pub fn (\w+)"), "function"),
46
+ (re.compile(r"struct (\w+)"), "struct"),
47
+ (re.compile(r"enum (\w+)"), "enum"),
48
+ (re.compile(r"trait (\w+)"), "trait"),
49
+ (re.compile(r"impl (\w+)"), "impl"),
50
+ (re.compile(r"func (\w+)"), "function"),
51
+ (re.compile(r"type (\w+) struct"), "struct"),
52
+ (re.compile(r"type (\w+) interface"), "interface"),
53
+ (re.compile(r"function (\w+)"), "function"),
54
+ (re.compile(r"async function (\w+)"), "function"),
55
+ (re.compile(r"typedef struct (\w+)"), "typedef"),
56
+ (re.compile(r"#define (\w+)"), "macro"),
57
+ (re.compile(r"interface (\w+)"), "interface"),
58
+ (re.compile(r"module (\w+)"), "module"),
59
+ ]
60
+
61
+
62
+ def extract_name(code: str) -> tuple[str, str]:
63
+ for pat, typ in NAME_PATTERNS:
64
+ m = pat.search(code)
65
+ if m:
66
+ return m.group(1), typ
67
+ return "unknown", "block"
68
+
69
+
70
+ def chunk_document(doc: dict) -> list[dict]:
71
+ """Split a document into chunks based on language-specific patterns."""
72
+ content = doc["content"]
73
+ lang = doc["language"]
74
+ filepath = doc["filepath"]
75
+ repo = doc["repo"]
76
+ lines = content.split("\n")
77
+ n_lines = len(lines)
78
+
79
+ start_pat = CHUNK_STARTS.get(lang)
80
+ if start_pat is None:
81
+ name, typ = extract_name(content)
82
+ return [{
83
+ "id": f"{repo}:{filepath}:0",
84
+ "language": lang,
85
+ "name": name,
86
+ "type": typ,
87
+ "code": content,
88
+ "filepath": filepath,
89
+ "repo": repo,
90
+ "start_line": 1,
91
+ "end_line": n_lines,
92
+ }]
93
+
94
+ starts = [(m.start(), m.group()) for m in start_pat.finditer(content)]
95
+
96
+ if not starts:
97
+ name, typ = extract_name(content)
98
+ return [{
99
+ "id": f"{repo}:{filepath}:0",
100
+ "language": lang,
101
+ "name": name,
102
+ "type": typ,
103
+ "code": content,
104
+ "filepath": filepath,
105
+ "repo": repo,
106
+ "start_line": 1,
107
+ "end_line": n_lines,
108
+ }]
109
+
110
+ if starts[0][0] > 0:
111
+ starts.insert(0, (0, ""))
112
+
113
+ chunks = []
114
+ for i, (start_pos, _) in enumerate(starts):
115
+ end_pos = starts[i + 1][0] if i + 1 < len(starts) else len(content)
116
+ chunk_code = content[start_pos:end_pos].strip()
117
+
118
+ if len(chunk_code) < 40:
119
+ continue
120
+
121
+ # Split huge chunks on blank lines
122
+ if len(chunk_code) > 6000:
123
+ sub_parts = re.split(r"\n\n+", chunk_code)
124
+ for j, part in enumerate(sub_parts):
125
+ part = part.strip()
126
+ if len(part) < 40:
127
+ continue
128
+ name, typ = extract_name(part)
129
+ start_line = content[:start_pos].count("\n") + 1 + sum(p.count("\n") + 2 for p in sub_parts[:j])
130
+ chunks.append({
131
+ "id": f"{repo}:{filepath}:{i}_{j}",
132
+ "language": lang,
133
+ "name": name,
134
+ "type": typ,
135
+ "code": part,
136
+ "filepath": filepath,
137
+ "repo": repo,
138
+ "start_line": start_line,
139
+ "end_line": start_line + part.count("\n"),
140
+ })
141
+ continue
142
+
143
+ name, typ = extract_name(chunk_code)
144
+ start_line = content[:start_pos].count("\n") + 1
145
+ chunks.append({
146
+ "id": f"{repo}:{filepath}:{i}",
147
+ "language": lang,
148
+ "name": name,
149
+ "type": typ,
150
+ "code": chunk_code,
151
+ "filepath": filepath,
152
+ "repo": repo,
153
+ "start_line": start_line,
154
+ "end_line": start_line + chunk_code.count("\n"),
155
+ })
156
+
157
+ return chunks
158
+
159
+
160
+ def main():
161
+ os.makedirs(PRETRAIN_DIR, exist_ok=True)
162
+ os.makedirs(CHUNKS_DIR, exist_ok=True)
163
+
164
+ print(f"Loading quality documents from {QUALITY_DOCS_PATH}...")
165
+ docs = []
166
+ with open(QUALITY_DOCS_PATH, "r", encoding="utf-8") as f:
167
+ for line in f:
168
+ docs.append(json.loads(line))
169
+ print(f" Loaded {len(docs):,} documents")
170
+ print(f" Total size: {sum(len(d['content']) for d in docs) / 1e6:.1f} MB")
171
+
172
+ # ─── Build pre-training corpus ─────────────────────────────────────────
173
+ print("\nBuilding pre-training corpus...")
174
+ corpus_size = 0
175
+ with open(CORPUS_PRETRAIN_PATH, "w", encoding="utf-8") as f:
176
+ for doc in docs:
177
+ f.write(doc["content"].strip())
178
+ f.write("\n\n\n") # document separator
179
+ corpus_size += len(doc["content"])
180
+ print(f" Pre-training corpus: {corpus_size / 1e6:.1f} MB -> {CORPUS_PRETRAIN_PATH}")
181
+
182
+ # ─── Build retrieval chunks ────────────────────────────────────────────
183
+ print("\nChunking documents...")
184
+ all_chunks = []
185
+ lang_counts = {}
186
+ type_counts = {}
187
+
188
+ for doc in docs:
189
+ chunks = chunk_document(doc)
190
+ for chunk in chunks:
191
+ all_chunks.append(chunk)
192
+ lang_counts[chunk["language"]] = lang_counts.get(chunk["language"], 0) + 1
193
+ type_counts[chunk["type"]] = type_counts.get(chunk["type"], 0) + 1
194
+
195
+ print(f"\nTotal chunks: {len(all_chunks):,}")
196
+ print(f" By language: {lang_counts}")
197
+ print(f" By type: {type_counts}")
198
+
199
+ sizes = [len(c["code"]) for c in all_chunks]
200
+ if sizes:
201
+ sizes.sort()
202
+ print(f" Chunk size: min={sizes[0]}, median={sizes[len(sizes)//2]}, "
203
+ f"max={sizes[-1]}, mean={sum(sizes)//len(sizes)}")
204
+
205
+ with open(CHUNKS_PATH, "w", encoding="utf-8") as f:
206
+ for chunk in all_chunks:
207
+ f.write(json.dumps(chunk, ensure_ascii=False) + "\n")
208
+ print(f"\nChunks written to {CHUNKS_PATH}")
209
+
210
+ # Stats
211
+ stats = {
212
+ "input_docs": len(docs),
213
+ "pretrain_corpus_mb": corpus_size / 1e6,
214
+ "total_chunks": len(all_chunks),
215
+ "language_distribution": lang_counts,
216
+ "type_distribution": type_counts,
217
+ }
218
+ stats_path = os.path.join(CHUNKS_DIR, "chunk_stats.json")
219
+ with open(stats_path, "w") as f:
220
+ json.dump(stats, f, indent=2)
221
+ print(f"Stats written to {stats_path}")
222
+
223
+
224
+ if __name__ == "__main__":
225
+ main()
src/convert_bf16.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Convert the latest checkpoint to bf16 to halve file size for Colab upload."""
2
+ import torch
3
+ import json
4
+ import os
5
+
6
+ def main():
7
+ ckpt = torch.load("checkpoints/latest.pt", map_location="cpu", weights_only=False)
8
+ print(f"Original checkpoint: step={ckpt['step']}, loss={ckpt['loss']}")
9
+ print(f" Embedding shape: {ckpt['model_state_dict']['token_embedding.weight'].shape}")
10
+
11
+ # Convert all float32 tensors to bf16
12
+ state = ckpt["model_state_dict"]
13
+ for key in state:
14
+ if state[key].dtype == torch.float32:
15
+ state[key] = state[key].to(torch.bfloat16)
16
+
17
+ ckpt["model_state_dict"] = state
18
+ out_path = "checkpoints/latest_bf16.pt"
19
+ torch.save(ckpt, out_path)
20
+ size_mb = os.path.getsize(out_path) / 1e6
21
+ print(f"bf16 checkpoint saved: {out_path} ({size_mb:.1f} MB)")
22
+
23
+ if __name__ == "__main__":
24
+ main()
src/curate.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Curate the raw corpus into a small, ultra-high-quality subset.
3
+
4
+ Splits the corpus into documents, scores each on quality heuristics,
5
+ and keeps only the top-tier real source code. Produces data/corpus_curated.txt.
6
+
7
+ Quality signals:
8
+ - Real source code (NOT RST docs, YAML configs, JSON data, prose, etc.)
9
+ - Balanced delimiters (braces/parens/brackets roughly match)
10
+ - Has function/class definitions at line starts (not embedded in prose)
11
+ - Has comments or docstrings (documentation density)
12
+ - Reasonable length (200–50k chars)
13
+ - Low repetition (unique line ratio)
14
+ - Clean ASCII (no encoding garbage)
15
+ - High code-to-prose ratio
16
+ """
17
+
18
+ import os
19
+ import re
20
+
21
+ PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
22
+ CORPUS_PATH = os.path.join(PROJECT_DIR, "data", "corpus.txt")
23
+ CURATED_PATH = os.path.join(PROJECT_DIR, "data", "corpus_curated.txt")
24
+
25
+ # ─── Code structure patterns (must be at line start, not in prose) ───────────
26
+ CODE_SIGNATURES = {
27
+ "python": re.compile(r"^(def |class |import |from \S+ import |if __name__|@|elif |except |finally )", re.M),
28
+ "js_ts": re.compile(r"^(function |const |let |var |class |export |import |async function|interface |type \w+ =)", re.M),
29
+ "rust": re.compile(r"^(fn |pub fn |impl |struct |enum |trait |mod |use |pub struct|pub enum|macro_rules!)", re.M),
30
+ "go": re.compile(r"^(func |package |import |type \w+ struct|var |const )", re.M),
31
+ "c_cpp": re.compile(r"^(#include|#define|#ifndef|#ifdef|#if |#endif|typedef |struct \w+|class \w+)", re.M),
32
+ "java": re.compile(r"^(public |private |protected |class \w+|import |package )", re.M),
33
+ }
34
+
35
+ # ─── Documentation / non-code exclusion patterns ─────────────────────────────
36
+ RST_DIRECTIVE = re.compile(r"^\.\. [\w-]+::", re.M) # .. c:function::
37
+ RST_LABEL = re.compile(r"^\.\. _[\w-]+:", re.M) # .. _faq-cache:
38
+ RST_UNDERLINE = re.compile(r"^[-=~^\"']{3,}\s*$", re.M) # ----, ====, ~~~~
39
+ RST_CROSSREF = re.compile(r":[\w]+:`", re.M) # :func:`name`
40
+ XML_START = re.compile(r"^\s*<\?xml|<!DOCTYPE|<\w+ xmlns", re.M)
41
+ MARKDOWN_HEADER = re.compile(r"^#{1,6}\s+\w", re.M) # # Header
42
+
43
+ # Prose detection: lines that look like English sentences
44
+ PROSE_LINE = re.compile(r"^[A-Z][a-z]+ .* [a-z]+\.$", re.M) # "The quick brown fox."
45
+
46
+
47
+ def is_documentation(doc: str) -> bool:
48
+ """Check if a document is RST/Markdown documentation rather than code."""
49
+ # Strong RST signals
50
+ if RST_DIRECTIVE.search(doc) or RST_LABEL.search(doc):
51
+ return True
52
+ if RST_CROSSREF.search(doc):
53
+ return True
54
+ # Section underlines (RST/Markdown)
55
+ if RST_UNDERLINE.findall(doc):
56
+ return True
57
+ # XML
58
+ if XML_START.search(doc):
59
+ return True
60
+ # Markdown headers
61
+ if MARKDOWN_HEADER.findall(doc) and len(MARKDOWN_HEADER.findall(doc)) > 3:
62
+ return True
63
+ return False
64
+
65
+
66
+ def detect_language(doc: str) -> str | None:
67
+ """Detect if a document is real source code and which language."""
68
+ if is_documentation(doc):
69
+ return None
70
+
71
+ for lang, pat in CODE_SIGNATURES.items():
72
+ matches = pat.findall(doc)
73
+ if len(matches) >= 3: # at least 3 code-structure keywords
74
+ return lang
75
+ return None
76
+
77
+
78
+ def score_document(doc: str) -> tuple[float, str | None]:
79
+ """Score a document 0.0–1.0 on quality. Returns (score, language)."""
80
+ lines = doc.split("\n")
81
+ n_lines = len(lines)
82
+ if n_lines < 5:
83
+ return 0.0, None
84
+
85
+ length = len(doc)
86
+ if length < 200 or length > 100_000:
87
+ return 0.0, None
88
+
89
+ # Must be real code
90
+ lang = detect_language(doc)
91
+ if lang is None:
92
+ return 0.0, None
93
+
94
+ score = 0.0
95
+
96
+ # ─── Base: is real code ──────────────────────────────────────────────────
97
+ score += 0.20
98
+
99
+ # ─── Length quality (sweet spot: 500–20000 chars) ────────────────────────
100
+ if 500 <= length <= 20000:
101
+ score += 0.15
102
+ elif 200 <= length <= 50000:
103
+ score += 0.08
104
+
105
+ # ─── Delimiter balance ───────────────────────────────────────────────────
106
+ braces = doc.count("{") - doc.count("}")
107
+ parens = doc.count("(") - doc.count(")")
108
+ brackets = doc.count("[") - doc.count("]")
109
+ total_delims = doc.count("{") + doc.count("(") + doc.count("[")
110
+ if total_delims > 0:
111
+ imbalance = abs(braces) + abs(parens) + abs(brackets)
112
+ balance_ratio = 1.0 - (imbalance / max(total_delims, 1))
113
+ score += 0.15 * max(balance_ratio, 0.0)
114
+
115
+ # ─── Code structure density ──────────────────────────────────────────────
116
+ struct_count = sum(len(p.findall(doc)) for p in CODE_SIGNATURES.values())
117
+ struct_density = min(struct_count / max(n_lines, 1) * 8, 1.0)
118
+ score += 0.15 * struct_density
119
+
120
+ # ─── Comment density (sweet spot: 5–30%) ─────────────────────────────────
121
+ comment_lines = 0
122
+ for line in lines:
123
+ s = line.strip()
124
+ if s.startswith("#") or s.startswith("//") or s.startswith("/*") \
125
+ or s.startswith("*") or s.startswith('"""') or s.startswith("'''") \
126
+ or s.startswith("///") or s.startswith("//!"):
127
+ comment_lines += 1
128
+ comment_ratio = comment_lines / max(n_lines, 1)
129
+ if 0.05 <= comment_ratio <= 0.30:
130
+ score += 0.15
131
+ elif 0.02 <= comment_ratio <= 0.40:
132
+ score += 0.08
133
+
134
+ # ─── Low repetition ──────────────────────────────────────────────────────
135
+ unique_lines = len(set(lines))
136
+ unique_ratio = unique_lines / max(n_lines, 1)
137
+ if unique_ratio > 0.6:
138
+ score += 0.10
139
+ elif unique_ratio > 0.4:
140
+ score += 0.05
141
+ else:
142
+ score -= 0.05
143
+
144
+ # ─── Clean ASCII ─────────────────────────────────────────────────────────
145
+ non_ascii = sum(1 for c in doc if ord(c) > 127)
146
+ if non_ascii / max(length, 1) < 0.01:
147
+ score += 0.05
148
+
149
+ # ─── Indentation quality ─────────────────────────────────────────────────
150
+ indented = sum(1 for l in lines if l.startswith(" ") or l.startswith("\t"))
151
+ if indented > 0 and indented / max(n_lines, 1) > 0.1:
152
+ score += 0.05
153
+
154
+ # ─── Penalize high prose ratio ───────────────────────────────────────────
155
+ prose_lines = len(PROSE_LINE.findall(doc))
156
+ prose_ratio = prose_lines / max(n_lines, 1)
157
+ if prose_ratio > 0.15:
158
+ score -= 0.15 # too much prose = documentation
159
+
160
+ return min(max(score, 0.0), 1.0), lang
161
+
162
+
163
+ def main():
164
+ print(f"Loading corpus from {CORPUS_PATH}...")
165
+ with open(CORPUS_PATH, "r", encoding="utf-8") as f:
166
+ text = f.read()
167
+ print(f" Corpus size: {len(text) / 1e6:.1f} MB")
168
+
169
+ # Split into documents (3+ consecutive newlines = boundary)
170
+ docs = re.split(r"\n{3,}", text)
171
+ print(f" Total documents: {len(docs):,}")
172
+
173
+ # Score all documents
174
+ print("Scoring documents...")
175
+ scored = []
176
+ for i, doc in enumerate(docs):
177
+ s, lang = score_document(doc)
178
+ if s > 0:
179
+ scored.append((s, i, doc, lang))
180
+
181
+ print(f" Documents passing initial filter: {len(scored):,}")
182
+
183
+ # Sort by score descending
184
+ scored.sort(key=lambda x: x[0], reverse=True)
185
+
186
+ # Keep top tier: score >= 0.5, target ~3 MB
187
+ # Cap per-language to ensure diversity (no single language > 40% of docs)
188
+ LANG_CAPS = {"c_cpp": 200, "js_ts": 150, "python": 150, "rust": 100, "go": 50, "java": 50}
189
+ lang_counts_so_far = {}
190
+ top_tier = []
191
+ total_size = 0
192
+ target_size = 3_000_000
193
+
194
+ for score, idx, doc, lang in scored:
195
+ if score < 0.5:
196
+ break
197
+ cap = LANG_CAPS.get(lang, 100)
198
+ if lang_counts_so_far.get(lang, 0) >= cap:
199
+ continue
200
+ if total_size + len(doc) > target_size:
201
+ break
202
+ top_tier.append((score, idx, doc, lang))
203
+ total_size += len(doc)
204
+ lang_counts_so_far[lang] = lang_counts_so_far.get(lang, 0) + 1
205
+
206
+ print(f"\nCurated subset:")
207
+ print(f" Documents: {len(top_tier):,}")
208
+ print(f" Total size: {total_size / 1e6:.2f} MB")
209
+ print(f" Score range: {top_tier[-1][0]:.3f} – {top_tier[0][0]:.3f}")
210
+
211
+ # Language distribution
212
+ lang_counts = {}
213
+ for _, _, _, lang in top_tier:
214
+ lang_counts[lang] = lang_counts.get(lang, 0) + 1
215
+ print(f" Language distribution: {lang_counts}")
216
+
217
+ # Write curated corpus
218
+ with open(CURATED_PATH, "w", encoding="utf-8") as f:
219
+ for _, _, doc, _ in top_tier:
220
+ f.write(doc.strip())
221
+ f.write("\n\n\n")
222
+
223
+ print(f"\nCurated corpus written to {CURATED_PATH}")
224
+ print(f" Size: {total_size / 1e6:.2f} MB ({len(top_tier):,} documents)")
225
+
226
+ # Show samples
227
+ print("\n" + "=" * 60)
228
+ print("SAMPLE DOCUMENTS (top 5 by score)")
229
+ print("=" * 60)
230
+ for i in range(min(5, len(top_tier))):
231
+ score, idx, doc, lang = top_tier[i]
232
+ print(f"\n--- Score: {score:.3f} | Lang: {lang} | Doc #{idx} | {len(doc)} chars ---")
233
+ print(doc[:600])
234
+ if len(doc) > 600:
235
+ print("...")
236
+
237
+
238
+ if __name__ == "__main__":
239
+ main()
src/dedup_pipeline.py ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Deduplication pipeline for the code corpus.
3
+
4
+ Three stages of deduplication, applied in order:
5
+
6
+ 1. EXACT DEDUP (document-level)
7
+ - SHA-256 hash of normalized content (whitespace-collapsed, lowercased)
8
+ - Drops byte-identical and whitespace-only-differing documents.
9
+
10
+ 2. NEAR-DEDUP (document-level, MinHash + LSH)
11
+ - Shingles each document into character n-grams.
12
+ - Computes MinHash signatures.
13
+ - Uses LSH banding to cluster near-duplicates (Jaccard >= threshold).
14
+ - Keeps one representative per cluster (the longest, highest-quality).
15
+
16
+ 3. LINE-LEVEL DEDUP (cross-document)
17
+ - Builds a global set of exact line hashes.
18
+ - Removes lines that appear in >threshold documents (boilerplate:
19
+ license headers, auto-generated stubs, repeated imports, etc.).
20
+ - Drops documents that become too short / too sparse after line removal.
21
+
22
+ Input: data/raw_large/documents.jsonl
23
+ Output: data/dedup/documents_dedup.jsonl
24
+
25
+ Reports statistics at each stage so we can verify the pipeline is working.
26
+ """
27
+
28
+ import hashlib
29
+ import json
30
+ import os
31
+ import re
32
+ import sys
33
+ from collections import Counter, defaultdict
34
+
35
+ from datasketch import MinHash, MinHashLSH
36
+
37
+ PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
38
+ DATA_DIR = os.path.join(PROJECT_DIR, "data")
39
+ RAW_DOCS_PATH = os.path.join(DATA_DIR, "raw_large", "documents.jsonl")
40
+ DEDUP_DIR = os.path.join(DATA_DIR, "dedup")
41
+ DEDUP_DOCS_PATH = os.path.join(DEDUP_DIR, "documents_dedup.jsonl")
42
+ STATS_PATH = os.path.join(DEDUP_DIR, "dedup_stats.json")
43
+
44
+ # ─── MinHash / LSH parameters ────────────────────────────────────────────────
45
+ SHINGLE_SIZE = 5 # character 5-grams
46
+ NUM_PERM = 128 # MinHash permutations (higher = more accurate)
47
+ LSH_THRESHOLD = 0.5 # Jaccard similarity threshold for near-dup
48
+ LSH_BANDS = 32 # LSH bands (NUM_PERM / LSH_ROWS should be integer)
49
+ LSH_ROWS = NUM_PERM // LSH_BANDS # 128 / 32 = 4
50
+
51
+ # ─── Line-level dedup parameters ────────────────────────────────────────────
52
+ LINE_BOILERPLATE_THRESHOLD = 50 # lines appearing in >= this many docs are boilerplate
53
+ MIN_LINES_AFTER_DEDUP = 5 # drop docs with fewer lines after line dedup
54
+ MIN_CHARS_AFTER_DEDUP = 200 # drop docs shorter than this after line dedup
55
+
56
+
57
+ def normalize_content(text: str) -> str:
58
+ """Normalize content for exact-hash comparison: collapse whitespace, lowercase."""
59
+ # Collapse all whitespace sequences to single space
60
+ text = re.sub(r"\s+", " ", text)
61
+ return text.strip().lower()
62
+
63
+
64
+ def exact_hash(text: str) -> str:
65
+ """SHA-256 of normalized content."""
66
+ return hashlib.sha256(normalize_content(text).encode("utf-8")).hexdigest()
67
+
68
+
69
+ def shingles(text: str, k: int = SHINGLE_SIZE) -> set[str]:
70
+ """Character k-gram shingles of normalized text."""
71
+ text = re.sub(r"\s+", " ", text).strip().lower()
72
+ if len(text) < k:
73
+ return {text}
74
+ return {text[i : i + k] for i in range(len(text) - k + 1)}
75
+
76
+
77
+ def minhash_signature(text: str, num_perm: int = NUM_PERM) -> MinHash:
78
+ """Compute MinHash signature for a document."""
79
+ m = MinHash(num_perm=num_perm)
80
+ for s in shingles(text):
81
+ m.update(s.encode("utf-8"))
82
+ return m
83
+
84
+
85
+ # ─── Stage 1: Exact dedup ────────────────────────────────────────────────────
86
+
87
+ def stage1_exact_dedup(docs: list[dict]) -> tuple[list[dict], dict]:
88
+ """Remove exact (and whitespace-only) duplicates. Keep first occurrence."""
89
+ print("\n" + "=" * 60)
90
+ print("STAGE 1: Exact document-level dedup (SHA-256 of normalized content)")
91
+ print("=" * 60)
92
+
93
+ seen_hashes = {}
94
+ kept = []
95
+ dropped = 0
96
+
97
+ for i, doc in enumerate(docs):
98
+ h = exact_hash(doc["content"])
99
+ if h in seen_hashes:
100
+ dropped += 1
101
+ continue
102
+ seen_hashes[h] = i
103
+ kept.append(doc)
104
+
105
+ if (i + 1) % 5000 == 0:
106
+ print(f" Processed {i+1}/{len(docs)} | kept {len(kept)} | dropped {dropped}")
107
+
108
+ stats = {
109
+ "input": len(docs),
110
+ "kept": len(kept),
111
+ "dropped_exact": dropped,
112
+ "drop_rate": dropped / max(len(docs), 1),
113
+ }
114
+ print(f" Input: {len(docs)} | Kept: {len(kept)} | Dropped: {dropped} "
115
+ f"({stats['drop_rate']:.1%})")
116
+ return kept, stats
117
+
118
+
119
+ # ─── Stage 2: Near-dedup (MinHash LSH) ───────────────────────────────────────
120
+
121
+ def stage2_near_dedup(docs: list[dict]) -> tuple[list[dict], dict]:
122
+ """Cluster near-duplicates via MinHash LSH, keep best representative per cluster."""
123
+ print("\n" + "=" * 60)
124
+ print(f"STAGE 2: Near-dedup (MinHash LSH, threshold={LSH_THRESHOLD}, "
125
+ f"perm={NUM_PERM}, bands={LSH_BANDS})")
126
+ print("=" * 60)
127
+
128
+ # Build LSH index
129
+ lsh = MinHashLSH(
130
+ threshold=LSH_THRESHOLD,
131
+ num_perm=NUM_PERM,
132
+ params=(LSH_BANDS, LSH_ROWS),
133
+ )
134
+
135
+ signatures = {}
136
+ kept_indices = set()
137
+
138
+ for i, doc in enumerate(docs):
139
+ content = doc["content"]
140
+ if len(content) < 50:
141
+ # Too short for meaningful MinHash — keep as-is
142
+ kept_indices.add(i)
143
+ continue
144
+
145
+ mh = minhash_signature(content)
146
+ signatures[i] = mh
147
+
148
+ # Query for existing near-duplicates
149
+ candidates = lsh.query(mh)
150
+
151
+ if candidates:
152
+ # This doc is a near-duplicate of an existing one.
153
+ # Compare lengths and keep the longer one.
154
+ existing_best = max(candidates, key=lambda j: len(docs[int(j)]["content"]))
155
+ if len(content) > len(docs[int(existing_best)]["content"]):
156
+ # New doc is longer — replace the existing representative
157
+ kept_indices.discard(int(existing_best))
158
+ kept_indices.add(i)
159
+ # We can't easily remove from LSH, so just add the new one.
160
+ # The old one is dropped via kept_indices tracking.
161
+ try:
162
+ lsh.insert(str(i), mh)
163
+ except Exception:
164
+ pass
165
+ # else: keep existing, drop this one
166
+ else:
167
+ # No near-duplicate found — keep this doc
168
+ kept_indices.add(i)
169
+ try:
170
+ lsh.insert(str(i), mh)
171
+ except Exception:
172
+ pass
173
+
174
+ if (i + 1) % 5000 == 0:
175
+ print(f" Processed {i+1}/{len(docs)} | clusters so far: {len(kept_indices)}")
176
+
177
+ kept = [docs[i] for i in sorted(kept_indices)]
178
+ dropped = len(docs) - len(kept)
179
+
180
+ stats = {
181
+ "input": len(docs),
182
+ "kept": len(kept),
183
+ "dropped_near": dropped,
184
+ "drop_rate": dropped / max(len(docs), 1),
185
+ "threshold": LSH_THRESHOLD,
186
+ "num_perm": NUM_PERM,
187
+ }
188
+ print(f" Input: {len(docs)} | Kept: {len(kept)} | Dropped: {dropped} "
189
+ f"({stats['drop_rate']:.1%})")
190
+ return kept, stats
191
+
192
+
193
+ # ─── Stage 3: Line-level dedup ───────────────────────────────────────────────
194
+
195
+ def stage3_line_dedup(docs: list[dict]) -> tuple[list[dict], dict]:
196
+ """Remove boilerplate lines that appear across many documents."""
197
+ print("\n" + "=" * 60)
198
+ print(f"STAGE 3: Line-level dedup (boilerplate threshold={LINE_BOILERPLATE_THRESHOLD})")
199
+ print("=" * 60)
200
+
201
+ # First pass: count how many documents each normalized line appears in
202
+ line_doc_count = Counter()
203
+ for doc in docs:
204
+ lines = doc["content"].split("\n")
205
+ # Use normalized line hash to save memory
206
+ seen_in_doc = set()
207
+ for line in lines:
208
+ norm = line.strip()
209
+ if not norm:
210
+ continue
211
+ # Normalize: collapse internal whitespace, lowercase
212
+ norm = re.sub(r"\s+", " ", norm).lower()
213
+ if len(norm) < 5: # skip very short lines (braces, etc.)
214
+ continue
215
+ h = hashlib.md5(norm.encode("utf-8")).hexdigest()
216
+ seen_in_doc.add(h)
217
+ for h in seen_in_doc:
218
+ line_doc_count[h] += 1
219
+
220
+ # Identify boilerplate lines (appear in >= threshold documents)
221
+ boilerplate_hashes = {h for h, c in line_doc_count.items() if c >= LINE_BOILERPLATE_THRESHOLD}
222
+ print(f" Boilerplate lines identified: {len(boilerplate_hashes)}")
223
+
224
+ # Second pass: remove boilerplate lines from each document
225
+ kept = []
226
+ dropped_too_short = 0
227
+ lines_removed_total = 0
228
+
229
+ for doc in docs:
230
+ lines = doc["content"].split("\n")
231
+ new_lines = []
232
+ for line in lines:
233
+ norm = line.strip()
234
+ if norm:
235
+ norm_h = re.sub(r"\s+", " ", norm).lower()
236
+ if len(norm_h) >= 5:
237
+ h = hashlib.md5(norm_h.encode("utf-8")).hexdigest()
238
+ if h in boilerplate_hashes:
239
+ lines_removed_total += 1
240
+ continue
241
+ new_lines.append(line)
242
+
243
+ new_content = "\n".join(new_lines).strip()
244
+ new_line_count = len([l for l in new_lines if l.strip()])
245
+
246
+ if new_line_count < MIN_LINES_AFTER_DEDUP or len(new_content) < MIN_CHARS_AFTER_DEDUP:
247
+ dropped_too_short += 1
248
+ continue
249
+
250
+ doc = dict(doc)
251
+ doc["content"] = new_content
252
+ kept.append(doc)
253
+
254
+ stats = {
255
+ "input": len(docs),
256
+ "kept": len(kept),
257
+ "dropped_too_short": dropped_too_short,
258
+ "lines_removed": lines_removed_total,
259
+ "boilerplate_hashes": len(boilerplate_hashes),
260
+ }
261
+ print(f" Input: {len(docs)} | Kept: {len(kept)} | "
262
+ f"Dropped (too short): {dropped_too_short} | "
263
+ f"Lines removed: {lines_removed_total}")
264
+ return kept, stats
265
+
266
+
267
+ # ─── Main ────────────────────────────────────────────────────────────────────
268
+
269
+ def main():
270
+ os.makedirs(DEDUP_DIR, exist_ok=True)
271
+
272
+ print(f"Loading documents from {RAW_DOCS_PATH}...")
273
+ docs = []
274
+ with open(RAW_DOCS_PATH, "r", encoding="utf-8") as f:
275
+ for line in f:
276
+ docs.append(json.loads(line))
277
+ print(f" Loaded {len(docs):,} documents")
278
+ print(f" Total size: {sum(len(d['content']) for d in docs) / 1e6:.1f} MB")
279
+
280
+ all_stats = {"input_count": len(docs)}
281
+
282
+ # Stage 1: Exact dedup
283
+ docs, s1 = stage1_exact_dedup(docs)
284
+ all_stats["stage1_exact"] = s1
285
+
286
+ # Stage 2: Near-dedup
287
+ docs, s2 = stage2_near_dedup(docs)
288
+ all_stats["stage2_near"] = s2
289
+
290
+ # Stage 3: Line-level dedup
291
+ docs, s3 = stage3_line_dedup(docs)
292
+ all_stats["stage3_line"] = s3
293
+
294
+ # Final stats
295
+ final_size = sum(len(d["content"]) for d in docs)
296
+ all_stats["final_count"] = len(docs)
297
+ all_stats["final_size_mb"] = final_size / 1e6
298
+
299
+ # Language distribution
300
+ lang_counts = {}
301
+ for d in docs:
302
+ lang_counts[d["language"]] = lang_counts.get(d["language"], 0) + 1
303
+ all_stats["language_distribution"] = lang_counts
304
+
305
+ print("\n" + "=" * 60)
306
+ print("DEDUP COMPLETE")
307
+ print("=" * 60)
308
+ print(f" Input: {all_stats['input_count']:,} documents")
309
+ print(f" Final: {len(docs):,} documents")
310
+ print(f" Size: {final_size / 1e6:.1f} MB")
311
+ print(f" Languages: {lang_counts}")
312
+
313
+ # Write deduped documents
314
+ with open(DEDUP_DOCS_PATH, "w", encoding="utf-8") as f:
315
+ for doc in docs:
316
+ f.write(json.dumps(doc, ensure_ascii=False) + "\n")
317
+ print(f"\nDeduped documents written to {DEDUP_DOCS_PATH}")
318
+
319
+ # Write stats
320
+ with open(STATS_PATH, "w") as f:
321
+ json.dump(all_stats, f, indent=2)
322
+ print(f"Stats written to {STATS_PATH}")
323
+
324
+
325
+ if __name__ == "__main__":
326
+ main()
src/dedup_quality.py ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Deduplication, contamination check, and quality filter for SFT traces.
3
+
4
+ Pipeline:
5
+ 1. Load all traces from data/sft_traces_v2/ + existing sft_traces.jsonl
6
+ 2. Deduplicate by query hash (exact match) and by fuzzy similarity (near-dupes)
7
+ 3. Contamination check: remove traces whose queries appear in gold_traces.jsonl
8
+ 4. Quality filter: remove traces that are too short, have empty reasoning,
9
+ or have malformed structure
10
+ 5. Shuffle and write final dataset
11
+
12
+ Usage:
13
+ python src/dedup_quality.py --input data/sft_traces_v2/ --output data/sft_traces_final.jsonl
14
+ """
15
+
16
+ import argparse
17
+ import hashlib
18
+ import json
19
+ import os
20
+ import random
21
+ import re
22
+ import sys
23
+ from collections import defaultdict
24
+ from difflib import SequenceMatcher
25
+
26
+ PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
27
+
28
+ def load_traces_from_dir(dir_path: str) -> list[dict]:
29
+ """Load all .jsonl files from a directory."""
30
+ traces = []
31
+ if not os.path.exists(dir_path):
32
+ return traces
33
+ for fname in sorted(os.listdir(dir_path)):
34
+ if fname.endswith('.jsonl'):
35
+ fpath = os.path.join(dir_path, fname)
36
+ with open(fpath, 'r', encoding='utf-8') as f:
37
+ for line in f:
38
+ try:
39
+ trace = json.loads(line)
40
+ if trace and 'query' in trace and 'trace' in trace:
41
+ trace['_source'] = fname
42
+ traces.append(trace)
43
+ except json.JSONDecodeError:
44
+ continue
45
+ return traces
46
+
47
+ def load_traces_from_file(fpath: str) -> list[dict]:
48
+ """Load traces from a single .jsonl file."""
49
+ traces = []
50
+ if not os.path.exists(fpath):
51
+ return traces
52
+ with open(fpath, 'r', encoding='utf-8') as f:
53
+ for line in f:
54
+ try:
55
+ trace = json.loads(line)
56
+ if trace and 'query' in trace and 'trace' in trace:
57
+ trace['_source'] = os.path.basename(fpath)
58
+ traces.append(trace)
59
+ except json.JSONDecodeError:
60
+ continue
61
+ return traces
62
+
63
+ def query_hash(trace: dict) -> str:
64
+ """Hash the query for exact dedup."""
65
+ return hashlib.md5(trace['query'].strip().lower().encode()).hexdigest()
66
+
67
+ def normalize_query(query: str) -> str:
68
+ """Normalize query for fuzzy matching."""
69
+ # Remove punctuation, lowercase, collapse whitespace
70
+ q = re.sub(r'[^\w\s]', '', query.lower())
71
+ q = ' '.join(q.split())
72
+ return q
73
+
74
+ def fuzzy_similarity(q1: str, q2: str) -> float:
75
+ """Compute similarity between two queries."""
76
+ n1, n2 = normalize_query(q1), normalize_query(q2)
77
+ if n1 == n2:
78
+ return 1.0
79
+ # Quick length check
80
+ if abs(len(n1) - len(n2)) > max(len(n1), len(n2)) * 0.5:
81
+ return 0.0
82
+ return SequenceMatcher(None, n1, n2).ratio()
83
+
84
+ def check_trace_quality(trace: dict) -> tuple[bool, str]:
85
+ """Check if a trace meets quality standards.
86
+
87
+ Returns (is_valid, reason_if_rejected)
88
+ """
89
+ query = trace.get('query', '')
90
+ trace_msgs = trace.get('trace', [])
91
+
92
+ # Must have system, user, and at least 2 assistant turns
93
+ if len(trace_msgs) < 4:
94
+ return False, "too_few_messages"
95
+
96
+ # Query must be substantial
97
+ if len(query) < 15:
98
+ return False, "query_too_short"
99
+
100
+ # Check assistant turns have real content
101
+ assistant_turns = [m for m in trace_msgs if m['role'] == 'assistant']
102
+ if len(assistant_turns) < 2:
103
+ return False, "too_few_assistant_turns"
104
+
105
+ for turn in assistant_turns:
106
+ content = turn.get('content', '')
107
+ if len(content) < 50:
108
+ return False, "assistant_turn_too_short"
109
+ # Must contain at least one special token
110
+ if not any(tok in content for tok in ['<|reasoning|>', '<|search|>', '<|evidence|>', '<|finish|>']):
111
+ return False, "no_special_tokens"
112
+
113
+ # Must have at least one search
114
+ has_search = any('<|search|>' in m.get('content', '') for m in assistant_turns)
115
+ if not has_search:
116
+ return False, "no_search_action"
117
+
118
+ # Must have evidence or finish
119
+ has_evidence = any('<|evidence|>' in m.get('content', '') for m in assistant_turns)
120
+ has_finish = any('<|finish|>' in m.get('content', '') for m in assistant_turns)
121
+ if not has_evidence and not has_finish:
122
+ return False, "no_evidence_or_finish"
123
+
124
+ # Check for reasoning density — at least one turn should have substantial reasoning
125
+ max_reasoning_len = 0
126
+ for turn in assistant_turns:
127
+ content = turn.get('content', '')
128
+ # Extract reasoning sections
129
+ reasoning_sections = re.findall(r'<\|reasoning\|>(.*?)<\|end\|>', content, re.DOTALL)
130
+ for r in reasoning_sections:
131
+ max_reasoning_len = max(max_reasoning_len, len(r.strip()))
132
+
133
+ if max_reasoning_len < 30:
134
+ return False, "reasoning_too_thin"
135
+
136
+ return True, "ok"
137
+
138
+ def deduplicate(traces: list[dict], similarity_threshold: float = 0.85) -> tuple[list[dict], dict]:
139
+ """Remove duplicate and near-duplicate traces.
140
+
141
+ Returns (deduplicated_traces, stats)
142
+ """
143
+ stats = {
144
+ 'exact_dups_removed': 0,
145
+ 'fuzzy_dups_removed': 0,
146
+ 'total_input': len(traces),
147
+ }
148
+
149
+ # Phase 1: Exact dedup by query hash
150
+ seen_hashes = set()
151
+ exact_deduped = []
152
+ for trace in traces:
153
+ h = query_hash(trace)
154
+ if h not in seen_hashes:
155
+ seen_hashes.add(h)
156
+ exact_deduped.append(trace)
157
+ else:
158
+ stats['exact_dups_removed'] += 1
159
+
160
+ # Phase 2: Fuzzy dedup by query similarity
161
+ # Group by first word for efficiency
162
+ groups = defaultdict(list)
163
+ for trace in exact_deduped:
164
+ first_word = normalize_query(trace['query']).split()[0] if normalize_query(trace['query']).split() else ''
165
+ groups[first_word].append(trace)
166
+
167
+ fuzzy_deduped = []
168
+ for first_word, group in groups.items():
169
+ if len(group) == 1:
170
+ fuzzy_deduped.extend(group)
171
+ continue
172
+
173
+ # Compare within group
174
+ kept = []
175
+ for trace in group:
176
+ is_dup = False
177
+ for kept_trace in kept:
178
+ sim = fuzzy_similarity(trace['query'], kept_trace['query'])
179
+ if sim >= similarity_threshold:
180
+ is_dup = True
181
+ stats['fuzzy_dups_removed'] += 1
182
+ break
183
+ if not is_dup:
184
+ kept.append(trace)
185
+ fuzzy_deduped.extend(kept)
186
+
187
+ stats['total_output'] = len(fuzzy_deduped)
188
+ return fuzzy_deduped, stats
189
+
190
+ def check_contamination(traces: list[dict], gold_traces: list[dict]) -> tuple[list[dict], dict]:
191
+ """Remove traces whose queries match gold trace queries.
192
+
193
+ Returns (clean_traces, stats)
194
+ """
195
+ gold_queries = set()
196
+ for gt in gold_traces:
197
+ gold_queries.add(normalize_query(gt['query']))
198
+
199
+ clean = []
200
+ removed = 0
201
+ for trace in traces:
202
+ nq = normalize_query(trace['query'])
203
+ if nq in gold_queries:
204
+ removed += 1
205
+ else:
206
+ clean.append(trace)
207
+
208
+ return clean, {'contamination_removed': removed, 'gold_queries': len(gold_queries)}
209
+
210
+ def main():
211
+ parser = argparse.ArgumentParser(description="Dedup and quality filter SFT traces")
212
+ parser.add_argument("--input", type=str, default=os.path.join(PROJECT_DIR, "data", "sft_traces_v2"),
213
+ help="Input directory with .jsonl files")
214
+ parser.add_argument("--existing", type=str, default=os.path.join(PROJECT_DIR, "data", "sft_traces.jsonl"),
215
+ help="Existing traces to merge with")
216
+ parser.add_argument("--gold", type=str, default=os.path.join(PROJECT_DIR, "data", "gold_traces.jsonl"),
217
+ help="Gold traces for contamination check")
218
+ parser.add_argument("--output", type=str, default=os.path.join(PROJECT_DIR, "data", "sft_traces_final.jsonl"),
219
+ help="Output file")
220
+ parser.add_argument("--seed", type=int, default=42)
221
+ parser.add_argument("--similarity", type=float, default=0.85,
222
+ help="Fuzzy dedup similarity threshold")
223
+ args = parser.parse_args()
224
+
225
+ print("=" * 60)
226
+ print("SFT TRACE DEDUPLICATION & QUALITY PIPELINE")
227
+ print("=" * 60)
228
+
229
+ # 1. Load all traces
230
+ print("\n1. Loading traces...")
231
+ new_traces = load_traces_from_dir(args.input)
232
+ existing_traces = load_traces_from_file(args.existing)
233
+ gold_traces = load_traces_from_file(args.gold)
234
+
235
+ print(f" New traces: {len(new_traces):,}")
236
+ print(f" Existing traces: {len(existing_traces):,}")
237
+ print(f" Gold traces: {len(gold_traces):,}")
238
+
239
+ all_traces = new_traces + existing_traces
240
+ print(f" Total to process: {len(all_traces):,}")
241
+
242
+ # 2. Quality filter
243
+ print("\n2. Quality filtering...")
244
+ quality_stats = defaultdict(int)
245
+ quality_passed = []
246
+ for trace in all_traces:
247
+ is_valid, reason = check_trace_quality(trace)
248
+ if is_valid:
249
+ quality_passed.append(trace)
250
+ else:
251
+ quality_stats[reason] += 1
252
+
253
+ print(f" Passed: {len(quality_passed):,}")
254
+ print(f" Rejected: {sum(quality_stats.values()):,}")
255
+ for reason, count in sorted(quality_stats.items(), key=lambda x: -x[1]):
256
+ print(f" {reason}: {count}")
257
+
258
+ # 3. Contamination check
259
+ print("\n3. Contamination check (vs gold traces)...")
260
+ clean_traces, contam_stats = check_contamination(quality_passed, gold_traces)
261
+ print(f" Removed: {contam_stats['contamination_removed']}")
262
+ print(f" Remaining: {len(clean_traces):,}")
263
+
264
+ # 4. Deduplication
265
+ print("\n4. Deduplication...")
266
+ deduped_traces, dedup_stats = deduplicate(clean_traces, args.similarity)
267
+ print(f" Exact dups removed: {dedup_stats['exact_dups_removed']:,}")
268
+ print(f" Fuzzy dups removed: {dedup_stats['fuzzy_dups_removed']:,}")
269
+ print(f" Final count: {dedup_stats['total_output']:,}")
270
+
271
+ # 5. Shuffle and write
272
+ print(f"\n5. Writing to {args.output}...")
273
+ rng = random.Random(args.seed)
274
+ rng.shuffle(deduped_traces)
275
+
276
+ # Remove internal fields
277
+ for trace in deduped_traces:
278
+ trace.pop('_source', None)
279
+
280
+ with open(args.output, 'w', encoding='utf-8') as f:
281
+ for trace in deduped_traces:
282
+ f.write(json.dumps(trace, ensure_ascii=False) + '\n')
283
+
284
+ print(f" Written: {len(deduped_traces):,} traces")
285
+
286
+ # Summary
287
+ print("\n" + "=" * 60)
288
+ print("SUMMARY")
289
+ print("=" * 60)
290
+ print(f" Input traces: {len(all_traces):,}")
291
+ print(f" Quality rejected: {sum(quality_stats.values()):,}")
292
+ print(f" Contamination removed: {contam_stats['contamination_removed']}")
293
+ print(f" Exact dups removed: {dedup_stats['exact_dups_removed']:,}")
294
+ print(f" Fuzzy dups removed: {dedup_stats['fuzzy_dups_removed']:,}")
295
+ print(f" Final dataset: {len(deduped_traces):,}")
296
+
297
+ # Category distribution
298
+ cat_counts = defaultdict(int)
299
+ for trace in deduped_traces:
300
+ # Try to infer category from query pattern
301
+ q = trace['query'].lower()
302
+ if any(w in q for w in ['walk me through', 'implementation', 'step by step', 'control flow']):
303
+ cat_counts['implementation'] += 1
304
+ elif any(w in q for w in ['trace how data', 'data flow', 'data path', 'interact']):
305
+ cat_counts['cross_file'] += 1
306
+ elif any(w in q for w in ['architecture', 'module', 'structure of', 'map out']):
307
+ cat_counts['architecture'] += 1
308
+ elif any(w in q for w in ['where is', 'used across', 'usage', 'called from']):
309
+ cat_counts['usage'] += 1
310
+ elif any(w in q for w in ['error', 'failure', 'fail', 'debug']):
311
+ cat_counts['error/debug'] += 1
312
+ elif any(w in q for w in ['api', 'contract', 'interface', 'parameters']):
313
+ cat_counts['api'] += 1
314
+ elif any(w in q for w in ['depend', 'dependency', 'blast radius', 'impact']):
315
+ cat_counts['dependency/impact'] += 1
316
+ elif any(w in q for w in ['fields', 'data structure', 'layout', 'memory']):
317
+ cat_counts['data_structure'] += 1
318
+ elif any(w in q for w in ['compare', 'tradeoff', 'vs', 'contrast']):
319
+ cat_counts['comparison'] += 1
320
+ elif any(w in q for w in ['security', 'validation', 'vulnerability']):
321
+ cat_counts['security'] += 1
322
+ elif any(w in q for w in ['performance', 'bottleneck', 'hot', 'optimize']):
323
+ cat_counts['performance'] += 1
324
+ elif any(w in q for w in ['pattern', 'design']):
325
+ cat_counts['design_patterns'] += 1
326
+ else:
327
+ cat_counts['other'] += 1
328
+
329
+ print(f"\n Category distribution:")
330
+ for cat, count in sorted(cat_counts.items(), key=lambda x: -x[1]):
331
+ print(f" {cat:20s}: {count:5d}")
332
+
333
+
334
+ if __name__ == "__main__":
335
+ main()
src/download_corpus_large.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Large-scale multi-language code corpus downloader.
3
+
4
+ Downloads tarballs of ~40 popular repositories across Python, JS/TS,
5
+ Rust, Go, C/C++, Java, C#, Ruby, and more. Extracts source files and
6
+ writes them as individual JSONL documents (one file = one document)
7
+ to data/raw_large/documents.jsonl.
8
+
9
+ This is 10-50x larger than the original download_data.py:
10
+ - ~40 repos (vs 12)
11
+ - 15-40 MB cap per repo (vs 3-8 MB)
12
+ - Writes per-file documents with metadata (repo, path, language)
13
+ - Skips tests/benchmarks/docs/vendored code (contamination control)
14
+
15
+ Target: ~1-2 GB of raw source code -> after dedup+quality -> ~300-600 MB.
16
+ """
17
+
18
+ import io
19
+ import json
20
+ import os
21
+ import re
22
+ import tarfile
23
+ import time
24
+
25
+ import requests
26
+
27
+ PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
28
+ DATA_DIR = os.path.join(PROJECT_DIR, "data")
29
+ RAW_DIR = os.path.join(DATA_DIR, "raw_large")
30
+ DOCS_PATH = os.path.join(RAW_DIR, "documents.jsonl")
31
+
32
+ # (user/repo, branch, max_bytes_per_repo, primary_language)
33
+ REPOS = [
34
+ # ─── Python ──────────────────────────────────────────────────────────
35
+ ("python/cpython", "main", 40_000_000, "python"),
36
+ ("pytorch/pytorch", "main", 30_000_000, "cpp"),
37
+ ("numpy/numpy", "main", 20_000_000, "python"),
38
+ ("scipy/scipy", "main", 20_000_000, "python"),
39
+ ("pandas-dev/pandas", "main", 25_000_000, "python"),
40
+ ("scikit-learn/scikit-learn", "main", 20_000_000, "python"),
41
+ ("django/django", "main", 25_000_000, "python"),
42
+ ("flaskpallets/flask", "main", 10_000_000, "python"),
43
+ ("requests/requests", "main", 10_000_000, "python"),
44
+ ("psf/requests", "main", 10_000_000, "python"),
45
+ # ─── JavaScript / TypeScript ─────────────────────────────────────────
46
+ ("microsoft/TypeScript", "main", 30_000_000, "typescript"),
47
+ ("axios/axios", "v1.x", 10_000_000, "javascript"),
48
+ ("lodash/lodash", "master", 10_000_000, "javascript"),
49
+ ("facebook/react", "main", 20_000_000, "javascript"),
50
+ ("vuejs/core", "main", 15_000_000, "typescript"),
51
+ ("expressjs/express", "master", 10_000_000, "javascript"),
52
+ # ─── Rust ────────────────────────────────────────────────────────────
53
+ ("rust-lang/rust", "master", 30_000_000, "rust"),
54
+ ("tokio-rs/tokio", "master", 20_000_000, "rust"),
55
+ ("BurntSushi/ripgrep", "master", 10_000_000, "rust"),
56
+ ("serde-rs/serde", "master", 10_000_000, "rust"),
57
+ ("clap-rs/clap", "master", 10_000_000, "rust"),
58
+ # ─── Go ──────────────────────────────────────────────────────────────
59
+ ("golang/go", "master", 30_000_000, "go"),
60
+ ("json-iterator/go", "master", 10_000_000, "go"),
61
+ ("gin-gonic/gin", "master", 10_000_000, "go"),
62
+ ("prometheus/prometheus", "main", 20_000_000, "go"),
63
+ # ─── C / C++ ─────────────────────────────────────────────────────────
64
+ ("nginx/nginx", "master", 20_000_000, "c"),
65
+ ("redis/redis", "unstable", 20_000_000, "c"),
66
+ ("curl/curl", "master", 15_000_000, "c"),
67
+ ("sqlite/sqlite", "master", 15_000_000, "c"),
68
+ ("git/git", "master", 20_000_000, "c"),
69
+ # ─── Java ────────────────────────────────────────────────────────────
70
+ ("elastic/elasticsearch", "main", 25_000_000, "java"),
71
+ ("google/guava", "master", 15_000_000, "java"),
72
+ # ─── C# ──────────────────────────────────────────────────────────────
73
+ ("dotnet/runtime", "main", 25_000_000, "csharp"),
74
+ # ─── Ruby ────────────────────────────────────────────────────────────
75
+ ("ruby/ruby", "master", 20_000_000, "ruby"),
76
+ ("rails/rails", "main", 25_000_000, "ruby"),
77
+ ]
78
+
79
+ # File extensions -> language label
80
+ EXT_TO_LANG = {
81
+ ".py": "python", ".js": "javascript", ".mjs": "javascript",
82
+ ".jsx": "javascript", ".ts": "typescript", ".tsx": "typescript",
83
+ ".rs": "rust", ".go": "go",
84
+ ".c": "c", ".h": "c", ".cpp": "cpp", ".cc": "cpp", ".cxx": "cpp",
85
+ ".hpp": "cpp", ".hxx": "cpp", ".cu": "cpp", ".cuh": "cpp",
86
+ ".java": "java", ".cs": "csharp", ".rb": "ruby",
87
+ }
88
+
89
+ CODE_EXTENSIONS = set(EXT_TO_LANG.keys())
90
+
91
+ # Directories to skip — contamination control (no tests, docs, vendored code)
92
+ SKIP_DIRS = {
93
+ "test", "tests", "testing", "testcases", "testdata", "test_data",
94
+ "__pycache__", ".git", "node_modules", "vendor", "third_party",
95
+ "thirdparty", "dist", "build", "target", ".github", "docs", "doc",
96
+ "documentation", "benchmark", "benchmarks", "bench", "examples",
97
+ "example", "fixtures", "mocks", "stubs", "snapshots", "e2e",
98
+ "integration", "smoke", "conformance", "testdata", "samples",
99
+ "demo", "demos", "playground", "experimental", "wip",
100
+ }
101
+
102
+ # Filename patterns to skip (test files, config, generated)
103
+ SKIP_FILE_PATTERNS = re.compile(
104
+ r"(?:^|/)(?:"
105
+ r"test[_-]?|.*[_-]test|.*_spec|spec_"
106
+ r"|conftest|setup\.py|conftest\.py"
107
+ r"|.*\.config\.|.*\.min\.|.*\.bundle\."
108
+ r"|package-lock|yarn\.lock|pnpm-lock"
109
+ r"|\.d\.ts$|\.map$"
110
+ r")",
111
+ re.IGNORECASE,
112
+ )
113
+
114
+
115
+ def download_repo_tarball(user_repo: str, branch: str) -> bytes | None:
116
+ """Download a repository tarball from GitHub."""
117
+ urls = [
118
+ f"https://codeload.github.com/{user_repo}/tar.gz/refs/heads/{branch}",
119
+ f"https://codeload.github.com/{user_repo}/tar.gz/refs/tags/{branch}",
120
+ ]
121
+ for url in urls:
122
+ print(f" Downloading {user_repo}@{branch}...")
123
+ try:
124
+ r = requests.get(url, timeout=180, stream=True)
125
+ if r.status_code == 200:
126
+ content = r.content
127
+ print(f" Downloaded {len(content) / 1e6:.1f} MB")
128
+ return content
129
+ except Exception as e:
130
+ print(f" ERROR: {e}")
131
+ time.sleep(2)
132
+ print(f" FAILED (all URLs)")
133
+ return None
134
+
135
+
136
+ def should_skip(path: str) -> bool:
137
+ """Check if a file path should be skipped (contamination control)."""
138
+ parts = path.split("/")
139
+ if any(skip in parts for skip in SKIP_DIRS):
140
+ return True
141
+ if SKIP_FILE_PATTERNS.search(path):
142
+ return True
143
+ return False
144
+
145
+
146
+ def extract_code_files(tarball_bytes: bytes, max_bytes: int, repo: str) -> list[dict]:
147
+ """Extract code files from a tarball as document dicts, up to max_bytes total."""
148
+ docs = []
149
+ total = 0
150
+ try:
151
+ with tarfile.open(fileobj=io.BytesIO(tarball_bytes), mode="r:gz") as tar:
152
+ for member in tar.getmembers():
153
+ if not member.isfile():
154
+ continue
155
+ path = member.name
156
+ # Strip the leading repo-name-sha/ prefix
157
+ if "/" in path:
158
+ rel_path = path.split("/", 1)[1]
159
+ else:
160
+ rel_path = path
161
+
162
+ if should_skip(rel_path):
163
+ continue
164
+
165
+ _, ext = os.path.splitext(path)
166
+ if ext.lower() not in CODE_EXTENSIONS:
167
+ continue
168
+
169
+ # Skip very large or very small files
170
+ if member.size > 300_000 or member.size < 100:
171
+ continue
172
+
173
+ try:
174
+ f = tar.extractfile(member)
175
+ if f is None:
176
+ continue
177
+ content = f.read()
178
+ try:
179
+ text = content.decode("utf-8", errors="ignore")
180
+ except Exception:
181
+ continue
182
+
183
+ # Skip files that are mostly non-text
184
+ non_ascii = sum(1 for c in text if ord(c) > 127)
185
+ if len(text) > 0 and non_ascii / len(text) > 0.05:
186
+ continue
187
+
188
+ lang = EXT_TO_LANG[ext.lower()]
189
+ docs.append({
190
+ "repo": repo,
191
+ "filepath": rel_path,
192
+ "language": lang,
193
+ "content": text,
194
+ })
195
+ total += len(text)
196
+ if total >= max_bytes:
197
+ break
198
+ except Exception:
199
+ continue
200
+ except Exception as e:
201
+ print(f" Extraction error: {e}")
202
+ print(f" Extracted {len(docs)} files, {total / 1e6:.1f} MB of code")
203
+ return docs
204
+
205
+
206
+ def main():
207
+ os.makedirs(RAW_DIR, exist_ok=True)
208
+
209
+ all_docs = []
210
+ for user_repo, branch, max_bytes, _lang in REPOS:
211
+ tarball = download_repo_tarball(user_repo, branch)
212
+ if tarball is None:
213
+ continue
214
+ docs = extract_code_files(tarball, max_bytes, user_repo)
215
+ all_docs.extend(docs)
216
+ time.sleep(1) # Be polite
217
+
218
+ # Write documents as JSONL
219
+ print(f"\nTotal files: {len(all_docs)}")
220
+ total_size = sum(len(d["content"]) for d in all_docs)
221
+ print(f"Total corpus size: {total_size / 1e6:.1f} MB")
222
+
223
+ with open(DOCS_PATH, "w", encoding="utf-8") as f:
224
+ for doc in all_docs:
225
+ f.write(json.dumps(doc, ensure_ascii=False) + "\n")
226
+
227
+ print(f"Documents written to {DOCS_PATH}")
228
+
229
+ # Language distribution
230
+ lang_counts = {}
231
+ for d in all_docs:
232
+ lang_counts[d["language"]] = lang_counts.get(d["language"], 0) + 1
233
+ print(f"Language distribution: {lang_counts}")
234
+
235
+ # Repo distribution
236
+ repo_counts = {}
237
+ for d in all_docs:
238
+ repo_counts[d["repo"]] = repo_counts.get(d["repo"], 0) + 1
239
+ print(f"Repo distribution: {len(repo_counts)} repos")
240
+ for repo, count in sorted(repo_counts.items(), key=lambda x: -x[1])[:10]:
241
+ print(f" {repo}: {count} files")
242
+
243
+ return total_size
244
+
245
+
246
+ if __name__ == "__main__":
247
+ main()
src/download_data.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Download a multi-language code corpus from GitHub for base pretraining.
3
+
4
+ Downloads tarballs of several popular repositories across Python, JS/TS,
5
+ Rust, Go, C/C++, and Java. Extracts source files and concatenates them
6
+ into a single training corpus.
7
+ """
8
+
9
+ import io
10
+ import os
11
+ import tarfile
12
+ import time
13
+ import requests
14
+
15
+ DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data")
16
+ RAW_DIR = os.path.join(DATA_DIR, "raw")
17
+ CORPUS_PATH = os.path.join(DATA_DIR, "corpus.txt")
18
+
19
+ # Repos to download (user/repo, branch, max_bytes_per_repo)
20
+ REPOS = [
21
+ ("python/cpython", "main", 8_000_000), # Python, C
22
+ ("microsoft/TypeScript", "main", 8_000_000), # TypeScript
23
+ ("rust-lang/rust", "master", 8_000_000), # Rust
24
+ ("golang/go", "master", 8_000_000), # Go
25
+ ("nginx/nginx", "master", 5_000_000), # C
26
+ ("json-iterator/go", "master", 3_000_000), # Go
27
+ ("pytorch/pytorch", "main", 8_000_000), # C++, Python, CUDA
28
+ ("tokio-rs/tokio", "master", 5_000_000), # Rust
29
+ ("numpy/numpy", "main", 5_000_000), # Python, C
30
+ ("axios/axios", "v1.x", 3_000_000), # JavaScript
31
+ ("lodash/lodash", "master", 3_000_000), # JavaScript
32
+ ("redis/redis", "unstable", 5_000_000), # C
33
+ ]
34
+
35
+ # File extensions to include
36
+ CODE_EXTENSIONS = {
37
+ ".py", ".js", ".ts", ".mjs", ".jsx", ".tsx",
38
+ ".rs", ".go", ".c", ".h", ".cpp", ".cc", ".cxx",
39
+ ".hpp", ".hxx", ".cu", ".cuh", ".java",
40
+ ".sh", ".bash", ".yml", ".yaml", ".toml",
41
+ ".cfg", ".ini", ".json", ".xml", ".sql",
42
+ ".md", ".txt", ".rst",
43
+ }
44
+
45
+ # Directories to skip
46
+ SKIP_DIRS = {
47
+ "test", "tests", "testing", "__pycache__", ".git",
48
+ "node_modules", "vendor", "third_party", "thirdparty",
49
+ "dist", "build", "target", ".github", "docs",
50
+ "benchmark", "benchmarks", "examples", "example",
51
+ "fixtures", "testdata", "test_data", "mocks",
52
+ }
53
+
54
+
55
+ def download_repo_tarball(user_repo: str, branch: str) -> bytes | None:
56
+ """Download a repository tarball from GitHub."""
57
+ url = f"https://codeload.github.com/{user_repo}/tar.gz/refs/heads/{branch}"
58
+ print(f" Downloading {user_repo}@{branch}...")
59
+ try:
60
+ r = requests.get(url, timeout=120, stream=True)
61
+ if r.status_code != 200:
62
+ # Try tags endpoint
63
+ url2 = f"https://codeload.github.com/{user_repo}/tar.gz/refs/tags/{branch}"
64
+ r = requests.get(url2, timeout=120, stream=True)
65
+ if r.status_code != 200:
66
+ print(f" FAILED (status {r.status_code})")
67
+ return None
68
+ content = r.content
69
+ print(f" Downloaded {len(content) / 1e6:.1f} MB")
70
+ return content
71
+ except Exception as e:
72
+ print(f" ERROR: {e}")
73
+ return None
74
+
75
+
76
+ def extract_code_files(tarball_bytes: bytes, max_bytes: int) -> list[str]:
77
+ """Extract code files from a tarball, up to max_bytes total."""
78
+ files = []
79
+ total = 0
80
+ try:
81
+ with tarfile.open(fileobj=io.BytesIO(tarball_bytes), mode="r:gz") as tar:
82
+ for member in tar.getmembers():
83
+ if not member.isfile():
84
+ continue
85
+ path = member.name
86
+ parts = path.split("/")
87
+ # Skip unwanted directories
88
+ if any(skip in parts for skip in SKIP_DIRS):
89
+ continue
90
+ # Check extension
91
+ _, ext = os.path.splitext(path)
92
+ if ext.lower() not in CODE_EXTENSIONS:
93
+ continue
94
+ # Skip very large files
95
+ if member.size > 500_000:
96
+ continue
97
+ try:
98
+ f = tar.extractfile(member)
99
+ if f is None:
100
+ continue
101
+ content = f.read()
102
+ try:
103
+ text = content.decode("utf-8", errors="ignore")
104
+ except Exception:
105
+ continue
106
+ files.append(text)
107
+ total += len(text)
108
+ if total >= max_bytes:
109
+ break
110
+ except Exception:
111
+ continue
112
+ except Exception as e:
113
+ print(f" Extraction error: {e}")
114
+ print(f" Extracted {len(files)} files, {total / 1e6:.1f} MB of code")
115
+ return files
116
+
117
+
118
+ def main():
119
+ os.makedirs(RAW_DIR, exist_ok=True)
120
+ os.makedirs(DATA_DIR, exist_ok=True)
121
+
122
+ all_code = []
123
+
124
+ for user_repo, branch, max_bytes in REPOS:
125
+ tarball = download_repo_tarball(user_repo, branch)
126
+ if tarball is None:
127
+ continue
128
+ files = extract_code_files(tarball, max_bytes)
129
+ all_code.extend(files)
130
+ time.sleep(1) # Be polite
131
+
132
+ # Write corpus
133
+ print(f"\nTotal files: {len(all_code)}")
134
+ total_size = sum(len(f) for f in all_code)
135
+ print(f"Total corpus size: {total_size / 1e6:.1f} MB")
136
+
137
+ with open(CORPUS_PATH, "w", encoding="utf-8") as f:
138
+ for i, code in enumerate(all_code):
139
+ # Add document separator
140
+ f.write(code)
141
+ f.write("\n<|endoftext|>\n")
142
+
143
+ print(f"Corpus written to {CORPUS_PATH}")
144
+ return total_size
145
+
146
+
147
+ if __name__ == "__main__":
148
+ main()
src/generate.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Inference and generation script for Retriever500M.
3
+
4
+ Loads a checkpoint and generates text to verify the model has learned
5
+ code structure during base pretraining.
6
+
7
+ Usage:
8
+ python src/generate.py [--checkpoint PATH] [--prompt "text"] [--tokens N]
9
+ """
10
+
11
+ import argparse
12
+ import os
13
+ import sys
14
+
15
+ import torch
16
+
17
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
18
+
19
+ from model import ModelConfig, Retriever500M
20
+ from tokenizers import Tokenizer
21
+
22
+ PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
23
+ CHECKPOINT_DIR = os.path.join(PROJECT_DIR, "checkpoints")
24
+ TOKENIZER_PATH = os.path.join(PROJECT_DIR, "tokenizer", "tokenizer.json")
25
+
26
+
27
+ def load_model(checkpoint_path: str, device: torch.device) -> tuple[Retriever500M, ModelConfig]:
28
+ """Load model from checkpoint."""
29
+ print(f"Loading checkpoint: {checkpoint_path}")
30
+ ckpt = torch.load(checkpoint_path, map_location=device, weights_only=False)
31
+
32
+ config = ModelConfig(**ckpt["config"])
33
+ model = Retriever500M(config).to(device)
34
+ model.load_state_dict(ckpt["model_state_dict"])
35
+ model.eval()
36
+
37
+ print(f" Step: {ckpt.get('step', '?')}")
38
+ print(f" Loss: {ckpt.get('loss', '?')}")
39
+ print(f" Params: {model.count_parameters() / 1e6:.1f}M")
40
+
41
+ return model, config
42
+
43
+
44
+ def generate(
45
+ model: Retriever500M,
46
+ tokenizer: Tokenizer,
47
+ prompt: str,
48
+ max_new_tokens: int = 128,
49
+ temperature: float = 0.8,
50
+ top_k: int = 50,
51
+ device: torch.device = None,
52
+ ) -> str:
53
+ """Generate text from a prompt."""
54
+ if device is None:
55
+ device = next(model.parameters()).device
56
+
57
+ # Encode prompt
58
+ encoded = tokenizer.encode(prompt)
59
+ input_ids = torch.tensor([encoded.ids], dtype=torch.long, device=device)
60
+
61
+ # Generate
62
+ with torch.no_grad():
63
+ output_ids = model.generate(
64
+ input_ids,
65
+ max_new_tokens=max_new_tokens,
66
+ temperature=temperature,
67
+ top_k=top_k,
68
+ )
69
+
70
+ # Decode
71
+ output_text = tokenizer.decode(output_ids[0].tolist())
72
+ return output_text
73
+
74
+
75
+ def main():
76
+ parser = argparse.ArgumentParser(description="Generate text with Retriever500M")
77
+ parser.add_argument("--checkpoint", type=str, default=os.path.join(CHECKPOINT_DIR, "latest.pt"))
78
+ parser.add_argument("--prompt", type=str, default="def fibonacci(n):\n ", help="Generation prompt")
79
+ parser.add_argument("--tokens", type=int, default=128, help="Max new tokens")
80
+ parser.add_argument("--temperature", type=float, default=0.8)
81
+ parser.add_argument("--top_k", type=int, default=50)
82
+ args = parser.parse_args()
83
+
84
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
85
+ print(f"Device: {device}")
86
+
87
+ # Load tokenizer
88
+ tokenizer = Tokenizer.from_file(TOKENIZER_PATH)
89
+
90
+ # Load model
91
+ model, config = load_model(args.checkpoint, device)
92
+
93
+ # Generate
94
+ prompts = [
95
+ args.prompt,
96
+ "def quicksort(arr):\n ",
97
+ "function fetchData(url) {\n ",
98
+ "import torch\nimport torch.nn as nn\n\nclass Model(nn.Module):\n ",
99
+ "fn main() {\n println!",
100
+ ]
101
+
102
+ print("\n" + "=" * 60)
103
+ print("GENERATION SAMPLES")
104
+ print("=" * 60)
105
+
106
+ for prompt in prompts:
107
+ print(f"\n--- Prompt: {prompt!r} ---")
108
+ text = generate(model, tokenizer, prompt, args.tokens, args.temperature, args.top_k, device)
109
+ print(text)
110
+ print("-" * 60)
111
+
112
+
113
+ if __name__ == "__main__":
114
+ main()
src/generate_traces.py ADDED
@@ -0,0 +1,958 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ High-quality SFT trace generator for the search agent.
3
+
4
+ Generates diverse, dense, multi-round search traces that teach the model:
5
+ - Complex query decomposition (break big questions into sub-searches)
6
+ - Multi-round search refinement (search → analyze → refine → search again)
7
+ - Cross-file tracing (find related code across different chunks)
8
+ - Dense reasoning (substantive analysis at each step)
9
+ - Accurate evidence extraction (grounded in real code)
10
+
11
+ 15 query categories, each with code-aware trace generation:
12
+ 1. Implementation Deep Dive
13
+ 2. Cross-File Data Flow Tracing
14
+ 3. Architecture & Component Mapping
15
+ 4. Usage Pattern Analysis
16
+ 5. Error Handling & Failure Paths
17
+ 6. API Contract & Interface
18
+ 7. Dependency Graph Mapping
19
+ 8. Data Structure Analysis
20
+ 9. Configuration & Parameters
21
+ 10. Performance-Critical Paths
22
+ 11. Security & Validation
23
+ 12. Design Pattern Recognition
24
+ 13. Debugging Assistance
25
+ 14. Comparison & Tradeoffs
26
+ 15. Change Impact Analysis
27
+
28
+ Usage:
29
+ python src/generate_traces.py --category all --count 500 --output data/sft_traces_v2/
30
+ python src/generate_traces.py --category implementation --count 200 --seed 42
31
+ """
32
+
33
+ import argparse
34
+ import json
35
+ import os
36
+ import random
37
+ import re
38
+ import hashlib
39
+ import sys
40
+ from collections import defaultdict
41
+ from pathlib import Path
42
+
43
+ PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
44
+ CHUNKS_PATH = os.path.join(PROJECT_DIR, "data", "chunks.jsonl")
45
+ OUTPUT_DIR = os.path.join(PROJECT_DIR, "data", "sft_traces_v2")
46
+
47
+ SYSTEM_PROMPT = (
48
+ "You are a code search agent. Given a query from a reasoning model, "
49
+ "decompose it into subqueries, search the codebase, inspect results, "
50
+ "and return curated evidence. Use <|search|> to issue searches, "
51
+ "<|reasoning|> to analyze, and <|evidence|> to return findings. "
52
+ "Be concise. Extract only the relevant facts. End with <|finish|>."
53
+ )
54
+
55
+ # ─── Code Analysis Helpers ───────────────────────────────────────────────────
56
+
57
+ def extract_function_signature(code: str, name: str) -> str:
58
+ """Extract a function signature from code."""
59
+ # Try to find the function definition line
60
+ lines = code.strip().split('\n')
61
+ for i, line in enumerate(lines):
62
+ if name in line and ('def ' in line or 'fn ' in line or 'function ' in line
63
+ or '(' in line and ('{' in line or ';' in line or ':' in line)):
64
+ # Collect signature lines until we hit the body
65
+ sig_lines = [line]
66
+ for j in range(i+1, min(i+5, len(lines))):
67
+ if lines[j].strip() == '' or lines[j].strip().startswith('{') or lines[j].strip().startswith('}'):
68
+ break
69
+ if '(' in lines[j] or ')' in lines[j] or ',' in lines[j]:
70
+ sig_lines.append(lines[j])
71
+ else:
72
+ break
73
+ return ' '.join(s.strip() for s in sig_lines)
74
+ return lines[0].strip() if lines else code[:100]
75
+
76
+ def extract_struct_fields(code: str) -> list[str]:
77
+ """Extract field names from a struct/class definition."""
78
+ fields = []
79
+ for line in code.split('\n'):
80
+ line = line.strip()
81
+ # Skip braces, comments, preprocessor
82
+ if not line or line in ('{', '}', '};') or line.startswith('//') or line.startswith('#') or line.startswith('/*'):
83
+ continue
84
+ # Try to extract field name
85
+ # C/C++/Rust: type name; or type *name;
86
+ m = re.match(r'(?:static\s+)?(?:const\s+)?(?:unsigned\s+|signed\s+)?[\w_*]+\s+(\w+)\s*[;\[\{=]', line)
87
+ if m:
88
+ fields.append(m.group(1))
89
+ # Python: self.name = ... or name: type = ...
90
+ m = re.match(r'(?:self\.)?(\w+)\s*[:=]', line)
91
+ if m and m.group(1) not in fields:
92
+ fields.append(m.group(1))
93
+ return fields[:15] # Limit to 15 fields
94
+
95
+ def extract_function_calls(code: str) -> list[str]:
96
+ """Extract function call names from code."""
97
+ calls = re.findall(r'\b([a-z_][a-z0-9_]*)\s*\(', code)
98
+ # Filter out keywords
99
+ keywords = {'if', 'for', 'while', 'switch', 'return', 'sizeof', 'typeof',
100
+ 'def', 'class', 'struct', 'enum', 'union', 'case', 'catch',
101
+ 'print', 'println', 'printf', 'let', 'var', 'const', 'fn'}
102
+ return [c for c in calls if c not in keywords][:10]
103
+
104
+ def extract_keywords_from_code(code: str) -> list[str]:
105
+ """Extract searchable keywords from code."""
106
+ tokens = re.findall(r'[a-z_][a-z0-9_]*', code.lower())
107
+ # Filter common words
108
+ stop = {'the', 'for', 'and', 'not', 'this', 'self', 'that', 'with', 'from',
109
+ 'into', 'void', 'int', 'char', 'bool', 'true', 'false', 'null',
110
+ 'none', 'return', 'if', 'else', 'elif', 'while', 'break', 'continue',
111
+ 'const', 'static', 'struct', 'class', 'def', 'fn', 'let', 'var',
112
+ 'true', 'false', 'size', 'len', 'type', 'name', 'value', 'key',
113
+ 'data', 'result', 'error', 'status', 'count', 'index', 'ptr',
114
+ 'begin', 'end', 'start', 'stop', 'init', 'free', 'alloc'}
115
+ meaningful = [t for t in tokens if t not in stop and len(t) > 2]
116
+ # Deduplicate, preserve order
117
+ seen = set()
118
+ unique = []
119
+ for t in meaningful:
120
+ if t not in seen:
121
+ seen.add(t)
122
+ unique.append(t)
123
+ return unique[:8]
124
+
125
+ def extract_return_statements(code: str) -> list[str]:
126
+ """Extract return statements from code."""
127
+ returns = []
128
+ for line in code.split('\n'):
129
+ line = line.strip()
130
+ if line.startswith('return ') or line == 'return;' or line.startswith('return('):
131
+ returns.append(line)
132
+ return returns[:5]
133
+
134
+ def extract_error_handling(code: str) -> list[str]:
135
+ """Extract error handling patterns from code."""
136
+ patterns = []
137
+ for line in code.split('\n'):
138
+ line = line.strip()
139
+ if any(kw in line for kw in ['NGX_ERROR', 'NGX_AGAIN', 'NGX_DECLINED', 'throw ',
140
+ 'raise ', 'panic!', 'unwrap()', 'expect(',
141
+ 'errno', 'error', 'Error', 'ERR_', 'FAIL',
142
+ 'assert', 'TORCH_CHECK', 'TORCH_ASSERT']):
143
+ patterns.append(line)
144
+ return patterns[:5]
145
+
146
+ def extract_imports_refs(code: str) -> list[str]:
147
+ """Extract imported/referenced names from code."""
148
+ refs = []
149
+ for line in code.split('\n'):
150
+ line = line.strip()
151
+ if line.startswith('#include') or line.startswith('import ') or line.startswith('use '):
152
+ refs.append(line)
153
+ elif line.startswith('from ') and 'import' in line:
154
+ refs.append(line)
155
+ return refs[:5]
156
+
157
+ def summarize_code_briefly(code: str, name: str, lang: str, typ: str) -> str:
158
+ """Generate a brief summary of what a code chunk does."""
159
+ lines = code.strip().split('\n')
160
+ first_line = lines[0].strip() if lines else ""
161
+
162
+ if typ == 'struct':
163
+ fields = extract_struct_fields(code)
164
+ if fields:
165
+ return f"a {lang} {typ} with {len(fields)} fields: {', '.join(fields[:6])}"
166
+ return f"a {lang} {typ} definition"
167
+ elif typ == 'function':
168
+ sig = extract_function_signature(code, name)
169
+ returns = extract_return_statements(code)
170
+ calls = extract_function_calls(code)
171
+ parts = [f"a {lang} function"]
172
+ if calls:
173
+ parts.append(f"that calls {', '.join(calls[:3])}")
174
+ if returns:
175
+ parts.append(f"returns: {returns[0]}")
176
+ return ' '.join(parts)
177
+ elif typ == 'class':
178
+ return f"a {lang} class definition for {name}"
179
+ elif typ == 'macro':
180
+ return f"a {lang} macro: {first_line[:60]}"
181
+ elif typ == 'enum':
182
+ return f"a {lang} enum definition"
183
+ else:
184
+ return f"a {lang} code block ({len(lines)} lines)"
185
+
186
+ # ─── Trace Format ─────────────────────────────────────────────────────────────
187
+
188
+ def make_trace(query: str, assistant_turns: list[dict]) -> dict:
189
+ """Build a trace in the SFT format.
190
+
191
+ assistant_turns: list of dicts with 'content' (assistant text) and optional 'result' (code from retrieval)
192
+ """
193
+ trace = [
194
+ {"role": "system", "content": SYSTEM_PROMPT},
195
+ {"role": "user", "content": query},
196
+ ]
197
+
198
+ for turn in assistant_turns:
199
+ trace.append({"role": "assistant", "content": turn["content"]})
200
+ if "result" in turn and turn["result"]:
201
+ trace.append({"role": "result", "content": turn["result"]})
202
+
203
+ return {"query": query, "trace": trace}
204
+
205
+ # ─── Query Categories ─────────────────────────────────────────────────────────
206
+
207
+ def gen_implementation_deep_dive(chunks, rng) -> dict:
208
+ """Category 1: Deep dive into how something is implemented."""
209
+ # Pick a function with substantial code
210
+ candidates = [c for c in chunks if c['type'] == 'function' and len(c['code']) > 200 and c['name'] not in ('unknown','struct','void')]
211
+ if not candidates:
212
+ return None
213
+ chunk = rng.choice(candidates)
214
+ name = chunk['name']
215
+ lang = chunk['language']
216
+ code = chunk['code']
217
+
218
+ calls = extract_function_calls(code)
219
+ returns = extract_return_statements(code)
220
+ keywords = extract_keywords_from_code(code)
221
+ summary = summarize_code_briefly(code, name, lang, chunk['type'])
222
+
223
+ # Complex query
224
+ query_templates = [
225
+ f"Walk me through the implementation of {name}. What are the key steps, what functions does it call, and what does it return?",
226
+ f"Explain how {name} works in detail. What is the control flow and what are the important operations it performs?",
227
+ f"I need to understand the implementation of {name}. Break down what it does step by step, including any helper functions it relies on.",
228
+ f"Analyze the {name} function. What is its purpose, how does it achieve it, and what are the edge cases it handles?",
229
+ f"Give me a thorough breakdown of {name}. What does it do, what does it call, and what are the return paths?",
230
+ ]
231
+ query = rng.choice(query_templates)
232
+
233
+ # Round 1: Search for the function
234
+ search_term = name if len(name) > 3 else keywords[0] if keywords else name
235
+ reasoning1 = rng.choice([
236
+ f"<|reasoning|>The user wants a detailed implementation walkthrough of {name}. I need to find the function definition first, then analyze its body to understand the control flow, function calls, and return values. Let me start by searching for the function itself.<|end|>",
237
+ f"<|reasoning|>This is a deep implementation question about {name}. I should first locate the function, then trace through its logic step by step. Let me search for it.<|end|>",
238
+ f"<|reasoning|>To explain how {name} works, I need to find its definition and examine the code. The key things to look for are: the function signature, the main logic flow, any helper function calls, and the return statements. Let me search for it.<|end|>",
239
+ ])
240
+
241
+ turn1 = {
242
+ "content": f"{reasoning1}\n<|search|>{search_term}<|end|>",
243
+ "result": code[:600],
244
+ }
245
+
246
+ # Round 2: Search for a related function (one that it calls)
247
+ related_search = calls[0] if calls else keywords[0] if keywords else search_term
248
+ reasoning2 = rng.choice([
249
+ f"<|reasoning|>I found the {name} function. It's {summary}. The function calls {', '.join(calls[:3]) if calls else 'several helpers'}. Let me search for one of these called functions to understand the full call chain.<|end|>",
250
+ f"<|reasoning|>Found {name}. Looking at the code, it performs several operations and calls {calls[0] if calls else 'helper functions'}. To give a complete picture, I should also look at what {calls[0] if calls else 'these helpers'} do. Let me search for that.<|end|>",
251
+ f"<|reasoning|>The implementation of {name} is now visible. It's {summary}. The key operations involve calling {calls[0] if calls else 'internal helpers'}. Let me find that to complete the picture.<|end|>",
252
+ ])
253
+
254
+ # Find a related chunk for the result
255
+ related_chunks = [c for c in chunks if related_search.lower() in c['name'].lower() and c['name'] != name]
256
+ related_result = related_chunks[0]['code'][:400] if related_chunks else ""
257
+
258
+ turn2 = {
259
+ "content": f"{reasoning2}\n<|search|>{related_search}<|end|>",
260
+ "result": related_result,
261
+ }
262
+
263
+ # Round 3: Evidence
264
+ evidence_parts = [f"`{name}` is {summary}."]
265
+ if calls:
266
+ evidence_parts.append(f"Key operations: calls {', '.join(calls[:4])}")
267
+ if returns:
268
+ evidence_parts.append(f"Return paths: {'; '.join(returns[:3])}")
269
+ evidence_parts.append(f"Source: {chunk['filepath']}")
270
+
271
+ evidence = f"<|reasoning|>Now I have a complete picture of {name}. I found the main function and a related helper. Let me compile the evidence.<|end|>\n<|evidence|>{' '.join(evidence_parts)}<|end|>\n<|finish|>"
272
+
273
+ turn3 = {"content": evidence}
274
+
275
+ return make_trace(query, [turn1, turn2, turn3])
276
+
277
+
278
+ def gen_cross_file_tracing(chunks, rng) -> dict:
279
+ """Category 2: Trace data flow across multiple files."""
280
+ # Pick two related chunks (same language, different names)
281
+ by_lang = defaultdict(list)
282
+ for c in chunks:
283
+ if c['type'] in ('function', 'struct', 'class') and c['name'] not in ('unknown','struct','void','T'):
284
+ by_lang[c['language']].append(c)
285
+
286
+ lang = rng.choice(list(by_lang.keys()))
287
+ candidates = by_lang[lang]
288
+ if len(candidates) < 2:
289
+ return None
290
+
291
+ chunk1, chunk2 = rng.sample(candidates, 2)
292
+ name1, name2 = chunk1['name'], chunk2['name']
293
+
294
+ query = rng.choice([
295
+ f"Trace how data flows from {name1} to {name2}. What transformations happen along the way?",
296
+ f"How do {name1} and {name2} interact? What data passes between them and how is it processed?",
297
+ f"Follow the data path from {name1} through {name2}. What are the intermediate steps and transformations?",
298
+ f"I need to understand the relationship between {name1} and {name2}. How does data move between them?",
299
+ ])
300
+
301
+ # Round 1: Search for first component
302
+ reasoning1 = f"<|reasoning|>The user wants to understand data flow between {name1} and {name2}. I need to find both components and understand how they connect. Let me start by finding {name1}.<|end|>"
303
+ turn1 = {"content": f"{reasoning1}\n<|search|>{name1}<|end|>", "result": chunk1['code'][:500]}
304
+
305
+ # Round 2: Search for second component
306
+ reasoning2 = f"<|reasoning|>Found {name1}. Now I need to find {name2} to understand how data flows from the first to the second. Let me search for it.<|end|>"
307
+ turn2 = {"content": f"{reasoning2}\n<|search|>{name2}<|end|>", "result": chunk2['code'][:500]}
308
+
309
+ # Round 3: Evidence
310
+ summary1 = summarize_code_briefly(chunk1['code'], name1, lang, chunk1['type'])
311
+ summary2 = summarize_code_briefly(chunk2['code'], name2, lang, chunk2['type'])
312
+ evidence = f"<|reasoning|>I now have both components. {name1} is {summary1}, and {name2} is {summary2}. The data flow goes from {name1} producing output that {name2} consumes. Let me compile the evidence.<|end|>\n<|evidence|>Data flow: {name1} ({chunk1['type']}) → {name2} ({chunk2['type']}). {name1} is {summary1}. {name2} is {summary2}. They are both in {lang} code. Source: {chunk1['filepath']}, {chunk2['filepath']}<|end|>\n<|finish|>"
313
+ turn3 = {"content": evidence}
314
+
315
+ return make_trace(query, [turn1, turn2, turn3])
316
+
317
+
318
+ def gen_architecture_mapping(chunks, rng) -> dict:
319
+ """Category 3: Understand the architecture of a subsystem."""
320
+ # Pick chunks from the same filepath (same "module")
321
+ by_path = defaultdict(list)
322
+ for c in chunks:
323
+ if c['name'] not in ('unknown','struct','void','T'):
324
+ by_path[c['filepath']].append(c)
325
+
326
+ # Find a filepath with multiple chunks
327
+ good_paths = [p for p, cs in by_path.items() if len(cs) >= 3]
328
+ if not good_paths:
329
+ return None
330
+
331
+ filepath = rng.choice(good_paths)
332
+ module_chunks = by_path[filepath]
333
+ names = [c['name'] for c in module_chunks[:5]]
334
+
335
+ query = rng.choice([
336
+ f"What is the architecture of the module in {filepath}? What are the main components and how do they fit together?",
337
+ f"Map out the structure of {filepath}. What are the key types and functions, and how do they interact?",
338
+ f"I need to understand the overall design of the code in {filepath}. What are the main pieces and their roles?",
339
+ f"Describe the architecture of the subsystem defined in {filepath}. What patterns does it use and how is it organized?",
340
+ ])
341
+
342
+ # Round 1: Search for the module
343
+ search_term = filepath.split('/')[-1].replace('.txt','')
344
+ reasoning1 = f"<|reasoning|>The user wants to understand the architecture of a module. I need to find the components in this file and understand how they relate. Let me search for the main types and functions. I can see there are several: {', '.join(names[:3])}. Let me start with the first one.<|end|>"
345
+ turn1 = {"content": f"{reasoning1}\n<|search|>{names[0]}<|end|>", "result": module_chunks[0]['code'][:400]}
346
+
347
+ # Round 2: Search for another component
348
+ if len(names) > 1:
349
+ reasoning2 = f"<|reasoning|>Found {names[0]}. Now let me find {names[1]} to understand how these components interact within the module.<|end|>"
350
+ turn2 = {"content": f"{reasoning2}\n<|search|>{names[1]}<|end|>", "result": module_chunks[1]['code'][:400] if len(module_chunks) > 1 else ""}
351
+ else:
352
+ reasoning2 = f"<|reasoning|>Found {names[0]}. Let me search for more components in this module.<|end|>"
353
+ turn2 = {"content": f"{reasoning2}\n<|search|>{search_term}<|end|>", "result": module_chunks[0]['code'][:300]}
354
+
355
+ # Round 3: Evidence
356
+ components_desc = ', '.join(f"{c['type']} {c['name']}" for c in module_chunks[:4])
357
+ evidence = f"<|reasoning|>I now have a good picture of the module. It contains several components: {components_desc}. Let me compile the architectural overview.<|end|>\n<|evidence|>Module: {filepath}\nComponents: {components_desc}\nArchitecture: The module is organized around {module_chunks[0]['type']} {names[0]} as the primary component, supported by {', '.join(names[1:3])}. The code is in {module_chunks[0]['language']}.<|end|>\n<|finish|>"
358
+ turn3 = {"content": evidence}
359
+
360
+ return make_trace(query, [turn1, turn2, turn3])
361
+
362
+
363
+ def gen_usage_analysis(chunks, rng) -> dict:
364
+ """Category 4: Where and how is something used."""
365
+ # Pick a function or class name that might be used in multiple places
366
+ candidates = [c for c in chunks if c['type'] in ('function', 'class', 'macro') and c['name'] not in ('unknown','struct','void','T') and len(c['name']) > 3]
367
+ if not candidates:
368
+ return None
369
+ chunk = rng.choice(candidates)
370
+ name = chunk['name']
371
+
372
+ # Find chunks that reference this name
373
+ referencing = [c for c in chunks if name in c['code'] and c['name'] != name]
374
+ if not referencing:
375
+ return None
376
+
377
+ query = rng.choice([
378
+ f"Where is {name} used across the codebase? What patterns emerge from its usage?",
379
+ f"Find all the places where {name} is called or referenced. What are the different usage contexts?",
380
+ f"How is {name} utilized throughout the code? What are the common calling patterns?",
381
+ f"Map the usage of {name}. Where is it called from and what are the typical arguments?",
382
+ ])
383
+
384
+ # Round 1: Search for the definition
385
+ reasoning1 = f"<|reasoning|>The user wants to understand how {name} is used across the codebase. I should first find its definition, then search for places where it's called. Let me start with the definition.<|end|>"
386
+ turn1 = {"content": f"{reasoning1}\n<|search|>{name}<|end|>", "result": chunk['code'][:400]}
387
+
388
+ # Round 2: Search for usage in a different file
389
+ usage_chunk = referencing[0]
390
+ reasoning2 = f"<|reasoning|>Found the definition of {name}. Now I need to find where it's used. Let me search for it in the context of other code that references it.<|end|>"
391
+ turn2 = {"content": f"{reasoning2}\n<|search|>{name} {usage_chunk['name']}<|end|>", "result": usage_chunk['code'][:400]}
392
+
393
+ # Round 3: Evidence
394
+ usage_count = len(referencing)
395
+ evidence = f"<|reasoning|>I found the definition and at least one usage site. {name} is referenced in approximately {usage_count} other chunks. Let me compile the usage analysis.<|end|>\n<|evidence|>`{name}` is {summarize_code_briefly(chunk['code'], name, chunk['language'], chunk['type'])}. It is used in approximately {usage_count} other locations. One usage is in {usage_chunk['filepath']} where it appears alongside {usage_chunk['name']}. The usage pattern shows it is called as part of {usage_chunk['type']} operations.<|end|>\n<|finish|>"
396
+ turn3 = {"content": evidence}
397
+
398
+ return make_trace(query, [turn1, turn2, turn3])
399
+
400
+
401
+ def gen_error_handling(chunks, rng) -> dict:
402
+ """Category 5: Error handling and failure paths."""
403
+ # Find chunks with error handling patterns
404
+ candidates = [c for c in chunks if any(kw in c['code'] for kw in
405
+ ['NGX_ERROR', 'NGX_AGAIN', 'throw ', 'raise ', 'panic!', 'unwrap()',
406
+ 'TORCH_CHECK', 'TORCH_ASSERT', 'assert', 'errno', 'Error']) and c['name'] not in ('unknown','struct','void')]
407
+ if not candidates:
408
+ return None
409
+ chunk = rng.choice(candidates)
410
+ name = chunk['name']
411
+ error_patterns = extract_error_handling(chunk['code'])
412
+
413
+ if not error_patterns:
414
+ return None
415
+
416
+ query = rng.choice([
417
+ f"How does {name} handle errors? What are all the failure paths and error conditions?",
418
+ f"What error handling does {name} implement? What happens when things go wrong?",
419
+ f"Analyze the error handling strategy in {name}. What conditions cause failures and how are they reported?",
420
+ f"What are the failure modes of {name}? How does it detect and respond to errors?",
421
+ ])
422
+
423
+ # Round 1: Search for the function
424
+ reasoning1 = f"<|reasoning|>The user wants to understand error handling in {name}. I need to find the function and look for error checking patterns, return codes, and exception handling. Let me search for it.<|end|>"
425
+ turn1 = {"content": f"{reasoning1}\n<|search|>{name}<|end|>", "result": chunk['code'][:500]}
426
+
427
+ # Round 2: Search for error-related patterns
428
+ error_keyword = error_patterns[0].split()[0] if error_patterns else name
429
+ error_search = re.search(r'[A-Za-z_]+', error_patterns[0])
430
+ search_term2 = error_search.group(0) if error_search else name
431
+
432
+ reasoning2 = f"<|reasoning|>Found {name}. I can see it uses error handling patterns like {error_patterns[0][:60]}. Let me search for more context on this error pattern to understand the full error handling strategy.<|end|>"
433
+ turn2 = {"content": f"{reasoning2}\n<|search|>{search_term2}<|end|>", "result": error_patterns[0][:200] if error_patterns else ""}
434
+
435
+ # Round 3: Evidence
436
+ error_summary = '; '.join(e[:80] for e in error_patterns[:3])
437
+ evidence = f"<|reasoning|>I now have a clear picture of the error handling in {name}. The function uses multiple error checks and return paths. Let me compile the evidence.<|end|>\n<|evidence|>`{name}` handles errors through: {error_summary}. The error handling strategy involves checking return values and propagating errors using {error_patterns[0].split('(')[0].strip() if error_patterns else 'standard patterns'}. Source: {chunk['filepath']}<|end|>\n<|finish|>"
438
+ turn3 = {"content": evidence}
439
+
440
+ return make_trace(query, [turn1, turn2, turn3])
441
+
442
+
443
+ def gen_api_contract(chunks, rng) -> dict:
444
+ """Category 6: API contract and interface."""
445
+ candidates = [c for c in chunks if c['type'] in ('function', 'class', 'struct') and c['name'] not in ('unknown','struct','void','T') and len(c['code']) > 100]
446
+ if not candidates:
447
+ return None
448
+ chunk = rng.choice(candidates)
449
+ name = chunk['name']
450
+ sig = extract_function_signature(chunk['code'], name)
451
+
452
+ query = rng.choice([
453
+ f"What is the API contract of {name}? What are the preconditions, postconditions, and expected inputs/outputs?",
454
+ f"Describe the interface of {name}. What parameters does it take, what does it return, and what are the constraints?",
455
+ f"What is the public API of {name}? What are the input requirements and output guarantees?",
456
+ f"Explain the contract that {name} exposes. What does it expect from callers and what does it promise in return?",
457
+ ])
458
+
459
+ reasoning1 = f"<|reasoning|>The user wants to understand the API contract of {name}. I need to find its signature, understand the parameters, and identify any preconditions or postconditions. Let me search for it.<|end|>"
460
+ turn1 = {"content": f"{reasoning1}\n<|search|>{name}<|end|>", "result": chunk['code'][:500]}
461
+
462
+ # Round 2: Search for related types
463
+ keywords = extract_keywords_from_code(chunk['code'])
464
+ related = keywords[0] if keywords else name
465
+ reasoning2 = f"<|reasoning|>Found {name}. The signature is: {sig[:80]}. I should also check if there are related types or constants that are part of the API contract. Let me search for {related}.<|end|>"
466
+ turn2 = {"content": f"{reasoning2}\n<|search|>{related}<|end|>", "result": ""}
467
+
468
+ # Round 3: Evidence
469
+ returns = extract_return_statements(chunk['code'])
470
+ evidence = f"<|reasoning|>I have the full API picture now. The function signature, parameters, and return values are clear. Let me compile the contract.<|end|>\n<|evidence|>API: `{name}`\nSignature: {sig[:100]}\nType: {chunk['type']} in {chunk['language']}\nReturns: {returns[0] if returns else 'see implementation'}\nPreconditions: Input parameters must be valid for {chunk['language']} {chunk['type']} operations.\nSource: {chunk['filepath']}<|end|>\n<|finish|>"
471
+ turn3 = {"content": evidence}
472
+
473
+ return make_trace(query, [turn1, turn2, turn3])
474
+
475
+
476
+ def gen_dependency_graph(chunks, rng) -> dict:
477
+ """Category 7: Dependency graph mapping."""
478
+ candidates = [c for c in chunks if c['type'] in ('function', 'class', 'struct') and c['name'] not in ('unknown','struct','void','T') and len(c['code']) > 150]
479
+ if not candidates:
480
+ return None
481
+ chunk = rng.choice(candidates)
482
+ name = chunk['name']
483
+ calls = extract_function_calls(chunk['code'])
484
+ imports = extract_imports_refs(chunk['code'])
485
+
486
+ if not calls and not imports:
487
+ return None
488
+
489
+ query = rng.choice([
490
+ f"What does {name} depend on? Map the dependency graph of everything it calls or references.",
491
+ f"Trace the dependencies of {name}. What functions, types, or modules does it rely on?",
492
+ f"Build a dependency tree for {name}. What are its direct and indirect dependencies?",
493
+ f"What are the dependencies of {name}? What would need to be available for it to work?",
494
+ ])
495
+
496
+ reasoning1 = f"<|reasoning|>The user wants to map the dependency graph of {name}. I need to find the function and identify everything it calls or references. Let me search for it first.<|end|>"
497
+ turn1 = {"content": f"{reasoning1}\n<|search|>{name}<|end|>", "result": chunk['code'][:500]}
498
+
499
+ # Round 2: Search for a dependency
500
+ dep = calls[0] if calls else (imports[0].split()[-1] if imports else name)
501
+ reasoning2 = f"<|reasoning|>Found {name}. It depends on: {', '.join(calls[:3]) if calls else 'several internal functions'}. Let me search for one of these dependencies to understand the full graph.<|end|>"
502
+ turn2 = {"content": f"{reasoning2}\n<|search|>{dep}<|end|>", "result": ""}
503
+
504
+ # Round 3: Evidence
505
+ deps_list = ', '.join(calls[:5]) if calls else 'internal helpers only'
506
+ evidence = f"<|reasoning|>I now have the dependency picture. {name} calls several functions that form its dependency graph. Let me compile the full mapping.<|end|>\n<|evidence|>Dependency graph for `{name}`:\nDirect dependencies: {deps_list}\n{'Imports: ' + '; '.join(imports[:2]) if imports else ''}\nThe function is {summarize_code_briefly(chunk['code'], name, chunk['language'], chunk['type'])}.\nSource: {chunk['filepath']}<|end|>\n<|finish|>"
507
+ turn3 = {"content": evidence}
508
+
509
+ return make_trace(query, [turn1, turn2, turn3])
510
+
511
+
512
+ def gen_data_structure_analysis(chunks, rng) -> dict:
513
+ """Category 8: Data structure analysis."""
514
+ candidates = [c for c in chunks if c['type'] in ('struct', 'class', 'enum') and c['name'] not in ('unknown','struct','void','T') and len(c['code']) > 100]
515
+ if not candidates:
516
+ return None
517
+ chunk = rng.choice(candidates)
518
+ name = chunk['name']
519
+ fields = extract_struct_fields(chunk['code'])
520
+
521
+ if not fields:
522
+ return None
523
+
524
+ query = rng.choice([
525
+ f"What data structures does {name} use? Analyze the fields, their types, and the memory layout.",
526
+ f"Break down the {name} data structure. What fields does it have, what are their types, and what is each used for?",
527
+ f"Analyze the {name} structure. What is its layout, what fields does it contain, and what are the relationships between them?",
528
+ f"Examine the {name} data type. What are its components and how is it organized in memory?",
529
+ ])
530
+
531
+ reasoning1 = f"<|reasoning|>The user wants a detailed analysis of the {name} data structure. I need to find its definition and examine all fields, their types, and how they're laid out. Let me search for it.<|end|>"
532
+ turn1 = {"content": f"{reasoning1}\n<|search|>{name}<|end|>", "result": chunk['code'][:500]}
533
+
534
+ # Round 2: Search for a field type
535
+ field_to_search = fields[0] if fields else name
536
+ reasoning2 = f"<|reasoning|>Found {name}. It has {len(fields)} fields: {', '.join(fields[:6])}. Let me search for one of these field names to understand what type it is and how it's used elsewhere.<|end|>"
537
+ turn2 = {"content": f"{reasoning2}\n<|search|>{field_to_search}<|end|>", "result": ""}
538
+
539
+ # Round 3: Evidence
540
+ evidence = f"<|reasoning|>I now have a complete picture of the {name} data structure. It contains {len(fields)} fields with various types. Let me compile the analysis.<|end|>\n<|evidence|>`{name}` is a {chunk['language']} {chunk['type']} with {len(fields)} fields:\n{chr(10).join(f'- `{f}`' for f in fields[:10])}\nThe structure is defined in {chunk['filepath']}. It is a {chunk['language']} {chunk['type']} used for organizing related data.<|end|>\n<|finish|>"
541
+ turn3 = {"content": evidence}
542
+
543
+ return make_trace(query, [turn1, turn2, turn3])
544
+
545
+
546
+ def gen_configuration_params(chunks, rng) -> dict:
547
+ """Category 9: Configuration and parameters."""
548
+ candidates = [c for c in chunks if c['type'] in ('macro', 'function', 'struct') and c['name'] not in ('unknown','struct','void','T') and
549
+ any(kw in c['code'].lower() for kw in ['config', 'param', 'option', 'setting', 'default', 'enable', 'disable'])]
550
+ if not candidates:
551
+ return None
552
+ chunk = rng.choice(candidates)
553
+ name = chunk['name']
554
+
555
+ query = rng.choice([
556
+ f"What configuration options are available for {name}? How do the parameters interact?",
557
+ f"Explain the parameters and configuration of {name}. What can be tuned and what are the defaults?",
558
+ f"What settings does {name} expose? What are the configuration knobs and their effects?",
559
+ f"Describe the configuration parameters for {name}. What options are available and how do they affect behavior?",
560
+ ])
561
+
562
+ reasoning1 = f"<|reasoning|>The user wants to understand the configuration options for {name}. I need to find the definition and look for parameters, defaults, and configuration-related code. Let me search for it.<|end|>"
563
+ turn1 = {"content": f"{reasoning1}\n<|search|>{name}<|end|>", "result": chunk['code'][:500]}
564
+
565
+ keywords = extract_keywords_from_code(chunk['code'])
566
+ related = keywords[0] if keywords else name
567
+ reasoning2 = f"<|reasoning|>Found {name}. I can see configuration-related code. Let me search for related configuration patterns to understand the full parameter space.<|end|>"
568
+ turn2 = {"content": f"{reasoning2}\n<|search|>{related} config<|end|>", "result": ""}
569
+
570
+ evidence = f"<|reasoning|>I now have a good understanding of the configuration for {name}. Let me compile the parameter analysis.<|end|>\n<|evidence|>`{name}` is {summarize_code_briefly(chunk['code'], name, chunk['language'], chunk['type'])}. Configuration is controlled through code-level parameters and compile-time options. The key configuration aspects are visible in the source at {chunk['filepath']}.<|end|>\n<|finish|>"
571
+ turn3 = {"content": evidence}
572
+
573
+ return make_trace(query, [turn1, turn2, turn3])
574
+
575
+
576
+ def gen_performance_paths(chunks, rng) -> dict:
577
+ """Category 10: Performance-critical paths."""
578
+ candidates = [c for c in chunks if c['type'] == 'function' and len(c['code']) > 200 and c['name'] not in ('unknown','struct','void','T') and
579
+ any(kw in c['code'].lower() for kw in ['loop', 'for', 'while', 'iter', 'batch', 'cache', 'buffer', 'alloc', 'pool'])]
580
+ if not candidates:
581
+ return None
582
+ chunk = rng.choice(candidates)
583
+ name = chunk['name']
584
+
585
+ query = rng.choice([
586
+ f"What are the performance-critical paths in {name}? Where are the potential bottlenecks?",
587
+ f"Analyze the performance characteristics of {name}. What operations are hot paths and where could bottlenecks occur?",
588
+ f"Identify the performance-sensitive operations in {name}. What loops, allocations, or I/O could be bottlenecks?",
589
+ f"Where are the performance hot spots in {name}? What should be optimized for better throughput?",
590
+ ])
591
+
592
+ reasoning1 = f"<|reasoning|>The user wants a performance analysis of {name}. I need to look for loops, memory allocations, I/O operations, and other performance-critical patterns. Let me find the function first.<|end|>"
593
+ turn1 = {"content": f"{reasoning1}\n<|search|>{name}<|end|>", "result": chunk['code'][:500]}
594
+
595
+ keywords = extract_keywords_from_code(chunk['code'])
596
+ perf_keyword = next((k for k in keywords if any(p in k for p in ['loop', 'iter', 'cache', 'buffer', 'alloc', 'pool', 'batch'])), keywords[0] if keywords else name)
597
+ reasoning2 = f"<|reasoning|>Found {name}. I can see performance-relevant patterns. Let me search for related performance patterns to understand the full picture.<|end|>"
598
+ turn2 = {"content": f"{reasoning2}\n<|search|>{perf_keyword}<|end|>", "result": ""}
599
+
600
+ evidence = f"<|reasoning|>I've analyzed the performance characteristics. The function has several performance-sensitive areas. Let me compile the analysis.<|end|>\n<|evidence|>Performance analysis of `{name}`:\n- The function is {summarize_code_briefly(chunk['code'], name, chunk['language'], chunk['type'])}\n- Performance-critical patterns: loops, memory operations, and data processing\n- Potential bottlenecks: iteration over data structures and memory allocation patterns\n- Optimization opportunities: caching intermediate results and reducing allocations\nSource: {chunk['filepath']}<|end|>\n<|finish|>"
601
+ turn3 = {"content": evidence}
602
+
603
+ return make_trace(query, [turn1, turn2, turn3])
604
+
605
+
606
+ def gen_security_validation(chunks, rng) -> dict:
607
+ """Category 11: Security and validation."""
608
+ candidates = [c for c in chunks if c['name'] not in ('unknown','struct','void','T') and
609
+ any(kw in c['code'].lower() for kw in ['valid', 'check', 'verify', 'auth', 'sanitiz', 'escape', 'bound', 'limit', 'overflow', 'secure'])]
610
+ if not candidates:
611
+ return None
612
+ chunk = rng.choice(candidates)
613
+ name = chunk['name']
614
+
615
+ query = rng.choice([
616
+ f"What security considerations are in {name}? What validation and sanitization does it perform?",
617
+ f"Analyze the security posture of {name}. What checks does it do and what could go wrong?",
618
+ f"What validation does {name} perform? Are there any potential security issues with its input handling?",
619
+ f"Review {name} for security. What protections are in place and what attack surfaces exist?",
620
+ ])
621
+
622
+ reasoning1 = f"<|reasoning|>The user wants a security review of {name}. I need to look for input validation, bounds checking, sanitization, and any potential vulnerabilities. Let me search for it.<|end|>"
623
+ turn1 = {"content": f"{reasoning1}\n<|search|>{name}<|end|>", "result": chunk['code'][:500]}
624
+
625
+ keywords = extract_keywords_from_code(chunk['code'])
626
+ sec_keyword = next((k for k in keywords if any(s in k for s in ['valid', 'check', 'verify', 'bound', 'limit', 'secure'])), keywords[0] if keywords else name)
627
+ reasoning2 = f"<|reasoning|>Found {name}. I can see validation and security-related code. Let me search for related validation patterns to understand the full security posture.<|end|>"
628
+ turn2 = {"content": f"{reasoning2}\n<|search|>{sec_keyword}<|end|>", "result": ""}
629
+
630
+ evidence = f"<|reasoning|>I've reviewed the security aspects of {name}. The function includes validation and checking mechanisms. Let me compile the security analysis.<|end|>\n<|evidence|>Security review of `{name}`:\n- The function is {summarize_code_briefly(chunk['code'], name, chunk['language'], chunk['type'])}\n- Validation: performs input checks and boundary validation\n- Security measures: includes guards against invalid inputs and error conditions\n- Potential concerns: ensure all input paths are validated before use\nSource: {chunk['filepath']}<|end|>\n<|finish|>"
631
+ turn3 = {"content": evidence}
632
+
633
+ return make_trace(query, [turn1, turn2, turn3])
634
+
635
+
636
+ def gen_design_patterns(chunks, rng) -> dict:
637
+ """Category 12: Design pattern recognition."""
638
+ candidates = [c for c in chunks if c['type'] in ('class', 'struct', 'impl') and c['name'] not in ('unknown','struct','void','T') and len(c['code']) > 150]
639
+ if not candidates:
640
+ return None
641
+ chunk = rng.choice(candidates)
642
+ name = chunk['name']
643
+
644
+ query = rng.choice([
645
+ f"What design patterns are used in {name}? How do they structure the code?",
646
+ f"Identify the software design patterns present in {name}. What architectural decisions do they reflect?",
647
+ f"What patterns does {name} implement? How do they contribute to the code's structure and extensibility?",
648
+ f"Analyze {name} from a design pattern perspective. What patterns are in use and why?",
649
+ ])
650
+
651
+ reasoning1 = f"<|reasoning|>The user wants to identify design patterns in {name}. I need to find the code and look for structural patterns like factories, singletons, observers, builders, etc. Let me search for it.<|end|>"
652
+ turn1 = {"content": f"{reasoning1}\n<|search|>{name}<|end|>", "result": chunk['code'][:500]}
653
+
654
+ keywords = extract_keywords_from_code(chunk['code'])
655
+ reasoning2 = f"<|reasoning|>Found {name}. Looking at the structure, I can see it uses {chunk['type']}-based organization. Let me search for related patterns to confirm my analysis.<|end|>"
656
+ turn2 = {"content": f"{reasoning2}\n<|search|>{keywords[0] if keywords else name}<|end|>", "result": ""}
657
+
658
+ evidence = f"<|reasoning|>I've analyzed the design patterns in {name}. The code structure reveals specific architectural choices. Let me compile the pattern analysis.<|end|>\n<|evidence|>Design patterns in `{name}`:\n- Primary pattern: {chunk['type']}-based abstraction with encapsulated state and behavior\n- The {chunk['language']} {chunk['type']} organizes related data and methods into a cohesive unit\n- This follows the object-oriented/encapsulation pattern common in {chunk['language']} codebases\n- The structure supports extensibility through its organized interface\nSource: {chunk['filepath']}<|end|>\n<|finish|>"
659
+ turn3 = {"content": evidence}
660
+
661
+ return make_trace(query, [turn1, turn2, turn3])
662
+
663
+
664
+ def gen_debugging_assistance(chunks, rng) -> dict:
665
+ """Category 13: Debugging assistance."""
666
+ candidates = [c for c in chunks if c['type'] == 'function' and c['name'] not in ('unknown','struct','void','T') and len(c['code']) > 150]
667
+ if not candidates:
668
+ return None
669
+ chunk = rng.choice(candidates)
670
+ name = chunk['name']
671
+ error_patterns = extract_error_handling(chunk['code'])
672
+ returns = extract_return_statements(chunk['code'])
673
+
674
+ query = rng.choice([
675
+ f"If {name} is failing unexpectedly, what are the likely causes and how would I debug it?",
676
+ f"I'm getting an error from {name}. What are the common failure modes and how can I diagnose them?",
677
+ f"What could go wrong when calling {name}? Help me understand the failure scenarios and debugging steps.",
678
+ f"Debug {name}: what are the error conditions, what symptoms would they produce, and how can I trace them?",
679
+ ])
680
+
681
+ reasoning1 = f"<|reasoning|>The user needs debugging help with {name}. I should find the function and look for error conditions, return codes, and potential failure points. Let me search for it.<|end|>"
682
+ turn1 = {"content": f"{reasoning1}\n<|search|>{name}<|end|>", "result": chunk['code'][:500]}
683
+
684
+ # Round 2: Search for error patterns
685
+ if error_patterns:
686
+ err_term = re.search(r'[A-Za-z_][A-Za-z0-9_]+', error_patterns[0])
687
+ search2 = err_term.group(0) if err_term else name
688
+ else:
689
+ search2 = name + " error"
690
+ reasoning2 = f"<|reasoning|>Found {name}. I can see {'error handling code with patterns like ' + error_patterns[0][:50] if error_patterns else 'the function logic'}. Let me search for the error pattern to understand common failure modes.<|end|>"
691
+ turn2 = {"content": f"{reasoning2}\n<|search|>{search2}<|end|>", "result": ""}
692
+
693
+ evidence = f"<|reasoning|>I've identified the failure modes and debugging approach for {name}. Let me compile the debugging guide.<|end|>\n<|evidence|>Debugging `{name}`:\n- Common failure modes: {', '.join(e[:60] for e in error_patterns[:3]) if error_patterns else 'unexpected return values'}\n- Return paths: {', '.join(r[:40] for r in returns[:3]) if returns else 'standard returns'}\n- Debugging steps: 1) Check input parameters are valid 2) Verify return values match expectations 3) Look for error conditions in the code\n- The function is {summarize_code_briefly(chunk['code'], name, chunk['language'], chunk['type'])}\nSource: {chunk['filepath']}<|end|>\n<|finish|>"
694
+ turn3 = {"content": evidence}
695
+
696
+ return make_trace(query, [turn1, turn2, turn3])
697
+
698
+
699
+ def gen_comparison_tradeoffs(chunks, rng) -> dict:
700
+ """Category 14: Comparison and tradeoffs."""
701
+ by_lang = defaultdict(list)
702
+ for c in chunks:
703
+ if c['type'] in ('function', 'struct', 'class') and c['name'] not in ('unknown','struct','void','T'):
704
+ by_lang[c['language']].append(c)
705
+
706
+ lang = rng.choice([l for l, cs in by_lang.items() if len(cs) >= 2])
707
+ candidates = by_lang[lang]
708
+ if len(candidates) < 2:
709
+ return None
710
+ chunk1, chunk2 = rng.sample(candidates, 2)
711
+ name1, name2 = chunk1['name'], chunk2['name']
712
+
713
+ query = rng.choice([
714
+ f"How do {name1} and {name2} compare? What are the tradeoffs between them?",
715
+ f"Compare {name1} vs {name2}. What are the differences in approach, performance, and use cases?",
716
+ f"What are the tradeoffs between {name1} and {name2}? When would you choose one over the other?",
717
+ f"Contrast {name1} and {name2}. What are the key differences and when should each be used?",
718
+ ])
719
+
720
+ reasoning1 = f"<|reasoning|>The user wants a comparison between {name1} and {name2}. I need to find both and analyze their differences, similarities, and tradeoffs. Let me start with {name1}.<|end|>"
721
+ turn1 = {"content": f"{reasoning1}\n<|search|>{name1}<|end|>", "result": chunk1['code'][:400]}
722
+
723
+ reasoning2 = f"<|reasoning|>Found {name1}. Now let me find {name2} so I can compare their approaches and characteristics.<|end|>"
724
+ turn2 = {"content": f"{reasoning2}\n<|search|>{name2}<|end|>", "result": chunk2['code'][:400]}
725
+
726
+ summary1 = summarize_code_briefly(chunk1['code'], name1, lang, chunk1['type'])
727
+ summary2 = summarize_code_briefly(chunk2['code'], name2, lang, chunk2['type'])
728
+ evidence = f"<|reasoning|>I now have both components. {name1} is {summary1}, while {name2} is {summary2}. They serve different purposes within the {lang} codebase. Let me compile the comparison.<|end|>\n<|evidence|>Comparison: `{name1}` vs `{name2}`\n{name1}: {summary1}. Located in {chunk1['filepath']}.\n{name2}: {summary2}. Located in {chunk2['filepath']}.\nKey difference: {name1} is a {chunk1['type']} while {name2} is a {chunk2['type']}, serving different roles in the {lang} codebase.\nTradeoff: Use {name1} for {chunk1['type']}-specific operations and {name2} for {chunk2['type']}-specific operations.<|end|>\n<|finish|>"
729
+ turn3 = {"content": evidence}
730
+
731
+ return make_trace(query, [turn1, turn2, turn3])
732
+
733
+
734
+ def gen_change_impact(chunks, rng) -> dict:
735
+ """Category 15: Change impact analysis."""
736
+ candidates = [c for c in chunks if c['type'] in ('function', 'class', 'struct') and c['name'] not in ('unknown','struct','void','T') and len(c['name']) > 3]
737
+ if not candidates:
738
+ return None
739
+ chunk = rng.choice(candidates)
740
+ name = chunk['name']
741
+
742
+ # Find referencing chunks
743
+ referencing = [c for c in chunks if name in c['code'] and c['name'] != name]
744
+
745
+ query = rng.choice([
746
+ f"If I need to modify {name}, what files and functions would be affected? Map the blast radius.",
747
+ f"What is the impact of changing {name}? What depends on it and what could break?",
748
+ f"I want to change the behavior of {name}. What else in the codebase would need to be updated?",
749
+ f"Analyze the change impact of modifying {name}. What are all the affected components?",
750
+ ])
751
+
752
+ reasoning1 = f"<|reasoning|>The user wants to understand the impact of changing {name}. I need to find the definition and then search for all places that reference it. Let me start with the definition.<|end|>"
753
+ turn1 = {"content": f"{reasoning1}\n<|search|>{name}<|end|>", "result": chunk['code'][:400]}
754
+
755
+ # Round 2: Search for usage
756
+ if referencing:
757
+ ref_chunk = referencing[0]
758
+ reasoning2 = f"<|reasoning|>Found {name}. Now I need to find all the places that reference it to understand the blast radius. I found at least one reference in {ref_chunk['name']}. Let me search for more.<|end|>"
759
+ turn2 = {"content": f"{reasoning2}\n<|search|>{name} {ref_chunk['name']}<|end|>", "result": ref_chunk['code'][:300]}
760
+ else:
761
+ reasoning2 = f"<|reasoning|>Found {name}. Let me search for references to understand the impact radius.<|end|>"
762
+ turn2 = {"content": f"{reasoning2}\n<|search|>{name} usage<|end|>", "result": ""}
763
+
764
+ # Round 3: Evidence
765
+ impact_count = len(referencing)
766
+ affected_files = list(set(c['filepath'] for c in referencing[:5]))
767
+ evidence = f"<|reasoning|>I've mapped the change impact. {name} is referenced in approximately {impact_count} other locations. Changing it would affect multiple components. Let me compile the impact analysis.<|end|>\n<|evidence|>Change impact for `{name}`:\n- Definition: {chunk['type']} in {chunk['filepath']}\n- Referenced by: ~{impact_count} other chunks across {len(affected_files)} files\n- Affected files: {', '.join(affected_files[:3])}\n- Risk level: {'HIGH' if impact_count > 5 else 'MEDIUM' if impact_count > 2 else 'LOW'} — {impact_count} dependent components would need review\n- Recommendation: Update all callers and verify return value compatibility<|end|>\n<|finish|>"
768
+ turn3 = {"content": evidence}
769
+
770
+ return make_trace(query, [turn1, turn2, turn3])
771
+
772
+
773
+ # ─── Multi-Round Complex Traces (4-5 rounds) ──────────────────────────────────
774
+
775
+ def gen_complex_multi_step(chunks, rng) -> dict:
776
+ """Generate a complex 4-5 round trace with multiple searches and refinements."""
777
+ by_lang = defaultdict(list)
778
+ for c in chunks:
779
+ if c['type'] in ('function', 'struct', 'class') and c['name'] not in ('unknown','struct','void','T') and len(c['code']) > 100:
780
+ by_lang[c['language']].append(c)
781
+
782
+ lang = rng.choice([l for l, cs in by_lang.items() if len(cs) >= 3])
783
+ candidates = by_lang[lang]
784
+ chunk1 = rng.choice(candidates)
785
+ name1 = chunk1['name']
786
+
787
+ # Find related chunks
788
+ related = [c for c in candidates if name1 in c['code'] or any(k in c['code'] for k in extract_keywords_from_code(chunk1['code'])[:3])]
789
+ related = [c for c in related if c['name'] != name1][:2]
790
+
791
+ if not related:
792
+ return None
793
+
794
+ chunk2, chunk3 = related[0], related[-1] if len(related) > 1 else related[0]
795
+ name2, name3 = chunk2['name'], chunk3['name']
796
+
797
+ query = rng.choice([
798
+ f"I need a comprehensive analysis of how {name1} works, including its relationship with {name2} and the broader system context. Walk me through the full picture.",
799
+ f"Give me a deep analysis of {name1}, its interaction with {name2}, and how they fit into the larger architecture. I need to understand the complete workflow.",
800
+ f"Analyze the {name1} subsystem: how it works, how it connects to {name2}, and what the overall data flow looks like. Be thorough.",
801
+ f"I need to understand {name1} end-to-end: its implementation, its relationship to {name2}, and its role in the system. Provide a complete breakdown.",
802
+ ])
803
+
804
+ # Round 1: Initial search
805
+ reasoning1 = f"<|reasoning|>This is a complex multi-part query. I need to: 1) Find and understand {name1}, 2) Find {name2} and understand the relationship, 3) Map the broader system context. Let me start with {name1}.<|end|>"
806
+ turn1 = {"content": f"{reasoning1}\n<|search|>{name1}<|end|>", "result": chunk1['code'][:500]}
807
+
808
+ # Round 2: Search for related component
809
+ reasoning2 = f"<|reasoning|>Found {name1}. It's {summarize_code_briefly(chunk1['code'], name1, lang, chunk1['type'])}. Now I need to find {name2} to understand how they interact. The relationship could be through function calls, shared data structures, or common interfaces.<|end|>"
810
+ turn2 = {"content": f"{reasoning2}\n<|search|>{name2}<|end|>", "result": chunk2['code'][:400]}
811
+
812
+ # Round 3: Search for broader context
813
+ keywords = extract_keywords_from_code(chunk1['code'])
814
+ context_search = keywords[0] if keywords else name1
815
+ reasoning3 = f"<|reasoning|>I now have both {name1} and {name2}. {name1} is {summarize_code_briefly(chunk1['code'], name1, lang, chunk1['type'])}, and {name2} is {summarize_code_briefly(chunk2['code'], name2, lang, chunk2['type'])}. Let me search for broader context to understand the system architecture.<|end|>"
816
+ turn3 = {"content": f"{reasoning3}\n<|search|>{context_search}<|end|>", "result": chunk3['code'][:300] if chunk3 != chunk2 else ""}
817
+
818
+ # Round 4: Evidence
819
+ evidence = f"<|reasoning|>I now have a comprehensive picture. {name1} and {name2} are both {lang} components that interact through shared code patterns. The broader context shows they're part of a larger system in {chunk1['filepath']}. Let me compile the complete analysis.<|end|>\n<|evidence|>Comprehensive analysis:\n1. `{name1}`: {summarize_code_briefly(chunk1['code'], name1, lang, chunk1['type'])}. Source: {chunk1['filepath']}\n2. `{name2}`: {summarize_code_briefly(chunk2['code'], name2, lang, chunk2['type'])}. Source: {chunk2['filepath']}\n3. Relationship: Both are {lang} components that share code patterns and may interact through common data structures or function calls.\n4. System context: They are part of the {chunk1['filepath'].split('/')[-1]} module, which contains multiple related components.\n5. Data flow: {name1} produces or transforms data that may be consumed by {name2} or related functions.<|end|>\n<|finish|>"
820
+ turn4 = {"content": evidence}
821
+
822
+ return make_trace(query, [turn1, turn2, turn3, turn4])
823
+
824
+
825
+ def gen_refinement_search(chunks, rng) -> dict:
826
+ """Generate a trace where the initial search fails and needs refinement."""
827
+ candidates = [c for c in chunks if c['type'] == 'function' and c['name'] not in ('unknown','struct','void','T') and len(c['code']) > 150]
828
+ if not candidates:
829
+ return None
830
+ chunk = rng.choice(candidates)
831
+ name = chunk['name']
832
+ keywords = extract_keywords_from_code(chunk['code'])
833
+
834
+ # Use a vague initial search term that would need refinement
835
+ vague_term = keywords[0] if keywords else name[:3]
836
+
837
+ query = rng.choice([
838
+ f"Find and explain {name}. I'm not sure of the exact name, but it's related to {vague_term}.",
839
+ f"I need to understand the function that handles {vague_term} in the codebase. Can you find it and explain what it does?",
840
+ f"There should be a function related to {vague_term}. Find it, analyze it, and tell me what it does.",
841
+ f"Search for code related to {vague_term} and find the main function. Explain its implementation.",
842
+ ])
843
+
844
+ # Round 1: Vague search
845
+ reasoning1 = f"<|reasoning|>The user's query is somewhat vague — they're looking for something related to {vague_term}. Let me start with a broad search and then refine based on the results.<|end|>"
846
+ turn1 = {"content": f"{reasoning1}\n<|search|>{vague_term}<|end|>", "result": chunk['code'][:300]}
847
+
848
+ # Round 2: Refined search
849
+ reasoning2 = f"<|reasoning|>The initial search returned some results, but I need to be more specific. Looking at the results, I can see references to {name}. Let me search for that specifically to get the full implementation.<|end|>"
850
+ turn2 = {"content": f"{reasoning2}\n<|search|>{name}<|end|>", "result": chunk['code'][:500]}
851
+
852
+ # Round 3: Evidence
853
+ summary = summarize_code_briefly(chunk['code'], name, chunk['language'], chunk['type'])
854
+ calls = extract_function_calls(chunk['code'])
855
+ returns = extract_return_statements(chunk['code'])
856
+
857
+ evidence = f"<|reasoning|>After refining the search, I found {name}. It's {summary}. The function {'calls ' + ', '.join(calls[:3]) if calls else 'performs internal operations'} and {'returns ' + returns[0] if returns else 'has standard return paths'}. Let me compile the evidence.<|end|>\n<|evidence|>Found `{name}` through search refinement:\n- Initial search for '{vague_term}' returned partial matches\n- Refined search for '{name}' found the full implementation\n- {name} is {summary}\n- Key operations: {', '.join(calls[:4]) if calls else 'internal processing'}\n- Return paths: {'; '.join(returns[:2]) if returns else 'standard'}\n- Source: {chunk['filepath']}<|end|>\n<|finish|>"
858
+ turn3 = {"content": evidence}
859
+
860
+ return make_trace(query, [turn1, turn2, turn3])
861
+
862
+
863
+ # ─── Category Registry ────────────────────────────────────────────────────────
864
+
865
+ CATEGORIES = {
866
+ "implementation": ("Implementation Deep Dive", gen_implementation_deep_dive),
867
+ "cross_file": ("Cross-File Data Flow Tracing", gen_cross_file_tracing),
868
+ "architecture": ("Architecture & Component Mapping", gen_architecture_mapping),
869
+ "usage": ("Usage Pattern Analysis", gen_usage_analysis),
870
+ "error_handling": ("Error Handling & Failure Paths", gen_error_handling),
871
+ "api_contract": ("API Contract & Interface", gen_api_contract),
872
+ "dependency": ("Dependency Graph Mapping", gen_dependency_graph),
873
+ "data_structure": ("Data Structure Analysis", gen_data_structure_analysis),
874
+ "configuration": ("Configuration & Parameters", gen_configuration_params),
875
+ "performance": ("Performance-Critical Paths", gen_performance_paths),
876
+ "security": ("Security & Validation", gen_security_validation),
877
+ "design_patterns": ("Design Pattern Recognition", gen_design_patterns),
878
+ "debugging": ("Debugging Assistance", gen_debugging_assistance),
879
+ "comparison": ("Comparison & Tradeoffs", gen_comparison_tradeoffs),
880
+ "change_impact": ("Change Impact Analysis", gen_change_impact),
881
+ "complex_multi": ("Complex Multi-Step Analysis", gen_complex_multi_step),
882
+ "refinement": ("Search Refinement", gen_refinement_search),
883
+ }
884
+
885
+ # ─── Main Generation Loop ─────────────────────────────────────────────────────
886
+
887
+ def generate_traces(category: str, count: int, seed: int, chunks: list) -> list[dict]:
888
+ """Generate traces for a specific category."""
889
+ if category not in CATEGORIES:
890
+ print(f"Unknown category: {category}")
891
+ return []
892
+
893
+ cat_name, gen_func = CATEGORIES[category]
894
+ rng = random.Random(seed)
895
+ traces = []
896
+ attempts = 0
897
+ max_attempts = count * 5 # Allow retries for None returns
898
+
899
+ while len(traces) < count and attempts < max_attempts:
900
+ attempts += 1
901
+ try:
902
+ trace = gen_func(chunks, rng)
903
+ if trace is not None and trace.get("query") and len(trace.get("trace", [])) >= 4:
904
+ traces.append(trace)
905
+ except Exception as e:
906
+ # Silently skip failed generations
907
+ pass
908
+
909
+ return traces
910
+
911
+
912
+ def main():
913
+ parser = argparse.ArgumentParser(description="Generate high-quality SFT traces")
914
+ parser.add_argument("--category", type=str, default="all",
915
+ help="Category name or 'all'")
916
+ parser.add_argument("--count", type=int, default=500,
917
+ help="Number of traces per category")
918
+ parser.add_argument("--seed", type=int, default=42,
919
+ help="Random seed")
920
+ parser.add_argument("--output", type=str, default=OUTPUT_DIR,
921
+ help="Output directory")
922
+ args = parser.parse_args()
923
+
924
+ # Load chunks
925
+ print(f"Loading chunks from {CHUNKS_PATH}...")
926
+ chunks = [json.loads(l) for l in open(CHUNKS_PATH, encoding="utf-8")]
927
+ print(f" Loaded {len(chunks):,} chunks")
928
+
929
+ # Create output directory
930
+ os.makedirs(args.output, exist_ok=True)
931
+
932
+ # Determine categories
933
+ if args.category == "all":
934
+ cats = list(CATEGORIES.keys())
935
+ else:
936
+ cats = [args.category]
937
+
938
+ total_traces = 0
939
+ for i, cat in enumerate(cats):
940
+ cat_name, _ = CATEGORIES[cat]
941
+ seed = args.seed + i * 1000
942
+ print(f"\n[{i+1}/{len(cats)}] Generating {args.count} traces for '{cat_name}' (seed={seed})...")
943
+
944
+ traces = generate_traces(cat, args.count, seed, chunks)
945
+
946
+ output_file = os.path.join(args.output, f"traces_{cat}.jsonl")
947
+ with open(output_file, "w", encoding="utf-8") as f:
948
+ for trace in traces:
949
+ f.write(json.dumps(trace, ensure_ascii=False) + "\n")
950
+
951
+ print(f" Generated {len(traces)} traces → {output_file}")
952
+ total_traces += len(traces)
953
+
954
+ print(f"\nTotal: {total_traces} traces generated across {len(cats)} categories")
955
+
956
+
957
+ if __name__ == "__main__":
958
+ main()
src/generate_traces_v2.py ADDED
@@ -0,0 +1,587 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Diverse search-agent trace generator v2.
3
+
4
+ Generates 20-50k unique, high-quality search agent traces using real code
5
+ chunks from the deduplicated corpus. Each trace follows the Toast 1-style
6
+ agentic search loop: reasoning -> search -> results -> analysis -> evidence -> finish.
7
+
8
+ Improvements over generate_traces.py:
9
+ - Combinatorial query phrasings (hundreds of unique templates)
10
+ - Smarter evidence extraction (analyzes actual code structure)
11
+ - More trace types: simple, noise, multi-step, decomposition,
12
+ not-found, struct inspection, behavior analysis, cross-file,
13
+ usage tracking, error-handling patterns
14
+ - Trace-level dedup (no two traces share the same query+search combo)
15
+ - Varied reasoning text (not random.choice from 5 options)
16
+ - Realistic evidence packages that extract key facts, not dump code
17
+
18
+ Output: data/traces/sft_traces_v2.jsonl
19
+ """
20
+
21
+ import hashlib
22
+ import json
23
+ import os
24
+ import random
25
+ import re
26
+
27
+ PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
28
+ DATA_DIR = os.path.join(PROJECT_DIR, "data")
29
+ CHUNKS_PATH = os.path.join(DATA_DIR, "chunks", "chunks.jsonl")
30
+ TRACES_DIR = os.path.join(DATA_DIR, "traces")
31
+ TRACES_PATH = os.path.join(TRACES_DIR, "sft_traces_v2.jsonl")
32
+
33
+ SYSTEM_PROMPT = (
34
+ "You are a code search agent. Given a query from a reasoning model, "
35
+ "decompose it into subqueries, search the codebase, inspect results, "
36
+ "and return curated evidence. Use <|search|> to issue searches, "
37
+ "<|reasoning|> to analyze, and <|evidence|> to return findings. "
38
+ "Be concise. Extract only the relevant facts. End with <|finish|>."
39
+ )
40
+
41
+ random.seed(2026)
42
+
43
+ # ─── Query phrasing components (combinatorial) ───────────────────────────────
44
+ QUERY_VERBS = ["What does", "How does", "Where is", "Explain", "Find",
45
+ "Describe", "What is", "How is", "What are", "Show me",
46
+ "Locate", "Identify", "Detail"]
47
+ QUERY_ACTIONS = {
48
+ "function": ["do", "work", "behave", "function", "operate", "process",
49
+ "handle", "compute", "return", "accept"],
50
+ "struct": ["contain", "have", "store", "represent", "look like",
51
+ "be structured", "be defined"],
52
+ "class": ["do", "work", "be structured", "be organized", "be designed",
53
+ "function", "be implemented"],
54
+ "enum": ["have", "contain", "represent", "be defined", "include"],
55
+ "macro": ["do", "define", "expand to", "be used for"],
56
+ "trait": ["define", "require", "provide", "specify"],
57
+ "interface": ["define", "require", "specify", "declare"],
58
+ "impl": ["implement", "provide", "define"],
59
+ "module": ["contain", "provide", "export", "define"],
60
+ "typedef": ["define", "represent", "be"],
61
+ "block": ["do", "work", "be used for"],
62
+ }
63
+ QUERY_SUFFIXES = ["?", "in the codebase?", "in this project?",
64
+ "? Explain briefly.", "? Give me the details.",
65
+ "", "? I need specifics.", "? Summarize the key points."]
66
+
67
+ # ─── Reasoning components ────────────────────────────────────────────────────
68
+ REASONING_OPENERS = [
69
+ "I need to find {desc}.", "Looking for {desc}.",
70
+ "The query is about {desc}.", "This requires locating {desc}.",
71
+ "Let me search for {desc}.", "I should find {desc} first.",
72
+ "Starting by searching for {desc}.", "The user wants to know about {desc}.",
73
+ "I'll look up {desc}.", "First, I need to find {desc}.",
74
+ ]
75
+ REASONING_PLANS = [
76
+ "Let me search using '{term}'.",
77
+ "I'll query the index for '{term}'.",
78
+ "Searching for '{term}' should find it.",
79
+ "Let me issue a search for '{term}'.",
80
+ "I'll search with the term '{term}'.",
81
+ "A search for '{term}' should surface the relevant code.",
82
+ "Let me look for '{term}' in the codebase.",
83
+ ]
84
+ ANALYSIS_FOUND = [
85
+ "Found the relevant code.", "This result contains what I need.",
86
+ "This is the right code.", "I found the target.",
87
+ "This matches the query.", "The search returned the relevant implementation.",
88
+ "This is exactly what I was looking for.",
89
+ ]
90
+ ANALYSIS_NOISE = [
91
+ "The first result is relevant; the others are not directly related.",
92
+ "Result 1 matches. The remaining results appear to be unrelated code.",
93
+ "I found the target in the first result. Ignoring the noise.",
94
+ "The relevant code is in the first result; the rest don't match.",
95
+ "Only the first result is pertinent to this query.",
96
+ ]
97
+ ANALYSIS_REFINE = [
98
+ "Found the definition. Now let me find where it's used.",
99
+ "I have the implementation. Let me search for usages.",
100
+ "Got the definition. Let me get more context with another search.",
101
+ "Found it. Let me do one more search for completeness.",
102
+ "Now I need to see how this is called. Let me search for that.",
103
+ ]
104
+ NOT_FOUND_REASONING = [
105
+ "No results found for '{term}'. Let me try a broader search.",
106
+ "The search returned nothing for '{term}'. Let me try different terms.",
107
+ "No matches for '{term}'. Let me verify with another query.",
108
+ "Empty results for '{term}'. Let me rephrase the search.",
109
+ ]
110
+
111
+
112
+ def generate_query(chunk: dict) -> str:
113
+ """Generate a diverse query for a chunk."""
114
+ name = chunk["name"]
115
+ typ = chunk["type"]
116
+ if name == "unknown" or name == "block":
117
+ # Use a key identifier from the code
118
+ ids = re.findall(r"\b(ngx_\w+|def\s+\w+|fn\s+\w+|struct\s+\w+|class\s+\w+)\b", chunk["code"])
119
+ if ids:
120
+ name = ids[0].replace("def ", "").replace("fn ", "").replace("struct ", "").replace("class ", "")
121
+ else:
122
+ words = re.findall(r"\b[a-z_]{4,}\b", chunk["code"])
123
+ name = words[0] if words else "this code"
124
+
125
+ verb = random.choice(QUERY_VERBS)
126
+ actions = QUERY_ACTIONS.get(typ, QUERY_ACTIONS["block"])
127
+ action = random.choice(actions)
128
+ suffix = random.choice(QUERY_SUFFIXES)
129
+
130
+ if verb in ("Where is", "Locate", "Find", "Show me", "Identify"):
131
+ return f"{verb} {name} {action}{suffix}".replace(f" {action}", "")
132
+ return f"{verb} {name} {action}{suffix}"
133
+
134
+
135
+ def generate_description(chunk: dict) -> str:
136
+ name = chunk["name"]
137
+ typ = chunk["type"]
138
+ lang = chunk["language"]
139
+ type_words = {
140
+ "function": f"the {name} function", "struct": f"the {name} structure",
141
+ "class": f"the {name} class", "enum": f"the {name} enum",
142
+ "macro": f"the {name} macro", "trait": f"the {name} trait",
143
+ "interface": f"the {name} interface", "impl": f"the {name} impl block",
144
+ "module": f"the {name} module", "typedef": f"the {name} typedef",
145
+ "block": f"code related to {name}",
146
+ }
147
+ return type_words.get(typ, f"the {name} definition")
148
+
149
+
150
+ def generate_search_term(chunk: dict) -> str:
151
+ name = chunk["name"]
152
+ if name != "unknown" and name != "block":
153
+ return name
154
+ code = chunk["code"]
155
+ identifiers = re.findall(r"\b(ngx_\w+|def\s+\w+|fn\s+\w+|struct\s+\w+|class\s+\w+)\b", code)
156
+ if identifiers:
157
+ return identifiers[0].replace("def ", "").replace("fn ", "").replace("struct ", "").replace("class ", "")
158
+ words = re.findall(r"\b[a-z_]{4,}\b", code)
159
+ return words[0] if words else "code"
160
+
161
+
162
+ # ─── Evidence extraction (smarter) ───────────────────────────────────────────
163
+
164
+ def extract_function_info(code: str, name: str) -> dict:
165
+ info = {"name": name, "params": [], "returns": "", "key_lines": [], "calls": []}
166
+ sig_match = re.search(r"(?:def|fn|func|function)\s+" + re.escape(name) + r"\s*\(([^)]*)\)", code)
167
+ if sig_match:
168
+ params_raw = sig_match.group(1).strip()
169
+ if params_raw:
170
+ info["params"] = [p.strip() for p in params_raw.split(",") if p.strip()][:6]
171
+ ret_match = re.search(r"->\s*([^{]+)", code)
172
+ if ret_match:
173
+ info["returns"] = ret_match.group(1).strip()
174
+ calls = re.findall(r"\b(\w+)\s*\(", code)
175
+ info["calls"] = [c for c in calls if c != name and c not in ("if", "for", "while", "return", "print")][:5]
176
+ for line in code.split("\n"):
177
+ s = line.strip()
178
+ if not s or s.startswith("//") or s.startswith("#"):
179
+ continue
180
+ if any(kw in s for kw in ["return ", "if ", "for ", "while ", "raise ", "throw ", "Err(", "Ok(", "assert", "break", "continue"]):
181
+ info["key_lines"].append(s)
182
+ info["key_lines"] = info["key_lines"][:4]
183
+ return info
184
+
185
+
186
+ def extract_struct_info(code: str, name: str) -> dict:
187
+ fields = []
188
+ for pat in [re.compile(r"^\s+(\w+)\s+(\w+);", re.M),
189
+ re.compile(r"^\s+(\w+):\s+([^,;]+)", re.M),
190
+ re.compile(r"^\s+(self\.)?(\w+)\s*[:=]", re.M),
191
+ re.compile(r"^\s+(\w+)\s+\w+", re.M)]:
192
+ for m in pat.findall(code):
193
+ fn = m[-1] if isinstance(m, tuple) else m
194
+ if fn and fn not in ("self", "pub", "fn", "def", "class", "struct", "const", "var"):
195
+ fields.append(fn)
196
+ return {"name": name, "fields": fields[:12]}
197
+
198
+
199
+ def generate_evidence(chunk: dict) -> str:
200
+ code = chunk["code"]
201
+ name = chunk["name"]
202
+ typ = chunk["type"]
203
+ lang = chunk["language"]
204
+ filepath = chunk["filepath"]
205
+ repo = chunk.get("repo", "")
206
+
207
+ if typ == "function":
208
+ info = extract_function_info(code, name)
209
+ parts = [f"`{name}` is a {lang} function."]
210
+ if info["params"]:
211
+ params_str = ", ".join(info["params"])
212
+ parts.append(f"- Parameters: {params_str}")
213
+ if info["returns"]:
214
+ parts.append(f"- Returns: {info['returns']}")
215
+ if info["key_lines"]:
216
+ parts.append("- Key behavior:")
217
+ for line in info["key_lines"][:3]:
218
+ parts.append(f" - `{line}`")
219
+ if info["calls"]:
220
+ parts.append(f"- Calls: {', '.join(info['calls'])}")
221
+ parts.append(f"- Source: {filepath}")
222
+ return "\n".join(parts)
223
+
224
+ elif typ == "struct":
225
+ info = extract_struct_info(code, name)
226
+ parts = [f"`{name}` is a {lang} structure with fields:"]
227
+ for field in info["fields"]:
228
+ parts.append(f"- `{field}`")
229
+ parts.append(f"- Source: {filepath}")
230
+ return "\n".join(parts)
231
+
232
+ elif typ == "class":
233
+ methods = re.findall(r"(?:def |fn |func |function )\s*(\w+)", code)
234
+ parts = [f"`{name}` is a {lang} class."]
235
+ if methods:
236
+ parts.append(f"- Methods: {', '.join(methods[:8])}")
237
+ base = re.search(r"class\s+\w+\s*(?:\(|:\s*|extends\s+|<\s*)(\w+)", code)
238
+ if base:
239
+ parts.append(f"- Inherits from: {base.group(1)}")
240
+ parts.append(f"- Source: {filepath}")
241
+ return "\n".join(parts)
242
+
243
+ elif typ == "enum":
244
+ variants = re.findall(r"^\s+(\w+)[,\s]*$", code, re.M)
245
+ parts = [f"`{name}` is a {lang} enum with variants:"]
246
+ for v in variants[:10]:
247
+ parts.append(f"- `{v}`")
248
+ parts.append(f"- Source: {filepath}")
249
+ return "\n".join(parts)
250
+
251
+ elif typ == "macro":
252
+ parts = [f"`{name}` is a {lang} macro/preprocessor definition:"]
253
+ for line in code.strip().split("\n")[:5]:
254
+ parts.append(f" `{line}`")
255
+ parts.append(f"- Source: {filepath}")
256
+ return "\n".join(parts)
257
+
258
+ elif typ in ("trait", "interface"):
259
+ methods = re.findall(r"(?:fn |def |function )\s*(\w+)", code)
260
+ parts = [f"`{name}` is a {lang} {typ}."]
261
+ if methods:
262
+ parts.append(f"- Required methods: {', '.join(methods[:8])}")
263
+ parts.append(f"- Source: {filepath}")
264
+ return "\n".join(parts)
265
+
266
+ else:
267
+ parts = [f"Found code related to `{name}` ({lang}):"]
268
+ for line in code.strip().split("\n")[:8]:
269
+ parts.append(f" `{line}`")
270
+ parts.append(f"- Source: {filepath}")
271
+ return "\n".join(parts)
272
+
273
+
274
+ # ─── Trace builders ──────────────────────────────────────────────────────────
275
+
276
+ def make_reasoning(desc: str, term: str) -> str:
277
+ opener = random.choice(REASONING_OPENERS).format(desc=desc)
278
+ plan = random.choice(REASONING_PLANS).format(term=term)
279
+ return f"{opener} {plan}"
280
+
281
+
282
+ def trace_key(query: str, searches: list[str]) -> str:
283
+ """Dedup key for a trace."""
284
+ return hashlib.md5((query + "|" + "|".join(searches)).encode()).hexdigest()
285
+
286
+
287
+ def make_trace_simple(chunk: dict) -> dict:
288
+ query = generate_query(chunk)
289
+ term = generate_search_term(chunk)
290
+ desc = generate_description(chunk)
291
+ evidence = generate_evidence(chunk)
292
+ reasoning1 = make_reasoning(desc, term)
293
+ analysis = random.choice(ANALYSIS_FOUND)
294
+ return {
295
+ "query": query,
296
+ "trace": [
297
+ {"role": "system", "content": SYSTEM_PROMPT},
298
+ {"role": "user", "content": query},
299
+ {"role": "assistant", "content": f"<|reasoning|>{reasoning1}<|end|>\n<|search|>{term}<|end|>"},
300
+ {"role": "result", "content": chunk["code"]},
301
+ {"role": "assistant", "content": f"<|reasoning|>{analysis} {desc.replace('the ', 'This is ', 1)}.<|end|>\n<|evidence|>{evidence}<|end|>\n<|finish|>"},
302
+ ],
303
+ }
304
+
305
+
306
+ def make_trace_with_noise(chunk: dict, noise_chunks: list) -> dict:
307
+ query = generate_query(chunk)
308
+ term = generate_search_term(chunk)
309
+ desc = generate_description(chunk)
310
+ evidence = generate_evidence(chunk)
311
+ reasoning1 = make_reasoning(desc, term)
312
+ n_noise = random.choice([1, 2])
313
+ noise = random.sample(noise_chunks, min(n_noise, len(noise_chunks)))
314
+ analysis = random.choice(ANALYSIS_NOISE)
315
+ trace = [
316
+ {"role": "system", "content": SYSTEM_PROMPT},
317
+ {"role": "user", "content": query},
318
+ {"role": "assistant", "content": f"<|reasoning|>{reasoning1}<|end|>\n<|search|>{term}<|end|>"},
319
+ {"role": "result", "content": chunk["code"]},
320
+ ]
321
+ for nc in noise:
322
+ trace.append({"role": "result", "content": nc["code"]})
323
+ trace.append({"role": "assistant", "content": f"<|reasoning|>{analysis}<|end|>\n<|evidence|>{evidence}<|end|>\n<|finish|>"})
324
+ return {"query": query, "trace": trace}
325
+
326
+
327
+ def make_trace_multi_step(chunk: dict, related: dict) -> dict:
328
+ name = chunk["name"]
329
+ rname = related["name"]
330
+ query = f"What does {name} do and where is it used?"
331
+ term1 = generate_search_term(chunk)
332
+ term2 = generate_search_term(related)
333
+ desc = generate_description(chunk)
334
+ evidence1 = generate_evidence(chunk)
335
+ evidence2 = generate_evidence(related)
336
+ reasoning1 = make_reasoning(desc, term1)
337
+ reasoning2 = random.choice(ANALYSIS_REFINE)
338
+ return {
339
+ "query": query,
340
+ "trace": [
341
+ {"role": "system", "content": SYSTEM_PROMPT},
342
+ {"role": "user", "content": query},
343
+ {"role": "assistant", "content": f"<|reasoning|>{reasoning1}<|end|>\n<|search|>{term1}<|end|>"},
344
+ {"role": "result", "content": chunk["code"]},
345
+ {"role": "assistant", "content": f"<|reasoning|>Found {desc}. {reasoning2}<|end|>\n<|search|>{term2}<|end|>"},
346
+ {"role": "result", "content": related["code"]},
347
+ {"role": "assistant", "content": f"<|reasoning|>Found both the definition and a usage. I now have complete context.<|end|>\n<|evidence|>{evidence1}\n\nRelated usage:\n{evidence2}<|end|>\n<|finish|>"},
348
+ ],
349
+ }
350
+
351
+
352
+ def make_trace_decomposition(chunk: dict, related: dict) -> dict:
353
+ name = chunk["name"]
354
+ rname = related["name"]
355
+ query = f"How does {name} work and how does it relate to {rname}?"
356
+ term1 = generate_search_term(chunk)
357
+ term2 = generate_search_term(related)
358
+ evidence1 = generate_evidence(chunk)
359
+ evidence2 = generate_evidence(related)
360
+ return {
361
+ "query": query,
362
+ "trace": [
363
+ {"role": "system", "content": SYSTEM_PROMPT},
364
+ {"role": "user", "content": query},
365
+ {"role": "assistant", "content": f"<|reasoning|>This query has two parts: (1) how {name} works, (2) its relationship to {rname}. Let me search for {name} first.<|end|>\n<|search|>{term1}<|end|>"},
366
+ {"role": "result", "content": chunk["code"]},
367
+ {"role": "assistant", "content": f"<|reasoning|>Found {name}. Now let me search for {rname} to understand the relationship.<|end|>\n<|search|>{term2}<|end|>"},
368
+ {"role": "result", "content": related["code"]},
369
+ {"role": "assistant", "content": f"<|reasoning|>Found both components. I can now explain how they relate.<|end|>\n<|evidence|>{evidence1}\n\nRelated: {evidence2}<|end|>\n<|finish|>"},
370
+ ],
371
+ }
372
+
373
+
374
+ def make_trace_not_found() -> dict:
375
+ fake_names = ["database_pool", "auth_middleware", "cache_manager", "session_store",
376
+ "websocket_handler", "graphql_resolver", "redis_client", "email_service",
377
+ "rate_limiter", "circuit_breaker", "event_bus", "message_queue",
378
+ "load_balancer", "api_gateway", "service_mesh", "config_center"]
379
+ name = random.choice(fake_names)
380
+ query = f"Where is the {name} implemented?"
381
+ search1 = name
382
+ search2 = name.replace("_", " ")
383
+ reasoning1 = make_reasoning(f"the {name}", search1)
384
+ reasoning2 = random.choice(NOT_FOUND_REASONING).format(term=search1)
385
+ return {
386
+ "query": query,
387
+ "trace": [
388
+ {"role": "system", "content": SYSTEM_PROMPT},
389
+ {"role": "user", "content": query},
390
+ {"role": "assistant", "content": f"<|reasoning|>{reasoning1}<|end|>\n<|search|>{search1}<|end|>"},
391
+ {"role": "result", "content": ""},
392
+ {"role": "assistant", "content": f"<|reasoning|>{reasoning2}<|end|>\n<|search|>{search2}<|end|>"},
393
+ {"role": "result", "content": ""},
394
+ {"role": "assistant", "content": f"<|reasoning|>No results found. The codebase does not contain {name}.<|end|>\n<|evidence|>No implementation of `{name}` was found in the codebase. Searched for: '{search1}', '{search2}'.<|end|>\n<|finish|>"},
395
+ ],
396
+ }
397
+
398
+
399
+ def make_trace_struct_inspection(chunk: dict) -> dict:
400
+ if chunk["type"] != "struct":
401
+ return make_trace_simple(chunk)
402
+ name = chunk["name"]
403
+ query = f"What fields does {name} have?"
404
+ term = name
405
+ evidence = generate_evidence(chunk)
406
+ return {
407
+ "query": query,
408
+ "trace": [
409
+ {"role": "system", "content": SYSTEM_PROMPT},
410
+ {"role": "user", "content": query},
411
+ {"role": "assistant", "content": f"<|reasoning|>Looking for the {name} struct definition to enumerate its fields.<|end|>\n<|search|>{term}<|end|>"},
412
+ {"role": "result", "content": chunk["code"]},
413
+ {"role": "assistant", "content": f"<|reasoning|>Found the struct definition. I can extract all fields from it.<|end|>\n<|evidence|>{evidence}<|end|>\n<|finish|>"},
414
+ ],
415
+ }
416
+
417
+
418
+ def make_trace_behavior(chunk: dict) -> dict:
419
+ if chunk["type"] != "function":
420
+ return make_trace_simple(chunk)
421
+ name = chunk["name"]
422
+ query = f"What does {name} do? Explain its behavior."
423
+ term = name
424
+ evidence = generate_evidence(chunk)
425
+ info = extract_function_info(chunk["code"], name)
426
+ params = ", ".join(info["params"][:4]) if info["params"] else "no parameters"
427
+ return {
428
+ "query": query,
429
+ "trace": [
430
+ {"role": "system", "content": SYSTEM_PROMPT},
431
+ {"role": "user", "content": query},
432
+ {"role": "assistant", "content": f"<|reasoning|>I need to find and analyze the {name} function. Let me search for its definition.<|end|>\n<|search|>{term}<|end|>"},
433
+ {"role": "result", "content": chunk["code"]},
434
+ {"role": "assistant", "content": f"<|reasoning|>Found the function. It takes {params}. Let me analyze its behavior from the code.<|end|>\n<|evidence|>{evidence}<|end|>\n<|finish|>"},
435
+ ],
436
+ }
437
+
438
+
439
+ def make_trace_cross_file(chunk: dict, related: dict) -> dict:
440
+ """Cross-file: definition in one file, usage in another."""
441
+ name = chunk["name"]
442
+ rname = related["name"]
443
+ query = f"How is {name} used across the codebase?"
444
+ term1 = generate_search_term(chunk)
445
+ term2 = generate_search_term(related)
446
+ evidence1 = generate_evidence(chunk)
447
+ evidence2 = generate_evidence(related)
448
+ return {
449
+ "query": query,
450
+ "trace": [
451
+ {"role": "system", "content": SYSTEM_PROMPT},
452
+ {"role": "user", "content": query},
453
+ {"role": "assistant", "content": f"<|reasoning|>I need to find the definition of {name} and then trace its usages across files.<|end|>\n<|search|>{term1}<|end|>"},
454
+ {"role": "result", "content": chunk["code"]},
455
+ {"role": "assistant", "content": f"<|reasoning|>Found the definition in {chunk['filepath']}. Now let me search for where it's called or referenced.<|end|>\n<|search|>{term2}<|end|>"},
456
+ {"role": "result", "content": related["code"]},
457
+ {"role": "assistant", "content": f"<|reasoning|>Found a usage in {related['filepath']}. I now have both the definition and a call site.<|end|>\n<|evidence|>Definition ({chunk['filepath']}):\n{evidence1}\n\nUsage ({related['filepath']}):\n{evidence2}<|end|>\n<|finish|>"},
458
+ ],
459
+ }
460
+
461
+
462
+ # ─── Main ────────────────────────────────────────────────────────────────────
463
+
464
+ def main():
465
+ os.makedirs(TRACES_DIR, exist_ok=True)
466
+
467
+ print(f"Loading chunks from {CHUNKS_PATH}...")
468
+ with open(CHUNKS_PATH, "r", encoding="utf-8") as f:
469
+ chunks = [json.loads(line) for line in f]
470
+ print(f" Loaded {len(chunks):,} chunks")
471
+
472
+ good_chunks = [c for c in chunks if c["name"] != "unknown" and c["name"] != "block" and 50 < len(c["code"]) < 4000]
473
+ print(f" Named chunks (good): {len(good_chunks):,}")
474
+
475
+ by_lang = {}
476
+ for c in good_chunks:
477
+ by_lang.setdefault(c["language"], []).append(c)
478
+
479
+ traces = []
480
+ seen_keys = set()
481
+
482
+ def add_trace(trace):
483
+ searches = [m["content"] for m in trace["trace"] if m["role"] == "assistant" and "<|search|>" in m["content"]]
484
+ # Extract search terms
485
+ search_terms = re.findall(r"<\|search\|>(.*?)<\|end\|>", "".join(m["content"] for m in trace["trace"] if m["role"] == "assistant"))
486
+ key = trace_key(trace["query"], search_terms)
487
+ if key in seen_keys:
488
+ return False
489
+ seen_keys.add(key)
490
+ traces.append(trace)
491
+ return True
492
+
493
+ def get_related(chunk):
494
+ lang_chunks = by_lang.get(chunk["language"], good_chunks)
495
+ pool = [c for c in lang_chunks if c["id"] != chunk["id"]]
496
+ return random.choice(pool) if pool else chunk
497
+
498
+ # Type 1: Simple lookup (25%)
499
+ n_simple = min(8000, len(good_chunks))
500
+ for chunk in random.sample(good_chunks, n_simple):
501
+ add_trace(make_trace_simple(chunk))
502
+ print(f" Simple lookup: {len(traces)}")
503
+
504
+ # Type 2: With noise (20%)
505
+ n_noise = min(6000, len(good_chunks))
506
+ for chunk in random.sample(good_chunks, n_noise):
507
+ lang_chunks = by_lang.get(chunk["language"], good_chunks)
508
+ noise_pool = [c for c in lang_chunks if c["id"] != chunk["id"]]
509
+ if len(noise_pool) >= 2:
510
+ add_trace(make_trace_with_noise(chunk, noise_pool))
511
+ print(f" With noise: {len(traces)}")
512
+
513
+ # Type 3: Multi-step (15%)
514
+ n_multi = min(4000, len(good_chunks) // 2)
515
+ for chunk in random.sample(good_chunks, n_multi):
516
+ add_trace(make_trace_multi_step(chunk, get_related(chunk)))
517
+ print(f" Multi-step: {len(traces)}")
518
+
519
+ # Type 4: Decomposition (10%)
520
+ n_decomp = min(3000, len(good_chunks) // 3)
521
+ for chunk in random.sample(good_chunks, n_decomp):
522
+ add_trace(make_trace_decomposition(chunk, get_related(chunk)))
523
+ print(f" Decomposition: {len(traces)}")
524
+
525
+ # Type 5: Not found (5%)
526
+ for _ in range(1500):
527
+ add_trace(make_trace_not_found())
528
+ print(f" Not found: {len(traces)}")
529
+
530
+ # Type 6: Struct inspection (10%)
531
+ struct_chunks = [c for c in good_chunks if c["type"] == "struct"]
532
+ for chunk in random.sample(struct_chunks, min(2000, len(struct_chunks))):
533
+ add_trace(make_trace_struct_inspection(chunk))
534
+ print(f" Struct inspection: {len(traces)}")
535
+
536
+ # Type 7: Behavior analysis (10%)
537
+ func_chunks = [c for c in good_chunks if c["type"] == "function"]
538
+ for chunk in random.sample(func_chunks, min(2000, len(func_chunks))):
539
+ add_trace(make_trace_behavior(chunk))
540
+ print(f" Behavior analysis: {len(traces)}")
541
+
542
+ # Type 8: Cross-file (5%)
543
+ n_cross = min(1500, len(good_chunks) // 4)
544
+ for chunk in random.sample(good_chunks, n_cross):
545
+ add_trace(make_trace_cross_file(chunk, get_related(chunk)))
546
+ print(f" Cross-file: {len(traces)}")
547
+
548
+ random.shuffle(traces)
549
+
550
+ # Write
551
+ print(f"\nTotal unique traces: {len(traces):,}")
552
+ total_size = 0
553
+ with open(TRACES_PATH, "w", encoding="utf-8") as f:
554
+ for trace in traces:
555
+ line = json.dumps(trace, ensure_ascii=False)
556
+ f.write(line + "\n")
557
+ total_size += len(line)
558
+ print(f"Traces written to {TRACES_PATH}")
559
+ print(f" File size: {total_size / 1e6:.2f} MB")
560
+
561
+ # Show a sample
562
+ print("\n" + "=" * 60)
563
+ print("SAMPLE TRACE")
564
+ print("=" * 60)
565
+ sample = traces[0]
566
+ print(f"\nQuery: {sample['query']}")
567
+ for msg in sample["trace"]:
568
+ content = msg["content"]
569
+ if len(content) > 200:
570
+ content = content[:200] + "..."
571
+ print(f"\n[{msg['role']}]")
572
+ print(content)
573
+
574
+ # Stats
575
+ stats = {
576
+ "total_traces": len(traces),
577
+ "file_size_mb": total_size / 1e6,
578
+ "unique_queries": len(set(t["query"] for t in traces)),
579
+ }
580
+ stats_path = os.path.join(TRACES_DIR, "trace_stats.json")
581
+ with open(stats_path, "w") as f:
582
+ json.dump(stats, f, indent=2)
583
+ print(f"\nStats: {stats}")
584
+
585
+
586
+ if __name__ == "__main__":
587
+ main()
src/gold_traces.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gold-standard search agent traces — hand-crafted examples using real code
3
+ from the corpus. These define the quality bar for the synthetic trace generator.
4
+
5
+ Each trace follows this format:
6
+ 1. System prompt (agent role definition)
7
+ 2. User query (from the bigger model)
8
+ 3. Agent response:
9
+ a. <|reasoning|> — decompose the query, plan searches
10
+ b. <|search|> — issue a search query
11
+ c. <|result|> — retrieved code chunks (injected by the harness)
12
+ d. <|reasoning|> — analyze results, decide if more search needed
13
+ e. (repeat b-d as needed)
14
+ f. <|evidence|> — curated evidence package
15
+ g. <|finish|> — signal completion
16
+
17
+ The agent learns to:
18
+ - Break complex queries into subqueries
19
+ - Search with specific terms (function names, types, patterns)
20
+ - Judge which results are relevant vs noise
21
+ - Extract key facts concisely (not dump entire code)
22
+ - Synthesize across multiple results
23
+ - Know when to stop searching
24
+ """
25
+
26
+ import json
27
+ import os
28
+
29
+ PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
30
+ GOLD_PATH = os.path.join(PROJECT_DIR, "data", "gold_traces.jsonl")
31
+
32
+ SYSTEM_PROMPT = (
33
+ "You are a code search agent. Given a query from a reasoning model, "
34
+ "decompose it into subqueries, search the codebase, inspect results, "
35
+ "and return curated evidence. Use <|search|> to issue searches, "
36
+ "<|reasoning|> to analyze, and <|evidence|> to return findings. "
37
+ "Be concise. Extract only the relevant facts. End with <|finish|>."
38
+ )
39
+
40
+ GOLD_TRACES = [
41
+ # ─── Trace 1: Simple function lookup (C) ───────────────────────────────
42
+ {
43
+ "query": "How does nginx handle reusable connections?",
44
+ "trace": [
45
+ {"role": "system", "content": SYSTEM_PROMPT},
46
+ {"role": "user", "content": "How does nginx handle reusable connections?"},
47
+ {"role": "assistant", "content": (
48
+ "<|reasoning|>The query asks about reusable connections in nginx. "
49
+ "I should search for functions related to reusable connections.<|end|>\n"
50
+ "<|search|>reusable connection<|end|>"
51
+ )},
52
+ {"role": "result", "content": (
53
+ "ngx_reusable_connection(ngx_connection_t *c, ngx_uint_t reusable)\n"
54
+ "{\n"
55
+ " ngx_log_debug1(NGX_LOG_DEBUG_CORE, c->log, 0,\n"
56
+ " \"reusable connection: %ui\", reusable);\n"
57
+ "\n"
58
+ " if (c->reusable) {\n"
59
+ " ngx_queue_remove(&c->queue);\n"
60
+ " ngx_cycle->reusable_connections_n--;\n"
61
+ " }\n"
62
+ "\n"
63
+ " c->reusable = reusable;\n"
64
+ "\n"
65
+ " if (reusable) {\n"
66
+ " ngx_queue_insert_head(\n"
67
+ " (ngx_queue_t *) &ngx_cycle->reusable_connections_queue, &c->queue);\n"
68
+ " ngx_cycle->reusable_connections_n++;\n"
69
+ " }\n"
70
+ "}"
71
+ )},
72
+ {"role": "assistant", "content": (
73
+ "<|reasoning|>This function manages the reusable connection queue. "
74
+ "When a connection is marked reusable, it's added to "
75
+ "reusable_connections_queue and the counter is incremented. "
76
+ "When unreusable, it's removed from the queue and counter decremented. "
77
+ "I have enough to answer.<|end|>\n"
78
+ "<|evidence|>nginx manages reusable connections via `ngx_reusable_connection()`:\n"
79
+ "- Marks connection as reusable/unreusable via the `reusable` flag\n"
80
+ "- When reusable: inserts into `reusable_connections_queue` (head), increments `reusable_connections_n`\n"
81
+ "- When unreusable: removes from queue, decrements counter\n"
82
+ "- Uses `ngx_queue_insert_head` and `ngx_queue_remove` for queue management\n"
83
+ "Source: ngx_connection.c<|end|>\n"
84
+ "<|finish|>"
85
+ )},
86
+ ],
87
+ },
88
+ # ─── Trace 2: Multi-step search (Python) ──────────────────────────────
89
+ {
90
+ "query": "What does the with_params_help decorator do and how is it used?",
91
+ "trace": [
92
+ {"role": "system", "content": SYSTEM_PROMPT},
93
+ {"role": "user", "content": "What does the with_params_help decorator do and how is it used?"},
94
+ {"role": "assistant", "content": (
95
+ "<|reasoning|>This asks about a specific decorator. I need to find its definition first, then find usages.<|end|>\n"
96
+ "<|search|>with_params_help<|end|>"
97
+ )},
98
+ {"role": "result", "content": (
99
+ "def with_params_help(params_cls: type, title: str = \"Parameter defaults\"):\n"
100
+ " \"\"\"\n"
101
+ " Class decorator that appends a help table generated from another dataclass\n"
102
+ " (e.g., VllmParameters) to the decorated class's docstring.\n"
103
+ " \"\"\"\n"
104
+ " if not is_dataclass(params_cls):\n"
105
+ " raise TypeError(f\"{params_cls} must be a dataclass\")\n"
106
+ "\n"
107
+ " def _decorator(cls: type) -> type:\n"
108
+ " block = generate_dataclass_help(params_cls)\n"
109
+ " cls.__doc__ = (cls.__doc__ or \"\") + f\"\\n\\n{title}:\\n{block}\"\n"
110
+ " return cls\n"
111
+ "\n"
112
+ " return _decorator"
113
+ )},
114
+ {"role": "assistant", "content": (
115
+ "<|reasoning|>Found the definition. It's a decorator factory that takes a dataclass and appends a help table to another class's docstring. Now I need to find where it's used to show a concrete example.<|end|>\n"
116
+ "<|search|>@with_params_help<|end|>"
117
+ )},
118
+ {"role": "result", "content": (
119
+ "@with_params_help(VllmBuildParameters)\n"
120
+ "class VllmBuildRunner(BaseRunner):\n"
121
+ " \"\"\"\n"
122
+ " Build vLLM using docker buildx.\n"
123
+ " \"\"\""
124
+ )},
125
+ {"role": "assistant", "content": (
126
+ "<|reasoning|>Found a usage. The decorator is applied to VllmBuildRunner with VllmBuildParameters as the params class. This appends parameter defaults from VllmBuildParameters to VllmBuildRunner's docstring. I have enough to answer.<|end|>\n"
127
+ "<|evidence|>`with_params_help` is a class decorator factory:\n"
128
+ "- Takes a dataclass (`params_cls`) and optional title (default \"Parameter defaults\")\n"
129
+ "- Generates a help table from the dataclass fields via `generate_dataclass_help()`\n"
130
+ "- Appends the table to the decorated class's `__doc__` docstring\n"
131
+ "- Usage example: `@with_params_help(VllmBuildParameters)` on `VllmBuildRunner`\n"
132
+ "- Requires `params_cls` to be a dataclass (raises TypeError otherwise)<|end|>\n"
133
+ "<|finish|>"
134
+ )},
135
+ ],
136
+ },
137
+ # ─── Trace 3: Type/struct lookup with noise (C) ───────────────────────
138
+ {
139
+ "query": "What fields does the nginx listening socket structure have?",
140
+ "trace": [
141
+ {"role": "system", "content": SYSTEM_PROMPT},
142
+ {"role": "user", "content": "What fields does the nginx listening socket structure have?"},
143
+ {"role": "assistant", "content": (
144
+ "<|reasoning|>Looking for the listening socket struct definition in nginx. Should search for ngx_listening struct.<|end|>\n"
145
+ "<|search|>ngx_listening struct fields<|end|>"
146
+ )},
147
+ {"role": "result", "content": (
148
+ "typedef struct ngx_listening_s ngx_listening_t;\n"
149
+ "\n"
150
+ "struct ngx_listening_s {\n"
151
+ " ngx_socket_t fd;\n"
152
+ " struct sockaddr *sockaddr;\n"
153
+ " socklen_t socklen;\n"
154
+ " size_t addr_text_max_len;\n"
155
+ " ngx_str_t addr_text;\n"
156
+ " int type;\n"
157
+ " int protocol;\n"
158
+ " int backlog;\n"
159
+ " int rcvbuf;\n"
160
+ " int sndbuf;\n"
161
+ "};"
162
+ )},
163
+ {"role": "result", "content": (
164
+ "ngx_create_listening(ngx_conf_t *cf, struct sockaddr *sockaddr,\n"
165
+ " socklen_t socklen)\n"
166
+ "{\n"
167
+ " ls = ngx_array_push(&cf->cycle->listening);\n"
168
+ " ngx_memzero(ls, sizeof(ngx_listening_t));\n"
169
+ " ls->sockaddr = sa;\n"
170
+ " ls->socklen = socklen;\n"
171
+ "}"
172
+ )},
173
+ {"role": "assistant", "content": (
174
+ "<|reasoning|>The first result is the struct definition — exactly what I need. The second result is the creation function, which is related but not asked for. I'll focus on the struct fields.<|end|>\n"
175
+ "<|evidence|>`ngx_listening_s` (aka `ngx_listening_t`) structure fields:\n"
176
+ "- `fd` (ngx_socket_t): file descriptor\n"
177
+ "- `sockaddr` (struct sockaddr*): socket address pointer\n"
178
+ "- `socklen` (socklen_t): socket address length\n"
179
+ "- `addr_text_max_len` (size_t): max address text length\n"
180
+ "- `addr_text` (ngx_str_t): address text representation\n"
181
+ "- `type` (int): socket type\n"
182
+ "- `protocol` (int): protocol\n"
183
+ "- `backlog` (int): listen backlog\n"
184
+ "- `rcvbuf` (int): receive buffer size\n"
185
+ "- `sndbuf` (int): send buffer size\n"
186
+ "Source: ngx_connection.h<|end|>\n"
187
+ "<|finish|>"
188
+ )},
189
+ ],
190
+ },
191
+ # ─── Trace 4: Query decomposition (JS/TS) ─────────────────────────────
192
+ {
193
+ "query": "How does the concatenate function work and what types does it accept?",
194
+ "trace": [
195
+ {"role": "system", "content": SYSTEM_PROMPT},
196
+ {"role": "user", "content": "How does the concatenate function work and what types does it accept?"},
197
+ {"role": "assistant", "content": (
198
+ "<|reasoning|>This has two parts: (1) how concatenate works, (2) what types it accepts. Let me search for the function definition.<|end|>\n"
199
+ "<|search|>concatenate function array<|end|>"
200
+ )},
201
+ {"role": "result", "content": (
202
+ "export function concatenate<T>(array1: T[], array2: T[] | undefined): T[];\n"
203
+ "// eslint-disable-line @typescript-eslint/unified-signatures"
204
+ )},
205
+ {"role": "result", "content": (
206
+ "function concatenate<T>(array1: T[], array2: T[] | undefined): T[] {\n"
207
+ " if (array2 === undefined) return array1;\n"
208
+ " return array1.concat(array2);\n"
209
+ "}"
210
+ )},
211
+ {"role": "assistant", "content": (
212
+ "<|reasoning|>Found both the declaration and implementation. It's a generic TypeScript function that concatenates two arrays. The second parameter is optional (can be undefined). If undefined, it returns the first array unchanged. Otherwise uses Array.concat. I have everything needed.<|end|>\n"
213
+ "<|evidence|>`concatenate<T>` is a TypeScript utility function:\n"
214
+ "- Signature: `concatenate<T>(array1: T[], array2: T[] | undefined): T[]`\n"
215
+ "- Generic over type T — accepts arrays of any type\n"
216
+ "- If `array2` is `undefined`, returns `array1` unchanged (no-op)\n"
217
+ "- Otherwise returns `array1.concat(array2)` (standard Array.concat)\n"
218
+ "- Exported as a public function<|end|>\n"
219
+ "<|finish|>"
220
+ )},
221
+ ],
222
+ },
223
+ # ─── Trace 5: Search with irrelevant results (Rust) ───────────────────
224
+ {
225
+ "query": "How is error handling done in the Rust codebase?",
226
+ "trace": [
227
+ {"role": "system", "content": SYSTEM_PROMPT},
228
+ {"role": "user", "content": "How is error handling done in the Rust codebase?"},
229
+ {"role": "assistant", "content": (
230
+ "<|reasoning|>Error handling in Rust typically uses Result types. Let me search for Result usage and error types.<|end|>\n"
231
+ "<|search|>Result error handling<|end|>"
232
+ )},
233
+ {"role": "result", "content": (
234
+ "pub fn parse_config(input: &str) -> Result<Config, ParseError> {\n"
235
+ " let parts: Vec<&str> = input.split_whitespace().collect();\n"
236
+ " if parts.len() < 2 {\n"
237
+ " return Err(ParseError::TooFewArguments);\n"
238
+ " }\n"
239
+ " Ok(Config { query: parts[0].to_string(), filename: parts[1].to_string() })\n"
240
+ "}"
241
+ )},
242
+ {"role": "result", "content": (
243
+ "use std::fmt;\n"
244
+ "\n"
245
+ "pub enum ParseError {\n"
246
+ " TooFewArguments,\n"
247
+ " InvalidSyntax,\n"
248
+ "}\n"
249
+ "\n"
250
+ "impl fmt::Display for ParseError {\n"
251
+ " fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {\n"
252
+ " match self {\n"
253
+ " ParseError::TooFewArguments => write!(f, \"too few arguments\"),\n"
254
+ " ParseError::InvalidSyntax => write!(f, \"invalid syntax\"),\n"
255
+ " }\n"
256
+ " }\n"
257
+ "}"
258
+ )},
259
+ {"role": "result", "content": (
260
+ "mod tests {\n"
261
+ " #[test]\n"
262
+ " fn test_basic() {\n"
263
+ " assert_eq!(2 + 2, 4);\n"
264
+ " }\n"
265
+ "}"
266
+ )},
267
+ {"role": "assistant", "content": (
268
+ "<|reasoning|>Result 1 shows a function returning Result<Config, ParseError> with early return on error. Result 2 shows the ParseError enum with Display impl. Result 3 is an unrelated test — not relevant. I have enough to answer.<|end|>\n"
269
+ "<|evidence|>Error handling pattern in this Rust codebase:\n"
270
+ "- Uses `Result<T, E>` for fallible operations\n"
271
+ "- Custom error type: `ParseError` enum with variants (TooFewArguments, InvalidSyntax)\n"
272
+ "- Implements `std::fmt::Display` for human-readable errors\n"
273
+ "- Early return pattern: `return Err(ParseError::...)` on validation failure\n"
274
+ "- Success path: `Ok(Config { ... })`\n"
275
+ "- No use of `unwrap()` or `expect()` in the found code — proper error propagation<|end|>\n"
276
+ "<|finish|>"
277
+ )},
278
+ ],
279
+ },
280
+ # ─── Trace 6: No results found ────────────────────────────────────────
281
+ {
282
+ "query": "Where is the database connection pool implemented?",
283
+ "trace": [
284
+ {"role": "system", "content": SYSTEM_PROMPT},
285
+ {"role": "user", "content": "Where is the database connection pool implemented?"},
286
+ {"role": "assistant", "content": (
287
+ "<|reasoning|>Looking for database connection pool implementation. Let me search for relevant terms.<|end|>\n"
288
+ "<|search|>database connection pool<|end|>"
289
+ )},
290
+ {"role": "result", "content": ""},
291
+ {"role": "assistant", "content": (
292
+ "<|reasoning|>No results found for 'database connection pool'. Let me try broader terms.<|end|>\n"
293
+ "<|search|>pool db<|end|>"
294
+ )},
295
+ {"role": "result", "content": ""},
296
+ {"role": "assistant", "content": (
297
+ "<|reasoning|>Still no results. The codebase does not appear to contain a database connection pool implementation.<|end|>\n"
298
+ "<|evidence|>No database connection pool implementation was found in the codebase. Searched for: 'database connection pool', 'pool db'. The indexed code may not include database layer code.<|end|>\n"
299
+ "<|finish|>"
300
+ )},
301
+ ],
302
+ },
303
+ ]
304
+
305
+
306
+ def main():
307
+ print(f"Writing {len(GOLD_TRACES)} gold-standard traces to {GOLD_PATH}...")
308
+ with open(GOLD_PATH, "w", encoding="utf-8") as f:
309
+ for trace in GOLD_TRACES:
310
+ f.write(json.dumps(trace) + "\n")
311
+
312
+ print(f"Done. {len(GOLD_TRACES)} traces written.")
313
+
314
+ # Print summary
315
+ for i, trace in enumerate(GOLD_TRACES):
316
+ query = trace["query"]
317
+ n_steps = len([m for m in trace["trace"] if m["role"] == "assistant"])
318
+ n_searches = len([m for m in trace["trace"] if m["role"] == "assistant" and "<|search|>" in m["content"]])
319
+ n_results = len([m for m in trace["trace"] if m["role"] == "result"])
320
+ print(f" Trace {i+1}: '{query[:50]}...' | {n_steps} agent turns | {n_searches} searches | {n_results} results")
321
+
322
+
323
+ if __name__ == "__main__":
324
+ main()
src/model.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Retriever500M - Decoder-only transformer built from scratch.
3
+
4
+ Architecture (LLaMA-style):
5
+ - vocab_size: 32,000
6
+ - d_model: 1,280
7
+ - n_layers: 23
8
+ - n_heads: 20
9
+ - d_ff: 3,456 (SwiGLU, 2/3 * 4 * d_model)
10
+ - RoPE positional encoding
11
+ - RMSNorm (no biases)
12
+ - Tied input/output embeddings
13
+ - Total parameters: ~497M
14
+ """
15
+
16
+ import math
17
+ from dataclasses import dataclass
18
+
19
+ import torch
20
+ import torch.nn as nn
21
+ import torch.nn.functional as F
22
+
23
+
24
+ @dataclass
25
+ class ModelConfig:
26
+ vocab_size: int = 32_000
27
+ d_model: int = 1_280
28
+ n_layers: int = 23
29
+ n_heads: int = 20
30
+ d_ff: int = 3_456
31
+ max_seq_len: int = 1_024
32
+ rope_theta: float = 10_000.0
33
+ rope_pct: float = 0.25 # fraction of d_model per head used for RoPE
34
+ dropout: float = 0.0
35
+ tie_embeddings: bool = True
36
+
37
+ def __post_init__(self):
38
+ assert self.d_model % self.n_heads == 0
39
+ self.d_head = self.d_model // self.n_heads # 64
40
+
41
+
42
+ class RMSNorm(nn.Module):
43
+ """RMSNorm with optional bias (no bias by default, LLaMA-style)."""
44
+
45
+ def __init__(self, dim: int, eps: float = 1e-6):
46
+ super().__init__()
47
+ self.weight = nn.Parameter(torch.ones(dim))
48
+ self.eps = eps
49
+
50
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
51
+ # Compute in float32 for stability, then cast back
52
+ orig_dtype = x.dtype
53
+ x = x.float()
54
+ rms = x.pow(2).mean(dim=-1, keepdim=True)
55
+ x = x * torch.rsqrt(rms + self.eps)
56
+ x = x.to(orig_dtype)
57
+ return x * self.weight
58
+
59
+
60
+ def precompute_rope_frequencies(
61
+ d_head: int,
62
+ max_seq_len: int,
63
+ theta: float = 10_000.0,
64
+ device: torch.device | None = None,
65
+ ) -> torch.Tensor:
66
+ """Precompute RoPE frequency table.
67
+
68
+ Returns tensor of shape (max_seq_len, d_head // 2) with complex
69
+ frequencies (cos, sin interleaved is handled in apply_rope).
70
+ """
71
+ inv_freq = 1.0 / (theta ** (torch.arange(0, d_head, 2, device=device).float() / d_head))
72
+ positions = torch.arange(max_seq_len, device=device).float()
73
+ freqs = torch.outer(positions, inv_freq) # (seq, d_head//2)
74
+ return freqs
75
+
76
+
77
+ def apply_rope(
78
+ x: torch.Tensor,
79
+ freqs: torch.Tensor,
80
+ ) -> torch.Tensor:
81
+ """Apply rotary position embeddings to tensor x.
82
+
83
+ x: (batch, n_heads, seq, d_head)
84
+ freqs: (seq, d_head // 2)
85
+ """
86
+ seq_len = x.shape[2]
87
+ d_head = x.shape[-1]
88
+ freqs = freqs[:seq_len] # (seq, d_head//2)
89
+
90
+ cos = freqs.cos()
91
+ sin = freqs.sin()
92
+
93
+ # Interleave cos/sin to match the rotate_half pattern
94
+ # x is split into two halves: x1 = x[..., :d//2], x2 = x[..., d//2:]
95
+ x1 = x[..., : d_head // 2]
96
+ x2 = x[..., d_head // 2 :]
97
+
98
+ # Broadcast cos/sin: (1, 1, seq, d_head//2)
99
+ cos = cos.unsqueeze(0).unsqueeze(0)
100
+ sin = sin.unsqueeze(0).unsqueeze(0)
101
+
102
+ rotated = torch.cat([x1 * cos - x2 * sin, x1 * sin + x2 * cos], dim=-1)
103
+ return rotated
104
+
105
+
106
+ class Attention(nn.Module):
107
+ """Multi-head self-attention with RoPE, no biases, causal masking."""
108
+
109
+ def __init__(self, config: ModelConfig):
110
+ super().__init__()
111
+ self.n_heads = config.n_heads
112
+ self.d_head = config.d_head
113
+ self.d_model = config.d_model
114
+ self.scale = 1.0 / math.sqrt(self.d_head)
115
+
116
+ # Fused QKV projection
117
+ self.qkv = nn.Linear(config.d_model, 3 * config.d_model, bias=False)
118
+ self.o_proj = nn.Linear(config.d_model, config.d_model, bias=False)
119
+ self.dropout = config.dropout
120
+
121
+ def forward(
122
+ self,
123
+ x: torch.Tensor,
124
+ rope_freqs: torch.Tensor,
125
+ mask: torch.Tensor | None = None,
126
+ ) -> torch.Tensor:
127
+ B, T, C = x.shape
128
+
129
+ qkv = self.qkv(x) # (B, T, 3*C)
130
+ q, k, v = qkv.chunk(3, dim=-1)
131
+
132
+ # Reshape to (B, n_heads, T, d_head)
133
+ q = q.view(B, T, self.n_heads, self.d_head).transpose(1, 2)
134
+ k = k.view(B, T, self.n_heads, self.d_head).transpose(1, 2)
135
+ v = v.view(B, T, self.n_heads, self.d_head).transpose(1, 2)
136
+
137
+ # Apply RoPE to Q and K
138
+ q = apply_rope(q, rope_freqs)
139
+ k = apply_rope(k, rope_freqs)
140
+
141
+ # Use PyTorch's scaled_dot_product_attention (uses Flash Attention on CUDA)
142
+ if mask is not None:
143
+ # mask: (1, 1, T, T) additive mask
144
+ attn_mask = mask
145
+ else:
146
+ attn_mask = None
147
+
148
+ out = F.scaled_dot_product_attention(
149
+ q, k, v,
150
+ attn_mask=attn_mask,
151
+ dropout_p=self.dropout if self.training else 0.0,
152
+ is_causal=(mask is None),
153
+ )
154
+
155
+ # (B, n_heads, T, d_head) -> (B, T, C)
156
+ out = out.transpose(1, 2).contiguous().view(B, T, C)
157
+ return self.o_proj(out)
158
+
159
+
160
+ class SwiGLU(nn.Module):
161
+ """SwiGLU feed-forward network: (xW_gate * SiLU(xW_up)) * W_down."""
162
+
163
+ def __init__(self, config: ModelConfig):
164
+ super().__init__()
165
+ self.w_gate = nn.Linear(config.d_model, config.d_ff, bias=False)
166
+ self.w_up = nn.Linear(config.d_model, config.d_ff, bias=False)
167
+ self.w_down = nn.Linear(config.d_ff, config.d_model, bias=False)
168
+
169
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
170
+ return self.w_down(F.silu(self.w_gate(x)) * self.w_up(x))
171
+
172
+
173
+ class TransformerBlock(nn.Module):
174
+ """One transformer decoder block: pre-norm attention + pre-norm FFN."""
175
+
176
+ def __init__(self, config: ModelConfig):
177
+ super().__init__()
178
+ self.norm1 = RMSNorm(config.d_model)
179
+ self.attn = Attention(config)
180
+ self.norm2 = RMSNorm(config.d_model)
181
+ self.ffn = SwiGLU(config)
182
+
183
+ def forward(
184
+ self,
185
+ x: torch.Tensor,
186
+ rope_freqs: torch.Tensor,
187
+ mask: torch.Tensor | None = None,
188
+ ) -> torch.Tensor:
189
+ x = x + self.attn(self.norm1(x), rope_freqs, mask)
190
+ x = x + self.ffn(self.norm2(x))
191
+ return x
192
+
193
+
194
+ class Retriever500M(nn.Module):
195
+ """Full decoder-only transformer model."""
196
+
197
+ def __init__(self, config: ModelConfig):
198
+ super().__init__()
199
+ self.config = config
200
+
201
+ # Token embedding (tied with output head)
202
+ self.token_embedding = nn.Embedding(config.vocab_size, config.d_model)
203
+
204
+ # Transformer blocks
205
+ self.layers = nn.ModuleList([
206
+ TransformerBlock(config) for _ in range(config.n_layers)
207
+ ])
208
+
209
+ # Final norm
210
+ self.norm_f = RMSNorm(config.d_model)
211
+
212
+ # Output projection (tied with embedding)
213
+ if config.tie_embeddings:
214
+ self.lm_head = None # use token_embedding weight
215
+ else:
216
+ self.lm_head = nn.Linear(config.d_model, config.vocab_size, bias=False)
217
+
218
+ # Precompute RoPE frequencies (registered as buffer, moved with .to())
219
+ freqs = precompute_rope_frequencies(
220
+ config.d_head,
221
+ config.max_seq_len,
222
+ config.rope_theta,
223
+ )
224
+ self.register_buffer("rope_freqs", freqs, persistent=False)
225
+
226
+ # Causal mask buffer
227
+ mask = torch.full(
228
+ (1, 1, config.max_seq_len, config.max_seq_len),
229
+ float("-inf"),
230
+ )
231
+ mask = torch.triu(mask, diagonal=1)
232
+ self.register_buffer("causal_mask", mask, persistent=False)
233
+
234
+ # Initialize weights
235
+ self.apply(self._init_weights)
236
+
237
+ def _init_weights(self, module: nn.Module):
238
+ if isinstance(module, nn.Linear):
239
+ nn.init.normal_(module.weight, mean=0.0, std=0.02)
240
+ if module.bias is not None:
241
+ nn.init.zeros_(module.bias)
242
+ elif isinstance(module, nn.Embedding):
243
+ nn.init.normal_(module.weight, mean=0.0, std=0.02)
244
+
245
+ def get_output_weight(self):
246
+ """Return the weight matrix for the output projection."""
247
+ if self.config.tie_embeddings:
248
+ return self.token_embedding.weight
249
+ return self.lm_head.weight
250
+
251
+ def forward(
252
+ self,
253
+ input_ids: torch.Tensor,
254
+ targets: torch.Tensor | None = None,
255
+ use_checkpoint: bool = False,
256
+ ) -> dict:
257
+ B, T = input_ids.shape
258
+
259
+ # Token embeddings
260
+ x = self.token_embedding(input_ids) # (B, T, d_model)
261
+
262
+ # Get RoPE freqs and causal mask for current sequence length
263
+ rope_freqs = self.rope_freqs[:T]
264
+ mask = self.causal_mask[:, :, :T, :T]
265
+
266
+ # Transformer blocks (with optional gradient checkpointing)
267
+ for layer in self.layers:
268
+ if use_checkpoint and self.training:
269
+ # Gradient checkpointing: recompute activations during backward
270
+ x = torch.utils.checkpoint.checkpoint(
271
+ layer, x, rope_freqs, mask, use_reentrant=False,
272
+ )
273
+ else:
274
+ x = layer(x, rope_freqs, mask)
275
+
276
+ x = self.norm_f(x)
277
+
278
+ # Output logits
279
+ logits = F.linear(x, self.get_output_weight()) # (B, T, vocab_size)
280
+
281
+ loss = None
282
+ if targets is not None:
283
+ loss = F.cross_entropy(
284
+ logits.view(-1, logits.size(-1)),
285
+ targets.view(-1),
286
+ ignore_index=-100,
287
+ )
288
+
289
+ return {"logits": logits, "loss": loss}
290
+
291
+ @torch.no_grad()
292
+ def generate(
293
+ self,
294
+ input_ids: torch.Tensor,
295
+ max_new_tokens: int = 128,
296
+ temperature: float = 1.0,
297
+ top_k: int | None = None,
298
+ eos_token_id: int | None = None,
299
+ ) -> torch.Tensor:
300
+ """Simple autoregressive generation."""
301
+ self.eval()
302
+ for _ in range(max_new_tokens):
303
+ # Crop context if it exceeds max_seq_len
304
+ idx_cond = input_ids if input_ids.size(1) <= self.config.max_seq_len else \
305
+ input_ids[:, -self.config.max_seq_len:]
306
+
307
+ logits = self(idx_cond)["logits"]
308
+ logits = logits[:, -1, :] / max(temperature, 1e-6)
309
+
310
+ if top_k is not None:
311
+ v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
312
+ logits[logits < v[:, [-1]]] = float("-inf")
313
+
314
+ probs = F.softmax(logits, dim=-1)
315
+ next_token = torch.multinomial(probs, num_samples=1)
316
+ input_ids = torch.cat([input_ids, next_token], dim=1)
317
+
318
+ if eos_token_id is not None and next_token.item() == eos_token_id:
319
+ break
320
+
321
+ return input_ids
322
+
323
+ def count_parameters(self) -> int:
324
+ """Count total trainable parameters."""
325
+ return sum(p.numel() for p in self.parameters() if p.requires_grad)
326
+
327
+
328
+ def build_model(config: ModelConfig | None = None) -> Retriever500M:
329
+ """Build the Retriever500M model."""
330
+ if config is None:
331
+ config = ModelConfig()
332
+ model = Retriever500M(config)
333
+ return model
334
+
335
+
336
+ if __name__ == "__main__":
337
+ config = ModelConfig()
338
+ model = build_model(config)
339
+
340
+ total_params = model.count_parameters()
341
+ print(f"Model: Retriever500M")
342
+ print(f" d_model: {config.d_model}")
343
+ print(f" n_layers: {config.n_layers}")
344
+ print(f" n_heads: {config.n_heads}")
345
+ print(f" d_ff: {config.d_ff}")
346
+ print(f" d_head: {config.d_head}")
347
+ print(f" vocab_size: {config.vocab_size}")
348
+ print(f" max_seq_len: {config.max_seq_len}")
349
+ print(f" Total parameters: {total_params:,} ({total_params / 1e6:.1f}M)")
350
+
351
+ # Quick forward pass test
352
+ device = "cuda" if torch.cuda.is_available() else "cpu"
353
+ model = model.to(device)
354
+ model.eval()
355
+
356
+ input_ids = torch.randint(0, config.vocab_size, (2, 64), device=device)
357
+ with torch.no_grad():
358
+ out = model(input_ids)
359
+ print(f" Output logits shape: {out['logits'].shape}")
360
+ print(" Forward pass OK.")
src/quality_filter.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Quality filtering, contamination control, and token-density validation.
3
+
4
+ Takes the deduplicated documents and applies final quality gates:
5
+
6
+ QUALITY FILTERS:
7
+ - Real source code (language signatures at line starts)
8
+ - Balanced delimiters (braces/parens/brackets roughly match)
9
+ - Reasonable length (200–100k chars)
10
+ - Low repetition (unique line ratio)
11
+ - Clean ASCII (low non-ASCII ratio)
12
+ - High code-to-prose ratio
13
+ - Has structure (function/class/struct definitions)
14
+
15
+ CONTAMINATION CONTROL:
16
+ - No test files (already filtered in download, double-check here)
17
+ - No auto-generated code markers
18
+ - No license-only files
19
+ - No binary/garbage content
20
+ - No files with extremely high repetition (copy-paste blocks)
21
+
22
+ TOKEN DENSITY:
23
+ - Every kept document must be "dense in tokens" — meaning the
24
+ content tokenizes to a meaningful number of tokens relative to
25
+ its character length (no whitespace-padding, no huge comment blocks).
26
+ - Reports token density stats using the project tokenizer.
27
+
28
+ Input: data/dedup/documents_dedup.jsonl
29
+ Output: data/quality/documents_quality.jsonl + quality_stats.json
30
+ """
31
+
32
+ import json
33
+ import os
34
+ import re
35
+ import sys
36
+
37
+ PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
38
+ DATA_DIR = os.path.join(PROJECT_DIR, "data")
39
+ DEDUP_DOCS_PATH = os.path.join(DATA_DIR, "dedup", "documents_dedup.jsonl")
40
+ QUALITY_DIR = os.path.join(DATA_DIR, "quality")
41
+ QUALITY_DOCS_PATH = os.path.join(QUALITY_DIR, "documents_quality.jsonl")
42
+ STATS_PATH = os.path.join(QUALITY_DIR, "quality_stats.json")
43
+
44
+ # ─── Code structure patterns (must be at line start) ─────────────────────────
45
+ CODE_SIGNATURES = {
46
+ "python": re.compile(r"^(def |class |import |from \S+ import |if __name__|@|async def )", re.M),
47
+ "js_ts": re.compile(r"^(function |const |let |var |class |export |import |async function|interface |type \w+ =|enum )", re.M),
48
+ "rust": re.compile(r"^(fn |pub fn |impl |struct |enum |trait |mod |use |pub struct|pub enum|macro_rules!|pub trait)", re.M),
49
+ "go": re.compile(r"^(func |package |import |type \w+ struct|var |const )", re.M),
50
+ "c": re.compile(r"^(#include|#define|#ifndef|#ifdef|#if |#endif|typedef |struct \w+|static |void |int |char )", re.M),
51
+ "cpp": re.compile(r"^(#include|#define|#ifndef|#ifdef|#if |#endif|template |class \w+|namespace |struct \w+|void |int )", re.M),
52
+ "java": re.compile(r"^(public |private |protected |class \w+|import |package |interface )", re.M),
53
+ "csharp": re.compile(r"^(public |private |protected |internal |class \w+|using |namespace |interface )", re.M),
54
+ "ruby": re.compile(r"^(def |class |module |require |require_relative |attr_|include )", re.M),
55
+ }
56
+
57
+ # ─── Contamination / exclusion patterns ──────────────────────────────────────
58
+ AUTO_GEN = re.compile(
59
+ r"(?:auto[- ]generated|do not edit|generated by|code generated|"
60
+ r"DO NOT MODIFY|@generated|automatically generated|"
61
+ r"this file was generated)",
62
+ re.IGNORECASE,
63
+ )
64
+ LICENSE_ONLY = re.compile(r"^(?:/\*|//|#)\s*(?:copyright|licensed|mit license|apache license|bsd license|gnu|gpl)", re.I)
65
+ PROSE_LINE = re.compile(r"^[A-Z][a-z]+ .* [a-z]+\.$", re.M)
66
+
67
+ # ─── Quality scoring ─────────────────────────────────────────────────────────
68
+
69
+ def detect_language_signatures(doc: dict) -> int:
70
+ """Count code-structure keywords at line starts. Returns count."""
71
+ content = doc["content"]
72
+ lang = doc["language"]
73
+ pat = CODE_SIGNATURES.get(lang)
74
+ if pat is None:
75
+ # Try all
76
+ return sum(len(p.findall(content)) for p in CODE_SIGNATURES.values())
77
+ return len(pat.findall(content))
78
+
79
+
80
+ def score_document(doc: dict) -> tuple[float, str | None]:
81
+ """Score a document 0.0–1.0 on quality. Returns (score, reject_reason)."""
82
+ content = doc["content"]
83
+ lines = content.split("\n")
84
+ n_lines = len(lines)
85
+ length = len(content)
86
+
87
+ # ─── Hard rejects (contamination) ──────────────────────────────────────
88
+ if length < 200:
89
+ return 0.0, "too_short"
90
+ if length > 200_000:
91
+ return 0.0, "too_long"
92
+ if n_lines < 5:
93
+ return 0.0, "too_few_lines"
94
+
95
+ # Auto-generated code
96
+ if AUTO_GEN.search(content[:2000]):
97
+ return 0.0, "auto_generated"
98
+
99
+ # License-only files (first 10 lines are all license comments)
100
+ first_lines = "\n".join(lines[:10])
101
+ if LICENSE_ONLY.search(first_lines) and n_lines < 30:
102
+ return 0.0, "license_only"
103
+
104
+ # High non-ASCII (garbage/encoding issues)
105
+ non_ascii = sum(1 for c in content if ord(c) > 127)
106
+ if length > 0 and non_ascii / length > 0.03:
107
+ return 0.0, "high_non_ascii"
108
+
109
+ # Extremely high repetition (copy-paste blocks)
110
+ unique_lines = len(set(lines))
111
+ unique_ratio = unique_lines / max(n_lines, 1)
112
+ if unique_ratio < 0.20:
113
+ return 0.0, "high_repetition"
114
+
115
+ # ─── Soft scoring ──────────────────────────────────────────────────────
116
+ score = 0.0
117
+
118
+ # Base: passes hard filters
119
+ score += 0.15
120
+
121
+ # Length quality (sweet spot: 500–30000 chars)
122
+ if 500 <= length <= 30000:
123
+ score += 0.15
124
+ elif 200 <= length <= 80000:
125
+ score += 0.08
126
+
127
+ # Delimiter balance
128
+ braces = content.count("{") - content.count("}")
129
+ parens = content.count("(") - content.count(")")
130
+ brackets = content.count("[") - content.count("]")
131
+ total_delims = content.count("{") + content.count("(") + content.count("[")
132
+ if total_delims > 0:
133
+ imbalance = abs(braces) + abs(parens) + abs(brackets)
134
+ balance_ratio = 1.0 - (imbalance / max(total_delims, 1))
135
+ score += 0.15 * max(balance_ratio, 0.0)
136
+
137
+ # Code structure density
138
+ struct_count = detect_language_signatures(doc)
139
+ struct_density = min(struct_count / max(n_lines, 1) * 10, 1.0)
140
+ score += 0.15 * struct_density
141
+
142
+ # Low repetition (unique line ratio)
143
+ if unique_ratio > 0.7:
144
+ score += 0.10
145
+ elif unique_ratio > 0.5:
146
+ score += 0.05
147
+ else:
148
+ score -= 0.05
149
+
150
+ # Clean ASCII
151
+ if length > 0 and non_ascii / length < 0.005:
152
+ score += 0.05
153
+
154
+ # Indentation quality (indented lines indicate real code structure)
155
+ indented = sum(1 for l in lines if l.startswith(" ") or l.startswith("\t"))
156
+ if indented > 0 and indented / max(n_lines, 1) > 0.15:
157
+ score += 0.05
158
+
159
+ # Penalize high prose ratio (documentation, not code)
160
+ prose_lines = len(PROSE_LINE.findall(content))
161
+ prose_ratio = prose_lines / max(n_lines, 1)
162
+ if prose_ratio > 0.20:
163
+ score -= 0.15
164
+
165
+ # Comment density (sweet spot: 3–30%)
166
+ comment_lines = 0
167
+ for line in lines:
168
+ s = line.strip()
169
+ if s.startswith("#") or s.startswith("//") or s.startswith("/*") \
170
+ or s.startswith("*") or s.startswith('"""') or s.startswith("'''") \
171
+ or s.startswith("///") or s.startswith("//!"):
172
+ comment_lines += 1
173
+ comment_ratio = comment_lines / max(n_lines, 1)
174
+ if 0.03 <= comment_ratio <= 0.30:
175
+ score += 0.10
176
+ elif comment_ratio > 0.50:
177
+ score -= 0.10 # too many comments = doc, not code
178
+
179
+ return min(max(score, 0.0), 1.0), None
180
+
181
+
182
+ def check_token_density(doc: dict) -> tuple[bool, float]:
183
+ """Check that a document is dense in tokens (not whitespace-padded).
184
+
185
+ Returns (passes, chars_per_token_ratio).
186
+ A good code document should have ~2.5-5 chars per token.
187
+ If the ratio is very high (>15), it's likely whitespace/garbage.
188
+ If very low (<1.5), it may be all symbols.
189
+ """
190
+ content = doc["content"]
191
+ # Rough estimate: count non-whitespace characters as a proxy
192
+ # Real tokenization happens with the tokenizer, but this is a fast filter
193
+ non_ws = len(content) - content.count(" ") - content.count("\n") - content.count("\t") - content.count("\r")
194
+ # Approximate token count: split on whitespace + common code delimiters
195
+ approx_tokens = len(re.findall(r"\w+|[^\w\s]", content))
196
+ if approx_tokens == 0:
197
+ return False, 0.0
198
+ chars_per_token = len(content) / approx_tokens
199
+ # Good density: 2.0 - 8.0 chars per token
200
+ passes = 2.0 <= chars_per_token <= 12.0
201
+ return passes, chars_per_token
202
+
203
+
204
+ def main():
205
+ os.makedirs(QUALITY_DIR, exist_ok=True)
206
+
207
+ print(f"Loading deduped documents from {DEDUP_DOCS_PATH}...")
208
+ docs = []
209
+ with open(DEDUP_DOCS_PATH, "r", encoding="utf-8") as f:
210
+ for line in f:
211
+ docs.append(json.loads(line))
212
+ print(f" Loaded {len(docs):,} documents")
213
+
214
+ kept = []
215
+ reject_reasons = {}
216
+ scores = []
217
+ token_densities = []
218
+
219
+ for i, doc in enumerate(docs):
220
+ score, reject = score_document(doc)
221
+
222
+ if reject:
223
+ reject_reasons[reject] = reject_reasons.get(reject, 0) + 1
224
+ continue
225
+
226
+ if score < 0.40:
227
+ reject_reasons["low_score"] = reject_reasons.get("low_score", 0) + 1
228
+ continue
229
+
230
+ # Token density check
231
+ dense, cpt = check_token_density(doc)
232
+ token_densities.append(cpt)
233
+ if not dense:
234
+ reject_reasons["low_token_density"] = reject_reasons.get("low_token_density", 0) + 1
235
+ continue
236
+
237
+ doc = dict(doc)
238
+ doc["quality_score"] = round(score, 4)
239
+ kept.append(doc)
240
+ scores.append(score)
241
+
242
+ if (i + 1) % 5000 == 0:
243
+ print(f" Processed {i+1}/{len(docs)} | kept {len(kept)} | "
244
+ f"rejected {i+1 - len(kept)}")
245
+
246
+ # Stats
247
+ final_size = sum(len(d["content"]) for d in kept)
248
+ avg_score = sum(scores) / len(scores) if scores else 0
249
+ avg_cpt = sum(token_densities) / len(token_densities) if token_densities else 0
250
+
251
+ lang_counts = {}
252
+ for d in kept:
253
+ lang_counts[d["language"]] = lang_counts.get(d["language"], 0) + 1
254
+
255
+ stats = {
256
+ "input": len(docs),
257
+ "kept": len(kept),
258
+ "rejected": len(docs) - len(kept),
259
+ "reject_reasons": reject_reasons,
260
+ "final_size_mb": final_size / 1e6,
261
+ "avg_quality_score": round(avg_score, 4),
262
+ "avg_chars_per_token": round(avg_cpt, 2),
263
+ "language_distribution": lang_counts,
264
+ "min_score_threshold": 0.40,
265
+ }
266
+
267
+ print("\n" + "=" * 60)
268
+ print("QUALITY FILTERING COMPLETE")
269
+ print("=" * 60)
270
+ print(f" Input: {len(docs):,}")
271
+ print(f" Kept: {len(kept):,}")
272
+ print(f" Rejected: {len(docs) - len(kept):,}")
273
+ print(f" Reject reasons: {reject_reasons}")
274
+ print(f" Final size: {final_size / 1e6:.1f} MB")
275
+ print(f" Avg quality score: {avg_score:.3f}")
276
+ print(f" Avg chars/token: {avg_cpt:.2f}")
277
+ print(f" Languages: {lang_counts}")
278
+
279
+ # Write quality-filtered documents
280
+ with open(QUALITY_DOCS_PATH, "w", encoding="utf-8") as f:
281
+ for doc in kept:
282
+ f.write(json.dumps(doc, ensure_ascii=False) + "\n")
283
+ print(f"\nQuality documents written to {QUALITY_DOCS_PATH}")
284
+
285
+ with open(STATS_PATH, "w") as f:
286
+ json.dump(stats, f, indent=2)
287
+ print(f"Stats written to {STATS_PATH}")
288
+
289
+
290
+ if __name__ == "__main__":
291
+ main()
src/search_agent.py ADDED
@@ -0,0 +1,385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Inference-time search agent harness.
3
+
4
+ This is the runtime that executes the search loop at inference time.
5
+ It:
6
+ 1. Takes a query from the calling (bigger) model
7
+ 2. Formats it as a chat message for the agent model
8
+ 3. Generates the agent's response (which contains <|search|> actions)
9
+ 4. Parses the search query from the response
10
+ 5. Retrieves code chunks from the index (simple keyword/TF-IDF search)
11
+ 6. Feeds the results back to the agent as <|result|> messages
12
+ 7. The agent generates more reasoning or <|evidence|>/<|finish|>
13
+ 8. Returns the evidence package to the caller
14
+
15
+ The retrieval backend is a simple in-memory keyword search over the
16
+ chunked corpus. This can be replaced with any retrieval backend
17
+ (embedding search, BM25, etc.) — the agent interface is the same.
18
+
19
+ Usage:
20
+ python src/search_agent.py --query "How does nginx handle connections?"
21
+ python src/search_agent.py --interactive
22
+ """
23
+
24
+ import argparse
25
+ import json
26
+ import os
27
+ import re
28
+ import sys
29
+ from collections import Counter
30
+
31
+ import torch
32
+ import torch.nn.functional as F
33
+
34
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
35
+
36
+ from model import ModelConfig, Retriever500M
37
+ from tokenizers import Tokenizer
38
+
39
+ # ─── Paths ───────────────────────────────────────────────────────────────────
40
+ PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
41
+ CHECKPOINT_DIR = os.path.join(PROJECT_DIR, "checkpoints")
42
+ TOKENIZER_PATH = os.path.join(PROJECT_DIR, "tokenizer", "tokenizer_agent.json")
43
+ CHUNKS_PATH = os.path.join(PROJECT_DIR, "data", "chunks.jsonl")
44
+
45
+ # ─── Special tokens ──────────────────────────────────────────────────────────
46
+ SYSTEM_PROMPT = (
47
+ "You are a code search agent. Given a query from a reasoning model, "
48
+ "decompose it into subqueries, search the codebase, inspect results, "
49
+ "and return curated evidence. Use <|search|> to issue searches, "
50
+ "<|reasoning|> to analyze, and <|evidence|> to return findings. "
51
+ "Be concise. Extract only the relevant facts. End with <|finish|>."
52
+ )
53
+
54
+
55
+ # ─── Simple retrieval backend ────────────────────────────────────────────────
56
+
57
+ class KeywordRetriever:
58
+ """Simple keyword-based retrieval over code chunks.
59
+
60
+ For production, replace this with an embedding-based retriever
61
+ (e.g., mxbai-embed-large or similar). The agent interface stays the same.
62
+ """
63
+
64
+ def __init__(self, chunks_path: str):
65
+ print(f"Loading chunks from {chunks_path}...")
66
+ self.chunks = []
67
+ with open(chunks_path, "r", encoding="utf-8") as f:
68
+ for line in f:
69
+ self.chunks.append(json.loads(line))
70
+ print(f" Loaded {len(self.chunks):,} chunks")
71
+
72
+ # Build simple term frequency index
73
+ self.chunk_tokens = []
74
+ for chunk in self.chunks:
75
+ code = chunk["code"].lower()
76
+ # Simple tokenization: split on non-alphanumeric
77
+ tokens = re.findall(r"[a-z_][a-z0-9_]*", code)
78
+ self.chunk_tokens.append(Counter(tokens))
79
+
80
+ def search(self, query: str, top_k: int = 3) -> list[dict]:
81
+ """Search for chunks matching the query. Returns top_k results."""
82
+ query_tokens = re.findall(r"[a-z_][a-z0-9_]*", query.lower())
83
+ if not query_tokens:
84
+ return []
85
+
86
+ scores = []
87
+ for i, chunk_tf in enumerate(self.chunk_tokens):
88
+ score = sum(chunk_tf.get(t, 0) for t in query_tokens)
89
+ # Normalize by chunk length to avoid bias toward long chunks
90
+ if sum(chunk_tf.values()) > 0:
91
+ score = score / (1 + sum(chunk_tf.values()) * 0.001)
92
+ scores.append((score, i))
93
+
94
+ scores.sort(reverse=True)
95
+ results = []
96
+ for score, idx in scores[:top_k]:
97
+ if score > 0:
98
+ chunk = self.chunks[idx]
99
+ results.append({
100
+ "code": chunk["code"],
101
+ "filepath": chunk["filepath"],
102
+ "name": chunk["name"],
103
+ "type": chunk["type"],
104
+ "language": chunk["language"],
105
+ "score": score,
106
+ })
107
+ return results
108
+
109
+
110
+ # ─── Agent harness ───────────────────────────────────────────────────────────
111
+
112
+ class SearchAgent:
113
+ """The search agent harness that runs the search loop."""
114
+
115
+ def __init__(
116
+ self,
117
+ checkpoint_path: str,
118
+ tokenizer_path: str,
119
+ chunks_path: str,
120
+ device: torch.device = None,
121
+ max_search_rounds: int = 5,
122
+ max_new_tokens: int = 512,
123
+ ):
124
+ self.device = device or torch.device("cuda" if torch.cuda.is_available() else "cpu")
125
+ self.max_search_rounds = max_search_rounds
126
+ self.max_new_tokens = max_new_tokens
127
+
128
+ # Load tokenizer
129
+ self.tokenizer = Tokenizer.from_file(tokenizer_path)
130
+
131
+ # Load model
132
+ print(f"Loading model from {checkpoint_path}...")
133
+ ckpt = torch.load(checkpoint_path, map_location=self.device, weights_only=False)
134
+ config = ModelConfig(**ckpt["config"])
135
+ self.model = Retriever500M(config).to(self.device)
136
+ self.model.load_state_dict(ckpt["model_state_dict"])
137
+ self.model.eval()
138
+ print(f" Loaded (step {ckpt.get('step', '?')}, loss {ckpt.get('loss', '?')})")
139
+
140
+ # Load retriever
141
+ self.retriever = KeywordRetriever(chunks_path)
142
+
143
+ # Special token IDs
144
+ vocab = self.tokenizer.get_vocab()
145
+ self.system_id = vocab.get("<tool_call>", 32000)
146
+ self.user_id = vocab.get("<tool_call>", 32001)
147
+ self.assistant_id = vocab.get("<tool_call>", 32002)
148
+ self.search_id = vocab.get("<|search|>", 32003)
149
+ self.result_id = vocab.get("<|result|>", 32004)
150
+ self.evidence_id = vocab.get("<|evidence|>", 32005)
151
+ self.reasoning_id = vocab.get("<|reasoning|>", 32006)
152
+ self.finish_id = vocab.get("<|finish|>", 32007)
153
+ self.end_id = vocab.get("<|end|>", 32008)
154
+
155
+ def _encode(self, text: str) -> list[int]:
156
+ """Encode text to token IDs."""
157
+ return self.tokenizer.encode(text).ids
158
+
159
+ def _decode(self, ids: list[int]) -> str:
160
+ """Decode token IDs to text."""
161
+ return self.tokenizer.decode(ids)
162
+
163
+ def _generate(self, input_ids: torch.Tensor, max_new_tokens: int) -> str:
164
+ """Generate text from the model.
165
+
166
+ Stops at <|finish|> (end of agent turn) or after a <|search|>query<|end|>
167
+ sequence (so the harness can inject results before continuing).
168
+ """
169
+ input_len = input_ids.size(1)
170
+ recent_tokens = []
171
+ with torch.no_grad():
172
+ for _ in range(max_new_tokens):
173
+ # Crop context if too long
174
+ if input_ids.size(1) > self.model.config.max_seq_len:
175
+ input_ids = input_ids[:, -self.model.config.max_seq_len:]
176
+
177
+ logits = self.model(input_ids)["logits"]
178
+ next_logits = logits[:, -1, :]
179
+
180
+ # Greedy decoding — more reliable for a 500M model
181
+ next_token = next_logits.argmax(dim=-1, keepdim=True)
182
+ tok_id = next_token.item()
183
+ input_ids = torch.cat([input_ids, next_token], dim=1)
184
+ recent_tokens.append(tok_id)
185
+
186
+ # Stop on <|finish|>
187
+ if tok_id == self.finish_id:
188
+ break
189
+
190
+ # Stop after <|search|>...<|end|> — harness needs to inject results
191
+ # Detect: we've seen <|search|> (32003) and then <|end|> (32008)
192
+ if tok_id == self.end_id and self.search_id in recent_tokens[-20:]:
193
+ # Check the search was recent (within last 20 tokens)
194
+ search_pos = None
195
+ for i in range(len(recent_tokens) - 1, max(0, len(recent_tokens) - 20), -1):
196
+ if recent_tokens[i] == self.search_id:
197
+ search_pos = i
198
+ break
199
+ if search_pos is not None:
200
+ # Only stop if <|end|> comes right after the search query
201
+ # (not an <|end|> from a <|reasoning|> section that happened
202
+ # to be within 20 tokens)
203
+ # Check: is there a <|reasoning|> after the <|search|>?
204
+ after_search = recent_tokens[search_pos+1:]
205
+ if self.reasoning_id not in after_search and self.evidence_id not in after_search:
206
+ break
207
+
208
+ # Decode only the newly generated tokens
209
+ generated_ids = input_ids[0, input_len:].tolist()
210
+ return self._decode(generated_ids)
211
+
212
+ def _parse_search_query(self, text: str) -> str | None:
213
+ """Extract the search query from the agent's response."""
214
+ # Look for <|search|>query<|end|>
215
+ match = re.search(r"<\|search\|>(.*?)<\|end\|>", text, re.DOTALL)
216
+ if match:
217
+ return match.group(1).strip()
218
+ return None
219
+
220
+ def _parse_evidence(self, text: str) -> str | None:
221
+ """Extract the evidence from the agent's response."""
222
+ match = re.search(r"<\|evidence\|>(.*?)(?:<\|end\|>|<\|finish\|>|$)", text, re.DOTALL)
223
+ if match:
224
+ return match.group(1).strip()
225
+ return None
226
+
227
+ def _has_finish(self, text: str) -> bool:
228
+ """Check if the agent has signaled completion."""
229
+ return "<|finish|>" in text
230
+
231
+ def search(self, query: str) -> dict:
232
+ """Run the full search loop for a query.
233
+
234
+ Returns:
235
+ {
236
+ "query": the original query,
237
+ "evidence": the curated evidence (or None if not found),
238
+ "searches": list of search queries issued,
239
+ "results": list of all results retrieved,
240
+ "trace": the full conversation trace,
241
+ }
242
+ """
243
+ print(f"\n{'='*60}")
244
+ print(f"QUERY: {query}")
245
+ print(f"{'='*60}")
246
+
247
+ # Build initial context
248
+ trace = []
249
+
250
+ # System prompt
251
+ system_tokens = [self.system_id] + self._encode(SYSTEM_PROMPT) + [self.end_id]
252
+ trace.append({"role": "system", "tokens": system_tokens})
253
+
254
+ # User query
255
+ user_tokens = [self.user_id] + self._encode(query) + [self.end_id]
256
+ trace.append({"role": "user", "tokens": user_tokens})
257
+
258
+ all_searches = []
259
+ all_results = []
260
+ evidence = None
261
+
262
+ for round_num in range(self.max_search_rounds):
263
+ # Build input from trace
264
+ all_tokens = []
265
+ for entry in trace:
266
+ all_tokens.extend(entry["tokens"])
267
+
268
+ input_ids = torch.tensor([all_tokens], dtype=torch.long, device=self.device)
269
+
270
+ # Generate agent response
271
+ print(f"\n--- Round {round_num + 1} ---")
272
+ response = self._generate(input_ids, self.max_new_tokens)
273
+ print(f"Agent: {response[:200]}...")
274
+
275
+ # Add assistant tokens to trace
276
+ assistant_tokens = [self.assistant_id] + self._encode(response)
277
+ if not response.endswith("<|end|>"):
278
+ assistant_tokens.append(self.end_id)
279
+ trace.append({"role": "assistant", "tokens": assistant_tokens})
280
+
281
+ # Parse search query FIRST (before checking finish)
282
+ search_query = self._parse_search_query(response)
283
+
284
+ if search_query:
285
+ print(f"[SEARCH]: {search_query}")
286
+ all_searches.append(search_query)
287
+
288
+ # Retrieve results
289
+ results = self.retriever.search(search_query, top_k=3)
290
+
291
+ if results:
292
+ for result in results:
293
+ print(f" [RESULT]: {result['name']} ({result['language']}, score={result['score']:.2f})")
294
+ result_tokens = [self.result_id] + self._encode(result["code"]) + [self.end_id]
295
+ trace.append({"role": "result", "tokens": result_tokens, "data": result})
296
+ all_results.append(result)
297
+ else:
298
+ print(" [NO RESULTS]")
299
+ result_tokens = [self.result_id, self.end_id]
300
+ trace.append({"role": "result", "tokens": result_tokens})
301
+
302
+ # Also check if the response contains evidence (model generated
303
+ # everything in one go but we intercepted the search)
304
+ if self._has_finish(response):
305
+ evidence = self._parse_evidence(response)
306
+ if evidence:
307
+ print(f"\n[EVIDENCE]: {evidence}")
308
+ break
309
+ elif self._has_finish(response):
310
+ # No search but has finish — extract evidence
311
+ evidence = self._parse_evidence(response)
312
+ print(f"\n[EVIDENCE]: {evidence}")
313
+ break
314
+ else:
315
+ # No search query found — try to extract evidence directly
316
+ evidence = self._parse_evidence(response)
317
+ if evidence:
318
+ print(f"\n[EVIDENCE]: {evidence}")
319
+ break
320
+ else:
321
+ print("[WARNING] No search or evidence found, continuing...")
322
+
323
+ return {
324
+ "query": query,
325
+ "evidence": evidence,
326
+ "searches": all_searches,
327
+ "results": all_results,
328
+ "trace": trace,
329
+ }
330
+
331
+
332
+ # ─── CLI ─────────────────────────────────────────────────────────────────────
333
+
334
+ def main():
335
+ parser = argparse.ArgumentParser(description="Run the search agent")
336
+ parser.add_argument("--checkpoint", type=str, default=os.path.join(CHECKPOINT_DIR, "sft_latest.pt"))
337
+ parser.add_argument("--query", type=str, default=None, help="Query to search for")
338
+ parser.add_argument("--interactive", action="store_true", help="Interactive mode")
339
+ parser.add_argument("--max_rounds", type=int, default=5, help="Max search rounds")
340
+ args = parser.parse_args()
341
+
342
+ agent = SearchAgent(
343
+ checkpoint_path=args.checkpoint,
344
+ tokenizer_path=TOKENIZER_PATH,
345
+ chunks_path=CHUNKS_PATH,
346
+ max_search_rounds=args.max_rounds,
347
+ )
348
+
349
+ if args.interactive:
350
+ print("\nInteractive mode. Type 'quit' to exit.")
351
+ while True:
352
+ query = input("\nQuery> ").strip()
353
+ if query.lower() in ("quit", "exit", "q"):
354
+ break
355
+ if query:
356
+ result = agent.search(query)
357
+ print(f"\n{'='*60}")
358
+ print(f"FINAL EVIDENCE:")
359
+ print(f"{'='*60}")
360
+ print(result["evidence"] or "No evidence found.")
361
+ elif args.query:
362
+ result = agent.search(args.query)
363
+ print(f"\n{'='*60}")
364
+ print(f"FINAL EVIDENCE:")
365
+ print(f"{'='*60}")
366
+ print(result["evidence"] or "No evidence found.")
367
+ else:
368
+ # Run sample queries
369
+ sample_queries = [
370
+ "How does nginx handle reusable connections?",
371
+ "What does the with_params_help decorator do?",
372
+ "What fields does the ngx_listening_s struct have?",
373
+ "How does the concatenate function work?",
374
+ "Where is the database connection pool implemented?",
375
+ ]
376
+ for query in sample_queries:
377
+ result = agent.search(query)
378
+ print(f"\n{'='*60}")
379
+ print(f"FINAL EVIDENCE:")
380
+ print(f"{'='*60}")
381
+ print(result["evidence"] or "No evidence found.")
382
+
383
+
384
+ if __name__ == "__main__":
385
+ main()
src/sft.py ADDED
@@ -0,0 +1,526 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Supervised fine-tuning (SFT) script for the search agent.
3
+
4
+ Key differences from pretraining:
5
+ 1. Uses the agent tokenizer (32009 vocab, includes special tokens)
6
+ 2. Formats traces as chat: <tool_call>...<|end|><tool_call>...<|end|><tool_call>...<|end|>
7
+ 3. Loss masking: only compute loss on ASSISTANT tokens, not on
8
+ system/user/result tokens (the model should learn to GENERATE
9
+ the agent responses, not predict the inputs)
10
+ 4. Lower learning rate (5e-5) — fine-tuning, not pretraining
11
+ 5. Resizes model embedding to 32009 (from 32000)
12
+
13
+ The trace format in the training data:
14
+ <tool_call>system_prompt<|end|>
15
+ <tool_call>user_query<|end|>
16
+ <tool_call>assistant_turn_1<|end|> ← loss computed here
17
+ [result injected by harness]
18
+ <tool_call>result_content<|end|>
19
+ <tool_call>assistant_turn_2<|end|> ← loss computed here
20
+ ...
21
+ <tool_call>evidence<|finish|><|end|> ← loss computed here
22
+
23
+ Usage:
24
+ python src/sft.py --steps N [--resume] [--batch_size N] [--lr F]
25
+ """
26
+
27
+ import argparse
28
+ import json
29
+ import os
30
+ import sys
31
+ import time
32
+ from dataclasses import asdict
33
+
34
+ import numpy as np
35
+ import torch
36
+ import torch.nn.functional as F
37
+ from tqdm import tqdm
38
+
39
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
40
+
41
+ from model import ModelConfig, Retriever500M
42
+ from tokenizers import Tokenizer
43
+
44
+ # ─── Paths ───────────────────────────────────────────────────────────────────
45
+ PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
46
+ DATA_DIR = os.path.join(PROJECT_DIR, "data")
47
+ TOKENIZER_DIR = os.path.join(PROJECT_DIR, "tokenizer")
48
+ CHECKPOINT_DIR = os.path.join(PROJECT_DIR, "checkpoints")
49
+ LOGS_DIR = os.path.join(PROJECT_DIR, "logs")
50
+
51
+ TRACES_PATH = os.path.join(DATA_DIR, "sft_traces.jsonl")
52
+ GOLD_PATH = os.path.join(DATA_DIR, "gold_traces.jsonl")
53
+ TOKENIZER_PATH = os.path.join(TOKENIZER_DIR, "tokenizer_agent.json")
54
+ SPECIAL_TOKENS_PATH = os.path.join(TOKENIZER_DIR, "special_tokens.json")
55
+
56
+ # ─── Special token IDs ───────────────────────────────────────────────────────
57
+ # Loaded from special_tokens.json, but hardcoded as fallback
58
+ SYSTEM_ID = 32000
59
+ USER_ID = 32001
60
+ ASSISTANT_ID = 32002
61
+ SEARCH_ID = 32003
62
+ RESULT_ID = 32004
63
+ EVIDENCE_ID = 32005
64
+ REASONING_ID = 32006
65
+ FINISH_ID = 32007
66
+ END_ID = 32008
67
+
68
+
69
+ def load_special_tokens():
70
+ """Load special token IDs from the mapping file."""
71
+ global SYSTEM_ID, USER_ID, ASSISTANT_ID, SEARCH_ID, RESULT_ID
72
+ global EVIDENCE_ID, REASONING_ID, FINISH_ID, END_ID
73
+
74
+ if os.path.exists(SPECIAL_TOKENS_PATH):
75
+ with open(SPECIAL_TOKENS_PATH, "r") as f:
76
+ data = json.load(f)
77
+ ids = data["token_ids"]
78
+ SYSTEM_ID = ids.get("<tool_call>", 32000)
79
+ USER_ID = ids.get("<tool_call>", 32001)
80
+ ASSISTANT_ID = ids.get("<tool_call>", 32002)
81
+ SEARCH_ID = ids.get("<|search|>", 32003)
82
+ RESULT_ID = ids.get("<|result|>", 32004)
83
+ EVIDENCE_ID = ids.get("<|evidence|>", 32005)
84
+ REASONING_ID = ids.get("<|reasoning|>", 32006)
85
+ FINISH_ID = ids.get("<|finish|>", 32007)
86
+ END_ID = ids.get("<|end|>", 32008)
87
+
88
+
89
+ # ─── Data formatting ─────────────────────────────────────────────────────────
90
+
91
+ def format_trace_to_tokens(trace: dict, tokenizer: Tokenizer, max_seq_len: int = 768) -> tuple[np.ndarray, np.ndarray]:
92
+ """Convert a trace to (input_ids, loss_mask) arrays.
93
+
94
+ loss_mask[i] = 1 if we should compute loss on token i, 0 otherwise.
95
+ Loss is only computed on ASSISTANT turns (and the special tokens within them).
96
+ """
97
+ messages = trace["trace"]
98
+
99
+ all_tokens = []
100
+ loss_mask = []
101
+
102
+ for msg in messages:
103
+ role = msg["role"]
104
+ content = msg["content"]
105
+
106
+ if role == "system":
107
+ # System: <tool_call>content<|end|> — no loss
108
+ role_id = SYSTEM_ID
109
+ tokens = [role_id] + tokenizer.encode(content).ids + [END_ID]
110
+ all_tokens.extend(tokens)
111
+ loss_mask.extend([0] * len(tokens))
112
+
113
+ elif role == "user":
114
+ # User: <tool_call>content<|end|> — no loss
115
+ role_id = USER_ID
116
+ tokens = [role_id] + tokenizer.encode(content).ids + [END_ID]
117
+ all_tokens.extend(tokens)
118
+ loss_mask.extend([0] * len(tokens))
119
+
120
+ elif role == "assistant":
121
+ # Assistant: <tool_call>content<|end|> — LOSS on all tokens
122
+ role_id = ASSISTANT_ID
123
+ # The content already contains <|search|>, <|reasoning|>, etc. as text
124
+ # We need to encode them properly
125
+ tokens = [role_id] + tokenizer.encode(content).ids + [END_ID]
126
+ all_tokens.extend(tokens)
127
+ loss_mask.extend([1] * len(tokens))
128
+
129
+ elif role == "result":
130
+ # Result: injected by harness, no loss
131
+ # Format as <|result|>content<|end|>
132
+ if content:
133
+ tokens = [RESULT_ID] + tokenizer.encode(content).ids + [END_ID]
134
+ else:
135
+ tokens = [RESULT_ID, END_ID]
136
+ all_tokens.extend(tokens)
137
+ loss_mask.extend([0] * len(tokens))
138
+
139
+ # Truncate to max_seq_len
140
+ if len(all_tokens) > max_seq_len:
141
+ all_tokens = all_tokens[:max_seq_len]
142
+ loss_mask = loss_mask[:max_seq_len]
143
+
144
+ return np.array(all_tokens, dtype=np.int32), np.array(loss_mask, dtype=np.int32)
145
+
146
+
147
+ def load_sft_dataset(traces_path: str, tokenizer: Tokenizer, max_seq_len: int = 768) -> list[tuple[np.ndarray, np.ndarray]]:
148
+ """Load all SFT traces and convert to (input_ids, loss_mask) pairs."""
149
+ print(f"Loading SFT traces from {traces_path}...")
150
+ dataset = []
151
+ with open(traces_path, "r", encoding="utf-8") as f:
152
+ for line in f:
153
+ trace = json.loads(line)
154
+ ids, mask = format_trace_to_tokens(trace, tokenizer, max_seq_len)
155
+ if len(ids) > 10: # skip traces that are too short
156
+ dataset.append((ids, mask))
157
+ print(f" Loaded {len(dataset):,} traces")
158
+ return dataset
159
+
160
+
161
+ def get_sft_batch(
162
+ dataset: list[tuple[np.ndarray, np.ndarray]],
163
+ batch_size: int,
164
+ seq_len: int,
165
+ device: torch.device,
166
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
167
+ """Sample a batch of SFT traces.
168
+
169
+ Returns (input_ids, targets, loss_mask) where:
170
+ - input_ids: (B, T) token IDs
171
+ - targets: (B, T) shifted targets (next token prediction)
172
+ - loss_mask: (B, T) 1 where loss should be computed, 0 elsewhere
173
+ """
174
+ # Randomly sample traces
175
+ indices = np.random.randint(0, len(dataset), size=batch_size)
176
+
177
+ input_ids_list = []
178
+ loss_mask_list = []
179
+
180
+ for idx in indices:
181
+ ids, mask = dataset[idx]
182
+ # Pad or truncate to seq_len
183
+ if len(ids) < seq_len:
184
+ pad_len = seq_len - len(ids)
185
+ ids = np.concatenate([ids, np.zeros(pad_len, dtype=np.int32)])
186
+ mask = np.concatenate([mask, np.zeros(pad_len, dtype=np.int32)])
187
+ else:
188
+ ids = ids[:seq_len]
189
+ mask = mask[:seq_len]
190
+ input_ids_list.append(ids)
191
+ loss_mask_list.append(mask)
192
+
193
+ input_ids = torch.from_numpy(np.stack(input_ids_list)).long().to(device)
194
+ loss_mask = torch.from_numpy(np.stack(loss_mask_list)).long().to(device)
195
+
196
+ # Targets are shifted input_ids
197
+ targets = torch.cat([input_ids[:, 1:], torch.zeros_like(input_ids[:, :1])], dim=1)
198
+
199
+ return input_ids, targets, loss_mask
200
+
201
+
202
+ # ─── Training ────────────────────────────────────────────────────────────────
203
+
204
+ def setup_optimizer(model, lr, use_8bit=True):
205
+ """Same as pretraining optimizer setup."""
206
+ decay_params, no_decay_params = [], []
207
+ for name, param in model.named_parameters():
208
+ if not param.requires_grad:
209
+ continue
210
+ if "embedding" in name or "norm" in name:
211
+ no_decay_params.append(param)
212
+ else:
213
+ decay_params.append(param)
214
+
215
+ param_groups = [
216
+ {"params": decay_params, "weight_decay": 0.1},
217
+ {"params": no_decay_params, "weight_decay": 0.0},
218
+ ]
219
+
220
+ if use_8bit:
221
+ try:
222
+ import bitsandbytes as bnb
223
+ optimizer = bnb.optim.AdamW8bit(param_groups, lr=lr, betas=(0.9, 0.95), eps=1e-8)
224
+ print("Using 8-bit AdamW (bitsandbytes)")
225
+ return optimizer
226
+ except Exception as e:
227
+ print(f"8-bit optimizer unavailable ({e}), falling back to AdamW")
228
+
229
+ optimizer = torch.optim.AdamW(param_groups, lr=lr, betas=(0.9, 0.95), eps=1e-8)
230
+ print("Using standard AdamW")
231
+ return optimizer
232
+
233
+
234
+ def get_lr(step, warmup, max_steps, max_lr, min_lr):
235
+ """Cosine LR schedule with linear warmup."""
236
+ if step < warmup:
237
+ return max_lr * (step + 1) / warmup
238
+ if step > max_steps:
239
+ return min_lr
240
+ decay_ratio = (step - warmup) / (max_steps - warmup)
241
+ coeff = 0.5 * (1.0 + np.cos(np.pi * decay_ratio))
242
+ return min_lr + coeff * (max_lr - min_lr)
243
+
244
+
245
+ def resize_embeddings(model, new_vocab_size):
246
+ """Resize the model's token embedding to accommodate new tokens."""
247
+ old_size = model.token_embedding.weight.shape[0]
248
+ if old_size == new_vocab_size:
249
+ return
250
+
251
+ print(f"Resizing embeddings: {old_size} -> {new_vocab_size}")
252
+ d_model = model.config.d_model
253
+
254
+ # Create new embedding with the old weights + new random weights
255
+ old_weight = model.token_embedding.weight.data
256
+ new_embedding = nn.Embedding(new_vocab_size, d_model)
257
+ new_embedding.weight.data[:old_size] = old_weight
258
+ # Initialize new tokens with small random values
259
+ nn.init.normal_(new_embedding.weight.data[old_size:], mean=0.0, std=0.02)
260
+ model.token_embedding = new_embedding
261
+
262
+ # If tied, the output weight is the same, so nothing else to do
263
+ # If not tied, resize lm_head too
264
+ if not model.config.tie_embeddings and model.lm_head is not None:
265
+ model.lm_head = nn.Linear(d_model, new_vocab_size, bias=False)
266
+ with torch.no_grad():
267
+ model.lm_head.weight.data[:old_size] = old_weight
268
+ nn.init.normal_(model.lm_head.weight.data[old_size:], mean=0.0, std=0.02)
269
+
270
+
271
+ from torch import nn
272
+
273
+
274
+ def train(args):
275
+ load_special_tokens()
276
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
277
+ print(f"Device: {device}")
278
+ if device.type == "cuda":
279
+ print(f"GPU: {torch.cuda.get_device_name(0)}")
280
+
281
+ os.makedirs(CHECKPOINT_DIR, exist_ok=True)
282
+ os.makedirs(LOGS_DIR, exist_ok=True)
283
+
284
+ # ─── Tokenizer ───────────────────────────────────────────────────────────
285
+ print("Loading agent tokenizer...")
286
+ tokenizer = Tokenizer.from_file(TOKENIZER_PATH)
287
+ vocab_size = tokenizer.get_vocab_size()
288
+ print(f"Vocab size: {vocab_size}")
289
+
290
+ # ─── Data ────────────────────────────────────────────────────────────────
291
+ dataset = load_sft_dataset(TRACES_PATH, tokenizer, args.seq_len)
292
+
293
+ # Also load gold traces
294
+ gold_dataset = load_sft_dataset(GOLD_PATH, tokenizer, args.seq_len)
295
+ dataset.extend(gold_dataset)
296
+ print(f" Total (with gold): {len(dataset):,}")
297
+
298
+ # ─── Model ───────────────────────────────────────────────────────────────
299
+ config = ModelConfig(
300
+ vocab_size=vocab_size, # 32009
301
+ d_model=1_280,
302
+ n_layers=23,
303
+ n_heads=20,
304
+ d_ff=3_456,
305
+ max_seq_len=args.seq_len,
306
+ dropout=0.0,
307
+ tie_embeddings=True,
308
+ )
309
+
310
+ model = Retriever500M(config).to(device)
311
+
312
+ # ─── Load pretrained checkpoint ──────────────────────────────────────────
313
+ ckpt_path = args.resume_path or os.path.join(CHECKPOINT_DIR, "latest.pt")
314
+ if os.path.exists(ckpt_path):
315
+ print(f"Loading pretrained weights from {ckpt_path}...")
316
+ ckpt = torch.load(ckpt_path, map_location=device, weights_only=False)
317
+ old_config = ModelConfig(**ckpt["config"])
318
+
319
+ # Load state dict, handling vocab size mismatch
320
+ state_dict = ckpt["model_state_dict"]
321
+ old_vocab = old_config.vocab_size
322
+
323
+ if old_vocab != vocab_size:
324
+ print(f" Vocab size mismatch: {old_vocab} -> {vocab_size}")
325
+ print(f" Resizing embeddings in state dict...")
326
+ # Resize token_embedding in the state dict
327
+ old_weight = state_dict["token_embedding.weight"]
328
+ d_model = old_weight.shape[1]
329
+ new_weight = torch.zeros(vocab_size, d_model)
330
+ new_weight[:old_vocab] = old_weight
331
+ nn.init.normal_(new_weight[old_vocab:], mean=0.0, std=0.02)
332
+ state_dict["token_embedding.weight"] = new_weight
333
+
334
+ model.load_state_dict(state_dict)
335
+ print(f" Loaded (step {ckpt.get('step', '?')}, loss {ckpt.get('loss', '?')})")
336
+ else:
337
+ print(f"WARNING: No checkpoint at {ckpt_path}, starting from scratch!")
338
+
339
+ total_params = model.count_parameters()
340
+ print(f"Model parameters: {total_params:,} ({total_params / 1e6:.1f}M)")
341
+
342
+ # ─── Optimizer ───────────────────────────────────────────────────────────
343
+ optimizer = setup_optimizer(model, args.lr, use_8bit=args.use_8bit_adam)
344
+
345
+ # ─── Training loop ───────────────────────────────────────────────────────
346
+ effective_batch = args.batch_size * args.grad_accum
347
+ print(f"\nSFT configuration:")
348
+ print(f" Batch size: {args.batch_size}")
349
+ print(f" Grad accum: {args.grad_accum}")
350
+ print(f" Effective batch: {effective_batch}")
351
+ print(f" Sequence length: {args.seq_len}")
352
+ print(f" Learning rate: {args.lr}")
353
+ print(f" Steps: {args.steps}")
354
+ print(f" Warmup: {args.warmup}")
355
+ print()
356
+
357
+ log = {
358
+ "config": asdict(config),
359
+ "train_args": vars(args),
360
+ "total_params": total_params,
361
+ "steps": [],
362
+ }
363
+
364
+ model.train()
365
+ start_time = time.time()
366
+ accum_loss = 0.0
367
+ best_loss = float("inf")
368
+
369
+ pbar = tqdm(range(args.steps), desc="SFT")
370
+ for step in pbar:
371
+ lr = get_lr(step, args.warmup, args.steps, args.lr, args.lr * 0.1)
372
+ for pg in optimizer.param_groups:
373
+ pg["lr"] = lr
374
+
375
+ optimizer.zero_grad(set_to_none=True)
376
+
377
+ total_loss = 0.0
378
+ for micro_step in range(args.grad_accum):
379
+ input_ids, targets, loss_mask = get_sft_batch(
380
+ dataset, args.batch_size, args.seq_len, device
381
+ )
382
+
383
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
384
+ out = model(input_ids, targets=targets, use_checkpoint=args.grad_checkpoint)
385
+ loss = out["loss"]
386
+
387
+ # Apply loss mask — only compute loss on assistant tokens
388
+ # loss is already computed over all positions; we need to recompute
389
+ # with the mask
390
+ logits = out["logits"]
391
+ # Recompute loss with mask
392
+ if loss_mask.sum() > 0:
393
+ # Shift mask to align with next-token prediction
394
+ shifted_mask = loss_mask[:, 1:].contiguous()
395
+ masked_logits = logits[:, :-1, :].contiguous()
396
+ masked_targets = targets[:, :-1].contiguous()
397
+
398
+ # Flatten and apply mask
399
+ flat_logits = masked_logits.view(-1, masked_logits.size(-1))
400
+ flat_targets = masked_targets.view(-1)
401
+ flat_mask = shifted_mask.view(-1).float()
402
+
403
+ per_token_loss = F.cross_entropy(
404
+ flat_logits, flat_targets,
405
+ ignore_index=-100, reduction="none"
406
+ )
407
+ masked_loss = (per_token_loss * flat_mask).sum() / flat_mask.sum().clamp(min=1)
408
+ loss = masked_loss / args.grad_accum
409
+ else:
410
+ loss = loss / args.grad_accum
411
+
412
+ loss.backward()
413
+ total_loss += loss.item()
414
+
415
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
416
+ optimizer.step()
417
+
418
+ avg_loss = total_loss
419
+ accum_loss = accum_loss * 0.95 + avg_loss * 0.05
420
+
421
+ if step % args.log_every == 0 or step == args.steps - 1:
422
+ elapsed = time.time() - start_time
423
+ steps_per_sec = (step + 1) / elapsed
424
+ vram_used = torch.cuda.max_memory_allocated() / 1e9 if device.type == "cuda" else 0
425
+
426
+ log_entry = {
427
+ "step": step,
428
+ "loss": avg_loss,
429
+ "ema_loss": accum_loss,
430
+ "lr": lr,
431
+ "elapsed_s": elapsed,
432
+ "steps_per_sec": steps_per_sec,
433
+ "vram_gb": vram_used,
434
+ }
435
+ log["steps"].append(log_entry)
436
+
437
+ pbar.set_postfix({
438
+ "loss": f"{avg_loss:.4f}",
439
+ "ema": f"{accum_loss:.4f}",
440
+ "lr": f"{lr:.2e}",
441
+ "vram": f"{vram_used:.1f}G",
442
+ })
443
+
444
+ if (step + 1) % args.save_every == 0 or step == args.steps - 1:
445
+ ckpt_path = os.path.join(CHECKPOINT_DIR, f"sft_step_{step + 1}.pt")
446
+ torch.save({
447
+ "model_state_dict": model.state_dict(),
448
+ "optimizer_state_dict": optimizer.state_dict(),
449
+ "config": asdict(config),
450
+ "step": step + 1,
451
+ "loss": accum_loss,
452
+ }, ckpt_path)
453
+ print(f"\n Saved checkpoint: {ckpt_path}")
454
+
455
+ latest_path = os.path.join(CHECKPOINT_DIR, "sft_latest.pt")
456
+ torch.save({
457
+ "model_state_dict": model.state_dict(),
458
+ "config": asdict(config),
459
+ "step": step + 1,
460
+ "loss": accum_loss,
461
+ }, latest_path)
462
+
463
+ if accum_loss < best_loss:
464
+ best_loss = accum_loss
465
+ best_path = os.path.join(CHECKPOINT_DIR, "sft_best.pt")
466
+ torch.save({
467
+ "model_state_dict": model.state_dict(),
468
+ "config": asdict(config),
469
+ "step": step + 1,
470
+ "loss": accum_loss,
471
+ }, best_path)
472
+
473
+ if step % 50 == 0 and device.type == "cuda":
474
+ torch.cuda.reset_peak_memory_stats()
475
+
476
+ # Save log
477
+ log_path = os.path.join(LOGS_DIR, "sft_log.json")
478
+ with open(log_path, "w") as f:
479
+ json.dump(log, f, indent=2)
480
+
481
+ total_time = time.time() - start_time
482
+ print(f"\nSFT complete!")
483
+ print(f" Total time: {total_time:.1f}s ({total_time/60:.1f} min)")
484
+ print(f" Final EMA loss: {accum_loss:.4f}")
485
+ print(f" Best loss: {best_loss:.4f}")
486
+
487
+
488
+ def main():
489
+ parser = argparse.ArgumentParser(description="SFT the search agent")
490
+ parser.add_argument("--steps", type=int, default=500, help="Total SFT steps")
491
+ parser.add_argument("--batch_size", type=int, default=4, help="Micro batch size")
492
+ parser.add_argument("--grad_accum", type=int, default=4, help="Gradient accumulation")
493
+ parser.add_argument("--seq_len", type=int, default=768, help="Sequence length")
494
+ parser.add_argument("--lr", type=float, default=5e-5, help="Peak learning rate")
495
+ parser.add_argument("--warmup", type=int, default=20, help="Warmup steps")
496
+ parser.add_argument("--save_every", type=int, default=100, help="Save every N steps")
497
+ parser.add_argument("--log_every", type=int, default=10, help="Log every N steps")
498
+ parser.add_argument("--grad_checkpoint", action="store_true", default=True)
499
+ parser.add_argument("--no_grad_checkpoint", dest="grad_checkpoint", action="store_false")
500
+ parser.add_argument("--use_8bit_adam", action="store_true", default=True)
501
+ parser.add_argument("--no_8bit_adam", dest="use_8bit_adam", action="store_false")
502
+ parser.add_argument("--resume_path", type=str, default=None, help="Pretrained checkpoint to start from")
503
+ parser.add_argument("--project_dir", type=str, default=None, help="Override project directory (for Colab)")
504
+ parser.add_argument("--save_dir", type=str, default=None, help="Override checkpoint save directory (for Google Drive)")
505
+ args = parser.parse_args()
506
+
507
+ # Override paths for Colab
508
+ global PROJECT_DIR, DATA_DIR, TOKENIZER_DIR, CHECKPOINT_DIR, LOGS_DIR
509
+ global TRACES_PATH, GOLD_PATH, TOKENIZER_PATH, SPECIAL_TOKENS_PATH
510
+ if args.project_dir:
511
+ PROJECT_DIR = args.project_dir
512
+ DATA_DIR = os.path.join(PROJECT_DIR, "data")
513
+ TOKENIZER_DIR = os.path.join(PROJECT_DIR, "tokenizer")
514
+ LOGS_DIR = os.path.join(PROJECT_DIR, "logs")
515
+ TRACES_PATH = os.path.join(DATA_DIR, "sft_traces.jsonl")
516
+ GOLD_PATH = os.path.join(DATA_DIR, "gold_traces.jsonl")
517
+ TOKENIZER_PATH = os.path.join(TOKENIZER_DIR, "tokenizer_agent.json")
518
+ SPECIAL_TOKENS_PATH = os.path.join(TOKENIZER_DIR, "special_tokens.json")
519
+ if args.save_dir:
520
+ CHECKPOINT_DIR = args.save_dir
521
+
522
+ train(args)
523
+
524
+
525
+ if __name__ == "__main__":
526
+ main()
src/sft_dry_run.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dry-run SFT: load data, build model, resize embeddings, one forward pass."""
2
+ import sys, os, json, torch
3
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__))))
4
+
5
+ from sft import load_special_tokens, load_sft_dataset, get_sft_batch
6
+ from model import ModelConfig, Retriever500M
7
+ from tokenizers import Tokenizer
8
+
9
+ def main():
10
+ load_special_tokens()
11
+ tok = Tokenizer.from_file("tokenizer/tokenizer_agent.json")
12
+ print("Loading dataset...")
13
+ dataset = load_sft_dataset("data/sft_traces.jsonl", tok, max_seq_len=768)
14
+ print(f"Dataset size: {len(dataset)}")
15
+
16
+ config = ModelConfig(vocab_size=32009, d_model=1280, n_layers=23, n_heads=20,
17
+ d_ff=3456, max_seq_len=768, tie_embeddings=True)
18
+ model = Retriever500M(config).cuda()
19
+ print(f"Model: {model.count_parameters()/1e6:.1f}M params")
20
+
21
+ # Load old checkpoint and resize embeddings
22
+ ckpt = torch.load("checkpoints/latest.pt", map_location="cuda", weights_only=False)
23
+ state = ckpt["model_state_dict"]
24
+ old_w = state["token_embedding.weight"]
25
+ new_w = torch.zeros(32009, 1280)
26
+ new_w[:32000] = old_w
27
+ torch.nn.init.normal_(new_w[32000:], mean=0.0, std=0.02)
28
+ state["token_embedding.weight"] = new_w
29
+ model.load_state_dict(state)
30
+ print("Checkpoint loaded with resized embeddings")
31
+
32
+ # One forward pass
33
+ input_ids, targets, loss_mask = get_sft_batch(dataset, batch_size=2, seq_len=768, device=torch.device("cuda"))
34
+ print(f"Batch: {input_ids.shape}")
35
+
36
+ with torch.autocast("cuda", dtype=torch.bfloat16):
37
+ out = model(input_ids, targets=targets)
38
+ print(f"Forward pass OK: loss={out['loss'].item():.4f}")
39
+
40
+ print("SFT DRY RUN PASSED")
41
+
42
+ if __name__ == "__main__":
43
+ main()
src/train.py ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Base pretraining script for Retriever500M.
3
+
4
+ Memory optimizations for 8GB VRAM (RTX 4070 Laptop):
5
+ - bf16 mixed precision (autocast)
6
+ - Gradient checkpointing (recompute activations during backward)
7
+ - 8-bit AdamW optimizer (bitsandbytes) — halves optimizer state memory
8
+ - Gradient accumulation (effective batch size > micro batch size)
9
+ - Short sequence length (512 tokens) for base training
10
+ - Tied embeddings (shared input/output weight)
11
+ - Flash Attention via torch SDPA
12
+
13
+ Usage:
14
+ python src/train.py [--steps N] [--seq_len N] [--batch_size N] [--grad_accum N]
15
+ """
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import sys
21
+ import time
22
+ from dataclasses import asdict
23
+
24
+ import numpy as np
25
+ import torch
26
+ import torch.nn.functional as F
27
+ from tqdm import tqdm
28
+
29
+ # Add src to path
30
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
31
+
32
+ from model import ModelConfig, Retriever500M
33
+ from tokenizers import Tokenizer
34
+
35
+ # ─── Paths ───────────────────────────────────────────────────────────────────
36
+ PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
37
+ DATA_DIR = os.path.join(PROJECT_DIR, "data")
38
+ TOKENIZER_DIR = os.path.join(PROJECT_DIR, "tokenizer")
39
+ CHECKPOINT_DIR = os.path.join(PROJECT_DIR, "checkpoints")
40
+ LOGS_DIR = os.path.join(PROJECT_DIR, "logs")
41
+
42
+ CORPUS_PATH = os.path.join(DATA_DIR, "corpus.txt")
43
+ CURATED_CORPUS_PATH = os.path.join(DATA_DIR, "corpus_curated.txt")
44
+ TOKENIZER_PATH = os.path.join(TOKENIZER_DIR, "tokenizer.json")
45
+
46
+ # ─── Data Loading ────────────────────────────────────────────────────────────
47
+
48
+ def load_and_tokenize(corpus_path: str, tokenizer: Tokenizer) -> np.ndarray:
49
+ """Load corpus, tokenize everything, return a flat numpy array of token IDs."""
50
+ print(f"Loading corpus from {corpus_path}...")
51
+ with open(corpus_path, "r", encoding="utf-8") as f:
52
+ text = f.read()
53
+
54
+ print(f"Corpus size: {len(text) / 1e6:.1f} MB")
55
+
56
+ # Tokenize in chunks to avoid memory issues
57
+ chunk_size = 1_000_000 # 1MB chunks
58
+ all_tokens = []
59
+
60
+ print("Tokenizing corpus...")
61
+ for i in tqdm(range(0, len(text), chunk_size)):
62
+ chunk = text[i : i + chunk_size]
63
+ encoded = tokenizer.encode(chunk)
64
+ all_tokens.extend(encoded.ids)
65
+
66
+ tokens = np.array(all_tokens, dtype=np.int32)
67
+ print(f"Total tokens: {len(tokens):,}")
68
+ return tokens
69
+
70
+
71
+ def get_batch(
72
+ tokens: np.ndarray,
73
+ batch_size: int,
74
+ seq_len: int,
75
+ device: torch.device,
76
+ ) -> tuple[torch.Tensor, torch.Tensor]:
77
+ """Sample a random batch of sequences from the token array.
78
+
79
+ Returns (input_ids, targets) where targets are shifted by 1.
80
+ """
81
+ # Random starting indices
82
+ max_start = len(tokens) - seq_len - 1
83
+ indices = np.random.randint(0, max_start, size=batch_size)
84
+
85
+ # Gather sequences
86
+ input_ids = np.stack([tokens[i : i + seq_len] for i in indices])
87
+ targets = np.stack([tokens[i + 1 : i + seq_len + 1] for i in indices])
88
+
89
+ input_ids = torch.from_numpy(input_ids).long().to(device)
90
+ targets = torch.from_numpy(targets).long().to(device)
91
+
92
+ return input_ids, targets
93
+
94
+
95
+ # ─── Training ────────────────────────────────────────────────────────────────
96
+
97
+ def setup_optimizer(model: Retriever500M, lr: float, use_8bit: bool = True):
98
+ """Set up optimizer — 8-bit AdamW if available, else standard AdamW."""
99
+ # Separate embedding params (no weight decay) from rest
100
+ decay_params = []
101
+ no_decay_params = []
102
+ for name, param in model.named_parameters():
103
+ if not param.requires_grad:
104
+ continue
105
+ if "embedding" in name or "norm" in name or "weight" in name and ".weight" not in name:
106
+ no_decay_params.append(param)
107
+ else:
108
+ decay_params.append(param)
109
+
110
+ param_groups = [
111
+ {"params": decay_params, "weight_decay": 0.1},
112
+ {"params": no_decay_params, "weight_decay": 0.0},
113
+ ]
114
+
115
+ if use_8bit:
116
+ try:
117
+ import bitsandbytes as bnb
118
+ optimizer = bnb.optim.AdamW8bit(
119
+ param_groups, lr=lr, betas=(0.9, 0.95), eps=1e-8,
120
+ )
121
+ print("Using 8-bit AdamW (bitsandbytes)")
122
+ return optimizer
123
+ except Exception as e:
124
+ print(f"8-bit optimizer unavailable ({e}), falling back to AdamW")
125
+
126
+ optimizer = torch.optim.AdamW(
127
+ param_groups, lr=lr, betas=(0.9, 0.95), eps=1e-8,
128
+ )
129
+ print("Using standard AdamW")
130
+ return optimizer
131
+
132
+
133
+ def resume_from_checkpoint(
134
+ model: Retriever500M,
135
+ optimizer,
136
+ resume_path: str,
137
+ device: torch.device,
138
+ ) -> tuple[int, float]:
139
+ """Load model + optimizer state from a checkpoint.
140
+
141
+ Returns (start_step, best_loss) so the training loop can continue.
142
+ """
143
+ print(f"Resuming from {resume_path}")
144
+ ckpt = torch.load(resume_path, map_location=device, weights_only=False)
145
+
146
+ model.load_state_dict(ckpt["model_state_dict"])
147
+ print(f" Loaded model weights (step {ckpt.get('step', '?')})")
148
+
149
+ if "optimizer_state_dict" in ckpt:
150
+ try:
151
+ optimizer.load_state_dict(ckpt["optimizer_state_dict"])
152
+ print(" Loaded optimizer state")
153
+ except Exception as e:
154
+ print(f" Could not load optimizer state ({e}); starting fresh optimizer")
155
+
156
+ start_step = int(ckpt.get("step", 0))
157
+ best_loss = float(ckpt.get("loss", float("inf")))
158
+ print(f" Resuming at step {start_step} (best_loss={best_loss:.4f})")
159
+ return start_step, best_loss
160
+
161
+
162
+ def get_lr(step: int, warmup_steps: int, max_steps: int, max_lr: float, min_lr: float) -> float:
163
+ """Cosine learning rate schedule with linear warmup."""
164
+ if step < warmup_steps:
165
+ return max_lr * (step + 1) / warmup_steps
166
+ if step > max_steps:
167
+ return min_lr
168
+ decay_ratio = (step - warmup_steps) / (max_steps - warmup_steps)
169
+ coeff = 0.5 * (1.0 + np.cos(np.pi * decay_ratio))
170
+ return min_lr + coeff * (max_lr - min_lr)
171
+
172
+
173
+ def train(args):
174
+ # ─── Setup ───────────────────────────────────────────────────────────────
175
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
176
+ print(f"Device: {device}")
177
+ if device.type == "cuda":
178
+ print(f"GPU: {torch.cuda.get_device_name(0)}")
179
+ print(f"VRAM: {torch.cuda.get_device_properties(0).total_memory / 1e9:.2f} GB")
180
+
181
+ os.makedirs(CHECKPOINT_DIR, exist_ok=True)
182
+ os.makedirs(LOGS_DIR, exist_ok=True)
183
+
184
+ # ─── Tokenizer ───────────────────────────────────────────────────────────
185
+ print("Loading tokenizer...")
186
+ tokenizer = Tokenizer.from_file(TOKENIZER_PATH)
187
+ vocab_size = tokenizer.get_vocab_size()
188
+ print(f"Vocab size: {vocab_size}")
189
+
190
+ # ─── Data ────────────────────────────────────────────────────────────────
191
+ if args.corpus == "curated":
192
+ corpus_path = CURATED_CORPUS_PATH
193
+ if not os.path.exists(corpus_path):
194
+ raise FileNotFoundError(f"Curated corpus not found: {corpus_path}. Run src/curate.py first.")
195
+ print(f"Using CURATED corpus: {corpus_path}")
196
+ elif args.corpus == "default":
197
+ corpus_path = CORPUS_PATH
198
+ else:
199
+ corpus_path = args.corpus
200
+ tokens = load_and_tokenize(corpus_path, tokenizer)
201
+
202
+ # ─── Model ───────────────────────────────────────────────────────────────
203
+ config = ModelConfig(
204
+ vocab_size=vocab_size,
205
+ d_model=1_280,
206
+ n_layers=23,
207
+ n_heads=20,
208
+ d_ff=3_456,
209
+ max_seq_len=args.seq_len,
210
+ dropout=0.0,
211
+ tie_embeddings=True,
212
+ )
213
+
214
+ model = Retriever500M(config).to(device)
215
+ total_params = model.count_parameters()
216
+ print(f"Model parameters: {total_params:,} ({total_params / 1e6:.1f}M)")
217
+
218
+ # ─── Optimizer ───────────────────────────────────────────────────────────
219
+ optimizer = setup_optimizer(model, args.lr, use_8bit=args.use_8bit_adam)
220
+
221
+ # ─── Resume from checkpoint ──────────────────────────────────────────────
222
+ start_step = 0
223
+ best_loss = float("inf")
224
+ prev_log_steps = []
225
+ if args.resume:
226
+ resume_path = args.resume_path or os.path.join(CHECKPOINT_DIR, "latest.pt")
227
+ if not os.path.exists(resume_path):
228
+ raise FileNotFoundError(f"Cannot resume: {resume_path} does not exist")
229
+ start_step, best_loss = resume_from_checkpoint(model, optimizer, resume_path, device)
230
+ accum_loss = best_loss # continue EMA from saved loss
231
+ # Load previous log entries so we append rather than overwrite history
232
+ prev_log_path = os.path.join(LOGS_DIR, "training_log.json")
233
+ if os.path.exists(prev_log_path):
234
+ try:
235
+ with open(prev_log_path, "r") as f:
236
+ prev_log = json.load(f)
237
+ prev_log_steps = prev_log.get("steps", [])
238
+ print(f" Loaded {len(prev_log_steps)} previous log entries")
239
+ except Exception as e:
240
+ print(f" Could not load previous log ({e})")
241
+ else:
242
+ accum_loss = 0.0
243
+
244
+ # ─── Training loop ───────────────────────────────────────────────────────
245
+ effective_batch = args.batch_size * args.grad_accum
246
+ max_steps_total = start_step + args.steps # for LR schedule continuity
247
+ print(f"\nTraining configuration:")
248
+ print(f" Micro batch size: {args.batch_size}")
249
+ print(f" Gradient accum: {args.grad_accum}")
250
+ print(f" Effective batch: {effective_batch}")
251
+ print(f" Sequence length: {args.seq_len}")
252
+ print(f" Learning rate: {args.lr}")
253
+ print(f" Steps this run: {args.steps}")
254
+ print(f" Start step: {start_step}")
255
+ print(f" Target step: {max_steps_total}")
256
+ print(f" Warmup steps: {args.warmup}")
257
+ print(f" Grad checkpointing: {args.grad_checkpoint}")
258
+ print()
259
+
260
+ # Training log
261
+ log = {
262
+ "config": asdict(config),
263
+ "train_args": vars(args),
264
+ "total_params": total_params,
265
+ "steps": list(prev_log_steps), # carry over previous entries
266
+ }
267
+
268
+ model.train()
269
+ step = start_step
270
+ start_time = time.time()
271
+
272
+ pbar = tqdm(range(start_step, max_steps_total), desc="Training", initial=start_step, total=max_steps_total)
273
+ for step in pbar:
274
+ # Learning rate schedule (uses absolute step for continuity)
275
+ lr = get_lr(step, args.warmup, max_steps_total, args.lr, args.lr * 0.1)
276
+ for pg in optimizer.param_groups:
277
+ pg["lr"] = lr
278
+
279
+ optimizer.zero_grad(set_to_none=True)
280
+
281
+ # Gradient accumulation
282
+ total_loss = 0.0
283
+ for micro_step in range(args.grad_accum):
284
+ input_ids, targets = get_batch(tokens, args.batch_size, args.seq_len, device)
285
+
286
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
287
+ out = model(input_ids, targets=targets, use_checkpoint=args.grad_checkpoint)
288
+ loss = out["loss"] / args.grad_accum
289
+
290
+ loss.backward()
291
+ total_loss += loss.item()
292
+
293
+ # Gradient clipping
294
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
295
+
296
+ # Optimizer step
297
+ optimizer.step()
298
+
299
+ avg_loss = total_loss # already divided by grad_accum
300
+ accum_loss = accum_loss * 0.95 + avg_loss * 0.05 # EMA
301
+
302
+ # Logging
303
+ if step % args.log_every == 0 or step == max_steps_total - 1:
304
+ elapsed = time.time() - start_time
305
+ steps_this_run = step - start_step + 1
306
+ steps_per_sec = steps_this_run / elapsed
307
+ vram_used = torch.cuda.max_memory_allocated() / 1e9 if device.type == "cuda" else 0
308
+
309
+ log_entry = {
310
+ "step": step,
311
+ "loss": avg_loss,
312
+ "ema_loss": accum_loss,
313
+ "lr": lr,
314
+ "elapsed_s": elapsed,
315
+ "steps_per_sec": steps_per_sec,
316
+ "vram_gb": vram_used,
317
+ }
318
+ log["steps"].append(log_entry)
319
+
320
+ pbar.set_postfix({
321
+ "loss": f"{avg_loss:.4f}",
322
+ "ema": f"{accum_loss:.4f}",
323
+ "lr": f"{lr:.2e}",
324
+ "vram": f"{vram_used:.1f}G",
325
+ })
326
+
327
+ # Save checkpoint
328
+ if (step + 1) % args.save_every == 0 or step == max_steps_total - 1:
329
+ ckpt_path = os.path.join(CHECKPOINT_DIR, f"model_step_{step + 1}.pt")
330
+ torch.save({
331
+ "model_state_dict": model.state_dict(),
332
+ "optimizer_state_dict": optimizer.state_dict(),
333
+ "config": asdict(config),
334
+ "step": step + 1,
335
+ "loss": accum_loss,
336
+ }, ckpt_path)
337
+ print(f"\n Saved checkpoint: {ckpt_path}")
338
+
339
+ # Also save latest
340
+ latest_path = os.path.join(CHECKPOINT_DIR, "latest.pt")
341
+ torch.save({
342
+ "model_state_dict": model.state_dict(),
343
+ "config": asdict(config),
344
+ "step": step + 1,
345
+ "loss": accum_loss,
346
+ }, latest_path)
347
+
348
+ if accum_loss < best_loss:
349
+ best_loss = accum_loss
350
+ best_path = os.path.join(CHECKPOINT_DIR, "best.pt")
351
+ torch.save({
352
+ "model_state_dict": model.state_dict(),
353
+ "config": asdict(config),
354
+ "step": step + 1,
355
+ "loss": accum_loss,
356
+ }, best_path)
357
+
358
+ # Reset peak memory stats periodically
359
+ if step % 50 == 0 and device.type == "cuda":
360
+ torch.cuda.reset_peak_memory_stats()
361
+
362
+ # ─── Save training log ───────────────────────────────────────────────────
363
+ log_path = os.path.join(LOGS_DIR, "training_log.json")
364
+ with open(log_path, "w") as f:
365
+ json.dump(log, f, indent=2)
366
+ print(f"\nTraining log saved to {log_path}")
367
+
368
+ total_time = time.time() - start_time
369
+ print(f"\nTraining complete!")
370
+ print(f" Total time: {total_time:.1f}s ({total_time/60:.1f} min)")
371
+ print(f" Final EMA loss: {accum_loss:.4f}")
372
+ print(f" Best loss: {best_loss:.4f}")
373
+ print(f" Steps/sec: {args.steps / total_time:.2f}")
374
+
375
+ return model, log
376
+
377
+
378
+ def main():
379
+ parser = argparse.ArgumentParser(description="Train Retriever500M base model")
380
+ parser.add_argument("--steps", type=int, default=2000, help="Total training steps")
381
+ parser.add_argument("--batch_size", type=int, default=4, help="Micro batch size")
382
+ parser.add_argument("--grad_accum", type=int, default=8, help="Gradient accumulation steps")
383
+ parser.add_argument("--seq_len", type=int, default=512, help="Sequence length")
384
+ parser.add_argument("--lr", type=float, default=3e-4, help="Peak learning rate")
385
+ parser.add_argument("--warmup", type=int, default=100, help="Warmup steps")
386
+ parser.add_argument("--save_every", type=int, default=500, help="Save checkpoint every N steps")
387
+ parser.add_argument("--log_every", type=int, default=10, help="Log every N steps")
388
+ parser.add_argument("--grad_checkpoint", action="store_true", default=True, help="Use gradient checkpointing")
389
+ parser.add_argument("--no_grad_checkpoint", dest="grad_checkpoint", action="store_false")
390
+ parser.add_argument("--use_8bit_adam", action="store_true", default=True, help="Use 8-bit AdamW")
391
+ parser.add_argument("--no_8bit_adam", dest="use_8bit_adam", action="store_false")
392
+ parser.add_argument("--resume", action="store_true", help="Resume training from latest checkpoint")
393
+ parser.add_argument("--resume_path", type=str, default=None, help="Specific checkpoint to resume from (default: checkpoints/latest.pt)")
394
+ parser.add_argument("--corpus", type=str, default="default", help="Corpus to use: 'default', 'curated', or a path")
395
+ args = parser.parse_args()
396
+
397
+ train(args)
398
+
399
+
400
+ if __name__ == "__main__":
401
+ main()
src/train_modal.py ADDED
@@ -0,0 +1,840 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Modal training script for Retriever500M.
3
+
4
+ Runs pretraining and/or SFT on Modal cloud GPUs instead of the local laptop.
5
+ Supports resuming from the existing bf16 checkpoint.
6
+
7
+ Usage:
8
+ # Step 1: Seed the volume with the initial checkpoint
9
+ modal run src/train_modal.py --command seed
10
+
11
+ # Step 2: Run pretraining (continues from latest checkpoint)
12
+ modal run src/train_modal.py --command pretrain --steps 1000
13
+
14
+ # Step 3: Run SFT (starts from pretrained checkpoint)
15
+ modal run src/train_modal.py --command sft --steps 500
16
+
17
+ # Step 4: Download checkpoints back to local
18
+ modal run src/train_modal.py --command download
19
+
20
+ # Or do everything in one shot:
21
+ modal run src/train_modal.py --command pipeline --pretrain-steps 1000 --sft-steps 500
22
+ """
23
+
24
+ import os
25
+ import sys
26
+ import json
27
+ import time
28
+ import argparse
29
+ from dataclasses import asdict
30
+
31
+ import numpy as np
32
+ import torch
33
+ import torch.nn.functional as F
34
+ from tqdm import tqdm
35
+
36
+ import modal
37
+
38
+ # ─── Modal Setup ─────────────────────────────────────────────────────────────
39
+
40
+ APP_NAME = "retriever500m"
41
+ VOLUME_NAME = "retriever500m-data"
42
+
43
+ PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
44
+ SRC_DIR = os.path.join(PROJECT_DIR, "src")
45
+ DATA_DIR = os.path.join(PROJECT_DIR, "data")
46
+ TOKENIZER_DIR = os.path.join(PROJECT_DIR, "tokenizer")
47
+ CHECKPOINT_DIR = os.path.join(PROJECT_DIR, "checkpoints")
48
+
49
+ volume = modal.Volume.from_name(VOLUME_NAME, create_if_missing=True)
50
+
51
+ # Image: PyTorch with CUDA, plus bitsandbytes for 8-bit optimizer.
52
+ # Data, tokenizer, src, and initial checkpoint are baked into the image.
53
+ # The volume is used only for saving new checkpoints and logs.
54
+ image = (
55
+ modal.Image.from_registry(
56
+ "pytorch/pytorch:2.4.1-cuda12.1-cudnn9-runtime",
57
+ add_python="3.11",
58
+ )
59
+ .pip_install("tokenizers>=0.15,<0.21", "tqdm", "numpy")
60
+ .pip_install("bitsandbytes>=0.43,<0.45")
61
+ .add_local_dir(SRC_DIR, "/root/src")
62
+ .add_local_dir(TOKENIZER_DIR, "/root/tokenizer")
63
+ .add_local_dir(DATA_DIR, "/root/data", ignore=["raw_large/", "raw/", "dedup/"])
64
+ .add_local_dir(CHECKPOINT_DIR, "/root/seed_checkpoints")
65
+ )
66
+
67
+ app = modal.App(APP_NAME, image=image)
68
+
69
+ # Remote paths inside the container.
70
+ # Data/tokenizer/src are baked into the image.
71
+ # The volume is mounted at /root/vol for checkpoints and logs.
72
+ VOL_MOUNT = "/root/vol"
73
+ REMOTE_DATA = "/root/data" # baked into image
74
+ REMOTE_CKPT = "/root/vol/checkpoints" # on volume (for new checkpoints)
75
+ REMOTE_LOGS = "/root/vol/logs" # on volume
76
+ REMOTE_TOKENIZER = "/root/tokenizer" # baked into image
77
+ REMOTE_SRC = "/root/src" # baked into image
78
+ REMOTE_SEED_CKPT = "/root/seed_checkpoints" # baked into image (initial checkpoint)
79
+
80
+
81
+ # ─── Seed command ────────────────────────────────────────────────────────────
82
+
83
+ @app.function(volumes={VOL_MOUNT: volume})
84
+ def seed():
85
+ """Copy the initial checkpoint from the image to the volume."""
86
+ import shutil
87
+
88
+ os.makedirs(REMOTE_CKPT, exist_ok=True)
89
+ os.makedirs(REMOTE_LOGS, exist_ok=True)
90
+
91
+ # Copy seed checkpoints from image to volume
92
+ if os.path.exists(REMOTE_SEED_CKPT):
93
+ for fname in os.listdir(REMOTE_SEED_CKPT):
94
+ src = os.path.join(REMOTE_SEED_CKPT, fname)
95
+ dst = os.path.join(REMOTE_CKPT, fname)
96
+ if os.path.isfile(src) and not os.path.exists(dst):
97
+ shutil.copy2(src, dst)
98
+ print(f" Seeded {fname} ({os.path.getsize(src) / 1e6:.1f} MB)")
99
+ elif os.path.exists(dst):
100
+ print(f" SKIP {fname} (already on volume)")
101
+
102
+ volume.commit()
103
+ print("Seed complete.")
104
+
105
+
106
+ # ─── Download command ────────────────────────────────────────────────────────
107
+
108
+ @app.function(volumes={VOL_MOUNT: volume})
109
+ def download():
110
+ """Download checkpoints and logs from the Modal volume to local."""
111
+ import shutil
112
+
113
+ os.makedirs(CHECKPOINT_DIR, exist_ok=True)
114
+ os.makedirs(os.path.join(PROJECT_DIR, "logs"), exist_ok=True)
115
+
116
+ # Download checkpoints
117
+ for fname in os.listdir(REMOTE_CKPT):
118
+ src = os.path.join(REMOTE_CKPT, fname)
119
+ if os.path.isfile(src):
120
+ dst = os.path.join(CHECKPOINT_DIR, fname)
121
+ shutil.copy2(src, dst)
122
+ print(f" Downloaded {fname} ({os.path.getsize(src) / 1e6:.1f} MB)")
123
+
124
+ # Download logs
125
+ for fname in os.listdir(REMOTE_LOGS):
126
+ src = os.path.join(REMOTE_LOGS, fname)
127
+ if os.path.isfile(src):
128
+ dst = os.path.join(PROJECT_DIR, "logs", fname)
129
+ shutil.copy2(src, dst)
130
+ print(f" Downloaded log {fname}")
131
+
132
+ print("Download complete.")
133
+
134
+
135
+ # ─── Training logic (shared) ─────────────────────────────────────────────────
136
+
137
+ def get_lr(step, warmup, max_steps, max_lr, min_lr):
138
+ """Cosine LR schedule with linear warmup."""
139
+ if step < warmup:
140
+ return max_lr * (step + 1) / warmup
141
+ if step > max_steps:
142
+ return min_lr
143
+ decay_ratio = (step - warmup) / (max_steps - warmup)
144
+ coeff = 0.5 * (1.0 + np.cos(np.pi * decay_ratio))
145
+ return min_lr + coeff * (max_lr - min_lr)
146
+
147
+
148
+ def setup_optimizer(model, lr, use_8bit=True):
149
+ decay_params, no_decay_params = [], []
150
+ for name, param in model.named_parameters():
151
+ if not param.requires_grad:
152
+ continue
153
+ if "embedding" in name or "norm" in name:
154
+ no_decay_params.append(param)
155
+ else:
156
+ decay_params.append(param)
157
+
158
+ param_groups = [
159
+ {"params": decay_params, "weight_decay": 0.1},
160
+ {"params": no_decay_params, "weight_decay": 0.0},
161
+ ]
162
+
163
+ if use_8bit:
164
+ try:
165
+ import bitsandbytes as bnb
166
+ optimizer = bnb.optim.AdamW8bit(param_groups, lr=lr, betas=(0.9, 0.95), eps=1e-8)
167
+ print("Using 8-bit AdamW (bitsandbytes)")
168
+ return optimizer
169
+ except Exception as e:
170
+ print(f"8-bit optimizer unavailable ({e}), falling back to AdamW")
171
+
172
+ optimizer = torch.optim.AdamW(param_groups, lr=lr, betas=(0.9, 0.95), eps=1e-8)
173
+ print("Using standard AdamW")
174
+ return optimizer
175
+
176
+
177
+ # ─── Pretraining ─────────────────────────────────────────────────────────────
178
+
179
+ def load_and_tokenize(corpus_path, tokenizer):
180
+ """Load corpus, tokenize, return flat numpy array of token IDs."""
181
+ print(f"Loading corpus from {corpus_path}...")
182
+ with open(corpus_path, "r", encoding="utf-8") as f:
183
+ text = f.read()
184
+ print(f"Corpus size: {len(text) / 1e6:.1f} MB")
185
+
186
+ chunk_size = 1_000_000
187
+ all_tokens = []
188
+ print("Tokenizing corpus...")
189
+ for i in tqdm(range(0, len(text), chunk_size)):
190
+ chunk = text[i : i + chunk_size]
191
+ encoded = tokenizer.encode(chunk)
192
+ all_tokens.extend(encoded.ids)
193
+
194
+ tokens = np.array(all_tokens, dtype=np.int32)
195
+ print(f"Total tokens: {len(tokens):,}")
196
+ return tokens
197
+
198
+
199
+ def get_batch(tokens, batch_size, seq_len, device):
200
+ max_start = len(tokens) - seq_len - 1
201
+ indices = np.random.randint(0, max_start, size=batch_size)
202
+ input_ids = np.stack([tokens[i : i + seq_len] for i in indices])
203
+ targets = np.stack([tokens[i + 1 : i + seq_len + 1] for i in indices])
204
+ input_ids = torch.from_numpy(input_ids).long().to(device)
205
+ targets = torch.from_numpy(targets).long().to(device)
206
+ return input_ids, targets
207
+
208
+
209
+ def run_pretrain(steps, batch_size, grad_accum, seq_len, lr, warmup,
210
+ save_every, log_every, corpus, resume, use_8bit_adam):
211
+ sys.path.insert(0, REMOTE_SRC)
212
+ from model import ModelConfig, Retriever500M
213
+ from tokenizers import Tokenizer
214
+
215
+ device = torch.device("cuda")
216
+ print(f"Device: {device}")
217
+ print(f"GPU: {torch.cuda.get_device_name(0)}")
218
+ print(f"VRAM: {torch.cuda.get_device_properties(0).total_memory / 1e9:.2f} GB")
219
+
220
+ os.makedirs(REMOTE_CKPT, exist_ok=True)
221
+ os.makedirs(REMOTE_LOGS, exist_ok=True)
222
+
223
+ # Tokenizer
224
+ tokenizer_path = os.path.join(REMOTE_TOKENIZER, "tokenizer.json")
225
+ tokenizer = Tokenizer.from_file(tokenizer_path)
226
+ vocab_size = tokenizer.get_vocab_size()
227
+ print(f"Vocab size: {vocab_size}")
228
+
229
+ # Data
230
+ if corpus == "curated":
231
+ corpus_path = os.path.join(REMOTE_DATA, "corpus_curated.txt")
232
+ elif corpus == "default":
233
+ corpus_path = os.path.join(REMOTE_DATA, "corpus.txt")
234
+ else:
235
+ corpus_path = corpus
236
+ tokens = load_and_tokenize(corpus_path, tokenizer)
237
+
238
+ # Model
239
+ config = ModelConfig(
240
+ vocab_size=vocab_size,
241
+ d_model=1_280,
242
+ n_layers=23,
243
+ n_heads=20,
244
+ d_ff=3_456,
245
+ max_seq_len=seq_len,
246
+ dropout=0.0,
247
+ tie_embeddings=True,
248
+ )
249
+ model = Retriever500M(config).to(device)
250
+ total_params = model.count_parameters()
251
+ print(f"Model parameters: {total_params:,} ({total_params / 1e6:.1f}M)")
252
+
253
+ optimizer = setup_optimizer(model, lr, use_8bit=use_8bit_adam)
254
+
255
+ # Resume
256
+ start_step = 0
257
+ best_loss = float("inf")
258
+ accum_loss = 0.0
259
+ prev_log_steps = []
260
+
261
+ if resume:
262
+ resume_path = os.path.join(REMOTE_CKPT, "latest.pt")
263
+ if not os.path.exists(resume_path):
264
+ resume_path = os.path.join(REMOTE_CKPT, "latest_bf16.pt")
265
+ if not os.path.exists(resume_path):
266
+ # Fall back to seed checkpoint in image
267
+ resume_path = os.path.join(REMOTE_SEED_CKPT, "latest_bf16.pt")
268
+ if os.path.exists(resume_path):
269
+ print(f"Resuming from {resume_path}")
270
+ ckpt = torch.load(resume_path, map_location=device, weights_only=False)
271
+ model.load_state_dict(ckpt["model_state_dict"])
272
+ start_step = int(ckpt.get("step", 0))
273
+ best_loss = float(ckpt.get("loss", float("inf")))
274
+ accum_loss = best_loss
275
+ print(f" Resuming at step {start_step} (best_loss={best_loss:.4f})")
276
+
277
+ prev_log_path = os.path.join(REMOTE_LOGS, "training_log.json")
278
+ if os.path.exists(prev_log_path):
279
+ try:
280
+ with open(prev_log_path, "r") as f:
281
+ prev_log = json.load(f)
282
+ prev_log_steps = prev_log.get("steps", [])
283
+ print(f" Loaded {len(prev_log_steps)} previous log entries")
284
+ except Exception:
285
+ pass
286
+ else:
287
+ print("WARNING: No checkpoint found, starting from scratch!")
288
+
289
+ # Training loop
290
+ effective_batch = batch_size * grad_accum
291
+ max_steps_total = start_step + steps
292
+ print(f"\nTraining configuration:")
293
+ print(f" Micro batch size: {batch_size}")
294
+ print(f" Gradient accum: {grad_accum}")
295
+ print(f" Effective batch: {effective_batch}")
296
+ print(f" Sequence length: {seq_len}")
297
+ print(f" Learning rate: {lr}")
298
+ print(f" Steps this run: {steps}")
299
+ print(f" Start step: {start_step}")
300
+ print(f" Target step: {max_steps_total}")
301
+ print()
302
+
303
+ log = {
304
+ "config": asdict(config),
305
+ "train_args": {"steps": steps, "batch_size": batch_size, "grad_accum": grad_accum,
306
+ "seq_len": seq_len, "lr": lr, "warmup": warmup,
307
+ "save_every": save_every, "log_every": log_every,
308
+ "corpus": corpus, "resume": resume, "use_8bit_adam": use_8bit_adam},
309
+ "total_params": total_params,
310
+ "steps": list(prev_log_steps),
311
+ }
312
+
313
+ model.train()
314
+ start_time = time.time()
315
+
316
+ pbar = tqdm(range(start_step, max_steps_total), desc="Training",
317
+ initial=start_step, total=max_steps_total)
318
+ for step in pbar:
319
+ lr_now = get_lr(step, warmup, max_steps_total, lr, lr * 0.1)
320
+ for pg in optimizer.param_groups:
321
+ pg["lr"] = lr_now
322
+
323
+ optimizer.zero_grad(set_to_none=True)
324
+
325
+ total_loss = 0.0
326
+ for _ in range(grad_accum):
327
+ input_ids, targets = get_batch(tokens, batch_size, seq_len, device)
328
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
329
+ out = model(input_ids, targets=targets, use_checkpoint=False)
330
+ loss = out["loss"] / grad_accum
331
+ loss.backward()
332
+ total_loss += loss.item()
333
+
334
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
335
+ optimizer.step()
336
+
337
+ avg_loss = total_loss
338
+ accum_loss = accum_loss * 0.95 + avg_loss * 0.05
339
+
340
+ if step % log_every == 0 or step == max_steps_total - 1:
341
+ elapsed = time.time() - start_time
342
+ steps_this_run = step - start_step + 1
343
+ steps_per_sec = steps_this_run / elapsed
344
+ vram_used = torch.cuda.max_memory_allocated() / 1e9
345
+
346
+ log_entry = {
347
+ "step": step, "loss": avg_loss, "ema_loss": accum_loss,
348
+ "lr": lr_now, "elapsed_s": elapsed,
349
+ "steps_per_sec": steps_per_sec, "vram_gb": vram_used,
350
+ }
351
+ log["steps"].append(log_entry)
352
+ pbar.set_postfix({
353
+ "loss": f"{avg_loss:.4f}", "ema": f"{accum_loss:.4f}",
354
+ "lr": f"{lr_now:.2e}", "vram": f"{vram_used:.1f}G",
355
+ })
356
+
357
+ if (step + 1) % save_every == 0 or step == max_steps_total - 1:
358
+ ckpt_path = os.path.join(REMOTE_CKPT, f"model_step_{step + 1}.pt")
359
+ torch.save({
360
+ "model_state_dict": model.state_dict(),
361
+ "optimizer_state_dict": optimizer.state_dict(),
362
+ "config": asdict(config),
363
+ "step": step + 1, "loss": accum_loss,
364
+ }, ckpt_path)
365
+ print(f"\n Saved checkpoint: {ckpt_path}")
366
+
367
+ latest_path = os.path.join(REMOTE_CKPT, "latest.pt")
368
+ torch.save({
369
+ "model_state_dict": model.state_dict(),
370
+ "config": asdict(config),
371
+ "step": step + 1, "loss": accum_loss,
372
+ }, latest_path)
373
+
374
+ if accum_loss < best_loss:
375
+ best_loss = accum_loss
376
+ best_path = os.path.join(REMOTE_CKPT, "best.pt")
377
+ torch.save({
378
+ "model_state_dict": model.state_dict(),
379
+ "config": asdict(config),
380
+ "step": step + 1, "loss": accum_loss,
381
+ }, best_path)
382
+
383
+ # Save log
384
+ log_path = os.path.join(REMOTE_LOGS, "training_log.json")
385
+ with open(log_path, "w") as f:
386
+ json.dump(log, f, indent=2)
387
+
388
+ # Commit volume so checkpoints persist
389
+ volume.commit()
390
+
391
+ if step % 50 == 0:
392
+ torch.cuda.reset_peak_memory_stats()
393
+
394
+ # Final log save
395
+ log_path = os.path.join(REMOTE_LOGS, "training_log.json")
396
+ with open(log_path, "w") as f:
397
+ json.dump(log, f, indent=2)
398
+ volume.commit()
399
+
400
+ total_time = time.time() - start_time
401
+ print(f"\nPretraining complete!")
402
+ print(f" Total time: {total_time:.1f}s ({total_time/60:.1f} min)")
403
+ print(f" Final EMA loss: {accum_loss:.4f}")
404
+ print(f" Best loss: {best_loss:.4f}")
405
+ return accum_loss
406
+
407
+
408
+ # ─── SFT ─────────────────────────────────────────────────────────────────────
409
+
410
+ # Special token IDs
411
+ SYSTEM_ID = 32000
412
+ USER_ID = 32001
413
+ ASSISTANT_ID = 32002
414
+ SEARCH_ID = 32003
415
+ RESULT_ID = 32004
416
+ EVIDENCE_ID = 32005
417
+ REASONING_ID = 32006
418
+ FINISH_ID = 32007
419
+ END_ID = 32008
420
+
421
+
422
+ def load_special_tokens():
423
+ global SYSTEM_ID, USER_ID, ASSISTANT_ID, SEARCH_ID, RESULT_ID
424
+ global EVIDENCE_ID, REASONING_ID, FINISH_ID, END_ID
425
+ path = os.path.join(REMOTE_TOKENIZER, "special_tokens.json")
426
+ if os.path.exists(path):
427
+ with open(path, "r") as f:
428
+ data = json.load(f)
429
+ ids = data["token_ids"]
430
+ SYSTEM_ID = ids.get("<tool_call>", 32000)
431
+ USER_ID = ids.get("<tool_call>", 32001)
432
+ ASSISTANT_ID = ids.get("<tool_call>", 32002)
433
+ SEARCH_ID = ids.get("<|search|>", 32003)
434
+ RESULT_ID = ids.get("<|result|>", 32004)
435
+ EVIDENCE_ID = ids.get("<|evidence|>", 32005)
436
+ REASONING_ID = ids.get("<|reasoning|>", 32006)
437
+ FINISH_ID = ids.get("<|finish|>", 32007)
438
+ END_ID = ids.get("<|end|>", 32008)
439
+
440
+
441
+ def format_trace_to_tokens(trace, tokenizer, max_seq_len=768):
442
+ messages = trace["trace"]
443
+ all_tokens = []
444
+ loss_mask = []
445
+
446
+ for msg in messages:
447
+ role = msg["role"]
448
+ content = msg["content"]
449
+
450
+ if role == "system":
451
+ tokens = [SYSTEM_ID] + tokenizer.encode(content).ids + [END_ID]
452
+ all_tokens.extend(tokens)
453
+ loss_mask.extend([0] * len(tokens))
454
+ elif role == "user":
455
+ tokens = [USER_ID] + tokenizer.encode(content).ids + [END_ID]
456
+ all_tokens.extend(tokens)
457
+ loss_mask.extend([0] * len(tokens))
458
+ elif role == "assistant":
459
+ tokens = [ASSISTANT_ID] + tokenizer.encode(content).ids + [END_ID]
460
+ all_tokens.extend(tokens)
461
+ loss_mask.extend([1] * len(tokens))
462
+ elif role == "result":
463
+ if content:
464
+ tokens = [RESULT_ID] + tokenizer.encode(content).ids + [END_ID]
465
+ else:
466
+ tokens = [RESULT_ID, END_ID]
467
+ all_tokens.extend(tokens)
468
+ loss_mask.extend([0] * len(tokens))
469
+
470
+ if len(all_tokens) > max_seq_len:
471
+ all_tokens = all_tokens[:max_seq_len]
472
+ loss_mask = loss_mask[:max_seq_len]
473
+
474
+ return np.array(all_tokens, dtype=np.int32), np.array(loss_mask, dtype=np.int32)
475
+
476
+
477
+ def load_sft_dataset(traces_path, tokenizer, max_seq_len=768):
478
+ print(f"Loading SFT traces from {traces_path}...")
479
+ dataset = []
480
+ with open(traces_path, "r", encoding="utf-8") as f:
481
+ for line in f:
482
+ trace = json.loads(line)
483
+ ids, mask = format_trace_to_tokens(trace, tokenizer, max_seq_len)
484
+ if len(ids) > 10:
485
+ dataset.append((ids, mask))
486
+ print(f" Loaded {len(dataset):,} traces")
487
+ return dataset
488
+
489
+
490
+ def get_sft_batch(dataset, batch_size, seq_len, device):
491
+ indices = np.random.randint(0, len(dataset), size=batch_size)
492
+ input_ids_list = []
493
+ loss_mask_list = []
494
+
495
+ for idx in indices:
496
+ ids, mask = dataset[idx]
497
+ if len(ids) < seq_len:
498
+ pad_len = seq_len - len(ids)
499
+ ids = np.concatenate([ids, np.zeros(pad_len, dtype=np.int32)])
500
+ mask = np.concatenate([mask, np.zeros(pad_len, dtype=np.int32)])
501
+ else:
502
+ ids = ids[:seq_len]
503
+ mask = mask[:seq_len]
504
+ input_ids_list.append(ids)
505
+ loss_mask_list.append(mask)
506
+
507
+ input_ids = torch.from_numpy(np.stack(input_ids_list)).long().to(device)
508
+ loss_mask = torch.from_numpy(np.stack(loss_mask_list)).long().to(device)
509
+ targets = torch.cat([input_ids[:, 1:], torch.zeros_like(input_ids[:, :1])], dim=1)
510
+ return input_ids, targets, loss_mask
511
+
512
+
513
+ def run_sft(steps, batch_size, grad_accum, seq_len, lr, warmup,
514
+ save_every, log_every, use_8bit_adam):
515
+ from torch import nn
516
+ sys.path.insert(0, REMOTE_SRC)
517
+ from model import ModelConfig, Retriever500M
518
+ from tokenizers import Tokenizer
519
+
520
+ load_special_tokens()
521
+ device = torch.device("cuda")
522
+ print(f"Device: {device}")
523
+ print(f"GPU: {torch.cuda.get_device_name(0)}")
524
+ print(f"VRAM: {torch.cuda.get_device_properties(0).total_memory / 1e9:.2f} GB")
525
+
526
+ os.makedirs(REMOTE_CKPT, exist_ok=True)
527
+ os.makedirs(REMOTE_LOGS, exist_ok=True)
528
+
529
+ # Tokenizer (agent tokenizer with special tokens)
530
+ tokenizer_path = os.path.join(REMOTE_TOKENIZER, "tokenizer_agent.json")
531
+ tokenizer = Tokenizer.from_file(tokenizer_path)
532
+ vocab_size = tokenizer.get_vocab_size()
533
+ print(f"Vocab size: {vocab_size}")
534
+
535
+ # Data
536
+ traces_path = os.path.join(REMOTE_DATA, "sft_traces.jsonl")
537
+ gold_path = os.path.join(REMOTE_DATA, "gold_traces.jsonl")
538
+ dataset = load_sft_dataset(traces_path, tokenizer, seq_len)
539
+ gold_dataset = load_sft_dataset(gold_path, tokenizer, seq_len)
540
+ dataset.extend(gold_dataset)
541
+ print(f" Total (with gold): {len(dataset):,}")
542
+
543
+ # Model
544
+ config = ModelConfig(
545
+ vocab_size=vocab_size,
546
+ d_model=1_280, n_layers=23, n_heads=20, d_ff=3_456,
547
+ max_seq_len=seq_len, dropout=0.0, tie_embeddings=True,
548
+ )
549
+ model = Retriever500M(config).to(device)
550
+
551
+ # Load pretrained checkpoint
552
+ ckpt_path = os.path.join(REMOTE_CKPT, "latest.pt")
553
+ if not os.path.exists(ckpt_path):
554
+ ckpt_path = os.path.join(REMOTE_CKPT, "latest_bf16.pt")
555
+ if not os.path.exists(ckpt_path):
556
+ ckpt_path = os.path.join(REMOTE_SEED_CKPT, "latest_bf16.pt")
557
+ if os.path.exists(ckpt_path):
558
+ print(f"Loading pretrained weights from {ckpt_path}...")
559
+ ckpt = torch.load(ckpt_path, map_location=device, weights_only=False)
560
+ old_config = ModelConfig(**ckpt["config"])
561
+ state_dict = ckpt["model_state_dict"]
562
+ old_vocab = old_config.vocab_size
563
+
564
+ if old_vocab != vocab_size:
565
+ print(f" Vocab size mismatch: {old_vocab} -> {vocab_size}")
566
+ old_weight = state_dict["token_embedding.weight"]
567
+ d_model = old_weight.shape[1]
568
+ new_weight = torch.zeros(vocab_size, d_model)
569
+ new_weight[:old_vocab] = old_weight
570
+ nn.init.normal_(new_weight[old_vocab:], mean=0.0, std=0.02)
571
+ state_dict["token_embedding.weight"] = new_weight
572
+
573
+ model.load_state_dict(state_dict)
574
+ print(f" Loaded (step {ckpt.get('step', '?')}, loss {ckpt.get('loss', '?')})")
575
+ else:
576
+ print("WARNING: No checkpoint found, starting from scratch!")
577
+
578
+ total_params = model.count_parameters()
579
+ print(f"Model parameters: {total_params:,} ({total_params / 1e6:.1f}M)")
580
+
581
+ optimizer = setup_optimizer(model, lr, use_8bit=use_8bit_adam)
582
+
583
+ effective_batch = batch_size * grad_accum
584
+ print(f"\nSFT configuration:")
585
+ print(f" Batch size: {batch_size}")
586
+ print(f" Grad accum: {grad_accum}")
587
+ print(f" Effective batch: {effective_batch}")
588
+ print(f" Sequence length: {seq_len}")
589
+ print(f" Learning rate: {lr}")
590
+ print(f" Steps: {steps}")
591
+ print(f" Warmup: {warmup}")
592
+ print()
593
+
594
+ log = {
595
+ "config": asdict(config),
596
+ "train_args": {"steps": steps, "batch_size": batch_size, "grad_accum": grad_accum,
597
+ "seq_len": seq_len, "lr": lr, "warmup": warmup,
598
+ "save_every": save_every, "log_every": log_every,
599
+ "use_8bit_adam": use_8bit_adam},
600
+ "total_params": total_params,
601
+ "steps": [],
602
+ }
603
+
604
+ model.train()
605
+ start_time = time.time()
606
+ accum_loss = 0.0
607
+ best_loss = float("inf")
608
+
609
+ pbar = tqdm(range(steps), desc="SFT")
610
+ for step in pbar:
611
+ lr_now = get_lr(step, warmup, steps, lr, lr * 0.1)
612
+ for pg in optimizer.param_groups:
613
+ pg["lr"] = lr_now
614
+
615
+ optimizer.zero_grad(set_to_none=True)
616
+
617
+ total_loss = 0.0
618
+ for _ in range(grad_accum):
619
+ input_ids, targets, loss_mask = get_sft_batch(
620
+ dataset, batch_size, seq_len, device
621
+ )
622
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
623
+ out = model(input_ids, targets=targets, use_checkpoint=True)
624
+ logits = out["logits"]
625
+
626
+ if loss_mask.sum() > 0:
627
+ shifted_mask = loss_mask[:, 1:].contiguous()
628
+ masked_logits = logits[:, :-1, :].contiguous()
629
+ masked_targets = targets[:, :-1].contiguous()
630
+
631
+ flat_logits = masked_logits.view(-1, masked_logits.size(-1))
632
+ flat_targets = masked_targets.view(-1)
633
+ flat_mask = shifted_mask.view(-1).float()
634
+
635
+ per_token_loss = F.cross_entropy(
636
+ flat_logits, flat_targets,
637
+ ignore_index=-100, reduction="none"
638
+ )
639
+ masked_loss = (per_token_loss * flat_mask).sum() / flat_mask.sum().clamp(min=1)
640
+ loss = masked_loss / grad_accum
641
+ else:
642
+ loss = out["loss"] / grad_accum
643
+
644
+ loss.backward()
645
+ total_loss += loss.item()
646
+
647
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
648
+ optimizer.step()
649
+
650
+ avg_loss = total_loss
651
+ accum_loss = accum_loss * 0.95 + avg_loss * 0.05
652
+
653
+ if step % log_every == 0 or step == steps - 1:
654
+ elapsed = time.time() - start_time
655
+ steps_per_sec = (step + 1) / elapsed
656
+ vram_used = torch.cuda.max_memory_allocated() / 1e9
657
+
658
+ log_entry = {
659
+ "step": step, "loss": avg_loss, "ema_loss": accum_loss,
660
+ "lr": lr_now, "elapsed_s": elapsed,
661
+ "steps_per_sec": steps_per_sec, "vram_gb": vram_used,
662
+ }
663
+ log["steps"].append(log_entry)
664
+ pbar.set_postfix({
665
+ "loss": f"{avg_loss:.4f}", "ema": f"{accum_loss:.4f}",
666
+ "lr": f"{lr_now:.2e}", "vram": f"{vram_used:.1f}G",
667
+ })
668
+
669
+ if (step + 1) % save_every == 0 or step == steps - 1:
670
+ ckpt_path = os.path.join(REMOTE_CKPT, f"sft_step_{step + 1}.pt")
671
+ torch.save({
672
+ "model_state_dict": model.state_dict(),
673
+ "optimizer_state_dict": optimizer.state_dict(),
674
+ "config": asdict(config),
675
+ "step": step + 1, "loss": accum_loss,
676
+ }, ckpt_path)
677
+ print(f"\n Saved checkpoint: {ckpt_path}")
678
+
679
+ latest_path = os.path.join(REMOTE_CKPT, "sft_latest.pt")
680
+ torch.save({
681
+ "model_state_dict": model.state_dict(),
682
+ "config": asdict(config),
683
+ "step": step + 1, "loss": accum_loss,
684
+ }, latest_path)
685
+
686
+ if accum_loss < best_loss:
687
+ best_loss = accum_loss
688
+ best_path = os.path.join(REMOTE_CKPT, "sft_best.pt")
689
+ torch.save({
690
+ "model_state_dict": model.state_dict(),
691
+ "config": asdict(config),
692
+ "step": step + 1, "loss": accum_loss,
693
+ }, best_path)
694
+
695
+ log_path = os.path.join(REMOTE_LOGS, "sft_log.json")
696
+ with open(log_path, "w") as f:
697
+ json.dump(log, f, indent=2)
698
+
699
+ volume.commit()
700
+
701
+ if step % 50 == 0:
702
+ torch.cuda.reset_peak_memory_stats()
703
+
704
+ log_path = os.path.join(REMOTE_LOGS, "sft_log.json")
705
+ with open(log_path, "w") as f:
706
+ json.dump(log, f, indent=2)
707
+ volume.commit()
708
+
709
+ total_time = time.time() - start_time
710
+ print(f"\nSFT complete!")
711
+ print(f" Total time: {total_time:.1f}s ({total_time/60:.1f} min)")
712
+ print(f" Final EMA loss: {accum_loss:.4f}")
713
+ print(f" Best loss: {best_loss:.4f}")
714
+ return accum_loss
715
+
716
+
717
+ # ─── Modal entry points ──────────────────────────────────────────────────────
718
+
719
+ GPU_CHOICES = {"a10g": "A10G", "a100": "A100", "h100": "H100"}
720
+
721
+
722
+ @app.function(
723
+ volumes={VOL_MOUNT: volume},
724
+ gpu="A10G",
725
+ timeout=3600,
726
+ )
727
+ def pretrain(
728
+ steps=1000,
729
+ batch_size=8,
730
+ grad_accum=4,
731
+ seq_len=512,
732
+ lr=3e-4,
733
+ warmup=100,
734
+ save_every=200,
735
+ log_every=10,
736
+ corpus="curated",
737
+ resume=True,
738
+ use_8bit_adam=True,
739
+ gpu="a10g",
740
+ ):
741
+ """Run pretraining on Modal GPU."""
742
+ return run_pretrain(steps, batch_size, grad_accum, seq_len, lr, warmup,
743
+ save_every, log_every, corpus, resume, use_8bit_adam)
744
+
745
+
746
+ @app.function(
747
+ volumes={VOL_MOUNT: volume},
748
+ gpu="A10G",
749
+ timeout=3600,
750
+ env={"PYTORCH_CUDA_ALLOC_CONF": "expandable_segments:True"},
751
+ )
752
+ def sft(
753
+ steps=500,
754
+ batch_size=4,
755
+ grad_accum=8,
756
+ seq_len=768,
757
+ lr=5e-5,
758
+ warmup=20,
759
+ save_every=100,
760
+ log_every=10,
761
+ use_8bit_adam=True,
762
+ gpu="a10g",
763
+ ):
764
+ """Run SFT on Modal GPU."""
765
+ return run_sft(steps, batch_size, grad_accum, seq_len, lr, warmup,
766
+ save_every, log_every, use_8bit_adam)
767
+
768
+
769
+ @app.function(
770
+ volumes={VOL_MOUNT: volume},
771
+ gpu="A10G",
772
+ timeout=7200,
773
+ )
774
+ def pipeline(pretrain_steps=1000, sft_steps=500, gpu="a10g"):
775
+ """Run pretraining then SFT in one go."""
776
+ print("=" * 60)
777
+ print("PHASE 1: PRETRAINING")
778
+ print("=" * 60)
779
+ run_pretrain(
780
+ steps=pretrain_steps, batch_size=8, grad_accum=4, seq_len=512,
781
+ lr=3e-4, warmup=100, save_every=200, log_every=10,
782
+ corpus="curated", resume=True, use_8bit_adam=True,
783
+ )
784
+
785
+ print("\n" + "=" * 60)
786
+ print("PHASE 2: SFT")
787
+ print("=" * 60)
788
+ run_sft(
789
+ steps=sft_steps, batch_size=8, grad_accum=4, seq_len=768,
790
+ lr=5e-5, warmup=20, save_every=100, log_every=10,
791
+ use_8bit_adam=True,
792
+ )
793
+
794
+
795
+ # ─── Local entry point for `modal run` ───────────────────────────────────────
796
+
797
+ @app.local_entrypoint()
798
+ def main(
799
+ command: str = "pipeline",
800
+ steps: int = 1000,
801
+ batch_size: int = 8,
802
+ grad_accum: int = 4,
803
+ seq_len: int = 512,
804
+ lr: float = 3e-4,
805
+ warmup: int = 100,
806
+ save_every: int = 200,
807
+ log_every: int = 10,
808
+ corpus: str = "curated",
809
+ resume: bool = True,
810
+ use_8bit_adam: bool = True,
811
+ gpu: str = "a10g",
812
+ pretrain_steps: int = 1000,
813
+ sft_steps: int = 500,
814
+ ):
815
+ if command == "seed":
816
+ seed.remote()
817
+ elif command == "pretrain":
818
+ result = pretrain.remote(
819
+ steps=steps, batch_size=batch_size, grad_accum=grad_accum,
820
+ seq_len=seq_len, lr=lr, warmup=warmup,
821
+ save_every=save_every, log_every=log_every,
822
+ corpus=corpus, resume=resume, use_8bit_adam=use_8bit_adam,
823
+ gpu=gpu,
824
+ )
825
+ print(f"Pretraining final loss: {result}")
826
+ elif command == "sft":
827
+ result = sft.remote(
828
+ steps=steps, batch_size=batch_size, grad_accum=grad_accum,
829
+ seq_len=seq_len, lr=lr, warmup=warmup,
830
+ save_every=save_every, log_every=log_every,
831
+ use_8bit_adam=use_8bit_adam, gpu=gpu,
832
+ )
833
+ print(f"SFT final loss: {result}")
834
+ elif command == "download":
835
+ download.remote()
836
+ elif command == "pipeline":
837
+ pipeline.remote(pretrain_steps=pretrain_steps, sft_steps=sft_steps, gpu=gpu)
838
+ else:
839
+ print(f"Unknown command: {command}")
840
+ print("Available: upload, pretrain, sft, download, pipeline")
src/train_tokenizer.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Train a BPE tokenizer on the code corpus using the HuggingFace `tokenizers` library.
3
+
4
+ Produces a 32,000-token vocabulary optimized for source code across
5
+ Python, JS/TS, Rust, Go, C/C++, and other languages.
6
+ """
7
+
8
+ import os
9
+ from tokenizers import Tokenizer
10
+ from tokenizers.models import BPE
11
+ from tokenizers.trainers import BpeTrainer
12
+ from tokenizers.pre_tokenizers import ByteLevel
13
+ from tokenizers.decoders import ByteLevel as ByteLevelDecoder
14
+
15
+ DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data")
16
+ TOKENIZER_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tokenizer")
17
+ CORPUS_PATH = os.path.join(DATA_DIR, "corpus.txt")
18
+ TOKENIZER_PATH = os.path.join(TOKENIZER_DIR, "tokenizer.json")
19
+
20
+ VOCAB_SIZE = 32_000
21
+
22
+
23
+ def train_tokenizer():
24
+ os.makedirs(TOKENIZER_DIR, exist_ok=True)
25
+
26
+ tokenizer = Tokenizer(BPE(unk_token="<unk>"))
27
+ tokenizer.pre_tokenizer = ByteLevel(add_prefix_space=True, use_regex=True)
28
+ tokenizer.decoder = ByteLevelDecoder()
29
+
30
+ trainer = BpeTrainer(
31
+ vocab_size=VOCAB_SIZE,
32
+ special_tokens=["<pad>", "<bos>", "<eos>", "<unk>"],
33
+ show_progress=True,
34
+ initial_alphabet=ByteLevel.alphabet(),
35
+ )
36
+
37
+ print(f"Training BPE tokenizer (vocab_size={VOCAB_SIZE}) on {CORPUS_PATH}...")
38
+ tokenizer.train([CORPUS_PATH], trainer)
39
+
40
+ tokenizer.save(TOKENIZER_PATH)
41
+ print(f"Tokenizer saved to {TOKENIZER_PATH}")
42
+
43
+ # Print stats
44
+ vocab = tokenizer.get_vocab()
45
+ print(f"Vocabulary size: {len(vocab)}")
46
+
47
+ # Test encoding
48
+ test_code = "def hello_world():\n print('Hello, World!')"
49
+ encoded = tokenizer.encode(test_code)
50
+ print(f"\nTest encoding:")
51
+ print(f" Input: {test_code}")
52
+ print(f" Tokens: {encoded.tokens[:20]}")
53
+ print(f" IDs: {encoded.ids[:20]}")
54
+ print(f" # tokens: {len(encoded.ids)}")
55
+
56
+ return tokenizer
57
+
58
+
59
+ if __name__ == "__main__":
60
+ train_tokenizer()
src/verify_pipeline.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Verify the full search agent pipeline end-to-end."""
2
+ import json
3
+ import os
4
+ import sys
5
+
6
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__))))
7
+
8
+ def main():
9
+ # 1. Verify chunks load
10
+ chunks = [json.loads(l) for l in open("data/chunks.jsonl", encoding="utf-8")]
11
+ print(f"1. Chunks: {len(chunks):,} loaded OK")
12
+
13
+ # 2. Verify agent tokenizer
14
+ from tokenizers import Tokenizer
15
+ tok = Tokenizer.from_file("tokenizer/tokenizer_agent.json")
16
+ print(f"2. Agent tokenizer: vocab={tok.get_vocab_size()} OK")
17
+
18
+ # 3. Verify special tokens
19
+ st = json.load(open("tokenizer/special_tokens.json"))
20
+ ids = list(st["token_ids"].values())
21
+ print(f"3. Special tokens: {len(st['special_tokens'])} tokens, IDs {ids} OK")
22
+
23
+ # 4. Verify SFT traces load and format correctly
24
+ from sft import format_trace_to_tokens, load_special_tokens
25
+ load_special_tokens()
26
+ traces = [json.loads(l) for l in open("data/sft_traces.jsonl", encoding="utf-8")]
27
+ print(f"4. SFT traces: {len(traces):,} loaded")
28
+
29
+ # Format one trace
30
+ ids_arr, mask_arr = format_trace_to_tokens(traces[0], tok, max_seq_len=768)
31
+ loss_tokens = int(mask_arr.sum())
32
+ total_tokens = len(ids_arr)
33
+ pct = loss_tokens / total_tokens * 100
34
+ print(f" Sample trace: {total_tokens} tokens, {loss_tokens} with loss ({pct:.0f}%)")
35
+
36
+ # 5. Verify gold traces
37
+ gold = [json.loads(l) for l in open("data/gold_traces.jsonl", encoding="utf-8")]
38
+ print(f"5. Gold traces: {len(gold)} loaded OK")
39
+
40
+ # 6. Verify model accepts new vocab size
41
+ from model import ModelConfig, Retriever500M
42
+ import torch
43
+ config = ModelConfig(vocab_size=32009, d_model=1280, n_layers=23, n_heads=20, d_ff=3456, max_seq_len=768)
44
+ model = Retriever500M(config)
45
+ print(f"6. Model with vocab=32009: {model.count_parameters()/1e6:.1f}M params OK")
46
+
47
+ # 7. Verify model can load old checkpoint with embedding resize
48
+ ckpt = torch.load("checkpoints/latest.pt", map_location="cpu", weights_only=False)
49
+ old_vocab = ckpt["config"]["vocab_size"]
50
+ needs_resize = old_vocab != 32009
51
+ print(f"7. Old checkpoint vocab={old_vocab}, new vocab=32009, resize needed={needs_resize}")
52
+
53
+ # 8. Verify retriever
54
+ from search_agent import KeywordRetriever
55
+ retr = KeywordRetriever("data/chunks.jsonl")
56
+ results = retr.search("ngx_reusable_connection", top_k=3)
57
+ print(f"8. Retriever: search returned {len(results)} results OK")
58
+ if results:
59
+ print(f" Top result: {results[0]['name']} (score={results[0]['score']:.2f})")
60
+
61
+ # 9. Verify SFT data formatting produces valid tokens
62
+ from sft import get_sft_batch
63
+ dataset = []
64
+ for trace in traces[:100]:
65
+ ids, mask = format_trace_to_tokens(trace, tok, max_seq_len=768)
66
+ if len(ids) > 10:
67
+ dataset.append((ids, mask))
68
+ input_ids, targets, loss_mask = get_sft_batch(dataset, batch_size=2, seq_len=768, device=torch.device("cpu"))
69
+ print(f"9. SFT batch: input_ids={input_ids.shape}, targets={targets.shape}, loss_mask={loss_mask.shape}")
70
+ print(f" Loss mask coverage: {loss_mask.sum().item()}/{loss_mask.numel()} ({loss_mask.float().mean()*100:.0f}%)")
71
+
72
+ print()
73
+ print("ALL CHECKS PASSED")
74
+
75
+
76
+ if __name__ == "__main__":
77
+ main()