Reizxn commited on
Commit
3738348
·
verified ·
1 Parent(s): 5d8ca17

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/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/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,605 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Synthetic search agent trace generator.
3
+
4
+ Generates thousands of diverse, realistic search agent traces using
5
+ actual code chunks from the corpus. Each trace follows the gold-standard
6
+ format: reasoning → search → results → analysis → evidence → finish.
7
+
8
+ Trace types generated:
9
+ 1. Simple lookup (1 search, 1 result)
10
+ 2. Lookup with noise (1 search, 2-3 results, some irrelevant)
11
+ 3. Multi-step search (2 searches, progressive refinement)
12
+ 4. Query decomposition (complex query → subqueries)
13
+ 5. Not found (search returns nothing)
14
+ 6. Type/struct inspection (field enumeration)
15
+ 7. Function behavior analysis (what does it do?)
16
+ 8. Usage pattern (how is X used?)
17
+
18
+ Query templates are varied to prevent memorization:
19
+ - "What does {name} do?"
20
+ - "Where is {name} defined?"
21
+ - "How does {name} work?"
22
+ - "What parameters does {name} accept?"
23
+ - "What fields does {name} have?"
24
+ - "How is {name} used in the codebase?"
25
+ - etc.
26
+
27
+ Output: data/sft_traces.jsonl
28
+ """
29
+
30
+ import json
31
+ import os
32
+ import random
33
+ import re
34
+
35
+ PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
36
+ CHUNKS_PATH = os.path.join(PROJECT_DIR, "data", "chunks.jsonl")
37
+ TRACES_PATH = os.path.join(PROJECT_DIR, "data", "sft_traces.jsonl")
38
+
39
+ SYSTEM_PROMPT = (
40
+ "You are a code search agent. Given a query from a reasoning model, "
41
+ "decompose it into subqueries, search the codebase, inspect results, "
42
+ "and return curated evidence. Use <|search|> to issue searches, "
43
+ "<|reasoning|> to analyze, and <|evidence|> to return findings. "
44
+ "Be concise. Extract only the relevant facts. End with <|finish|>."
45
+ )
46
+
47
+ random.seed(42)
48
+
49
+ # ─── Query templates ─────────────────────────────────────────────────────────
50
+ QUERY_TEMPLATES = {
51
+ "function": [
52
+ "What does {name} do?",
53
+ "Where is {name} defined?",
54
+ "How does {name} work?",
55
+ "What parameters does {name} accept?",
56
+ "What does {name} return?",
57
+ "Find the implementation of {name}.",
58
+ "Explain what {name} does step by step.",
59
+ "What is the signature of {name}?",
60
+ ],
61
+ "struct": [
62
+ "What fields does {name} have?",
63
+ "What is the structure of {name}?",
64
+ "Where is {name} defined?",
65
+ "What data does {name} contain?",
66
+ "Describe the {name} type.",
67
+ ],
68
+ "class": [
69
+ "What does the {name} class do?",
70
+ "What methods does {name} have?",
71
+ "Where is the {name} class defined?",
72
+ "How is {name} structured?",
73
+ "What is the inheritance of {name}?",
74
+ ],
75
+ "macro": [
76
+ "What does {name} do?",
77
+ "Where is {name} defined?",
78
+ "What is {name}?",
79
+ ],
80
+ "enum": [
81
+ "What variants does {name} have?",
82
+ "Where is {name} defined?",
83
+ "What values can {name} take?",
84
+ ],
85
+ "block": [
86
+ "What does {name} do?",
87
+ "Where is {name} defined?",
88
+ "Find code related to {name}.",
89
+ "How is {name} used?",
90
+ ],
91
+ }
92
+
93
+ # ─── Reasoning templates ─────────────────────────────────────────────────────
94
+ INITIAL_REASONING = [
95
+ "I need to find {desc}. Let me search for {search_term}.",
96
+ "Looking for {desc}. I'll search using the term '{search_term}'.",
97
+ "The query asks about {desc}. Let me search the codebase.",
98
+ "I should find {desc}. Searching for '{search_term}'.",
99
+ "This requires finding {desc}. Let me issue a search.",
100
+ ]
101
+
102
+ ANALYSIS_REASONING_FOUND = [
103
+ "Found the relevant code. {analysis}",
104
+ "This result contains what I need. {analysis}",
105
+ "I found {desc}. {analysis}",
106
+ "This is the right code. {analysis}",
107
+ ]
108
+
109
+ ANALYSIS_REASONING_NOISE = [
110
+ "The first result is relevant. {analysis} The other results are not directly related.",
111
+ "Result 1 matches the query. {analysis} The remaining results appear to be unrelated code.",
112
+ "I found the target in the first result. {analysis} The other results don't match.",
113
+ "The relevant code is in the first result. {analysis} Ignoring the noise results.",
114
+ ]
115
+
116
+ ANALYSIS_REASONING_REFINE = [
117
+ "Found the definition, but I need to see how it's used. Let me search for usages.",
118
+ "I have the implementation. Now let me find where it's called.",
119
+ "Got the definition. Let me also search for related patterns.",
120
+ "Found it. Let me do one more search to get complete context.",
121
+ ]
122
+
123
+ NOT_FOUND_REASONING = [
124
+ "No results found for '{search_term}'. Let me try a broader search.",
125
+ "The search returned nothing. Let me try different terms.",
126
+ "No matches. The codebase may not contain this. Let me verify with another search.",
127
+ ]
128
+
129
+ # ─── Code analysis functions ─���───────────────────────────────────────────────
130
+
131
+ def extract_function_info(code: str, name: str) -> dict:
132
+ """Extract key information from a function."""
133
+ info = {"name": name, "params": [], "returns": "", "key_lines": []}
134
+
135
+ # Extract parameters from signature
136
+ sig_match = re.search(r"(?:def|fn|func|function)\s+" + re.escape(name) + r"\s*\(([^)]*)\)", code)
137
+ if sig_match:
138
+ params_raw = sig_match.group(1).strip()
139
+ if params_raw:
140
+ info["params"] = [p.strip() for p in params_raw.split(",") if p.strip()]
141
+
142
+ # Extract return type (Rust/TS)
143
+ ret_match = re.search(r"->\s*([^{]+)", code)
144
+ if ret_match:
145
+ info["returns"] = ret_match.group(1).strip()
146
+
147
+ # Extract key lines (non-trivial lines)
148
+ lines = code.split("\n")
149
+ key_lines = []
150
+ for line in lines:
151
+ stripped = line.strip()
152
+ if not stripped or stripped.startswith("//") or stripped.startswith("#"):
153
+ continue
154
+ if any(kw in stripped for kw in ["return ", "if ", "for ", "while ", "raise ", "throw ", "Err(", "Ok(", "assert"]):
155
+ key_lines.append(stripped)
156
+ info["key_lines"] = key_lines[:5]
157
+
158
+ return info
159
+
160
+
161
+ def extract_struct_info(code: str, name: str) -> dict:
162
+ """Extract field information from a struct."""
163
+ info = {"name": name, "fields": []}
164
+
165
+ # Find field declarations (type name; or name: type)
166
+ field_patterns = [
167
+ re.compile(r"^\s+(\w+)\s+(\w+);", re.M), # C: type name;
168
+ re.compile(r"^\s+(\w+):\s+([^,;]+)", re.M), # Rust/TS: name: type
169
+ re.compile(r"^\s+(self\.)?(\w+)\s*[:=]", re.M), # Python: self.name =
170
+ ]
171
+
172
+ for pat in field_patterns:
173
+ matches = pat.findall(code)
174
+ for m in matches:
175
+ if isinstance(m, tuple):
176
+ field_name = m[-1] if m[-1] else m[0]
177
+ else:
178
+ field_name = m
179
+ if field_name and field_name not in ("self", "pub", "fn", "def", "class"):
180
+ info["fields"].append(field_name)
181
+
182
+ return info
183
+
184
+
185
+ def generate_evidence(chunk: dict, analysis_type: str = "found") -> str:
186
+ """Generate curated evidence from a code chunk."""
187
+ code = chunk["code"]
188
+ name = chunk["name"]
189
+ typ = chunk["type"]
190
+ lang = chunk["language"]
191
+ filepath = chunk["filepath"]
192
+
193
+ if typ in ("function",):
194
+ info = extract_function_info(code, name)
195
+ parts = [f"`{name}` is a {lang} function:"]
196
+
197
+ # Signature
198
+ if info["params"]:
199
+ params_str = ", ".join(info["params"][:6])
200
+ if len(info["params"]) > 6:
201
+ params_str += ", ..."
202
+ parts.append(f"- Parameters: {params_str}")
203
+
204
+ if info["returns"]:
205
+ parts.append(f"- Returns: {info['returns']}")
206
+
207
+ # Key behavior
208
+ if info["key_lines"]:
209
+ parts.append("- Key behavior:")
210
+ for line in info["key_lines"][:3]:
211
+ parts.append(f" - `{line}`")
212
+
213
+ parts.append(f"Source: {filepath}")
214
+ return "\n".join(parts)
215
+
216
+ elif typ in ("struct",):
217
+ info = extract_struct_info(code, name)
218
+ parts = [f"`{name}` is a {lang} structure with fields:"]
219
+ for field in info["fields"][:10]:
220
+ parts.append(f"- `{field}`")
221
+ if len(info["fields"]) > 10:
222
+ parts.append(f"- ... ({len(info['fields'])} total fields)")
223
+ parts.append(f"Source: {filepath}")
224
+ return "\n".join(parts)
225
+
226
+ elif typ in ("class",):
227
+ parts = [f"`{name}` is a {lang} class:"]
228
+ # Find methods
229
+ methods = re.findall(r"(?:def |fn |func |function )\s*(\w+)", code)
230
+ if methods:
231
+ parts.append(f"- Methods: {', '.join(methods[:8])}")
232
+ # Find inheritance
233
+ base_match = re.search(r"class\s+\w+\s*(?:\(|:\s*|extends\s+|<\s*)(\w+)", code)
234
+ if base_match:
235
+ parts.append(f"- Inherits from: {base_match.group(1)}")
236
+ parts.append(f"Source: {filepath}")
237
+ return "\n".join(parts)
238
+
239
+ elif typ in ("macro",):
240
+ parts = [f"`{name}` is a {lang} macro/preprocessor definition:"]
241
+ # First few lines of the macro
242
+ lines = code.strip().split("\n")[:5]
243
+ for line in lines:
244
+ parts.append(f" `{line}`")
245
+ parts.append(f"Source: {filepath}")
246
+ return "\n".join(parts)
247
+
248
+ elif typ in ("enum",):
249
+ parts = [f"`{name}` is a {lang} enum with variants:"]
250
+ variants = re.findall(r"^\s+(\w+)[,\s]*$", code, re.M)
251
+ for v in variants[:10]:
252
+ parts.append(f"- `{v}`")
253
+ parts.append(f"Source: {filepath}")
254
+ return "\n".join(parts)
255
+
256
+ else:
257
+ # Generic block
258
+ parts = [f"Found code related to `{name}` ({lang}):"]
259
+ lines = code.strip().split("\n")[:8]
260
+ for line in lines:
261
+ parts.append(f" `{line}`")
262
+ parts.append(f"Source: {filepath}")
263
+ return "\n".join(parts)
264
+
265
+
266
+ def generate_search_term(chunk: dict) -> str:
267
+ """Generate a realistic search term for a chunk."""
268
+ name = chunk["name"]
269
+ if name != "unknown" and name != "block":
270
+ return name
271
+
272
+ # For unnamed blocks, extract a key identifier
273
+ code = chunk["code"]
274
+ identifiers = re.findall(r"\b(ngx_\w+|def\s+\w+|fn\s+\w+|struct\s+\w+|class\s+\w+)\b", code)
275
+ if identifiers:
276
+ return identifiers[0].replace("def ", "").replace("fn ", "").replace("struct ", "").replace("class ", "")
277
+
278
+ # Fallback: first meaningful word
279
+ words = re.findall(r"\b[a-z_]{4,}\b", code)
280
+ return words[0] if words else "code"
281
+
282
+
283
+ def generate_description(chunk: dict) -> str:
284
+ """Generate a natural language description of what we're looking for."""
285
+ name = chunk["name"]
286
+ typ = chunk["type"]
287
+ lang = chunk["language"]
288
+
289
+ if typ == "function":
290
+ return f"the {name} function"
291
+ elif typ == "struct":
292
+ return f"the {name} structure"
293
+ elif typ == "class":
294
+ return f"the {name} class"
295
+ elif typ == "macro":
296
+ return f"the {name} macro"
297
+ elif typ == "enum":
298
+ return f"the {name} enum"
299
+ else:
300
+ return f"code related to {name}"
301
+
302
+
303
+ # ─── Trace generators ────────────────────────────────────────────────────────
304
+
305
+ def make_trace_simple(chunk: dict) -> dict:
306
+ """Type 1: Simple lookup — 1 search, 1 result."""
307
+ name = chunk["name"]
308
+ typ = chunk["type"]
309
+ templates = QUERY_TEMPLATES.get(typ, QUERY_TEMPLATES["block"])
310
+ query = random.choice(templates).format(name=name)
311
+
312
+ search_term = generate_search_term(chunk)
313
+ desc = generate_description(chunk)
314
+
315
+ reasoning1 = random.choice(INITIAL_REASONING).format(desc=desc, search_term=search_term)
316
+ evidence = generate_evidence(chunk)
317
+
318
+ return {
319
+ "query": query,
320
+ "trace": [
321
+ {"role": "system", "content": SYSTEM_PROMPT},
322
+ {"role": "user", "content": query},
323
+ {"role": "assistant", "content": f"<|reasoning|>{reasoning1}<|end|>\n<|search|>{search_term}<|end|>"},
324
+ {"role": "result", "content": chunk["code"]},
325
+ {"role": "assistant", "content": f"<|reasoning|>Found the relevant code. This is {desc}.<|end|>\n<|evidence|>{evidence}<|end|>\n<|finish|>"},
326
+ ],
327
+ }
328
+
329
+
330
+ def make_trace_with_noise(chunk: dict, noise_chunks: list) -> dict:
331
+ """Type 2: Lookup with noise — 1 search, 2-3 results (some irrelevant)."""
332
+ name = chunk["name"]
333
+ typ = chunk["type"]
334
+ templates = QUERY_TEMPLATES.get(typ, QUERY_TEMPLATES["block"])
335
+ query = random.choice(templates).format(name=name)
336
+
337
+ search_term = generate_search_term(chunk)
338
+ desc = generate_description(chunk)
339
+ evidence = generate_evidence(chunk)
340
+
341
+ reasoning1 = random.choice(INITIAL_REASONING).format(desc=desc, search_term=search_term)
342
+
343
+ # Select 1-2 noise chunks
344
+ n_noise = random.choice([1, 2])
345
+ noise = random.sample(noise_chunks, min(n_noise, len(noise_chunks)))
346
+
347
+ analysis = f"This contains {desc}."
348
+ analysis_template = random.choice(ANALYSIS_REASONING_NOISE).format(analysis=analysis)
349
+
350
+ trace = [
351
+ {"role": "system", "content": SYSTEM_PROMPT},
352
+ {"role": "user", "content": query},
353
+ {"role": "assistant", "content": f"<|reasoning|>{reasoning1}<|end|>\n<|search|>{search_term}<|end|>"},
354
+ {"role": "result", "content": chunk["code"]},
355
+ ]
356
+ for nc in noise:
357
+ trace.append({"role": "result", "content": nc["code"]})
358
+
359
+ trace.append({"role": "assistant", "content": f"<|reasoning|>{analysis_template}<|end|>\n<|evidence|>{evidence}<|end|>\n<|finish|>"})
360
+
361
+ return {"query": query, "trace": trace}
362
+
363
+
364
+ def make_trace_multi_step(chunk: dict, related_chunk: dict) -> dict:
365
+ """Type 3: Multi-step search — 2 searches, progressive refinement."""
366
+ name = chunk["name"]
367
+ typ = chunk["type"]
368
+ query = f"What does {name} do and where is it used?"
369
+
370
+ search_term1 = generate_search_term(chunk)
371
+ search_term2 = generate_search_term(related_chunk)
372
+ desc = generate_description(chunk)
373
+ evidence1 = generate_evidence(chunk)
374
+ evidence2 = generate_evidence(related_chunk)
375
+
376
+ reasoning1 = random.choice(INITIAL_REASONING).format(desc=desc, search_term=search_term1)
377
+ reasoning2 = random.choice(ANALYSIS_REASONING_REFINE)
378
+
379
+ return {
380
+ "query": query,
381
+ "trace": [
382
+ {"role": "system", "content": SYSTEM_PROMPT},
383
+ {"role": "user", "content": query},
384
+ {"role": "assistant", "content": f"<|reasoning|>{reasoning1}<|end|>\n<|search|>{search_term1}<|end|>"},
385
+ {"role": "result", "content": chunk["code"]},
386
+ {"role": "assistant", "content": f"<|reasoning|>Found {desc}. {reasoning2}<|end|>\n<|search|>{search_term2}<|end|>"},
387
+ {"role": "result", "content": related_chunk["code"]},
388
+ {"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|>"},
389
+ ],
390
+ }
391
+
392
+
393
+ def make_trace_not_found() -> dict:
394
+ """Type 5: Not found — search returns nothing."""
395
+ fake_names = ["database_pool", "auth_middleware", "cache_manager", "session_store",
396
+ "websocket_handler", "graphql_resolver", "redis_client", "email_service"]
397
+ name = random.choice(fake_names)
398
+ query = f"Where is the {name} implemented?"
399
+
400
+ search1 = name
401
+ search2 = name.replace("_", " ")
402
+
403
+ reasoning1 = random.choice(INITIAL_REASONING).format(desc=f"the {name}", search_term=search1)
404
+ reasoning2 = random.choice(NOT_FOUND_REASONING).format(search_term=search1)
405
+
406
+ return {
407
+ "query": query,
408
+ "trace": [
409
+ {"role": "system", "content": SYSTEM_PROMPT},
410
+ {"role": "user", "content": query},
411
+ {"role": "assistant", "content": f"<|reasoning|>{reasoning1}<|end|>\n<|search|>{search1}<|end|>"},
412
+ {"role": "result", "content": ""},
413
+ {"role": "assistant", "content": f"<|reasoning|>{reasoning2}<|end|>\n<|search|>{search2}<|end|>"},
414
+ {"role": "result", "content": ""},
415
+ {"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|>"},
416
+ ],
417
+ }
418
+
419
+
420
+ def make_trace_decomposition(chunk: dict, related_chunk: dict) -> dict:
421
+ """Type 4: Query decomposition — complex query broken into subqueries."""
422
+ name = chunk["name"]
423
+ related_name = related_chunk["name"]
424
+
425
+ query = f"How does {name} work and how does it relate to {related_name}?"
426
+
427
+ search1 = generate_search_term(chunk)
428
+ search2 = generate_search_term(related_chunk)
429
+ evidence1 = generate_evidence(chunk)
430
+ evidence2 = generate_evidence(related_chunk)
431
+
432
+ return {
433
+ "query": query,
434
+ "trace": [
435
+ {"role": "system", "content": SYSTEM_PROMPT},
436
+ {"role": "user", "content": query},
437
+ {"role": "assistant", "content": f"<|reasoning|>This query has two parts: (1) how {name} works, (2) its relationship to {related_name}. Let me search for {name} first.<|end|>\n<|search|>{search1}<|end|>"},
438
+ {"role": "result", "content": chunk["code"]},
439
+ {"role": "assistant", "content": f"<|reasoning|>Found {name}. Now let me search for {related_name} to understand the relationship.<|end|>\n<|search|>{search2}<|end|>"},
440
+ {"role": "result", "content": related_chunk["code"]},
441
+ {"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|>"},
442
+ ],
443
+ }
444
+
445
+
446
+ def make_trace_struct_inspection(chunk: dict) -> dict:
447
+ """Type 6: Struct inspection — enumerate fields."""
448
+ if chunk["type"] not in ("struct",):
449
+ return make_trace_simple(chunk)
450
+
451
+ name = chunk["name"]
452
+ query = f"What fields does {name} have?"
453
+ search_term = name
454
+ evidence = generate_evidence(chunk)
455
+
456
+ return {
457
+ "query": query,
458
+ "trace": [
459
+ {"role": "system", "content": SYSTEM_PROMPT},
460
+ {"role": "user", "content": query},
461
+ {"role": "assistant", "content": f"<|reasoning|>Looking for the {name} struct definition to enumerate its fields.<|end|>\n<|search|>{search_term}<|end|>"},
462
+ {"role": "result", "content": chunk["code"]},
463
+ {"role": "assistant", "content": f"<|reasoning|>Found the struct definition. I can extract all fields from it.<|end|>\n<|evidence|>{evidence}<|end|>\n<|finish|>"},
464
+ ],
465
+ }
466
+
467
+
468
+ def make_trace_behavior_analysis(chunk: dict) -> dict:
469
+ """Type 7: Function behavior analysis — what does it do?"""
470
+ if chunk["type"] not in ("function",):
471
+ return make_trace_simple(chunk)
472
+
473
+ name = chunk["name"]
474
+ query = f"What does {name} do? Explain its behavior."
475
+ search_term = name
476
+ evidence = generate_evidence(chunk)
477
+
478
+ info = extract_function_info(chunk["code"], name)
479
+ params = ", ".join(info["params"][:4]) if info["params"] else "no parameters"
480
+
481
+ return {
482
+ "query": query,
483
+ "trace": [
484
+ {"role": "system", "content": SYSTEM_PROMPT},
485
+ {"role": "user", "content": query},
486
+ {"role": "assistant", "content": f"<|reasoning|>I need to find and analyze the {name} function. Let me search for its definition.<|end|>\n<|search|>{search_term}<|end|>"},
487
+ {"role": "result", "content": chunk["code"]},
488
+ {"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|>"},
489
+ ],
490
+ }
491
+
492
+
493
+ # ─── Main generation ─────────────────────────────────────────────────────────
494
+
495
+ def main():
496
+ print(f"Loading chunks from {CHUNKS_PATH}...")
497
+ with open(CHUNKS_PATH, "r", encoding="utf-8") as f:
498
+ chunks = [json.loads(line) for line in f]
499
+ print(f" Loaded {len(chunks):,} chunks")
500
+
501
+ # Filter to chunks with real names and reasonable size
502
+ good_chunks = [c for c in chunks if c["name"] != "unknown" and c["name"] != "block" and 50 < len(c["code"]) < 3000]
503
+ block_chunks = [c for c in chunks if c["name"] == "block" or c["name"] == "unknown"]
504
+ print(f" Named chunks (good): {len(good_chunks):,}")
505
+ print(f" Block chunks: {len(block_chunks):,}")
506
+
507
+ # Group by language for related chunk pairing
508
+ by_lang = {}
509
+ for c in good_chunks:
510
+ by_lang.setdefault(c["language"], []).append(c)
511
+
512
+ traces = []
513
+
514
+ # ─── Generate traces ─────────────────────────────────────────────────────
515
+ print("\nGenerating traces...")
516
+
517
+ # Type 1: Simple lookup (30% of traces)
518
+ n_simple = min(2000, len(good_chunks))
519
+ sampled = random.sample(good_chunks, n_simple)
520
+ for chunk in sampled:
521
+ traces.append(make_trace_simple(chunk))
522
+ print(f" Simple lookup: {n_simple}")
523
+
524
+ # Type 2: With noise (20%)
525
+ n_noise = min(1500, len(good_chunks))
526
+ sampled = random.sample(good_chunks, n_noise)
527
+ for chunk in sampled:
528
+ # Pick noise chunks from same language
529
+ lang_chunks = by_lang.get(chunk["language"], good_chunks)
530
+ noise_pool = [c for c in lang_chunks if c["id"] != chunk["id"]]
531
+ if len(noise_pool) >= 2:
532
+ traces.append(make_trace_with_noise(chunk, noise_pool))
533
+ print(f" With noise: {n_noise}")
534
+
535
+ # Type 3: Multi-step (15%)
536
+ n_multi = min(1000, len(good_chunks) // 2)
537
+ sampled = random.sample(good_chunks, n_multi)
538
+ for chunk in sampled:
539
+ lang_chunks = by_lang.get(chunk["language"], good_chunks)
540
+ related_pool = [c for c in lang_chunks if c["id"] != chunk["id"]]
541
+ if related_pool:
542
+ related = random.choice(related_pool)
543
+ traces.append(make_trace_multi_step(chunk, related))
544
+ print(f" Multi-step: {n_multi}")
545
+
546
+ # Type 4: Query decomposition (10%)
547
+ n_decomp = min(700, len(good_chunks) // 3)
548
+ sampled = random.sample(good_chunks, n_decomp)
549
+ for chunk in sampled:
550
+ lang_chunks = by_lang.get(chunk["language"], good_chunks)
551
+ related_pool = [c for c in lang_chunks if c["id"] != chunk["id"]]
552
+ if related_pool:
553
+ related = random.choice(related_pool)
554
+ traces.append(make_trace_decomposition(chunk, related))
555
+ print(f" Query decomposition: {n_decomp}")
556
+
557
+ # Type 5: Not found (5%)
558
+ n_notfound = 300
559
+ for _ in range(n_notfound):
560
+ traces.append(make_trace_not_found())
561
+ print(f" Not found: {n_notfound}")
562
+
563
+ # Type 6: Struct inspection (10%)
564
+ struct_chunks = [c for c in good_chunks if c["type"] == "struct"]
565
+ for chunk in struct_chunks[:500]:
566
+ traces.append(make_trace_struct_inspection(chunk))
567
+ print(f" Struct inspection: {min(len(struct_chunks), 500)}")
568
+
569
+ # Type 7: Behavior analysis (10%)
570
+ func_chunks = [c for c in good_chunks if c["type"] == "function"]
571
+ for chunk in func_chunks[:500]:
572
+ traces.append(make_trace_behavior_analysis(chunk))
573
+ print(f" Behavior analysis: {min(len(func_chunks), 500)}")
574
+
575
+ # Shuffle
576
+ random.shuffle(traces)
577
+
578
+ # Write
579
+ print(f"\nTotal traces: {len(traces):,}")
580
+ with open(TRACES_PATH, "w", encoding="utf-8") as f:
581
+ for trace in traces:
582
+ f.write(json.dumps(trace) + "\n")
583
+ print(f"Traces written to {TRACES_PATH}")
584
+
585
+ # Stats
586
+ total_size = os.path.getsize(TRACES_PATH)
587
+ print(f" File size: {total_size / 1e6:.2f} MB")
588
+
589
+ # Show a sample
590
+ print("\n" + "=" * 60)
591
+ print("SAMPLE TRACE")
592
+ print("=" * 60)
593
+ sample = traces[0]
594
+ print(f"\nQuery: {sample['query']}")
595
+ for msg in sample["trace"]:
596
+ role = msg["role"]
597
+ content = msg["content"]
598
+ if len(content) > 200:
599
+ content = content[:200] + "..."
600
+ print(f"\n[{role}]")
601
+ print(content)
602
+
603
+
604
+ if __name__ == "__main__":
605
+ 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/search_agent.py ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = 256,
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, stopping at <|end|> or <|finish|>."""
165
+ with torch.no_grad():
166
+ for _ in range(max_new_tokens):
167
+ # Crop context if too long
168
+ if input_ids.size(1) > self.model.config.max_seq_len:
169
+ input_ids = input_ids[:, -self.model.config.max_seq_len:]
170
+
171
+ logits = self.model(input_ids)["logits"]
172
+ next_logits = logits[:, -1, :]
173
+
174
+ # Apply temperature and sample
175
+ probs = F.softmax(next_logits / 0.8, dim=-1)
176
+ next_token = torch.multinomial(probs, num_samples=1)
177
+ input_ids = torch.cat([input_ids, next_token], dim=1)
178
+
179
+ # Stop on <|end|> or <|finish|>
180
+ if next_token.item() == self.end_id or next_token.item() == self.finish_id:
181
+ break
182
+
183
+ # Decode the generated part (after the input)
184
+ generated_ids = input_ids[0, -max_new_tokens:].tolist()
185
+ return self._decode(generated_ids)
186
+
187
+ def _parse_search_query(self, text: str) -> str | None:
188
+ """Extract the search query from the agent's response."""
189
+ # Look for <|search|>query<|end|>
190
+ match = re.search(r"<\|search\|>(.*?)<\|end\|>", text, re.DOTALL)
191
+ if match:
192
+ return match.group(1).strip()
193
+ return None
194
+
195
+ def _parse_evidence(self, text: str) -> str | None:
196
+ """Extract the evidence from the agent's response."""
197
+ match = re.search(r"<\|evidence\|>(.*?)(?:<\|end\|>|<\|finish\|>|$)", text, re.DOTALL)
198
+ if match:
199
+ return match.group(1).strip()
200
+ return None
201
+
202
+ def _has_finish(self, text: str) -> bool:
203
+ """Check if the agent has signaled completion."""
204
+ return "<|finish|>" in text
205
+
206
+ def search(self, query: str) -> dict:
207
+ """Run the full search loop for a query.
208
+
209
+ Returns:
210
+ {
211
+ "query": the original query,
212
+ "evidence": the curated evidence (or None if not found),
213
+ "searches": list of search queries issued,
214
+ "results": list of all results retrieved,
215
+ "trace": the full conversation trace,
216
+ }
217
+ """
218
+ print(f"\n{'='*60}")
219
+ print(f"QUERY: {query}")
220
+ print(f"{'='*60}")
221
+
222
+ # Build initial context
223
+ trace = []
224
+
225
+ # System prompt
226
+ system_tokens = [self.system_id] + self._encode(SYSTEM_PROMPT) + [self.end_id]
227
+ trace.append({"role": "system", "tokens": system_tokens})
228
+
229
+ # User query
230
+ user_tokens = [self.user_id] + self._encode(query) + [self.end_id]
231
+ trace.append({"role": "user", "tokens": user_tokens})
232
+
233
+ all_searches = []
234
+ all_results = []
235
+ evidence = None
236
+
237
+ for round_num in range(self.max_search_rounds):
238
+ # Build input from trace
239
+ all_tokens = []
240
+ for entry in trace:
241
+ all_tokens.extend(entry["tokens"])
242
+
243
+ input_ids = torch.tensor([all_tokens], dtype=torch.long, device=self.device)
244
+
245
+ # Generate agent response
246
+ print(f"\n--- Round {round_num + 1} ---")
247
+ response = self._generate(input_ids, self.max_new_tokens)
248
+ print(f"Agent: {response[:200]}...")
249
+
250
+ # Add assistant tokens to trace
251
+ assistant_tokens = [self.assistant_id] + self._encode(response)
252
+ if not response.endswith("<|end|>"):
253
+ assistant_tokens.append(self.end_id)
254
+ trace.append({"role": "assistant", "tokens": assistant_tokens})
255
+
256
+ # Check for finish
257
+ if self._has_finish(response):
258
+ evidence = self._parse_evidence(response)
259
+ print(f"\n[EVIDENCE]: {evidence}")
260
+ break
261
+
262
+ # Parse search query
263
+ search_query = self._parse_search_query(response)
264
+ if search_query:
265
+ print(f"[SEARCH]: {search_query}")
266
+ all_searches.append(search_query)
267
+
268
+ # Retrieve results
269
+ results = self.retriever.search(search_query, top_k=3)
270
+
271
+ if results:
272
+ for result in results:
273
+ print(f" [RESULT]: {result['name']} ({result['language']}, score={result['score']:.2f})")
274
+ result_tokens = [self.result_id] + self._encode(result["code"]) + [self.end_id]
275
+ trace.append({"role": "result", "tokens": result_tokens, "data": result})
276
+ all_results.append(result)
277
+ else:
278
+ print(" [NO RESULTS]")
279
+ result_tokens = [self.result_id, self.end_id]
280
+ trace.append({"role": "result", "tokens": result_tokens})
281
+ else:
282
+ # No search query found — try to extract evidence directly
283
+ evidence = self._parse_evidence(response)
284
+ if evidence:
285
+ print(f"\n[EVIDENCE]: {evidence}")
286
+ break
287
+ else:
288
+ print("[WARNING] No search or evidence found, continuing...")
289
+
290
+ return {
291
+ "query": query,
292
+ "evidence": evidence,
293
+ "searches": all_searches,
294
+ "results": all_results,
295
+ "trace": trace,
296
+ }
297
+
298
+
299
+ # ─── CLI ─────────────────────────────────────────────────────────────────────
300
+
301
+ def main():
302
+ parser = argparse.ArgumentParser(description="Run the search agent")
303
+ parser.add_argument("--checkpoint", type=str, default=os.path.join(CHECKPOINT_DIR, "sft_latest.pt"))
304
+ parser.add_argument("--query", type=str, default=None, help="Query to search for")
305
+ parser.add_argument("--interactive", action="store_true", help="Interactive mode")
306
+ parser.add_argument("--max_rounds", type=int, default=5, help="Max search rounds")
307
+ args = parser.parse_args()
308
+
309
+ agent = SearchAgent(
310
+ checkpoint_path=args.checkpoint,
311
+ tokenizer_path=TOKENIZER_PATH,
312
+ chunks_path=CHUNKS_PATH,
313
+ max_search_rounds=args.max_rounds,
314
+ )
315
+
316
+ if args.interactive:
317
+ print("\nInteractive mode. Type 'quit' to exit.")
318
+ while True:
319
+ query = input("\nQuery> ").strip()
320
+ if query.lower() in ("quit", "exit", "q"):
321
+ break
322
+ if query:
323
+ result = agent.search(query)
324
+ print(f"\n{'='*60}")
325
+ print(f"FINAL EVIDENCE:")
326
+ print(f"{'='*60}")
327
+ print(result["evidence"] or "No evidence found.")
328
+ elif args.query:
329
+ result = agent.search(args.query)
330
+ print(f"\n{'='*60}")
331
+ print(f"FINAL EVIDENCE:")
332
+ print(f"{'='*60}")
333
+ print(result["evidence"] or "No evidence found.")
334
+ else:
335
+ # Run sample queries
336
+ sample_queries = [
337
+ "How does nginx handle reusable connections?",
338
+ "What does the with_params_help decorator do?",
339
+ "What fields does the ngx_listening_s struct have?",
340
+ "How does the concatenate function work?",
341
+ "Where is the database connection pool implemented?",
342
+ ]
343
+ for query in sample_queries:
344
+ result = agent.search(query)
345
+ print(f"\n{'='*60}")
346
+ print(f"FINAL EVIDENCE:")
347
+ print(f"{'='*60}")
348
+ print(result["evidence"] or "No evidence found.")
349
+
350
+
351
+ if __name__ == "__main__":
352
+ 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_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()