Upload folder using huggingface_hub
Browse files- src/add_special_tokens.py +87 -0
- src/chunk_corpus.py +232 -0
- src/convert_bf16.py +24 -0
- src/curate.py +239 -0
- src/download_data.py +148 -0
- src/generate.py +114 -0
- src/generate_traces.py +605 -0
- src/gold_traces.py +324 -0
- src/model.py +360 -0
- src/search_agent.py +352 -0
- src/sft.py +526 -0
- src/sft_dry_run.py +43 -0
- src/train.py +401 -0
- src/train_tokenizer.py +60 -0
- src/verify_pipeline.py +77 -0
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()
|