Upload folder using huggingface_hub
Browse files- src/add_special_tokens.py +87 -0
- src/chunk_corpus.py +232 -0
- src/chunk_documents.py +225 -0
- src/convert_bf16.py +24 -0
- src/curate.py +239 -0
- src/dedup_pipeline.py +326 -0
- src/dedup_quality.py +335 -0
- src/download_corpus_large.py +247 -0
- src/download_data.py +148 -0
- src/generate.py +114 -0
- src/generate_traces.py +958 -0
- src/generate_traces_v2.py +587 -0
- src/gold_traces.py +324 -0
- src/model.py +360 -0
- src/quality_filter.py +291 -0
- src/search_agent.py +385 -0
- src/sft.py +526 -0
- src/sft_dry_run.py +43 -0
- src/train.py +401 -0
- src/train_modal.py +840 -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/chunk_documents.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Chunk quality-filtered documents into:
|
| 3 |
+
1. A pre-training corpus (data/pretrain/corpus_pretrain.txt)
|
| 4 |
+
- Concatenated source code with document separators
|
| 5 |
+
- Used for base LM pre-training
|
| 6 |
+
2. Retrieval chunks (data/chunks/chunks.jsonl)
|
| 7 |
+
- Semantically meaningful code units (functions, classes, structs)
|
| 8 |
+
- Used by the search agent and for SFT trace generation
|
| 9 |
+
|
| 10 |
+
Input: data/quality/documents_quality.jsonl
|
| 11 |
+
Output: data/pretrain/corpus_pretrain.txt
|
| 12 |
+
data/chunks/chunks.jsonl
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import re
|
| 18 |
+
|
| 19 |
+
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 20 |
+
DATA_DIR = os.path.join(PROJECT_DIR, "data")
|
| 21 |
+
QUALITY_DOCS_PATH = os.path.join(DATA_DIR, "quality", "documents_quality.jsonl")
|
| 22 |
+
PRETRAIN_DIR = os.path.join(DATA_DIR, "pretrain")
|
| 23 |
+
CORPUS_PRETRAIN_PATH = os.path.join(PRETRAIN_DIR, "corpus_pretrain.txt")
|
| 24 |
+
CHUNKS_DIR = os.path.join(DATA_DIR, "chunks")
|
| 25 |
+
CHUNKS_PATH = os.path.join(CHUNKS_DIR, "chunks.jsonl")
|
| 26 |
+
|
| 27 |
+
# ─── Chunk start patterns per language ───────────────────────────────────────
|
| 28 |
+
CHUNK_STARTS = {
|
| 29 |
+
"python": re.compile(r"^(def |class |@|async def )", re.M),
|
| 30 |
+
"javascript": re.compile(r"^(function |const |let |class |export |async function|interface |type \w+ =)", re.M),
|
| 31 |
+
"typescript": re.compile(r"^(function |const |let |class |export |async function|interface |type \w+ =|enum )", re.M),
|
| 32 |
+
"rust": re.compile(r"^(fn |pub fn |impl |struct |enum |trait |mod |macro_rules!|pub struct|pub enum|pub trait)", re.M),
|
| 33 |
+
"go": re.compile(r"^(func |type \w+ struct|type \w+ interface)", re.M),
|
| 34 |
+
"c": re.compile(r"^(static |void |int |char |size_t |typedef |struct \w+ \{|#define|#if|#ifndef|ngx_)", re.M),
|
| 35 |
+
"cpp": re.compile(r"^(static |void |int |char |size_t |template |class \w+|namespace |struct \w+|#define|#if|#ifndef)", re.M),
|
| 36 |
+
"java": re.compile(r"^(public |private |protected |class \w+|interface |enum )", re.M),
|
| 37 |
+
"csharp": re.compile(r"^(public |private |protected |internal |class \w+|interface |enum )", re.M),
|
| 38 |
+
"ruby": re.compile(r"^(def |class |module )", re.M),
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
NAME_PATTERNS = [
|
| 42 |
+
(re.compile(r"def (\w+)"), "function"),
|
| 43 |
+
(re.compile(r"class (\w+)"), "class"),
|
| 44 |
+
(re.compile(r"fn (\w+)"), "function"),
|
| 45 |
+
(re.compile(r"pub fn (\w+)"), "function"),
|
| 46 |
+
(re.compile(r"struct (\w+)"), "struct"),
|
| 47 |
+
(re.compile(r"enum (\w+)"), "enum"),
|
| 48 |
+
(re.compile(r"trait (\w+)"), "trait"),
|
| 49 |
+
(re.compile(r"impl (\w+)"), "impl"),
|
| 50 |
+
(re.compile(r"func (\w+)"), "function"),
|
| 51 |
+
(re.compile(r"type (\w+) struct"), "struct"),
|
| 52 |
+
(re.compile(r"type (\w+) interface"), "interface"),
|
| 53 |
+
(re.compile(r"function (\w+)"), "function"),
|
| 54 |
+
(re.compile(r"async function (\w+)"), "function"),
|
| 55 |
+
(re.compile(r"typedef struct (\w+)"), "typedef"),
|
| 56 |
+
(re.compile(r"#define (\w+)"), "macro"),
|
| 57 |
+
(re.compile(r"interface (\w+)"), "interface"),
|
| 58 |
+
(re.compile(r"module (\w+)"), "module"),
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def extract_name(code: str) -> tuple[str, str]:
|
| 63 |
+
for pat, typ in NAME_PATTERNS:
|
| 64 |
+
m = pat.search(code)
|
| 65 |
+
if m:
|
| 66 |
+
return m.group(1), typ
|
| 67 |
+
return "unknown", "block"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def chunk_document(doc: dict) -> list[dict]:
|
| 71 |
+
"""Split a document into chunks based on language-specific patterns."""
|
| 72 |
+
content = doc["content"]
|
| 73 |
+
lang = doc["language"]
|
| 74 |
+
filepath = doc["filepath"]
|
| 75 |
+
repo = doc["repo"]
|
| 76 |
+
lines = content.split("\n")
|
| 77 |
+
n_lines = len(lines)
|
| 78 |
+
|
| 79 |
+
start_pat = CHUNK_STARTS.get(lang)
|
| 80 |
+
if start_pat is None:
|
| 81 |
+
name, typ = extract_name(content)
|
| 82 |
+
return [{
|
| 83 |
+
"id": f"{repo}:{filepath}:0",
|
| 84 |
+
"language": lang,
|
| 85 |
+
"name": name,
|
| 86 |
+
"type": typ,
|
| 87 |
+
"code": content,
|
| 88 |
+
"filepath": filepath,
|
| 89 |
+
"repo": repo,
|
| 90 |
+
"start_line": 1,
|
| 91 |
+
"end_line": n_lines,
|
| 92 |
+
}]
|
| 93 |
+
|
| 94 |
+
starts = [(m.start(), m.group()) for m in start_pat.finditer(content)]
|
| 95 |
+
|
| 96 |
+
if not starts:
|
| 97 |
+
name, typ = extract_name(content)
|
| 98 |
+
return [{
|
| 99 |
+
"id": f"{repo}:{filepath}:0",
|
| 100 |
+
"language": lang,
|
| 101 |
+
"name": name,
|
| 102 |
+
"type": typ,
|
| 103 |
+
"code": content,
|
| 104 |
+
"filepath": filepath,
|
| 105 |
+
"repo": repo,
|
| 106 |
+
"start_line": 1,
|
| 107 |
+
"end_line": n_lines,
|
| 108 |
+
}]
|
| 109 |
+
|
| 110 |
+
if starts[0][0] > 0:
|
| 111 |
+
starts.insert(0, (0, ""))
|
| 112 |
+
|
| 113 |
+
chunks = []
|
| 114 |
+
for i, (start_pos, _) in enumerate(starts):
|
| 115 |
+
end_pos = starts[i + 1][0] if i + 1 < len(starts) else len(content)
|
| 116 |
+
chunk_code = content[start_pos:end_pos].strip()
|
| 117 |
+
|
| 118 |
+
if len(chunk_code) < 40:
|
| 119 |
+
continue
|
| 120 |
+
|
| 121 |
+
# Split huge chunks on blank lines
|
| 122 |
+
if len(chunk_code) > 6000:
|
| 123 |
+
sub_parts = re.split(r"\n\n+", chunk_code)
|
| 124 |
+
for j, part in enumerate(sub_parts):
|
| 125 |
+
part = part.strip()
|
| 126 |
+
if len(part) < 40:
|
| 127 |
+
continue
|
| 128 |
+
name, typ = extract_name(part)
|
| 129 |
+
start_line = content[:start_pos].count("\n") + 1 + sum(p.count("\n") + 2 for p in sub_parts[:j])
|
| 130 |
+
chunks.append({
|
| 131 |
+
"id": f"{repo}:{filepath}:{i}_{j}",
|
| 132 |
+
"language": lang,
|
| 133 |
+
"name": name,
|
| 134 |
+
"type": typ,
|
| 135 |
+
"code": part,
|
| 136 |
+
"filepath": filepath,
|
| 137 |
+
"repo": repo,
|
| 138 |
+
"start_line": start_line,
|
| 139 |
+
"end_line": start_line + part.count("\n"),
|
| 140 |
+
})
|
| 141 |
+
continue
|
| 142 |
+
|
| 143 |
+
name, typ = extract_name(chunk_code)
|
| 144 |
+
start_line = content[:start_pos].count("\n") + 1
|
| 145 |
+
chunks.append({
|
| 146 |
+
"id": f"{repo}:{filepath}:{i}",
|
| 147 |
+
"language": lang,
|
| 148 |
+
"name": name,
|
| 149 |
+
"type": typ,
|
| 150 |
+
"code": chunk_code,
|
| 151 |
+
"filepath": filepath,
|
| 152 |
+
"repo": repo,
|
| 153 |
+
"start_line": start_line,
|
| 154 |
+
"end_line": start_line + chunk_code.count("\n"),
|
| 155 |
+
})
|
| 156 |
+
|
| 157 |
+
return chunks
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def main():
|
| 161 |
+
os.makedirs(PRETRAIN_DIR, exist_ok=True)
|
| 162 |
+
os.makedirs(CHUNKS_DIR, exist_ok=True)
|
| 163 |
+
|
| 164 |
+
print(f"Loading quality documents from {QUALITY_DOCS_PATH}...")
|
| 165 |
+
docs = []
|
| 166 |
+
with open(QUALITY_DOCS_PATH, "r", encoding="utf-8") as f:
|
| 167 |
+
for line in f:
|
| 168 |
+
docs.append(json.loads(line))
|
| 169 |
+
print(f" Loaded {len(docs):,} documents")
|
| 170 |
+
print(f" Total size: {sum(len(d['content']) for d in docs) / 1e6:.1f} MB")
|
| 171 |
+
|
| 172 |
+
# ─── Build pre-training corpus ─────────────────────────────────────────
|
| 173 |
+
print("\nBuilding pre-training corpus...")
|
| 174 |
+
corpus_size = 0
|
| 175 |
+
with open(CORPUS_PRETRAIN_PATH, "w", encoding="utf-8") as f:
|
| 176 |
+
for doc in docs:
|
| 177 |
+
f.write(doc["content"].strip())
|
| 178 |
+
f.write("\n\n\n") # document separator
|
| 179 |
+
corpus_size += len(doc["content"])
|
| 180 |
+
print(f" Pre-training corpus: {corpus_size / 1e6:.1f} MB -> {CORPUS_PRETRAIN_PATH}")
|
| 181 |
+
|
| 182 |
+
# ─── Build retrieval chunks ────────────────────────────────────────────
|
| 183 |
+
print("\nChunking documents...")
|
| 184 |
+
all_chunks = []
|
| 185 |
+
lang_counts = {}
|
| 186 |
+
type_counts = {}
|
| 187 |
+
|
| 188 |
+
for doc in docs:
|
| 189 |
+
chunks = chunk_document(doc)
|
| 190 |
+
for chunk in chunks:
|
| 191 |
+
all_chunks.append(chunk)
|
| 192 |
+
lang_counts[chunk["language"]] = lang_counts.get(chunk["language"], 0) + 1
|
| 193 |
+
type_counts[chunk["type"]] = type_counts.get(chunk["type"], 0) + 1
|
| 194 |
+
|
| 195 |
+
print(f"\nTotal chunks: {len(all_chunks):,}")
|
| 196 |
+
print(f" By language: {lang_counts}")
|
| 197 |
+
print(f" By type: {type_counts}")
|
| 198 |
+
|
| 199 |
+
sizes = [len(c["code"]) for c in all_chunks]
|
| 200 |
+
if sizes:
|
| 201 |
+
sizes.sort()
|
| 202 |
+
print(f" Chunk size: min={sizes[0]}, median={sizes[len(sizes)//2]}, "
|
| 203 |
+
f"max={sizes[-1]}, mean={sum(sizes)//len(sizes)}")
|
| 204 |
+
|
| 205 |
+
with open(CHUNKS_PATH, "w", encoding="utf-8") as f:
|
| 206 |
+
for chunk in all_chunks:
|
| 207 |
+
f.write(json.dumps(chunk, ensure_ascii=False) + "\n")
|
| 208 |
+
print(f"\nChunks written to {CHUNKS_PATH}")
|
| 209 |
+
|
| 210 |
+
# Stats
|
| 211 |
+
stats = {
|
| 212 |
+
"input_docs": len(docs),
|
| 213 |
+
"pretrain_corpus_mb": corpus_size / 1e6,
|
| 214 |
+
"total_chunks": len(all_chunks),
|
| 215 |
+
"language_distribution": lang_counts,
|
| 216 |
+
"type_distribution": type_counts,
|
| 217 |
+
}
|
| 218 |
+
stats_path = os.path.join(CHUNKS_DIR, "chunk_stats.json")
|
| 219 |
+
with open(stats_path, "w") as f:
|
| 220 |
+
json.dump(stats, f, indent=2)
|
| 221 |
+
print(f"Stats written to {stats_path}")
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
if __name__ == "__main__":
|
| 225 |
+
main()
|
src/convert_bf16.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Convert the latest checkpoint to bf16 to halve file size for Colab upload."""
|
| 2 |
+
import torch
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
def main():
|
| 7 |
+
ckpt = torch.load("checkpoints/latest.pt", map_location="cpu", weights_only=False)
|
| 8 |
+
print(f"Original checkpoint: step={ckpt['step']}, loss={ckpt['loss']}")
|
| 9 |
+
print(f" Embedding shape: {ckpt['model_state_dict']['token_embedding.weight'].shape}")
|
| 10 |
+
|
| 11 |
+
# Convert all float32 tensors to bf16
|
| 12 |
+
state = ckpt["model_state_dict"]
|
| 13 |
+
for key in state:
|
| 14 |
+
if state[key].dtype == torch.float32:
|
| 15 |
+
state[key] = state[key].to(torch.bfloat16)
|
| 16 |
+
|
| 17 |
+
ckpt["model_state_dict"] = state
|
| 18 |
+
out_path = "checkpoints/latest_bf16.pt"
|
| 19 |
+
torch.save(ckpt, out_path)
|
| 20 |
+
size_mb = os.path.getsize(out_path) / 1e6
|
| 21 |
+
print(f"bf16 checkpoint saved: {out_path} ({size_mb:.1f} MB)")
|
| 22 |
+
|
| 23 |
+
if __name__ == "__main__":
|
| 24 |
+
main()
|
src/curate.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Curate the raw corpus into a small, ultra-high-quality subset.
|
| 3 |
+
|
| 4 |
+
Splits the corpus into documents, scores each on quality heuristics,
|
| 5 |
+
and keeps only the top-tier real source code. Produces data/corpus_curated.txt.
|
| 6 |
+
|
| 7 |
+
Quality signals:
|
| 8 |
+
- Real source code (NOT RST docs, YAML configs, JSON data, prose, etc.)
|
| 9 |
+
- Balanced delimiters (braces/parens/brackets roughly match)
|
| 10 |
+
- Has function/class definitions at line starts (not embedded in prose)
|
| 11 |
+
- Has comments or docstrings (documentation density)
|
| 12 |
+
- Reasonable length (200–50k chars)
|
| 13 |
+
- Low repetition (unique line ratio)
|
| 14 |
+
- Clean ASCII (no encoding garbage)
|
| 15 |
+
- High code-to-prose ratio
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import re
|
| 20 |
+
|
| 21 |
+
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 22 |
+
CORPUS_PATH = os.path.join(PROJECT_DIR, "data", "corpus.txt")
|
| 23 |
+
CURATED_PATH = os.path.join(PROJECT_DIR, "data", "corpus_curated.txt")
|
| 24 |
+
|
| 25 |
+
# ─── Code structure patterns (must be at line start, not in prose) ───────────
|
| 26 |
+
CODE_SIGNATURES = {
|
| 27 |
+
"python": re.compile(r"^(def |class |import |from \S+ import |if __name__|@|elif |except |finally )", re.M),
|
| 28 |
+
"js_ts": re.compile(r"^(function |const |let |var |class |export |import |async function|interface |type \w+ =)", re.M),
|
| 29 |
+
"rust": re.compile(r"^(fn |pub fn |impl |struct |enum |trait |mod |use |pub struct|pub enum|macro_rules!)", re.M),
|
| 30 |
+
"go": re.compile(r"^(func |package |import |type \w+ struct|var |const )", re.M),
|
| 31 |
+
"c_cpp": re.compile(r"^(#include|#define|#ifndef|#ifdef|#if |#endif|typedef |struct \w+|class \w+)", re.M),
|
| 32 |
+
"java": re.compile(r"^(public |private |protected |class \w+|import |package )", re.M),
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
# ─── Documentation / non-code exclusion patterns ─────────────────────────────
|
| 36 |
+
RST_DIRECTIVE = re.compile(r"^\.\. [\w-]+::", re.M) # .. c:function::
|
| 37 |
+
RST_LABEL = re.compile(r"^\.\. _[\w-]+:", re.M) # .. _faq-cache:
|
| 38 |
+
RST_UNDERLINE = re.compile(r"^[-=~^\"']{3,}\s*$", re.M) # ----, ====, ~~~~
|
| 39 |
+
RST_CROSSREF = re.compile(r":[\w]+:`", re.M) # :func:`name`
|
| 40 |
+
XML_START = re.compile(r"^\s*<\?xml|<!DOCTYPE|<\w+ xmlns", re.M)
|
| 41 |
+
MARKDOWN_HEADER = re.compile(r"^#{1,6}\s+\w", re.M) # # Header
|
| 42 |
+
|
| 43 |
+
# Prose detection: lines that look like English sentences
|
| 44 |
+
PROSE_LINE = re.compile(r"^[A-Z][a-z]+ .* [a-z]+\.$", re.M) # "The quick brown fox."
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def is_documentation(doc: str) -> bool:
|
| 48 |
+
"""Check if a document is RST/Markdown documentation rather than code."""
|
| 49 |
+
# Strong RST signals
|
| 50 |
+
if RST_DIRECTIVE.search(doc) or RST_LABEL.search(doc):
|
| 51 |
+
return True
|
| 52 |
+
if RST_CROSSREF.search(doc):
|
| 53 |
+
return True
|
| 54 |
+
# Section underlines (RST/Markdown)
|
| 55 |
+
if RST_UNDERLINE.findall(doc):
|
| 56 |
+
return True
|
| 57 |
+
# XML
|
| 58 |
+
if XML_START.search(doc):
|
| 59 |
+
return True
|
| 60 |
+
# Markdown headers
|
| 61 |
+
if MARKDOWN_HEADER.findall(doc) and len(MARKDOWN_HEADER.findall(doc)) > 3:
|
| 62 |
+
return True
|
| 63 |
+
return False
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def detect_language(doc: str) -> str | None:
|
| 67 |
+
"""Detect if a document is real source code and which language."""
|
| 68 |
+
if is_documentation(doc):
|
| 69 |
+
return None
|
| 70 |
+
|
| 71 |
+
for lang, pat in CODE_SIGNATURES.items():
|
| 72 |
+
matches = pat.findall(doc)
|
| 73 |
+
if len(matches) >= 3: # at least 3 code-structure keywords
|
| 74 |
+
return lang
|
| 75 |
+
return None
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def score_document(doc: str) -> tuple[float, str | None]:
|
| 79 |
+
"""Score a document 0.0–1.0 on quality. Returns (score, language)."""
|
| 80 |
+
lines = doc.split("\n")
|
| 81 |
+
n_lines = len(lines)
|
| 82 |
+
if n_lines < 5:
|
| 83 |
+
return 0.0, None
|
| 84 |
+
|
| 85 |
+
length = len(doc)
|
| 86 |
+
if length < 200 or length > 100_000:
|
| 87 |
+
return 0.0, None
|
| 88 |
+
|
| 89 |
+
# Must be real code
|
| 90 |
+
lang = detect_language(doc)
|
| 91 |
+
if lang is None:
|
| 92 |
+
return 0.0, None
|
| 93 |
+
|
| 94 |
+
score = 0.0
|
| 95 |
+
|
| 96 |
+
# ─── Base: is real code ──────────────────────────────────────────────────
|
| 97 |
+
score += 0.20
|
| 98 |
+
|
| 99 |
+
# ─── Length quality (sweet spot: 500–20000 chars) ────────────────────────
|
| 100 |
+
if 500 <= length <= 20000:
|
| 101 |
+
score += 0.15
|
| 102 |
+
elif 200 <= length <= 50000:
|
| 103 |
+
score += 0.08
|
| 104 |
+
|
| 105 |
+
# ─── Delimiter balance ───────────────────────────────────────────────────
|
| 106 |
+
braces = doc.count("{") - doc.count("}")
|
| 107 |
+
parens = doc.count("(") - doc.count(")")
|
| 108 |
+
brackets = doc.count("[") - doc.count("]")
|
| 109 |
+
total_delims = doc.count("{") + doc.count("(") + doc.count("[")
|
| 110 |
+
if total_delims > 0:
|
| 111 |
+
imbalance = abs(braces) + abs(parens) + abs(brackets)
|
| 112 |
+
balance_ratio = 1.0 - (imbalance / max(total_delims, 1))
|
| 113 |
+
score += 0.15 * max(balance_ratio, 0.0)
|
| 114 |
+
|
| 115 |
+
# ─── Code structure density ──────────────────────────────────────────────
|
| 116 |
+
struct_count = sum(len(p.findall(doc)) for p in CODE_SIGNATURES.values())
|
| 117 |
+
struct_density = min(struct_count / max(n_lines, 1) * 8, 1.0)
|
| 118 |
+
score += 0.15 * struct_density
|
| 119 |
+
|
| 120 |
+
# ─── Comment density (sweet spot: 5–30%) ─────────────────────────────────
|
| 121 |
+
comment_lines = 0
|
| 122 |
+
for line in lines:
|
| 123 |
+
s = line.strip()
|
| 124 |
+
if s.startswith("#") or s.startswith("//") or s.startswith("/*") \
|
| 125 |
+
or s.startswith("*") or s.startswith('"""') or s.startswith("'''") \
|
| 126 |
+
or s.startswith("///") or s.startswith("//!"):
|
| 127 |
+
comment_lines += 1
|
| 128 |
+
comment_ratio = comment_lines / max(n_lines, 1)
|
| 129 |
+
if 0.05 <= comment_ratio <= 0.30:
|
| 130 |
+
score += 0.15
|
| 131 |
+
elif 0.02 <= comment_ratio <= 0.40:
|
| 132 |
+
score += 0.08
|
| 133 |
+
|
| 134 |
+
# ─── Low repetition ──────────────────────────────────────────────────────
|
| 135 |
+
unique_lines = len(set(lines))
|
| 136 |
+
unique_ratio = unique_lines / max(n_lines, 1)
|
| 137 |
+
if unique_ratio > 0.6:
|
| 138 |
+
score += 0.10
|
| 139 |
+
elif unique_ratio > 0.4:
|
| 140 |
+
score += 0.05
|
| 141 |
+
else:
|
| 142 |
+
score -= 0.05
|
| 143 |
+
|
| 144 |
+
# ─── Clean ASCII ─────────────────────────────────────────────────────────
|
| 145 |
+
non_ascii = sum(1 for c in doc if ord(c) > 127)
|
| 146 |
+
if non_ascii / max(length, 1) < 0.01:
|
| 147 |
+
score += 0.05
|
| 148 |
+
|
| 149 |
+
# ─── Indentation quality ─────────────────────────────────────────────────
|
| 150 |
+
indented = sum(1 for l in lines if l.startswith(" ") or l.startswith("\t"))
|
| 151 |
+
if indented > 0 and indented / max(n_lines, 1) > 0.1:
|
| 152 |
+
score += 0.05
|
| 153 |
+
|
| 154 |
+
# ─── Penalize high prose ratio ───────────────────────────────────────────
|
| 155 |
+
prose_lines = len(PROSE_LINE.findall(doc))
|
| 156 |
+
prose_ratio = prose_lines / max(n_lines, 1)
|
| 157 |
+
if prose_ratio > 0.15:
|
| 158 |
+
score -= 0.15 # too much prose = documentation
|
| 159 |
+
|
| 160 |
+
return min(max(score, 0.0), 1.0), lang
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def main():
|
| 164 |
+
print(f"Loading corpus from {CORPUS_PATH}...")
|
| 165 |
+
with open(CORPUS_PATH, "r", encoding="utf-8") as f:
|
| 166 |
+
text = f.read()
|
| 167 |
+
print(f" Corpus size: {len(text) / 1e6:.1f} MB")
|
| 168 |
+
|
| 169 |
+
# Split into documents (3+ consecutive newlines = boundary)
|
| 170 |
+
docs = re.split(r"\n{3,}", text)
|
| 171 |
+
print(f" Total documents: {len(docs):,}")
|
| 172 |
+
|
| 173 |
+
# Score all documents
|
| 174 |
+
print("Scoring documents...")
|
| 175 |
+
scored = []
|
| 176 |
+
for i, doc in enumerate(docs):
|
| 177 |
+
s, lang = score_document(doc)
|
| 178 |
+
if s > 0:
|
| 179 |
+
scored.append((s, i, doc, lang))
|
| 180 |
+
|
| 181 |
+
print(f" Documents passing initial filter: {len(scored):,}")
|
| 182 |
+
|
| 183 |
+
# Sort by score descending
|
| 184 |
+
scored.sort(key=lambda x: x[0], reverse=True)
|
| 185 |
+
|
| 186 |
+
# Keep top tier: score >= 0.5, target ~3 MB
|
| 187 |
+
# Cap per-language to ensure diversity (no single language > 40% of docs)
|
| 188 |
+
LANG_CAPS = {"c_cpp": 200, "js_ts": 150, "python": 150, "rust": 100, "go": 50, "java": 50}
|
| 189 |
+
lang_counts_so_far = {}
|
| 190 |
+
top_tier = []
|
| 191 |
+
total_size = 0
|
| 192 |
+
target_size = 3_000_000
|
| 193 |
+
|
| 194 |
+
for score, idx, doc, lang in scored:
|
| 195 |
+
if score < 0.5:
|
| 196 |
+
break
|
| 197 |
+
cap = LANG_CAPS.get(lang, 100)
|
| 198 |
+
if lang_counts_so_far.get(lang, 0) >= cap:
|
| 199 |
+
continue
|
| 200 |
+
if total_size + len(doc) > target_size:
|
| 201 |
+
break
|
| 202 |
+
top_tier.append((score, idx, doc, lang))
|
| 203 |
+
total_size += len(doc)
|
| 204 |
+
lang_counts_so_far[lang] = lang_counts_so_far.get(lang, 0) + 1
|
| 205 |
+
|
| 206 |
+
print(f"\nCurated subset:")
|
| 207 |
+
print(f" Documents: {len(top_tier):,}")
|
| 208 |
+
print(f" Total size: {total_size / 1e6:.2f} MB")
|
| 209 |
+
print(f" Score range: {top_tier[-1][0]:.3f} – {top_tier[0][0]:.3f}")
|
| 210 |
+
|
| 211 |
+
# Language distribution
|
| 212 |
+
lang_counts = {}
|
| 213 |
+
for _, _, _, lang in top_tier:
|
| 214 |
+
lang_counts[lang] = lang_counts.get(lang, 0) + 1
|
| 215 |
+
print(f" Language distribution: {lang_counts}")
|
| 216 |
+
|
| 217 |
+
# Write curated corpus
|
| 218 |
+
with open(CURATED_PATH, "w", encoding="utf-8") as f:
|
| 219 |
+
for _, _, doc, _ in top_tier:
|
| 220 |
+
f.write(doc.strip())
|
| 221 |
+
f.write("\n\n\n")
|
| 222 |
+
|
| 223 |
+
print(f"\nCurated corpus written to {CURATED_PATH}")
|
| 224 |
+
print(f" Size: {total_size / 1e6:.2f} MB ({len(top_tier):,} documents)")
|
| 225 |
+
|
| 226 |
+
# Show samples
|
| 227 |
+
print("\n" + "=" * 60)
|
| 228 |
+
print("SAMPLE DOCUMENTS (top 5 by score)")
|
| 229 |
+
print("=" * 60)
|
| 230 |
+
for i in range(min(5, len(top_tier))):
|
| 231 |
+
score, idx, doc, lang = top_tier[i]
|
| 232 |
+
print(f"\n--- Score: {score:.3f} | Lang: {lang} | Doc #{idx} | {len(doc)} chars ---")
|
| 233 |
+
print(doc[:600])
|
| 234 |
+
if len(doc) > 600:
|
| 235 |
+
print("...")
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
if __name__ == "__main__":
|
| 239 |
+
main()
|
src/dedup_pipeline.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Deduplication pipeline for the code corpus.
|
| 3 |
+
|
| 4 |
+
Three stages of deduplication, applied in order:
|
| 5 |
+
|
| 6 |
+
1. EXACT DEDUP (document-level)
|
| 7 |
+
- SHA-256 hash of normalized content (whitespace-collapsed, lowercased)
|
| 8 |
+
- Drops byte-identical and whitespace-only-differing documents.
|
| 9 |
+
|
| 10 |
+
2. NEAR-DEDUP (document-level, MinHash + LSH)
|
| 11 |
+
- Shingles each document into character n-grams.
|
| 12 |
+
- Computes MinHash signatures.
|
| 13 |
+
- Uses LSH banding to cluster near-duplicates (Jaccard >= threshold).
|
| 14 |
+
- Keeps one representative per cluster (the longest, highest-quality).
|
| 15 |
+
|
| 16 |
+
3. LINE-LEVEL DEDUP (cross-document)
|
| 17 |
+
- Builds a global set of exact line hashes.
|
| 18 |
+
- Removes lines that appear in >threshold documents (boilerplate:
|
| 19 |
+
license headers, auto-generated stubs, repeated imports, etc.).
|
| 20 |
+
- Drops documents that become too short / too sparse after line removal.
|
| 21 |
+
|
| 22 |
+
Input: data/raw_large/documents.jsonl
|
| 23 |
+
Output: data/dedup/documents_dedup.jsonl
|
| 24 |
+
|
| 25 |
+
Reports statistics at each stage so we can verify the pipeline is working.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
import hashlib
|
| 29 |
+
import json
|
| 30 |
+
import os
|
| 31 |
+
import re
|
| 32 |
+
import sys
|
| 33 |
+
from collections import Counter, defaultdict
|
| 34 |
+
|
| 35 |
+
from datasketch import MinHash, MinHashLSH
|
| 36 |
+
|
| 37 |
+
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 38 |
+
DATA_DIR = os.path.join(PROJECT_DIR, "data")
|
| 39 |
+
RAW_DOCS_PATH = os.path.join(DATA_DIR, "raw_large", "documents.jsonl")
|
| 40 |
+
DEDUP_DIR = os.path.join(DATA_DIR, "dedup")
|
| 41 |
+
DEDUP_DOCS_PATH = os.path.join(DEDUP_DIR, "documents_dedup.jsonl")
|
| 42 |
+
STATS_PATH = os.path.join(DEDUP_DIR, "dedup_stats.json")
|
| 43 |
+
|
| 44 |
+
# ─── MinHash / LSH parameters ────────────────────────────────────────────────
|
| 45 |
+
SHINGLE_SIZE = 5 # character 5-grams
|
| 46 |
+
NUM_PERM = 128 # MinHash permutations (higher = more accurate)
|
| 47 |
+
LSH_THRESHOLD = 0.5 # Jaccard similarity threshold for near-dup
|
| 48 |
+
LSH_BANDS = 32 # LSH bands (NUM_PERM / LSH_ROWS should be integer)
|
| 49 |
+
LSH_ROWS = NUM_PERM // LSH_BANDS # 128 / 32 = 4
|
| 50 |
+
|
| 51 |
+
# ─── Line-level dedup parameters ────────────────────────────────────────────
|
| 52 |
+
LINE_BOILERPLATE_THRESHOLD = 50 # lines appearing in >= this many docs are boilerplate
|
| 53 |
+
MIN_LINES_AFTER_DEDUP = 5 # drop docs with fewer lines after line dedup
|
| 54 |
+
MIN_CHARS_AFTER_DEDUP = 200 # drop docs shorter than this after line dedup
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def normalize_content(text: str) -> str:
|
| 58 |
+
"""Normalize content for exact-hash comparison: collapse whitespace, lowercase."""
|
| 59 |
+
# Collapse all whitespace sequences to single space
|
| 60 |
+
text = re.sub(r"\s+", " ", text)
|
| 61 |
+
return text.strip().lower()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def exact_hash(text: str) -> str:
|
| 65 |
+
"""SHA-256 of normalized content."""
|
| 66 |
+
return hashlib.sha256(normalize_content(text).encode("utf-8")).hexdigest()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def shingles(text: str, k: int = SHINGLE_SIZE) -> set[str]:
|
| 70 |
+
"""Character k-gram shingles of normalized text."""
|
| 71 |
+
text = re.sub(r"\s+", " ", text).strip().lower()
|
| 72 |
+
if len(text) < k:
|
| 73 |
+
return {text}
|
| 74 |
+
return {text[i : i + k] for i in range(len(text) - k + 1)}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def minhash_signature(text: str, num_perm: int = NUM_PERM) -> MinHash:
|
| 78 |
+
"""Compute MinHash signature for a document."""
|
| 79 |
+
m = MinHash(num_perm=num_perm)
|
| 80 |
+
for s in shingles(text):
|
| 81 |
+
m.update(s.encode("utf-8"))
|
| 82 |
+
return m
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ─── Stage 1: Exact dedup ────────────────────────────────────────────────────
|
| 86 |
+
|
| 87 |
+
def stage1_exact_dedup(docs: list[dict]) -> tuple[list[dict], dict]:
|
| 88 |
+
"""Remove exact (and whitespace-only) duplicates. Keep first occurrence."""
|
| 89 |
+
print("\n" + "=" * 60)
|
| 90 |
+
print("STAGE 1: Exact document-level dedup (SHA-256 of normalized content)")
|
| 91 |
+
print("=" * 60)
|
| 92 |
+
|
| 93 |
+
seen_hashes = {}
|
| 94 |
+
kept = []
|
| 95 |
+
dropped = 0
|
| 96 |
+
|
| 97 |
+
for i, doc in enumerate(docs):
|
| 98 |
+
h = exact_hash(doc["content"])
|
| 99 |
+
if h in seen_hashes:
|
| 100 |
+
dropped += 1
|
| 101 |
+
continue
|
| 102 |
+
seen_hashes[h] = i
|
| 103 |
+
kept.append(doc)
|
| 104 |
+
|
| 105 |
+
if (i + 1) % 5000 == 0:
|
| 106 |
+
print(f" Processed {i+1}/{len(docs)} | kept {len(kept)} | dropped {dropped}")
|
| 107 |
+
|
| 108 |
+
stats = {
|
| 109 |
+
"input": len(docs),
|
| 110 |
+
"kept": len(kept),
|
| 111 |
+
"dropped_exact": dropped,
|
| 112 |
+
"drop_rate": dropped / max(len(docs), 1),
|
| 113 |
+
}
|
| 114 |
+
print(f" Input: {len(docs)} | Kept: {len(kept)} | Dropped: {dropped} "
|
| 115 |
+
f"({stats['drop_rate']:.1%})")
|
| 116 |
+
return kept, stats
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
# ─── Stage 2: Near-dedup (MinHash LSH) ───────────────────────────────────────
|
| 120 |
+
|
| 121 |
+
def stage2_near_dedup(docs: list[dict]) -> tuple[list[dict], dict]:
|
| 122 |
+
"""Cluster near-duplicates via MinHash LSH, keep best representative per cluster."""
|
| 123 |
+
print("\n" + "=" * 60)
|
| 124 |
+
print(f"STAGE 2: Near-dedup (MinHash LSH, threshold={LSH_THRESHOLD}, "
|
| 125 |
+
f"perm={NUM_PERM}, bands={LSH_BANDS})")
|
| 126 |
+
print("=" * 60)
|
| 127 |
+
|
| 128 |
+
# Build LSH index
|
| 129 |
+
lsh = MinHashLSH(
|
| 130 |
+
threshold=LSH_THRESHOLD,
|
| 131 |
+
num_perm=NUM_PERM,
|
| 132 |
+
params=(LSH_BANDS, LSH_ROWS),
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
signatures = {}
|
| 136 |
+
kept_indices = set()
|
| 137 |
+
|
| 138 |
+
for i, doc in enumerate(docs):
|
| 139 |
+
content = doc["content"]
|
| 140 |
+
if len(content) < 50:
|
| 141 |
+
# Too short for meaningful MinHash — keep as-is
|
| 142 |
+
kept_indices.add(i)
|
| 143 |
+
continue
|
| 144 |
+
|
| 145 |
+
mh = minhash_signature(content)
|
| 146 |
+
signatures[i] = mh
|
| 147 |
+
|
| 148 |
+
# Query for existing near-duplicates
|
| 149 |
+
candidates = lsh.query(mh)
|
| 150 |
+
|
| 151 |
+
if candidates:
|
| 152 |
+
# This doc is a near-duplicate of an existing one.
|
| 153 |
+
# Compare lengths and keep the longer one.
|
| 154 |
+
existing_best = max(candidates, key=lambda j: len(docs[int(j)]["content"]))
|
| 155 |
+
if len(content) > len(docs[int(existing_best)]["content"]):
|
| 156 |
+
# New doc is longer — replace the existing representative
|
| 157 |
+
kept_indices.discard(int(existing_best))
|
| 158 |
+
kept_indices.add(i)
|
| 159 |
+
# We can't easily remove from LSH, so just add the new one.
|
| 160 |
+
# The old one is dropped via kept_indices tracking.
|
| 161 |
+
try:
|
| 162 |
+
lsh.insert(str(i), mh)
|
| 163 |
+
except Exception:
|
| 164 |
+
pass
|
| 165 |
+
# else: keep existing, drop this one
|
| 166 |
+
else:
|
| 167 |
+
# No near-duplicate found — keep this doc
|
| 168 |
+
kept_indices.add(i)
|
| 169 |
+
try:
|
| 170 |
+
lsh.insert(str(i), mh)
|
| 171 |
+
except Exception:
|
| 172 |
+
pass
|
| 173 |
+
|
| 174 |
+
if (i + 1) % 5000 == 0:
|
| 175 |
+
print(f" Processed {i+1}/{len(docs)} | clusters so far: {len(kept_indices)}")
|
| 176 |
+
|
| 177 |
+
kept = [docs[i] for i in sorted(kept_indices)]
|
| 178 |
+
dropped = len(docs) - len(kept)
|
| 179 |
+
|
| 180 |
+
stats = {
|
| 181 |
+
"input": len(docs),
|
| 182 |
+
"kept": len(kept),
|
| 183 |
+
"dropped_near": dropped,
|
| 184 |
+
"drop_rate": dropped / max(len(docs), 1),
|
| 185 |
+
"threshold": LSH_THRESHOLD,
|
| 186 |
+
"num_perm": NUM_PERM,
|
| 187 |
+
}
|
| 188 |
+
print(f" Input: {len(docs)} | Kept: {len(kept)} | Dropped: {dropped} "
|
| 189 |
+
f"({stats['drop_rate']:.1%})")
|
| 190 |
+
return kept, stats
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# ─── Stage 3: Line-level dedup ───────────────────────────────────────────────
|
| 194 |
+
|
| 195 |
+
def stage3_line_dedup(docs: list[dict]) -> tuple[list[dict], dict]:
|
| 196 |
+
"""Remove boilerplate lines that appear across many documents."""
|
| 197 |
+
print("\n" + "=" * 60)
|
| 198 |
+
print(f"STAGE 3: Line-level dedup (boilerplate threshold={LINE_BOILERPLATE_THRESHOLD})")
|
| 199 |
+
print("=" * 60)
|
| 200 |
+
|
| 201 |
+
# First pass: count how many documents each normalized line appears in
|
| 202 |
+
line_doc_count = Counter()
|
| 203 |
+
for doc in docs:
|
| 204 |
+
lines = doc["content"].split("\n")
|
| 205 |
+
# Use normalized line hash to save memory
|
| 206 |
+
seen_in_doc = set()
|
| 207 |
+
for line in lines:
|
| 208 |
+
norm = line.strip()
|
| 209 |
+
if not norm:
|
| 210 |
+
continue
|
| 211 |
+
# Normalize: collapse internal whitespace, lowercase
|
| 212 |
+
norm = re.sub(r"\s+", " ", norm).lower()
|
| 213 |
+
if len(norm) < 5: # skip very short lines (braces, etc.)
|
| 214 |
+
continue
|
| 215 |
+
h = hashlib.md5(norm.encode("utf-8")).hexdigest()
|
| 216 |
+
seen_in_doc.add(h)
|
| 217 |
+
for h in seen_in_doc:
|
| 218 |
+
line_doc_count[h] += 1
|
| 219 |
+
|
| 220 |
+
# Identify boilerplate lines (appear in >= threshold documents)
|
| 221 |
+
boilerplate_hashes = {h for h, c in line_doc_count.items() if c >= LINE_BOILERPLATE_THRESHOLD}
|
| 222 |
+
print(f" Boilerplate lines identified: {len(boilerplate_hashes)}")
|
| 223 |
+
|
| 224 |
+
# Second pass: remove boilerplate lines from each document
|
| 225 |
+
kept = []
|
| 226 |
+
dropped_too_short = 0
|
| 227 |
+
lines_removed_total = 0
|
| 228 |
+
|
| 229 |
+
for doc in docs:
|
| 230 |
+
lines = doc["content"].split("\n")
|
| 231 |
+
new_lines = []
|
| 232 |
+
for line in lines:
|
| 233 |
+
norm = line.strip()
|
| 234 |
+
if norm:
|
| 235 |
+
norm_h = re.sub(r"\s+", " ", norm).lower()
|
| 236 |
+
if len(norm_h) >= 5:
|
| 237 |
+
h = hashlib.md5(norm_h.encode("utf-8")).hexdigest()
|
| 238 |
+
if h in boilerplate_hashes:
|
| 239 |
+
lines_removed_total += 1
|
| 240 |
+
continue
|
| 241 |
+
new_lines.append(line)
|
| 242 |
+
|
| 243 |
+
new_content = "\n".join(new_lines).strip()
|
| 244 |
+
new_line_count = len([l for l in new_lines if l.strip()])
|
| 245 |
+
|
| 246 |
+
if new_line_count < MIN_LINES_AFTER_DEDUP or len(new_content) < MIN_CHARS_AFTER_DEDUP:
|
| 247 |
+
dropped_too_short += 1
|
| 248 |
+
continue
|
| 249 |
+
|
| 250 |
+
doc = dict(doc)
|
| 251 |
+
doc["content"] = new_content
|
| 252 |
+
kept.append(doc)
|
| 253 |
+
|
| 254 |
+
stats = {
|
| 255 |
+
"input": len(docs),
|
| 256 |
+
"kept": len(kept),
|
| 257 |
+
"dropped_too_short": dropped_too_short,
|
| 258 |
+
"lines_removed": lines_removed_total,
|
| 259 |
+
"boilerplate_hashes": len(boilerplate_hashes),
|
| 260 |
+
}
|
| 261 |
+
print(f" Input: {len(docs)} | Kept: {len(kept)} | "
|
| 262 |
+
f"Dropped (too short): {dropped_too_short} | "
|
| 263 |
+
f"Lines removed: {lines_removed_total}")
|
| 264 |
+
return kept, stats
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
# ─── Main ────────────────────────────────────────────────────────────────────
|
| 268 |
+
|
| 269 |
+
def main():
|
| 270 |
+
os.makedirs(DEDUP_DIR, exist_ok=True)
|
| 271 |
+
|
| 272 |
+
print(f"Loading documents from {RAW_DOCS_PATH}...")
|
| 273 |
+
docs = []
|
| 274 |
+
with open(RAW_DOCS_PATH, "r", encoding="utf-8") as f:
|
| 275 |
+
for line in f:
|
| 276 |
+
docs.append(json.loads(line))
|
| 277 |
+
print(f" Loaded {len(docs):,} documents")
|
| 278 |
+
print(f" Total size: {sum(len(d['content']) for d in docs) / 1e6:.1f} MB")
|
| 279 |
+
|
| 280 |
+
all_stats = {"input_count": len(docs)}
|
| 281 |
+
|
| 282 |
+
# Stage 1: Exact dedup
|
| 283 |
+
docs, s1 = stage1_exact_dedup(docs)
|
| 284 |
+
all_stats["stage1_exact"] = s1
|
| 285 |
+
|
| 286 |
+
# Stage 2: Near-dedup
|
| 287 |
+
docs, s2 = stage2_near_dedup(docs)
|
| 288 |
+
all_stats["stage2_near"] = s2
|
| 289 |
+
|
| 290 |
+
# Stage 3: Line-level dedup
|
| 291 |
+
docs, s3 = stage3_line_dedup(docs)
|
| 292 |
+
all_stats["stage3_line"] = s3
|
| 293 |
+
|
| 294 |
+
# Final stats
|
| 295 |
+
final_size = sum(len(d["content"]) for d in docs)
|
| 296 |
+
all_stats["final_count"] = len(docs)
|
| 297 |
+
all_stats["final_size_mb"] = final_size / 1e6
|
| 298 |
+
|
| 299 |
+
# Language distribution
|
| 300 |
+
lang_counts = {}
|
| 301 |
+
for d in docs:
|
| 302 |
+
lang_counts[d["language"]] = lang_counts.get(d["language"], 0) + 1
|
| 303 |
+
all_stats["language_distribution"] = lang_counts
|
| 304 |
+
|
| 305 |
+
print("\n" + "=" * 60)
|
| 306 |
+
print("DEDUP COMPLETE")
|
| 307 |
+
print("=" * 60)
|
| 308 |
+
print(f" Input: {all_stats['input_count']:,} documents")
|
| 309 |
+
print(f" Final: {len(docs):,} documents")
|
| 310 |
+
print(f" Size: {final_size / 1e6:.1f} MB")
|
| 311 |
+
print(f" Languages: {lang_counts}")
|
| 312 |
+
|
| 313 |
+
# Write deduped documents
|
| 314 |
+
with open(DEDUP_DOCS_PATH, "w", encoding="utf-8") as f:
|
| 315 |
+
for doc in docs:
|
| 316 |
+
f.write(json.dumps(doc, ensure_ascii=False) + "\n")
|
| 317 |
+
print(f"\nDeduped documents written to {DEDUP_DOCS_PATH}")
|
| 318 |
+
|
| 319 |
+
# Write stats
|
| 320 |
+
with open(STATS_PATH, "w") as f:
|
| 321 |
+
json.dump(all_stats, f, indent=2)
|
| 322 |
+
print(f"Stats written to {STATS_PATH}")
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
if __name__ == "__main__":
|
| 326 |
+
main()
|
src/dedup_quality.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Deduplication, contamination check, and quality filter for SFT traces.
|
| 3 |
+
|
| 4 |
+
Pipeline:
|
| 5 |
+
1. Load all traces from data/sft_traces_v2/ + existing sft_traces.jsonl
|
| 6 |
+
2. Deduplicate by query hash (exact match) and by fuzzy similarity (near-dupes)
|
| 7 |
+
3. Contamination check: remove traces whose queries appear in gold_traces.jsonl
|
| 8 |
+
4. Quality filter: remove traces that are too short, have empty reasoning,
|
| 9 |
+
or have malformed structure
|
| 10 |
+
5. Shuffle and write final dataset
|
| 11 |
+
|
| 12 |
+
Usage:
|
| 13 |
+
python src/dedup_quality.py --input data/sft_traces_v2/ --output data/sft_traces_final.jsonl
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import argparse
|
| 17 |
+
import hashlib
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
import random
|
| 21 |
+
import re
|
| 22 |
+
import sys
|
| 23 |
+
from collections import defaultdict
|
| 24 |
+
from difflib import SequenceMatcher
|
| 25 |
+
|
| 26 |
+
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 27 |
+
|
| 28 |
+
def load_traces_from_dir(dir_path: str) -> list[dict]:
|
| 29 |
+
"""Load all .jsonl files from a directory."""
|
| 30 |
+
traces = []
|
| 31 |
+
if not os.path.exists(dir_path):
|
| 32 |
+
return traces
|
| 33 |
+
for fname in sorted(os.listdir(dir_path)):
|
| 34 |
+
if fname.endswith('.jsonl'):
|
| 35 |
+
fpath = os.path.join(dir_path, fname)
|
| 36 |
+
with open(fpath, 'r', encoding='utf-8') as f:
|
| 37 |
+
for line in f:
|
| 38 |
+
try:
|
| 39 |
+
trace = json.loads(line)
|
| 40 |
+
if trace and 'query' in trace and 'trace' in trace:
|
| 41 |
+
trace['_source'] = fname
|
| 42 |
+
traces.append(trace)
|
| 43 |
+
except json.JSONDecodeError:
|
| 44 |
+
continue
|
| 45 |
+
return traces
|
| 46 |
+
|
| 47 |
+
def load_traces_from_file(fpath: str) -> list[dict]:
|
| 48 |
+
"""Load traces from a single .jsonl file."""
|
| 49 |
+
traces = []
|
| 50 |
+
if not os.path.exists(fpath):
|
| 51 |
+
return traces
|
| 52 |
+
with open(fpath, 'r', encoding='utf-8') as f:
|
| 53 |
+
for line in f:
|
| 54 |
+
try:
|
| 55 |
+
trace = json.loads(line)
|
| 56 |
+
if trace and 'query' in trace and 'trace' in trace:
|
| 57 |
+
trace['_source'] = os.path.basename(fpath)
|
| 58 |
+
traces.append(trace)
|
| 59 |
+
except json.JSONDecodeError:
|
| 60 |
+
continue
|
| 61 |
+
return traces
|
| 62 |
+
|
| 63 |
+
def query_hash(trace: dict) -> str:
|
| 64 |
+
"""Hash the query for exact dedup."""
|
| 65 |
+
return hashlib.md5(trace['query'].strip().lower().encode()).hexdigest()
|
| 66 |
+
|
| 67 |
+
def normalize_query(query: str) -> str:
|
| 68 |
+
"""Normalize query for fuzzy matching."""
|
| 69 |
+
# Remove punctuation, lowercase, collapse whitespace
|
| 70 |
+
q = re.sub(r'[^\w\s]', '', query.lower())
|
| 71 |
+
q = ' '.join(q.split())
|
| 72 |
+
return q
|
| 73 |
+
|
| 74 |
+
def fuzzy_similarity(q1: str, q2: str) -> float:
|
| 75 |
+
"""Compute similarity between two queries."""
|
| 76 |
+
n1, n2 = normalize_query(q1), normalize_query(q2)
|
| 77 |
+
if n1 == n2:
|
| 78 |
+
return 1.0
|
| 79 |
+
# Quick length check
|
| 80 |
+
if abs(len(n1) - len(n2)) > max(len(n1), len(n2)) * 0.5:
|
| 81 |
+
return 0.0
|
| 82 |
+
return SequenceMatcher(None, n1, n2).ratio()
|
| 83 |
+
|
| 84 |
+
def check_trace_quality(trace: dict) -> tuple[bool, str]:
|
| 85 |
+
"""Check if a trace meets quality standards.
|
| 86 |
+
|
| 87 |
+
Returns (is_valid, reason_if_rejected)
|
| 88 |
+
"""
|
| 89 |
+
query = trace.get('query', '')
|
| 90 |
+
trace_msgs = trace.get('trace', [])
|
| 91 |
+
|
| 92 |
+
# Must have system, user, and at least 2 assistant turns
|
| 93 |
+
if len(trace_msgs) < 4:
|
| 94 |
+
return False, "too_few_messages"
|
| 95 |
+
|
| 96 |
+
# Query must be substantial
|
| 97 |
+
if len(query) < 15:
|
| 98 |
+
return False, "query_too_short"
|
| 99 |
+
|
| 100 |
+
# Check assistant turns have real content
|
| 101 |
+
assistant_turns = [m for m in trace_msgs if m['role'] == 'assistant']
|
| 102 |
+
if len(assistant_turns) < 2:
|
| 103 |
+
return False, "too_few_assistant_turns"
|
| 104 |
+
|
| 105 |
+
for turn in assistant_turns:
|
| 106 |
+
content = turn.get('content', '')
|
| 107 |
+
if len(content) < 50:
|
| 108 |
+
return False, "assistant_turn_too_short"
|
| 109 |
+
# Must contain at least one special token
|
| 110 |
+
if not any(tok in content for tok in ['<|reasoning|>', '<|search|>', '<|evidence|>', '<|finish|>']):
|
| 111 |
+
return False, "no_special_tokens"
|
| 112 |
+
|
| 113 |
+
# Must have at least one search
|
| 114 |
+
has_search = any('<|search|>' in m.get('content', '') for m in assistant_turns)
|
| 115 |
+
if not has_search:
|
| 116 |
+
return False, "no_search_action"
|
| 117 |
+
|
| 118 |
+
# Must have evidence or finish
|
| 119 |
+
has_evidence = any('<|evidence|>' in m.get('content', '') for m in assistant_turns)
|
| 120 |
+
has_finish = any('<|finish|>' in m.get('content', '') for m in assistant_turns)
|
| 121 |
+
if not has_evidence and not has_finish:
|
| 122 |
+
return False, "no_evidence_or_finish"
|
| 123 |
+
|
| 124 |
+
# Check for reasoning density — at least one turn should have substantial reasoning
|
| 125 |
+
max_reasoning_len = 0
|
| 126 |
+
for turn in assistant_turns:
|
| 127 |
+
content = turn.get('content', '')
|
| 128 |
+
# Extract reasoning sections
|
| 129 |
+
reasoning_sections = re.findall(r'<\|reasoning\|>(.*?)<\|end\|>', content, re.DOTALL)
|
| 130 |
+
for r in reasoning_sections:
|
| 131 |
+
max_reasoning_len = max(max_reasoning_len, len(r.strip()))
|
| 132 |
+
|
| 133 |
+
if max_reasoning_len < 30:
|
| 134 |
+
return False, "reasoning_too_thin"
|
| 135 |
+
|
| 136 |
+
return True, "ok"
|
| 137 |
+
|
| 138 |
+
def deduplicate(traces: list[dict], similarity_threshold: float = 0.85) -> tuple[list[dict], dict]:
|
| 139 |
+
"""Remove duplicate and near-duplicate traces.
|
| 140 |
+
|
| 141 |
+
Returns (deduplicated_traces, stats)
|
| 142 |
+
"""
|
| 143 |
+
stats = {
|
| 144 |
+
'exact_dups_removed': 0,
|
| 145 |
+
'fuzzy_dups_removed': 0,
|
| 146 |
+
'total_input': len(traces),
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
# Phase 1: Exact dedup by query hash
|
| 150 |
+
seen_hashes = set()
|
| 151 |
+
exact_deduped = []
|
| 152 |
+
for trace in traces:
|
| 153 |
+
h = query_hash(trace)
|
| 154 |
+
if h not in seen_hashes:
|
| 155 |
+
seen_hashes.add(h)
|
| 156 |
+
exact_deduped.append(trace)
|
| 157 |
+
else:
|
| 158 |
+
stats['exact_dups_removed'] += 1
|
| 159 |
+
|
| 160 |
+
# Phase 2: Fuzzy dedup by query similarity
|
| 161 |
+
# Group by first word for efficiency
|
| 162 |
+
groups = defaultdict(list)
|
| 163 |
+
for trace in exact_deduped:
|
| 164 |
+
first_word = normalize_query(trace['query']).split()[0] if normalize_query(trace['query']).split() else ''
|
| 165 |
+
groups[first_word].append(trace)
|
| 166 |
+
|
| 167 |
+
fuzzy_deduped = []
|
| 168 |
+
for first_word, group in groups.items():
|
| 169 |
+
if len(group) == 1:
|
| 170 |
+
fuzzy_deduped.extend(group)
|
| 171 |
+
continue
|
| 172 |
+
|
| 173 |
+
# Compare within group
|
| 174 |
+
kept = []
|
| 175 |
+
for trace in group:
|
| 176 |
+
is_dup = False
|
| 177 |
+
for kept_trace in kept:
|
| 178 |
+
sim = fuzzy_similarity(trace['query'], kept_trace['query'])
|
| 179 |
+
if sim >= similarity_threshold:
|
| 180 |
+
is_dup = True
|
| 181 |
+
stats['fuzzy_dups_removed'] += 1
|
| 182 |
+
break
|
| 183 |
+
if not is_dup:
|
| 184 |
+
kept.append(trace)
|
| 185 |
+
fuzzy_deduped.extend(kept)
|
| 186 |
+
|
| 187 |
+
stats['total_output'] = len(fuzzy_deduped)
|
| 188 |
+
return fuzzy_deduped, stats
|
| 189 |
+
|
| 190 |
+
def check_contamination(traces: list[dict], gold_traces: list[dict]) -> tuple[list[dict], dict]:
|
| 191 |
+
"""Remove traces whose queries match gold trace queries.
|
| 192 |
+
|
| 193 |
+
Returns (clean_traces, stats)
|
| 194 |
+
"""
|
| 195 |
+
gold_queries = set()
|
| 196 |
+
for gt in gold_traces:
|
| 197 |
+
gold_queries.add(normalize_query(gt['query']))
|
| 198 |
+
|
| 199 |
+
clean = []
|
| 200 |
+
removed = 0
|
| 201 |
+
for trace in traces:
|
| 202 |
+
nq = normalize_query(trace['query'])
|
| 203 |
+
if nq in gold_queries:
|
| 204 |
+
removed += 1
|
| 205 |
+
else:
|
| 206 |
+
clean.append(trace)
|
| 207 |
+
|
| 208 |
+
return clean, {'contamination_removed': removed, 'gold_queries': len(gold_queries)}
|
| 209 |
+
|
| 210 |
+
def main():
|
| 211 |
+
parser = argparse.ArgumentParser(description="Dedup and quality filter SFT traces")
|
| 212 |
+
parser.add_argument("--input", type=str, default=os.path.join(PROJECT_DIR, "data", "sft_traces_v2"),
|
| 213 |
+
help="Input directory with .jsonl files")
|
| 214 |
+
parser.add_argument("--existing", type=str, default=os.path.join(PROJECT_DIR, "data", "sft_traces.jsonl"),
|
| 215 |
+
help="Existing traces to merge with")
|
| 216 |
+
parser.add_argument("--gold", type=str, default=os.path.join(PROJECT_DIR, "data", "gold_traces.jsonl"),
|
| 217 |
+
help="Gold traces for contamination check")
|
| 218 |
+
parser.add_argument("--output", type=str, default=os.path.join(PROJECT_DIR, "data", "sft_traces_final.jsonl"),
|
| 219 |
+
help="Output file")
|
| 220 |
+
parser.add_argument("--seed", type=int, default=42)
|
| 221 |
+
parser.add_argument("--similarity", type=float, default=0.85,
|
| 222 |
+
help="Fuzzy dedup similarity threshold")
|
| 223 |
+
args = parser.parse_args()
|
| 224 |
+
|
| 225 |
+
print("=" * 60)
|
| 226 |
+
print("SFT TRACE DEDUPLICATION & QUALITY PIPELINE")
|
| 227 |
+
print("=" * 60)
|
| 228 |
+
|
| 229 |
+
# 1. Load all traces
|
| 230 |
+
print("\n1. Loading traces...")
|
| 231 |
+
new_traces = load_traces_from_dir(args.input)
|
| 232 |
+
existing_traces = load_traces_from_file(args.existing)
|
| 233 |
+
gold_traces = load_traces_from_file(args.gold)
|
| 234 |
+
|
| 235 |
+
print(f" New traces: {len(new_traces):,}")
|
| 236 |
+
print(f" Existing traces: {len(existing_traces):,}")
|
| 237 |
+
print(f" Gold traces: {len(gold_traces):,}")
|
| 238 |
+
|
| 239 |
+
all_traces = new_traces + existing_traces
|
| 240 |
+
print(f" Total to process: {len(all_traces):,}")
|
| 241 |
+
|
| 242 |
+
# 2. Quality filter
|
| 243 |
+
print("\n2. Quality filtering...")
|
| 244 |
+
quality_stats = defaultdict(int)
|
| 245 |
+
quality_passed = []
|
| 246 |
+
for trace in all_traces:
|
| 247 |
+
is_valid, reason = check_trace_quality(trace)
|
| 248 |
+
if is_valid:
|
| 249 |
+
quality_passed.append(trace)
|
| 250 |
+
else:
|
| 251 |
+
quality_stats[reason] += 1
|
| 252 |
+
|
| 253 |
+
print(f" Passed: {len(quality_passed):,}")
|
| 254 |
+
print(f" Rejected: {sum(quality_stats.values()):,}")
|
| 255 |
+
for reason, count in sorted(quality_stats.items(), key=lambda x: -x[1]):
|
| 256 |
+
print(f" {reason}: {count}")
|
| 257 |
+
|
| 258 |
+
# 3. Contamination check
|
| 259 |
+
print("\n3. Contamination check (vs gold traces)...")
|
| 260 |
+
clean_traces, contam_stats = check_contamination(quality_passed, gold_traces)
|
| 261 |
+
print(f" Removed: {contam_stats['contamination_removed']}")
|
| 262 |
+
print(f" Remaining: {len(clean_traces):,}")
|
| 263 |
+
|
| 264 |
+
# 4. Deduplication
|
| 265 |
+
print("\n4. Deduplication...")
|
| 266 |
+
deduped_traces, dedup_stats = deduplicate(clean_traces, args.similarity)
|
| 267 |
+
print(f" Exact dups removed: {dedup_stats['exact_dups_removed']:,}")
|
| 268 |
+
print(f" Fuzzy dups removed: {dedup_stats['fuzzy_dups_removed']:,}")
|
| 269 |
+
print(f" Final count: {dedup_stats['total_output']:,}")
|
| 270 |
+
|
| 271 |
+
# 5. Shuffle and write
|
| 272 |
+
print(f"\n5. Writing to {args.output}...")
|
| 273 |
+
rng = random.Random(args.seed)
|
| 274 |
+
rng.shuffle(deduped_traces)
|
| 275 |
+
|
| 276 |
+
# Remove internal fields
|
| 277 |
+
for trace in deduped_traces:
|
| 278 |
+
trace.pop('_source', None)
|
| 279 |
+
|
| 280 |
+
with open(args.output, 'w', encoding='utf-8') as f:
|
| 281 |
+
for trace in deduped_traces:
|
| 282 |
+
f.write(json.dumps(trace, ensure_ascii=False) + '\n')
|
| 283 |
+
|
| 284 |
+
print(f" Written: {len(deduped_traces):,} traces")
|
| 285 |
+
|
| 286 |
+
# Summary
|
| 287 |
+
print("\n" + "=" * 60)
|
| 288 |
+
print("SUMMARY")
|
| 289 |
+
print("=" * 60)
|
| 290 |
+
print(f" Input traces: {len(all_traces):,}")
|
| 291 |
+
print(f" Quality rejected: {sum(quality_stats.values()):,}")
|
| 292 |
+
print(f" Contamination removed: {contam_stats['contamination_removed']}")
|
| 293 |
+
print(f" Exact dups removed: {dedup_stats['exact_dups_removed']:,}")
|
| 294 |
+
print(f" Fuzzy dups removed: {dedup_stats['fuzzy_dups_removed']:,}")
|
| 295 |
+
print(f" Final dataset: {len(deduped_traces):,}")
|
| 296 |
+
|
| 297 |
+
# Category distribution
|
| 298 |
+
cat_counts = defaultdict(int)
|
| 299 |
+
for trace in deduped_traces:
|
| 300 |
+
# Try to infer category from query pattern
|
| 301 |
+
q = trace['query'].lower()
|
| 302 |
+
if any(w in q for w in ['walk me through', 'implementation', 'step by step', 'control flow']):
|
| 303 |
+
cat_counts['implementation'] += 1
|
| 304 |
+
elif any(w in q for w in ['trace how data', 'data flow', 'data path', 'interact']):
|
| 305 |
+
cat_counts['cross_file'] += 1
|
| 306 |
+
elif any(w in q for w in ['architecture', 'module', 'structure of', 'map out']):
|
| 307 |
+
cat_counts['architecture'] += 1
|
| 308 |
+
elif any(w in q for w in ['where is', 'used across', 'usage', 'called from']):
|
| 309 |
+
cat_counts['usage'] += 1
|
| 310 |
+
elif any(w in q for w in ['error', 'failure', 'fail', 'debug']):
|
| 311 |
+
cat_counts['error/debug'] += 1
|
| 312 |
+
elif any(w in q for w in ['api', 'contract', 'interface', 'parameters']):
|
| 313 |
+
cat_counts['api'] += 1
|
| 314 |
+
elif any(w in q for w in ['depend', 'dependency', 'blast radius', 'impact']):
|
| 315 |
+
cat_counts['dependency/impact'] += 1
|
| 316 |
+
elif any(w in q for w in ['fields', 'data structure', 'layout', 'memory']):
|
| 317 |
+
cat_counts['data_structure'] += 1
|
| 318 |
+
elif any(w in q for w in ['compare', 'tradeoff', 'vs', 'contrast']):
|
| 319 |
+
cat_counts['comparison'] += 1
|
| 320 |
+
elif any(w in q for w in ['security', 'validation', 'vulnerability']):
|
| 321 |
+
cat_counts['security'] += 1
|
| 322 |
+
elif any(w in q for w in ['performance', 'bottleneck', 'hot', 'optimize']):
|
| 323 |
+
cat_counts['performance'] += 1
|
| 324 |
+
elif any(w in q for w in ['pattern', 'design']):
|
| 325 |
+
cat_counts['design_patterns'] += 1
|
| 326 |
+
else:
|
| 327 |
+
cat_counts['other'] += 1
|
| 328 |
+
|
| 329 |
+
print(f"\n Category distribution:")
|
| 330 |
+
for cat, count in sorted(cat_counts.items(), key=lambda x: -x[1]):
|
| 331 |
+
print(f" {cat:20s}: {count:5d}")
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
if __name__ == "__main__":
|
| 335 |
+
main()
|
src/download_corpus_large.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Large-scale multi-language code corpus downloader.
|
| 3 |
+
|
| 4 |
+
Downloads tarballs of ~40 popular repositories across Python, JS/TS,
|
| 5 |
+
Rust, Go, C/C++, Java, C#, Ruby, and more. Extracts source files and
|
| 6 |
+
writes them as individual JSONL documents (one file = one document)
|
| 7 |
+
to data/raw_large/documents.jsonl.
|
| 8 |
+
|
| 9 |
+
This is 10-50x larger than the original download_data.py:
|
| 10 |
+
- ~40 repos (vs 12)
|
| 11 |
+
- 15-40 MB cap per repo (vs 3-8 MB)
|
| 12 |
+
- Writes per-file documents with metadata (repo, path, language)
|
| 13 |
+
- Skips tests/benchmarks/docs/vendored code (contamination control)
|
| 14 |
+
|
| 15 |
+
Target: ~1-2 GB of raw source code -> after dedup+quality -> ~300-600 MB.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import io
|
| 19 |
+
import json
|
| 20 |
+
import os
|
| 21 |
+
import re
|
| 22 |
+
import tarfile
|
| 23 |
+
import time
|
| 24 |
+
|
| 25 |
+
import requests
|
| 26 |
+
|
| 27 |
+
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 28 |
+
DATA_DIR = os.path.join(PROJECT_DIR, "data")
|
| 29 |
+
RAW_DIR = os.path.join(DATA_DIR, "raw_large")
|
| 30 |
+
DOCS_PATH = os.path.join(RAW_DIR, "documents.jsonl")
|
| 31 |
+
|
| 32 |
+
# (user/repo, branch, max_bytes_per_repo, primary_language)
|
| 33 |
+
REPOS = [
|
| 34 |
+
# ─── Python ──────────────────────────────────────────────────────────
|
| 35 |
+
("python/cpython", "main", 40_000_000, "python"),
|
| 36 |
+
("pytorch/pytorch", "main", 30_000_000, "cpp"),
|
| 37 |
+
("numpy/numpy", "main", 20_000_000, "python"),
|
| 38 |
+
("scipy/scipy", "main", 20_000_000, "python"),
|
| 39 |
+
("pandas-dev/pandas", "main", 25_000_000, "python"),
|
| 40 |
+
("scikit-learn/scikit-learn", "main", 20_000_000, "python"),
|
| 41 |
+
("django/django", "main", 25_000_000, "python"),
|
| 42 |
+
("flaskpallets/flask", "main", 10_000_000, "python"),
|
| 43 |
+
("requests/requests", "main", 10_000_000, "python"),
|
| 44 |
+
("psf/requests", "main", 10_000_000, "python"),
|
| 45 |
+
# ─── JavaScript / TypeScript ─────────────────────────────────────────
|
| 46 |
+
("microsoft/TypeScript", "main", 30_000_000, "typescript"),
|
| 47 |
+
("axios/axios", "v1.x", 10_000_000, "javascript"),
|
| 48 |
+
("lodash/lodash", "master", 10_000_000, "javascript"),
|
| 49 |
+
("facebook/react", "main", 20_000_000, "javascript"),
|
| 50 |
+
("vuejs/core", "main", 15_000_000, "typescript"),
|
| 51 |
+
("expressjs/express", "master", 10_000_000, "javascript"),
|
| 52 |
+
# ─── Rust ────────────────────────────────────────────────────────────
|
| 53 |
+
("rust-lang/rust", "master", 30_000_000, "rust"),
|
| 54 |
+
("tokio-rs/tokio", "master", 20_000_000, "rust"),
|
| 55 |
+
("BurntSushi/ripgrep", "master", 10_000_000, "rust"),
|
| 56 |
+
("serde-rs/serde", "master", 10_000_000, "rust"),
|
| 57 |
+
("clap-rs/clap", "master", 10_000_000, "rust"),
|
| 58 |
+
# ─── Go ──────────────────────────────────────────────────────────────
|
| 59 |
+
("golang/go", "master", 30_000_000, "go"),
|
| 60 |
+
("json-iterator/go", "master", 10_000_000, "go"),
|
| 61 |
+
("gin-gonic/gin", "master", 10_000_000, "go"),
|
| 62 |
+
("prometheus/prometheus", "main", 20_000_000, "go"),
|
| 63 |
+
# ─── C / C++ ─────────────────────────────────────────────────────────
|
| 64 |
+
("nginx/nginx", "master", 20_000_000, "c"),
|
| 65 |
+
("redis/redis", "unstable", 20_000_000, "c"),
|
| 66 |
+
("curl/curl", "master", 15_000_000, "c"),
|
| 67 |
+
("sqlite/sqlite", "master", 15_000_000, "c"),
|
| 68 |
+
("git/git", "master", 20_000_000, "c"),
|
| 69 |
+
# ─── Java ────────────────────────────────────────────────────────────
|
| 70 |
+
("elastic/elasticsearch", "main", 25_000_000, "java"),
|
| 71 |
+
("google/guava", "master", 15_000_000, "java"),
|
| 72 |
+
# ─── C# ──────────────────────────────────────────────────────────────
|
| 73 |
+
("dotnet/runtime", "main", 25_000_000, "csharp"),
|
| 74 |
+
# ─── Ruby ────────────────────────────────────────────────────────────
|
| 75 |
+
("ruby/ruby", "master", 20_000_000, "ruby"),
|
| 76 |
+
("rails/rails", "main", 25_000_000, "ruby"),
|
| 77 |
+
]
|
| 78 |
+
|
| 79 |
+
# File extensions -> language label
|
| 80 |
+
EXT_TO_LANG = {
|
| 81 |
+
".py": "python", ".js": "javascript", ".mjs": "javascript",
|
| 82 |
+
".jsx": "javascript", ".ts": "typescript", ".tsx": "typescript",
|
| 83 |
+
".rs": "rust", ".go": "go",
|
| 84 |
+
".c": "c", ".h": "c", ".cpp": "cpp", ".cc": "cpp", ".cxx": "cpp",
|
| 85 |
+
".hpp": "cpp", ".hxx": "cpp", ".cu": "cpp", ".cuh": "cpp",
|
| 86 |
+
".java": "java", ".cs": "csharp", ".rb": "ruby",
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
CODE_EXTENSIONS = set(EXT_TO_LANG.keys())
|
| 90 |
+
|
| 91 |
+
# Directories to skip — contamination control (no tests, docs, vendored code)
|
| 92 |
+
SKIP_DIRS = {
|
| 93 |
+
"test", "tests", "testing", "testcases", "testdata", "test_data",
|
| 94 |
+
"__pycache__", ".git", "node_modules", "vendor", "third_party",
|
| 95 |
+
"thirdparty", "dist", "build", "target", ".github", "docs", "doc",
|
| 96 |
+
"documentation", "benchmark", "benchmarks", "bench", "examples",
|
| 97 |
+
"example", "fixtures", "mocks", "stubs", "snapshots", "e2e",
|
| 98 |
+
"integration", "smoke", "conformance", "testdata", "samples",
|
| 99 |
+
"demo", "demos", "playground", "experimental", "wip",
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
# Filename patterns to skip (test files, config, generated)
|
| 103 |
+
SKIP_FILE_PATTERNS = re.compile(
|
| 104 |
+
r"(?:^|/)(?:"
|
| 105 |
+
r"test[_-]?|.*[_-]test|.*_spec|spec_"
|
| 106 |
+
r"|conftest|setup\.py|conftest\.py"
|
| 107 |
+
r"|.*\.config\.|.*\.min\.|.*\.bundle\."
|
| 108 |
+
r"|package-lock|yarn\.lock|pnpm-lock"
|
| 109 |
+
r"|\.d\.ts$|\.map$"
|
| 110 |
+
r")",
|
| 111 |
+
re.IGNORECASE,
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def download_repo_tarball(user_repo: str, branch: str) -> bytes | None:
|
| 116 |
+
"""Download a repository tarball from GitHub."""
|
| 117 |
+
urls = [
|
| 118 |
+
f"https://codeload.github.com/{user_repo}/tar.gz/refs/heads/{branch}",
|
| 119 |
+
f"https://codeload.github.com/{user_repo}/tar.gz/refs/tags/{branch}",
|
| 120 |
+
]
|
| 121 |
+
for url in urls:
|
| 122 |
+
print(f" Downloading {user_repo}@{branch}...")
|
| 123 |
+
try:
|
| 124 |
+
r = requests.get(url, timeout=180, stream=True)
|
| 125 |
+
if r.status_code == 200:
|
| 126 |
+
content = r.content
|
| 127 |
+
print(f" Downloaded {len(content) / 1e6:.1f} MB")
|
| 128 |
+
return content
|
| 129 |
+
except Exception as e:
|
| 130 |
+
print(f" ERROR: {e}")
|
| 131 |
+
time.sleep(2)
|
| 132 |
+
print(f" FAILED (all URLs)")
|
| 133 |
+
return None
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def should_skip(path: str) -> bool:
|
| 137 |
+
"""Check if a file path should be skipped (contamination control)."""
|
| 138 |
+
parts = path.split("/")
|
| 139 |
+
if any(skip in parts for skip in SKIP_DIRS):
|
| 140 |
+
return True
|
| 141 |
+
if SKIP_FILE_PATTERNS.search(path):
|
| 142 |
+
return True
|
| 143 |
+
return False
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def extract_code_files(tarball_bytes: bytes, max_bytes: int, repo: str) -> list[dict]:
|
| 147 |
+
"""Extract code files from a tarball as document dicts, up to max_bytes total."""
|
| 148 |
+
docs = []
|
| 149 |
+
total = 0
|
| 150 |
+
try:
|
| 151 |
+
with tarfile.open(fileobj=io.BytesIO(tarball_bytes), mode="r:gz") as tar:
|
| 152 |
+
for member in tar.getmembers():
|
| 153 |
+
if not member.isfile():
|
| 154 |
+
continue
|
| 155 |
+
path = member.name
|
| 156 |
+
# Strip the leading repo-name-sha/ prefix
|
| 157 |
+
if "/" in path:
|
| 158 |
+
rel_path = path.split("/", 1)[1]
|
| 159 |
+
else:
|
| 160 |
+
rel_path = path
|
| 161 |
+
|
| 162 |
+
if should_skip(rel_path):
|
| 163 |
+
continue
|
| 164 |
+
|
| 165 |
+
_, ext = os.path.splitext(path)
|
| 166 |
+
if ext.lower() not in CODE_EXTENSIONS:
|
| 167 |
+
continue
|
| 168 |
+
|
| 169 |
+
# Skip very large or very small files
|
| 170 |
+
if member.size > 300_000 or member.size < 100:
|
| 171 |
+
continue
|
| 172 |
+
|
| 173 |
+
try:
|
| 174 |
+
f = tar.extractfile(member)
|
| 175 |
+
if f is None:
|
| 176 |
+
continue
|
| 177 |
+
content = f.read()
|
| 178 |
+
try:
|
| 179 |
+
text = content.decode("utf-8", errors="ignore")
|
| 180 |
+
except Exception:
|
| 181 |
+
continue
|
| 182 |
+
|
| 183 |
+
# Skip files that are mostly non-text
|
| 184 |
+
non_ascii = sum(1 for c in text if ord(c) > 127)
|
| 185 |
+
if len(text) > 0 and non_ascii / len(text) > 0.05:
|
| 186 |
+
continue
|
| 187 |
+
|
| 188 |
+
lang = EXT_TO_LANG[ext.lower()]
|
| 189 |
+
docs.append({
|
| 190 |
+
"repo": repo,
|
| 191 |
+
"filepath": rel_path,
|
| 192 |
+
"language": lang,
|
| 193 |
+
"content": text,
|
| 194 |
+
})
|
| 195 |
+
total += len(text)
|
| 196 |
+
if total >= max_bytes:
|
| 197 |
+
break
|
| 198 |
+
except Exception:
|
| 199 |
+
continue
|
| 200 |
+
except Exception as e:
|
| 201 |
+
print(f" Extraction error: {e}")
|
| 202 |
+
print(f" Extracted {len(docs)} files, {total / 1e6:.1f} MB of code")
|
| 203 |
+
return docs
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def main():
|
| 207 |
+
os.makedirs(RAW_DIR, exist_ok=True)
|
| 208 |
+
|
| 209 |
+
all_docs = []
|
| 210 |
+
for user_repo, branch, max_bytes, _lang in REPOS:
|
| 211 |
+
tarball = download_repo_tarball(user_repo, branch)
|
| 212 |
+
if tarball is None:
|
| 213 |
+
continue
|
| 214 |
+
docs = extract_code_files(tarball, max_bytes, user_repo)
|
| 215 |
+
all_docs.extend(docs)
|
| 216 |
+
time.sleep(1) # Be polite
|
| 217 |
+
|
| 218 |
+
# Write documents as JSONL
|
| 219 |
+
print(f"\nTotal files: {len(all_docs)}")
|
| 220 |
+
total_size = sum(len(d["content"]) for d in all_docs)
|
| 221 |
+
print(f"Total corpus size: {total_size / 1e6:.1f} MB")
|
| 222 |
+
|
| 223 |
+
with open(DOCS_PATH, "w", encoding="utf-8") as f:
|
| 224 |
+
for doc in all_docs:
|
| 225 |
+
f.write(json.dumps(doc, ensure_ascii=False) + "\n")
|
| 226 |
+
|
| 227 |
+
print(f"Documents written to {DOCS_PATH}")
|
| 228 |
+
|
| 229 |
+
# Language distribution
|
| 230 |
+
lang_counts = {}
|
| 231 |
+
for d in all_docs:
|
| 232 |
+
lang_counts[d["language"]] = lang_counts.get(d["language"], 0) + 1
|
| 233 |
+
print(f"Language distribution: {lang_counts}")
|
| 234 |
+
|
| 235 |
+
# Repo distribution
|
| 236 |
+
repo_counts = {}
|
| 237 |
+
for d in all_docs:
|
| 238 |
+
repo_counts[d["repo"]] = repo_counts.get(d["repo"], 0) + 1
|
| 239 |
+
print(f"Repo distribution: {len(repo_counts)} repos")
|
| 240 |
+
for repo, count in sorted(repo_counts.items(), key=lambda x: -x[1])[:10]:
|
| 241 |
+
print(f" {repo}: {count} files")
|
| 242 |
+
|
| 243 |
+
return total_size
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
if __name__ == "__main__":
|
| 247 |
+
main()
|
src/download_data.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Download a multi-language code corpus from GitHub for base pretraining.
|
| 3 |
+
|
| 4 |
+
Downloads tarballs of several popular repositories across Python, JS/TS,
|
| 5 |
+
Rust, Go, C/C++, and Java. Extracts source files and concatenates them
|
| 6 |
+
into a single training corpus.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import io
|
| 10 |
+
import os
|
| 11 |
+
import tarfile
|
| 12 |
+
import time
|
| 13 |
+
import requests
|
| 14 |
+
|
| 15 |
+
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data")
|
| 16 |
+
RAW_DIR = os.path.join(DATA_DIR, "raw")
|
| 17 |
+
CORPUS_PATH = os.path.join(DATA_DIR, "corpus.txt")
|
| 18 |
+
|
| 19 |
+
# Repos to download (user/repo, branch, max_bytes_per_repo)
|
| 20 |
+
REPOS = [
|
| 21 |
+
("python/cpython", "main", 8_000_000), # Python, C
|
| 22 |
+
("microsoft/TypeScript", "main", 8_000_000), # TypeScript
|
| 23 |
+
("rust-lang/rust", "master", 8_000_000), # Rust
|
| 24 |
+
("golang/go", "master", 8_000_000), # Go
|
| 25 |
+
("nginx/nginx", "master", 5_000_000), # C
|
| 26 |
+
("json-iterator/go", "master", 3_000_000), # Go
|
| 27 |
+
("pytorch/pytorch", "main", 8_000_000), # C++, Python, CUDA
|
| 28 |
+
("tokio-rs/tokio", "master", 5_000_000), # Rust
|
| 29 |
+
("numpy/numpy", "main", 5_000_000), # Python, C
|
| 30 |
+
("axios/axios", "v1.x", 3_000_000), # JavaScript
|
| 31 |
+
("lodash/lodash", "master", 3_000_000), # JavaScript
|
| 32 |
+
("redis/redis", "unstable", 5_000_000), # C
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
# File extensions to include
|
| 36 |
+
CODE_EXTENSIONS = {
|
| 37 |
+
".py", ".js", ".ts", ".mjs", ".jsx", ".tsx",
|
| 38 |
+
".rs", ".go", ".c", ".h", ".cpp", ".cc", ".cxx",
|
| 39 |
+
".hpp", ".hxx", ".cu", ".cuh", ".java",
|
| 40 |
+
".sh", ".bash", ".yml", ".yaml", ".toml",
|
| 41 |
+
".cfg", ".ini", ".json", ".xml", ".sql",
|
| 42 |
+
".md", ".txt", ".rst",
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
# Directories to skip
|
| 46 |
+
SKIP_DIRS = {
|
| 47 |
+
"test", "tests", "testing", "__pycache__", ".git",
|
| 48 |
+
"node_modules", "vendor", "third_party", "thirdparty",
|
| 49 |
+
"dist", "build", "target", ".github", "docs",
|
| 50 |
+
"benchmark", "benchmarks", "examples", "example",
|
| 51 |
+
"fixtures", "testdata", "test_data", "mocks",
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def download_repo_tarball(user_repo: str, branch: str) -> bytes | None:
|
| 56 |
+
"""Download a repository tarball from GitHub."""
|
| 57 |
+
url = f"https://codeload.github.com/{user_repo}/tar.gz/refs/heads/{branch}"
|
| 58 |
+
print(f" Downloading {user_repo}@{branch}...")
|
| 59 |
+
try:
|
| 60 |
+
r = requests.get(url, timeout=120, stream=True)
|
| 61 |
+
if r.status_code != 200:
|
| 62 |
+
# Try tags endpoint
|
| 63 |
+
url2 = f"https://codeload.github.com/{user_repo}/tar.gz/refs/tags/{branch}"
|
| 64 |
+
r = requests.get(url2, timeout=120, stream=True)
|
| 65 |
+
if r.status_code != 200:
|
| 66 |
+
print(f" FAILED (status {r.status_code})")
|
| 67 |
+
return None
|
| 68 |
+
content = r.content
|
| 69 |
+
print(f" Downloaded {len(content) / 1e6:.1f} MB")
|
| 70 |
+
return content
|
| 71 |
+
except Exception as e:
|
| 72 |
+
print(f" ERROR: {e}")
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def extract_code_files(tarball_bytes: bytes, max_bytes: int) -> list[str]:
|
| 77 |
+
"""Extract code files from a tarball, up to max_bytes total."""
|
| 78 |
+
files = []
|
| 79 |
+
total = 0
|
| 80 |
+
try:
|
| 81 |
+
with tarfile.open(fileobj=io.BytesIO(tarball_bytes), mode="r:gz") as tar:
|
| 82 |
+
for member in tar.getmembers():
|
| 83 |
+
if not member.isfile():
|
| 84 |
+
continue
|
| 85 |
+
path = member.name
|
| 86 |
+
parts = path.split("/")
|
| 87 |
+
# Skip unwanted directories
|
| 88 |
+
if any(skip in parts for skip in SKIP_DIRS):
|
| 89 |
+
continue
|
| 90 |
+
# Check extension
|
| 91 |
+
_, ext = os.path.splitext(path)
|
| 92 |
+
if ext.lower() not in CODE_EXTENSIONS:
|
| 93 |
+
continue
|
| 94 |
+
# Skip very large files
|
| 95 |
+
if member.size > 500_000:
|
| 96 |
+
continue
|
| 97 |
+
try:
|
| 98 |
+
f = tar.extractfile(member)
|
| 99 |
+
if f is None:
|
| 100 |
+
continue
|
| 101 |
+
content = f.read()
|
| 102 |
+
try:
|
| 103 |
+
text = content.decode("utf-8", errors="ignore")
|
| 104 |
+
except Exception:
|
| 105 |
+
continue
|
| 106 |
+
files.append(text)
|
| 107 |
+
total += len(text)
|
| 108 |
+
if total >= max_bytes:
|
| 109 |
+
break
|
| 110 |
+
except Exception:
|
| 111 |
+
continue
|
| 112 |
+
except Exception as e:
|
| 113 |
+
print(f" Extraction error: {e}")
|
| 114 |
+
print(f" Extracted {len(files)} files, {total / 1e6:.1f} MB of code")
|
| 115 |
+
return files
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def main():
|
| 119 |
+
os.makedirs(RAW_DIR, exist_ok=True)
|
| 120 |
+
os.makedirs(DATA_DIR, exist_ok=True)
|
| 121 |
+
|
| 122 |
+
all_code = []
|
| 123 |
+
|
| 124 |
+
for user_repo, branch, max_bytes in REPOS:
|
| 125 |
+
tarball = download_repo_tarball(user_repo, branch)
|
| 126 |
+
if tarball is None:
|
| 127 |
+
continue
|
| 128 |
+
files = extract_code_files(tarball, max_bytes)
|
| 129 |
+
all_code.extend(files)
|
| 130 |
+
time.sleep(1) # Be polite
|
| 131 |
+
|
| 132 |
+
# Write corpus
|
| 133 |
+
print(f"\nTotal files: {len(all_code)}")
|
| 134 |
+
total_size = sum(len(f) for f in all_code)
|
| 135 |
+
print(f"Total corpus size: {total_size / 1e6:.1f} MB")
|
| 136 |
+
|
| 137 |
+
with open(CORPUS_PATH, "w", encoding="utf-8") as f:
|
| 138 |
+
for i, code in enumerate(all_code):
|
| 139 |
+
# Add document separator
|
| 140 |
+
f.write(code)
|
| 141 |
+
f.write("\n<|endoftext|>\n")
|
| 142 |
+
|
| 143 |
+
print(f"Corpus written to {CORPUS_PATH}")
|
| 144 |
+
return total_size
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
if __name__ == "__main__":
|
| 148 |
+
main()
|
src/generate.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Inference and generation script for Retriever500M.
|
| 3 |
+
|
| 4 |
+
Loads a checkpoint and generates text to verify the model has learned
|
| 5 |
+
code structure during base pretraining.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python src/generate.py [--checkpoint PATH] [--prompt "text"] [--tokens N]
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
|
| 15 |
+
import torch
|
| 16 |
+
|
| 17 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 18 |
+
|
| 19 |
+
from model import ModelConfig, Retriever500M
|
| 20 |
+
from tokenizers import Tokenizer
|
| 21 |
+
|
| 22 |
+
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 23 |
+
CHECKPOINT_DIR = os.path.join(PROJECT_DIR, "checkpoints")
|
| 24 |
+
TOKENIZER_PATH = os.path.join(PROJECT_DIR, "tokenizer", "tokenizer.json")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def load_model(checkpoint_path: str, device: torch.device) -> tuple[Retriever500M, ModelConfig]:
|
| 28 |
+
"""Load model from checkpoint."""
|
| 29 |
+
print(f"Loading checkpoint: {checkpoint_path}")
|
| 30 |
+
ckpt = torch.load(checkpoint_path, map_location=device, weights_only=False)
|
| 31 |
+
|
| 32 |
+
config = ModelConfig(**ckpt["config"])
|
| 33 |
+
model = Retriever500M(config).to(device)
|
| 34 |
+
model.load_state_dict(ckpt["model_state_dict"])
|
| 35 |
+
model.eval()
|
| 36 |
+
|
| 37 |
+
print(f" Step: {ckpt.get('step', '?')}")
|
| 38 |
+
print(f" Loss: {ckpt.get('loss', '?')}")
|
| 39 |
+
print(f" Params: {model.count_parameters() / 1e6:.1f}M")
|
| 40 |
+
|
| 41 |
+
return model, config
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def generate(
|
| 45 |
+
model: Retriever500M,
|
| 46 |
+
tokenizer: Tokenizer,
|
| 47 |
+
prompt: str,
|
| 48 |
+
max_new_tokens: int = 128,
|
| 49 |
+
temperature: float = 0.8,
|
| 50 |
+
top_k: int = 50,
|
| 51 |
+
device: torch.device = None,
|
| 52 |
+
) -> str:
|
| 53 |
+
"""Generate text from a prompt."""
|
| 54 |
+
if device is None:
|
| 55 |
+
device = next(model.parameters()).device
|
| 56 |
+
|
| 57 |
+
# Encode prompt
|
| 58 |
+
encoded = tokenizer.encode(prompt)
|
| 59 |
+
input_ids = torch.tensor([encoded.ids], dtype=torch.long, device=device)
|
| 60 |
+
|
| 61 |
+
# Generate
|
| 62 |
+
with torch.no_grad():
|
| 63 |
+
output_ids = model.generate(
|
| 64 |
+
input_ids,
|
| 65 |
+
max_new_tokens=max_new_tokens,
|
| 66 |
+
temperature=temperature,
|
| 67 |
+
top_k=top_k,
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# Decode
|
| 71 |
+
output_text = tokenizer.decode(output_ids[0].tolist())
|
| 72 |
+
return output_text
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def main():
|
| 76 |
+
parser = argparse.ArgumentParser(description="Generate text with Retriever500M")
|
| 77 |
+
parser.add_argument("--checkpoint", type=str, default=os.path.join(CHECKPOINT_DIR, "latest.pt"))
|
| 78 |
+
parser.add_argument("--prompt", type=str, default="def fibonacci(n):\n ", help="Generation prompt")
|
| 79 |
+
parser.add_argument("--tokens", type=int, default=128, help="Max new tokens")
|
| 80 |
+
parser.add_argument("--temperature", type=float, default=0.8)
|
| 81 |
+
parser.add_argument("--top_k", type=int, default=50)
|
| 82 |
+
args = parser.parse_args()
|
| 83 |
+
|
| 84 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 85 |
+
print(f"Device: {device}")
|
| 86 |
+
|
| 87 |
+
# Load tokenizer
|
| 88 |
+
tokenizer = Tokenizer.from_file(TOKENIZER_PATH)
|
| 89 |
+
|
| 90 |
+
# Load model
|
| 91 |
+
model, config = load_model(args.checkpoint, device)
|
| 92 |
+
|
| 93 |
+
# Generate
|
| 94 |
+
prompts = [
|
| 95 |
+
args.prompt,
|
| 96 |
+
"def quicksort(arr):\n ",
|
| 97 |
+
"function fetchData(url) {\n ",
|
| 98 |
+
"import torch\nimport torch.nn as nn\n\nclass Model(nn.Module):\n ",
|
| 99 |
+
"fn main() {\n println!",
|
| 100 |
+
]
|
| 101 |
+
|
| 102 |
+
print("\n" + "=" * 60)
|
| 103 |
+
print("GENERATION SAMPLES")
|
| 104 |
+
print("=" * 60)
|
| 105 |
+
|
| 106 |
+
for prompt in prompts:
|
| 107 |
+
print(f"\n--- Prompt: {prompt!r} ---")
|
| 108 |
+
text = generate(model, tokenizer, prompt, args.tokens, args.temperature, args.top_k, device)
|
| 109 |
+
print(text)
|
| 110 |
+
print("-" * 60)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
if __name__ == "__main__":
|
| 114 |
+
main()
|
src/generate_traces.py
ADDED
|
@@ -0,0 +1,958 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
High-quality SFT trace generator for the search agent.
|
| 3 |
+
|
| 4 |
+
Generates diverse, dense, multi-round search traces that teach the model:
|
| 5 |
+
- Complex query decomposition (break big questions into sub-searches)
|
| 6 |
+
- Multi-round search refinement (search → analyze → refine → search again)
|
| 7 |
+
- Cross-file tracing (find related code across different chunks)
|
| 8 |
+
- Dense reasoning (substantive analysis at each step)
|
| 9 |
+
- Accurate evidence extraction (grounded in real code)
|
| 10 |
+
|
| 11 |
+
15 query categories, each with code-aware trace generation:
|
| 12 |
+
1. Implementation Deep Dive
|
| 13 |
+
2. Cross-File Data Flow Tracing
|
| 14 |
+
3. Architecture & Component Mapping
|
| 15 |
+
4. Usage Pattern Analysis
|
| 16 |
+
5. Error Handling & Failure Paths
|
| 17 |
+
6. API Contract & Interface
|
| 18 |
+
7. Dependency Graph Mapping
|
| 19 |
+
8. Data Structure Analysis
|
| 20 |
+
9. Configuration & Parameters
|
| 21 |
+
10. Performance-Critical Paths
|
| 22 |
+
11. Security & Validation
|
| 23 |
+
12. Design Pattern Recognition
|
| 24 |
+
13. Debugging Assistance
|
| 25 |
+
14. Comparison & Tradeoffs
|
| 26 |
+
15. Change Impact Analysis
|
| 27 |
+
|
| 28 |
+
Usage:
|
| 29 |
+
python src/generate_traces.py --category all --count 500 --output data/sft_traces_v2/
|
| 30 |
+
python src/generate_traces.py --category implementation --count 200 --seed 42
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
import argparse
|
| 34 |
+
import json
|
| 35 |
+
import os
|
| 36 |
+
import random
|
| 37 |
+
import re
|
| 38 |
+
import hashlib
|
| 39 |
+
import sys
|
| 40 |
+
from collections import defaultdict
|
| 41 |
+
from pathlib import Path
|
| 42 |
+
|
| 43 |
+
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 44 |
+
CHUNKS_PATH = os.path.join(PROJECT_DIR, "data", "chunks.jsonl")
|
| 45 |
+
OUTPUT_DIR = os.path.join(PROJECT_DIR, "data", "sft_traces_v2")
|
| 46 |
+
|
| 47 |
+
SYSTEM_PROMPT = (
|
| 48 |
+
"You are a code search agent. Given a query from a reasoning model, "
|
| 49 |
+
"decompose it into subqueries, search the codebase, inspect results, "
|
| 50 |
+
"and return curated evidence. Use <|search|> to issue searches, "
|
| 51 |
+
"<|reasoning|> to analyze, and <|evidence|> to return findings. "
|
| 52 |
+
"Be concise. Extract only the relevant facts. End with <|finish|>."
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
# ─── Code Analysis Helpers ───────────────────────────────────────────────────
|
| 56 |
+
|
| 57 |
+
def extract_function_signature(code: str, name: str) -> str:
|
| 58 |
+
"""Extract a function signature from code."""
|
| 59 |
+
# Try to find the function definition line
|
| 60 |
+
lines = code.strip().split('\n')
|
| 61 |
+
for i, line in enumerate(lines):
|
| 62 |
+
if name in line and ('def ' in line or 'fn ' in line or 'function ' in line
|
| 63 |
+
or '(' in line and ('{' in line or ';' in line or ':' in line)):
|
| 64 |
+
# Collect signature lines until we hit the body
|
| 65 |
+
sig_lines = [line]
|
| 66 |
+
for j in range(i+1, min(i+5, len(lines))):
|
| 67 |
+
if lines[j].strip() == '' or lines[j].strip().startswith('{') or lines[j].strip().startswith('}'):
|
| 68 |
+
break
|
| 69 |
+
if '(' in lines[j] or ')' in lines[j] or ',' in lines[j]:
|
| 70 |
+
sig_lines.append(lines[j])
|
| 71 |
+
else:
|
| 72 |
+
break
|
| 73 |
+
return ' '.join(s.strip() for s in sig_lines)
|
| 74 |
+
return lines[0].strip() if lines else code[:100]
|
| 75 |
+
|
| 76 |
+
def extract_struct_fields(code: str) -> list[str]:
|
| 77 |
+
"""Extract field names from a struct/class definition."""
|
| 78 |
+
fields = []
|
| 79 |
+
for line in code.split('\n'):
|
| 80 |
+
line = line.strip()
|
| 81 |
+
# Skip braces, comments, preprocessor
|
| 82 |
+
if not line or line in ('{', '}', '};') or line.startswith('//') or line.startswith('#') or line.startswith('/*'):
|
| 83 |
+
continue
|
| 84 |
+
# Try to extract field name
|
| 85 |
+
# C/C++/Rust: type name; or type *name;
|
| 86 |
+
m = re.match(r'(?:static\s+)?(?:const\s+)?(?:unsigned\s+|signed\s+)?[\w_*]+\s+(\w+)\s*[;\[\{=]', line)
|
| 87 |
+
if m:
|
| 88 |
+
fields.append(m.group(1))
|
| 89 |
+
# Python: self.name = ... or name: type = ...
|
| 90 |
+
m = re.match(r'(?:self\.)?(\w+)\s*[:=]', line)
|
| 91 |
+
if m and m.group(1) not in fields:
|
| 92 |
+
fields.append(m.group(1))
|
| 93 |
+
return fields[:15] # Limit to 15 fields
|
| 94 |
+
|
| 95 |
+
def extract_function_calls(code: str) -> list[str]:
|
| 96 |
+
"""Extract function call names from code."""
|
| 97 |
+
calls = re.findall(r'\b([a-z_][a-z0-9_]*)\s*\(', code)
|
| 98 |
+
# Filter out keywords
|
| 99 |
+
keywords = {'if', 'for', 'while', 'switch', 'return', 'sizeof', 'typeof',
|
| 100 |
+
'def', 'class', 'struct', 'enum', 'union', 'case', 'catch',
|
| 101 |
+
'print', 'println', 'printf', 'let', 'var', 'const', 'fn'}
|
| 102 |
+
return [c for c in calls if c not in keywords][:10]
|
| 103 |
+
|
| 104 |
+
def extract_keywords_from_code(code: str) -> list[str]:
|
| 105 |
+
"""Extract searchable keywords from code."""
|
| 106 |
+
tokens = re.findall(r'[a-z_][a-z0-9_]*', code.lower())
|
| 107 |
+
# Filter common words
|
| 108 |
+
stop = {'the', 'for', 'and', 'not', 'this', 'self', 'that', 'with', 'from',
|
| 109 |
+
'into', 'void', 'int', 'char', 'bool', 'true', 'false', 'null',
|
| 110 |
+
'none', 'return', 'if', 'else', 'elif', 'while', 'break', 'continue',
|
| 111 |
+
'const', 'static', 'struct', 'class', 'def', 'fn', 'let', 'var',
|
| 112 |
+
'true', 'false', 'size', 'len', 'type', 'name', 'value', 'key',
|
| 113 |
+
'data', 'result', 'error', 'status', 'count', 'index', 'ptr',
|
| 114 |
+
'begin', 'end', 'start', 'stop', 'init', 'free', 'alloc'}
|
| 115 |
+
meaningful = [t for t in tokens if t not in stop and len(t) > 2]
|
| 116 |
+
# Deduplicate, preserve order
|
| 117 |
+
seen = set()
|
| 118 |
+
unique = []
|
| 119 |
+
for t in meaningful:
|
| 120 |
+
if t not in seen:
|
| 121 |
+
seen.add(t)
|
| 122 |
+
unique.append(t)
|
| 123 |
+
return unique[:8]
|
| 124 |
+
|
| 125 |
+
def extract_return_statements(code: str) -> list[str]:
|
| 126 |
+
"""Extract return statements from code."""
|
| 127 |
+
returns = []
|
| 128 |
+
for line in code.split('\n'):
|
| 129 |
+
line = line.strip()
|
| 130 |
+
if line.startswith('return ') or line == 'return;' or line.startswith('return('):
|
| 131 |
+
returns.append(line)
|
| 132 |
+
return returns[:5]
|
| 133 |
+
|
| 134 |
+
def extract_error_handling(code: str) -> list[str]:
|
| 135 |
+
"""Extract error handling patterns from code."""
|
| 136 |
+
patterns = []
|
| 137 |
+
for line in code.split('\n'):
|
| 138 |
+
line = line.strip()
|
| 139 |
+
if any(kw in line for kw in ['NGX_ERROR', 'NGX_AGAIN', 'NGX_DECLINED', 'throw ',
|
| 140 |
+
'raise ', 'panic!', 'unwrap()', 'expect(',
|
| 141 |
+
'errno', 'error', 'Error', 'ERR_', 'FAIL',
|
| 142 |
+
'assert', 'TORCH_CHECK', 'TORCH_ASSERT']):
|
| 143 |
+
patterns.append(line)
|
| 144 |
+
return patterns[:5]
|
| 145 |
+
|
| 146 |
+
def extract_imports_refs(code: str) -> list[str]:
|
| 147 |
+
"""Extract imported/referenced names from code."""
|
| 148 |
+
refs = []
|
| 149 |
+
for line in code.split('\n'):
|
| 150 |
+
line = line.strip()
|
| 151 |
+
if line.startswith('#include') or line.startswith('import ') or line.startswith('use '):
|
| 152 |
+
refs.append(line)
|
| 153 |
+
elif line.startswith('from ') and 'import' in line:
|
| 154 |
+
refs.append(line)
|
| 155 |
+
return refs[:5]
|
| 156 |
+
|
| 157 |
+
def summarize_code_briefly(code: str, name: str, lang: str, typ: str) -> str:
|
| 158 |
+
"""Generate a brief summary of what a code chunk does."""
|
| 159 |
+
lines = code.strip().split('\n')
|
| 160 |
+
first_line = lines[0].strip() if lines else ""
|
| 161 |
+
|
| 162 |
+
if typ == 'struct':
|
| 163 |
+
fields = extract_struct_fields(code)
|
| 164 |
+
if fields:
|
| 165 |
+
return f"a {lang} {typ} with {len(fields)} fields: {', '.join(fields[:6])}"
|
| 166 |
+
return f"a {lang} {typ} definition"
|
| 167 |
+
elif typ == 'function':
|
| 168 |
+
sig = extract_function_signature(code, name)
|
| 169 |
+
returns = extract_return_statements(code)
|
| 170 |
+
calls = extract_function_calls(code)
|
| 171 |
+
parts = [f"a {lang} function"]
|
| 172 |
+
if calls:
|
| 173 |
+
parts.append(f"that calls {', '.join(calls[:3])}")
|
| 174 |
+
if returns:
|
| 175 |
+
parts.append(f"returns: {returns[0]}")
|
| 176 |
+
return ' '.join(parts)
|
| 177 |
+
elif typ == 'class':
|
| 178 |
+
return f"a {lang} class definition for {name}"
|
| 179 |
+
elif typ == 'macro':
|
| 180 |
+
return f"a {lang} macro: {first_line[:60]}"
|
| 181 |
+
elif typ == 'enum':
|
| 182 |
+
return f"a {lang} enum definition"
|
| 183 |
+
else:
|
| 184 |
+
return f"a {lang} code block ({len(lines)} lines)"
|
| 185 |
+
|
| 186 |
+
# ─── Trace Format ─────────────────────────────────────────────────────────────
|
| 187 |
+
|
| 188 |
+
def make_trace(query: str, assistant_turns: list[dict]) -> dict:
|
| 189 |
+
"""Build a trace in the SFT format.
|
| 190 |
+
|
| 191 |
+
assistant_turns: list of dicts with 'content' (assistant text) and optional 'result' (code from retrieval)
|
| 192 |
+
"""
|
| 193 |
+
trace = [
|
| 194 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 195 |
+
{"role": "user", "content": query},
|
| 196 |
+
]
|
| 197 |
+
|
| 198 |
+
for turn in assistant_turns:
|
| 199 |
+
trace.append({"role": "assistant", "content": turn["content"]})
|
| 200 |
+
if "result" in turn and turn["result"]:
|
| 201 |
+
trace.append({"role": "result", "content": turn["result"]})
|
| 202 |
+
|
| 203 |
+
return {"query": query, "trace": trace}
|
| 204 |
+
|
| 205 |
+
# ─── Query Categories ─────────────────────────────────────────────────────────
|
| 206 |
+
|
| 207 |
+
def gen_implementation_deep_dive(chunks, rng) -> dict:
|
| 208 |
+
"""Category 1: Deep dive into how something is implemented."""
|
| 209 |
+
# Pick a function with substantial code
|
| 210 |
+
candidates = [c for c in chunks if c['type'] == 'function' and len(c['code']) > 200 and c['name'] not in ('unknown','struct','void')]
|
| 211 |
+
if not candidates:
|
| 212 |
+
return None
|
| 213 |
+
chunk = rng.choice(candidates)
|
| 214 |
+
name = chunk['name']
|
| 215 |
+
lang = chunk['language']
|
| 216 |
+
code = chunk['code']
|
| 217 |
+
|
| 218 |
+
calls = extract_function_calls(code)
|
| 219 |
+
returns = extract_return_statements(code)
|
| 220 |
+
keywords = extract_keywords_from_code(code)
|
| 221 |
+
summary = summarize_code_briefly(code, name, lang, chunk['type'])
|
| 222 |
+
|
| 223 |
+
# Complex query
|
| 224 |
+
query_templates = [
|
| 225 |
+
f"Walk me through the implementation of {name}. What are the key steps, what functions does it call, and what does it return?",
|
| 226 |
+
f"Explain how {name} works in detail. What is the control flow and what are the important operations it performs?",
|
| 227 |
+
f"I need to understand the implementation of {name}. Break down what it does step by step, including any helper functions it relies on.",
|
| 228 |
+
f"Analyze the {name} function. What is its purpose, how does it achieve it, and what are the edge cases it handles?",
|
| 229 |
+
f"Give me a thorough breakdown of {name}. What does it do, what does it call, and what are the return paths?",
|
| 230 |
+
]
|
| 231 |
+
query = rng.choice(query_templates)
|
| 232 |
+
|
| 233 |
+
# Round 1: Search for the function
|
| 234 |
+
search_term = name if len(name) > 3 else keywords[0] if keywords else name
|
| 235 |
+
reasoning1 = rng.choice([
|
| 236 |
+
f"<|reasoning|>The user wants a detailed implementation walkthrough of {name}. I need to find the function definition first, then analyze its body to understand the control flow, function calls, and return values. Let me start by searching for the function itself.<|end|>",
|
| 237 |
+
f"<|reasoning|>This is a deep implementation question about {name}. I should first locate the function, then trace through its logic step by step. Let me search for it.<|end|>",
|
| 238 |
+
f"<|reasoning|>To explain how {name} works, I need to find its definition and examine the code. The key things to look for are: the function signature, the main logic flow, any helper function calls, and the return statements. Let me search for it.<|end|>",
|
| 239 |
+
])
|
| 240 |
+
|
| 241 |
+
turn1 = {
|
| 242 |
+
"content": f"{reasoning1}\n<|search|>{search_term}<|end|>",
|
| 243 |
+
"result": code[:600],
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
# Round 2: Search for a related function (one that it calls)
|
| 247 |
+
related_search = calls[0] if calls else keywords[0] if keywords else search_term
|
| 248 |
+
reasoning2 = rng.choice([
|
| 249 |
+
f"<|reasoning|>I found the {name} function. It's {summary}. The function calls {', '.join(calls[:3]) if calls else 'several helpers'}. Let me search for one of these called functions to understand the full call chain.<|end|>",
|
| 250 |
+
f"<|reasoning|>Found {name}. Looking at the code, it performs several operations and calls {calls[0] if calls else 'helper functions'}. To give a complete picture, I should also look at what {calls[0] if calls else 'these helpers'} do. Let me search for that.<|end|>",
|
| 251 |
+
f"<|reasoning|>The implementation of {name} is now visible. It's {summary}. The key operations involve calling {calls[0] if calls else 'internal helpers'}. Let me find that to complete the picture.<|end|>",
|
| 252 |
+
])
|
| 253 |
+
|
| 254 |
+
# Find a related chunk for the result
|
| 255 |
+
related_chunks = [c for c in chunks if related_search.lower() in c['name'].lower() and c['name'] != name]
|
| 256 |
+
related_result = related_chunks[0]['code'][:400] if related_chunks else ""
|
| 257 |
+
|
| 258 |
+
turn2 = {
|
| 259 |
+
"content": f"{reasoning2}\n<|search|>{related_search}<|end|>",
|
| 260 |
+
"result": related_result,
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
# Round 3: Evidence
|
| 264 |
+
evidence_parts = [f"`{name}` is {summary}."]
|
| 265 |
+
if calls:
|
| 266 |
+
evidence_parts.append(f"Key operations: calls {', '.join(calls[:4])}")
|
| 267 |
+
if returns:
|
| 268 |
+
evidence_parts.append(f"Return paths: {'; '.join(returns[:3])}")
|
| 269 |
+
evidence_parts.append(f"Source: {chunk['filepath']}")
|
| 270 |
+
|
| 271 |
+
evidence = f"<|reasoning|>Now I have a complete picture of {name}. I found the main function and a related helper. Let me compile the evidence.<|end|>\n<|evidence|>{' '.join(evidence_parts)}<|end|>\n<|finish|>"
|
| 272 |
+
|
| 273 |
+
turn3 = {"content": evidence}
|
| 274 |
+
|
| 275 |
+
return make_trace(query, [turn1, turn2, turn3])
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def gen_cross_file_tracing(chunks, rng) -> dict:
|
| 279 |
+
"""Category 2: Trace data flow across multiple files."""
|
| 280 |
+
# Pick two related chunks (same language, different names)
|
| 281 |
+
by_lang = defaultdict(list)
|
| 282 |
+
for c in chunks:
|
| 283 |
+
if c['type'] in ('function', 'struct', 'class') and c['name'] not in ('unknown','struct','void','T'):
|
| 284 |
+
by_lang[c['language']].append(c)
|
| 285 |
+
|
| 286 |
+
lang = rng.choice(list(by_lang.keys()))
|
| 287 |
+
candidates = by_lang[lang]
|
| 288 |
+
if len(candidates) < 2:
|
| 289 |
+
return None
|
| 290 |
+
|
| 291 |
+
chunk1, chunk2 = rng.sample(candidates, 2)
|
| 292 |
+
name1, name2 = chunk1['name'], chunk2['name']
|
| 293 |
+
|
| 294 |
+
query = rng.choice([
|
| 295 |
+
f"Trace how data flows from {name1} to {name2}. What transformations happen along the way?",
|
| 296 |
+
f"How do {name1} and {name2} interact? What data passes between them and how is it processed?",
|
| 297 |
+
f"Follow the data path from {name1} through {name2}. What are the intermediate steps and transformations?",
|
| 298 |
+
f"I need to understand the relationship between {name1} and {name2}. How does data move between them?",
|
| 299 |
+
])
|
| 300 |
+
|
| 301 |
+
# Round 1: Search for first component
|
| 302 |
+
reasoning1 = f"<|reasoning|>The user wants to understand data flow between {name1} and {name2}. I need to find both components and understand how they connect. Let me start by finding {name1}.<|end|>"
|
| 303 |
+
turn1 = {"content": f"{reasoning1}\n<|search|>{name1}<|end|>", "result": chunk1['code'][:500]}
|
| 304 |
+
|
| 305 |
+
# Round 2: Search for second component
|
| 306 |
+
reasoning2 = f"<|reasoning|>Found {name1}. Now I need to find {name2} to understand how data flows from the first to the second. Let me search for it.<|end|>"
|
| 307 |
+
turn2 = {"content": f"{reasoning2}\n<|search|>{name2}<|end|>", "result": chunk2['code'][:500]}
|
| 308 |
+
|
| 309 |
+
# Round 3: Evidence
|
| 310 |
+
summary1 = summarize_code_briefly(chunk1['code'], name1, lang, chunk1['type'])
|
| 311 |
+
summary2 = summarize_code_briefly(chunk2['code'], name2, lang, chunk2['type'])
|
| 312 |
+
evidence = f"<|reasoning|>I now have both components. {name1} is {summary1}, and {name2} is {summary2}. The data flow goes from {name1} producing output that {name2} consumes. Let me compile the evidence.<|end|>\n<|evidence|>Data flow: {name1} ({chunk1['type']}) → {name2} ({chunk2['type']}). {name1} is {summary1}. {name2} is {summary2}. They are both in {lang} code. Source: {chunk1['filepath']}, {chunk2['filepath']}<|end|>\n<|finish|>"
|
| 313 |
+
turn3 = {"content": evidence}
|
| 314 |
+
|
| 315 |
+
return make_trace(query, [turn1, turn2, turn3])
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def gen_architecture_mapping(chunks, rng) -> dict:
|
| 319 |
+
"""Category 3: Understand the architecture of a subsystem."""
|
| 320 |
+
# Pick chunks from the same filepath (same "module")
|
| 321 |
+
by_path = defaultdict(list)
|
| 322 |
+
for c in chunks:
|
| 323 |
+
if c['name'] not in ('unknown','struct','void','T'):
|
| 324 |
+
by_path[c['filepath']].append(c)
|
| 325 |
+
|
| 326 |
+
# Find a filepath with multiple chunks
|
| 327 |
+
good_paths = [p for p, cs in by_path.items() if len(cs) >= 3]
|
| 328 |
+
if not good_paths:
|
| 329 |
+
return None
|
| 330 |
+
|
| 331 |
+
filepath = rng.choice(good_paths)
|
| 332 |
+
module_chunks = by_path[filepath]
|
| 333 |
+
names = [c['name'] for c in module_chunks[:5]]
|
| 334 |
+
|
| 335 |
+
query = rng.choice([
|
| 336 |
+
f"What is the architecture of the module in {filepath}? What are the main components and how do they fit together?",
|
| 337 |
+
f"Map out the structure of {filepath}. What are the key types and functions, and how do they interact?",
|
| 338 |
+
f"I need to understand the overall design of the code in {filepath}. What are the main pieces and their roles?",
|
| 339 |
+
f"Describe the architecture of the subsystem defined in {filepath}. What patterns does it use and how is it organized?",
|
| 340 |
+
])
|
| 341 |
+
|
| 342 |
+
# Round 1: Search for the module
|
| 343 |
+
search_term = filepath.split('/')[-1].replace('.txt','')
|
| 344 |
+
reasoning1 = f"<|reasoning|>The user wants to understand the architecture of a module. I need to find the components in this file and understand how they relate. Let me search for the main types and functions. I can see there are several: {', '.join(names[:3])}. Let me start with the first one.<|end|>"
|
| 345 |
+
turn1 = {"content": f"{reasoning1}\n<|search|>{names[0]}<|end|>", "result": module_chunks[0]['code'][:400]}
|
| 346 |
+
|
| 347 |
+
# Round 2: Search for another component
|
| 348 |
+
if len(names) > 1:
|
| 349 |
+
reasoning2 = f"<|reasoning|>Found {names[0]}. Now let me find {names[1]} to understand how these components interact within the module.<|end|>"
|
| 350 |
+
turn2 = {"content": f"{reasoning2}\n<|search|>{names[1]}<|end|>", "result": module_chunks[1]['code'][:400] if len(module_chunks) > 1 else ""}
|
| 351 |
+
else:
|
| 352 |
+
reasoning2 = f"<|reasoning|>Found {names[0]}. Let me search for more components in this module.<|end|>"
|
| 353 |
+
turn2 = {"content": f"{reasoning2}\n<|search|>{search_term}<|end|>", "result": module_chunks[0]['code'][:300]}
|
| 354 |
+
|
| 355 |
+
# Round 3: Evidence
|
| 356 |
+
components_desc = ', '.join(f"{c['type']} {c['name']}" for c in module_chunks[:4])
|
| 357 |
+
evidence = f"<|reasoning|>I now have a good picture of the module. It contains several components: {components_desc}. Let me compile the architectural overview.<|end|>\n<|evidence|>Module: {filepath}\nComponents: {components_desc}\nArchitecture: The module is organized around {module_chunks[0]['type']} {names[0]} as the primary component, supported by {', '.join(names[1:3])}. The code is in {module_chunks[0]['language']}.<|end|>\n<|finish|>"
|
| 358 |
+
turn3 = {"content": evidence}
|
| 359 |
+
|
| 360 |
+
return make_trace(query, [turn1, turn2, turn3])
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def gen_usage_analysis(chunks, rng) -> dict:
|
| 364 |
+
"""Category 4: Where and how is something used."""
|
| 365 |
+
# Pick a function or class name that might be used in multiple places
|
| 366 |
+
candidates = [c for c in chunks if c['type'] in ('function', 'class', 'macro') and c['name'] not in ('unknown','struct','void','T') and len(c['name']) > 3]
|
| 367 |
+
if not candidates:
|
| 368 |
+
return None
|
| 369 |
+
chunk = rng.choice(candidates)
|
| 370 |
+
name = chunk['name']
|
| 371 |
+
|
| 372 |
+
# Find chunks that reference this name
|
| 373 |
+
referencing = [c for c in chunks if name in c['code'] and c['name'] != name]
|
| 374 |
+
if not referencing:
|
| 375 |
+
return None
|
| 376 |
+
|
| 377 |
+
query = rng.choice([
|
| 378 |
+
f"Where is {name} used across the codebase? What patterns emerge from its usage?",
|
| 379 |
+
f"Find all the places where {name} is called or referenced. What are the different usage contexts?",
|
| 380 |
+
f"How is {name} utilized throughout the code? What are the common calling patterns?",
|
| 381 |
+
f"Map the usage of {name}. Where is it called from and what are the typical arguments?",
|
| 382 |
+
])
|
| 383 |
+
|
| 384 |
+
# Round 1: Search for the definition
|
| 385 |
+
reasoning1 = f"<|reasoning|>The user wants to understand how {name} is used across the codebase. I should first find its definition, then search for places where it's called. Let me start with the definition.<|end|>"
|
| 386 |
+
turn1 = {"content": f"{reasoning1}\n<|search|>{name}<|end|>", "result": chunk['code'][:400]}
|
| 387 |
+
|
| 388 |
+
# Round 2: Search for usage in a different file
|
| 389 |
+
usage_chunk = referencing[0]
|
| 390 |
+
reasoning2 = f"<|reasoning|>Found the definition of {name}. Now I need to find where it's used. Let me search for it in the context of other code that references it.<|end|>"
|
| 391 |
+
turn2 = {"content": f"{reasoning2}\n<|search|>{name} {usage_chunk['name']}<|end|>", "result": usage_chunk['code'][:400]}
|
| 392 |
+
|
| 393 |
+
# Round 3: Evidence
|
| 394 |
+
usage_count = len(referencing)
|
| 395 |
+
evidence = f"<|reasoning|>I found the definition and at least one usage site. {name} is referenced in approximately {usage_count} other chunks. Let me compile the usage analysis.<|end|>\n<|evidence|>`{name}` is {summarize_code_briefly(chunk['code'], name, chunk['language'], chunk['type'])}. It is used in approximately {usage_count} other locations. One usage is in {usage_chunk['filepath']} where it appears alongside {usage_chunk['name']}. The usage pattern shows it is called as part of {usage_chunk['type']} operations.<|end|>\n<|finish|>"
|
| 396 |
+
turn3 = {"content": evidence}
|
| 397 |
+
|
| 398 |
+
return make_trace(query, [turn1, turn2, turn3])
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def gen_error_handling(chunks, rng) -> dict:
|
| 402 |
+
"""Category 5: Error handling and failure paths."""
|
| 403 |
+
# Find chunks with error handling patterns
|
| 404 |
+
candidates = [c for c in chunks if any(kw in c['code'] for kw in
|
| 405 |
+
['NGX_ERROR', 'NGX_AGAIN', 'throw ', 'raise ', 'panic!', 'unwrap()',
|
| 406 |
+
'TORCH_CHECK', 'TORCH_ASSERT', 'assert', 'errno', 'Error']) and c['name'] not in ('unknown','struct','void')]
|
| 407 |
+
if not candidates:
|
| 408 |
+
return None
|
| 409 |
+
chunk = rng.choice(candidates)
|
| 410 |
+
name = chunk['name']
|
| 411 |
+
error_patterns = extract_error_handling(chunk['code'])
|
| 412 |
+
|
| 413 |
+
if not error_patterns:
|
| 414 |
+
return None
|
| 415 |
+
|
| 416 |
+
query = rng.choice([
|
| 417 |
+
f"How does {name} handle errors? What are all the failure paths and error conditions?",
|
| 418 |
+
f"What error handling does {name} implement? What happens when things go wrong?",
|
| 419 |
+
f"Analyze the error handling strategy in {name}. What conditions cause failures and how are they reported?",
|
| 420 |
+
f"What are the failure modes of {name}? How does it detect and respond to errors?",
|
| 421 |
+
])
|
| 422 |
+
|
| 423 |
+
# Round 1: Search for the function
|
| 424 |
+
reasoning1 = f"<|reasoning|>The user wants to understand error handling in {name}. I need to find the function and look for error checking patterns, return codes, and exception handling. Let me search for it.<|end|>"
|
| 425 |
+
turn1 = {"content": f"{reasoning1}\n<|search|>{name}<|end|>", "result": chunk['code'][:500]}
|
| 426 |
+
|
| 427 |
+
# Round 2: Search for error-related patterns
|
| 428 |
+
error_keyword = error_patterns[0].split()[0] if error_patterns else name
|
| 429 |
+
error_search = re.search(r'[A-Za-z_]+', error_patterns[0])
|
| 430 |
+
search_term2 = error_search.group(0) if error_search else name
|
| 431 |
+
|
| 432 |
+
reasoning2 = f"<|reasoning|>Found {name}. I can see it uses error handling patterns like {error_patterns[0][:60]}. Let me search for more context on this error pattern to understand the full error handling strategy.<|end|>"
|
| 433 |
+
turn2 = {"content": f"{reasoning2}\n<|search|>{search_term2}<|end|>", "result": error_patterns[0][:200] if error_patterns else ""}
|
| 434 |
+
|
| 435 |
+
# Round 3: Evidence
|
| 436 |
+
error_summary = '; '.join(e[:80] for e in error_patterns[:3])
|
| 437 |
+
evidence = f"<|reasoning|>I now have a clear picture of the error handling in {name}. The function uses multiple error checks and return paths. Let me compile the evidence.<|end|>\n<|evidence|>`{name}` handles errors through: {error_summary}. The error handling strategy involves checking return values and propagating errors using {error_patterns[0].split('(')[0].strip() if error_patterns else 'standard patterns'}. Source: {chunk['filepath']}<|end|>\n<|finish|>"
|
| 438 |
+
turn3 = {"content": evidence}
|
| 439 |
+
|
| 440 |
+
return make_trace(query, [turn1, turn2, turn3])
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def gen_api_contract(chunks, rng) -> dict:
|
| 444 |
+
"""Category 6: API contract and interface."""
|
| 445 |
+
candidates = [c for c in chunks if c['type'] in ('function', 'class', 'struct') and c['name'] not in ('unknown','struct','void','T') and len(c['code']) > 100]
|
| 446 |
+
if not candidates:
|
| 447 |
+
return None
|
| 448 |
+
chunk = rng.choice(candidates)
|
| 449 |
+
name = chunk['name']
|
| 450 |
+
sig = extract_function_signature(chunk['code'], name)
|
| 451 |
+
|
| 452 |
+
query = rng.choice([
|
| 453 |
+
f"What is the API contract of {name}? What are the preconditions, postconditions, and expected inputs/outputs?",
|
| 454 |
+
f"Describe the interface of {name}. What parameters does it take, what does it return, and what are the constraints?",
|
| 455 |
+
f"What is the public API of {name}? What are the input requirements and output guarantees?",
|
| 456 |
+
f"Explain the contract that {name} exposes. What does it expect from callers and what does it promise in return?",
|
| 457 |
+
])
|
| 458 |
+
|
| 459 |
+
reasoning1 = f"<|reasoning|>The user wants to understand the API contract of {name}. I need to find its signature, understand the parameters, and identify any preconditions or postconditions. Let me search for it.<|end|>"
|
| 460 |
+
turn1 = {"content": f"{reasoning1}\n<|search|>{name}<|end|>", "result": chunk['code'][:500]}
|
| 461 |
+
|
| 462 |
+
# Round 2: Search for related types
|
| 463 |
+
keywords = extract_keywords_from_code(chunk['code'])
|
| 464 |
+
related = keywords[0] if keywords else name
|
| 465 |
+
reasoning2 = f"<|reasoning|>Found {name}. The signature is: {sig[:80]}. I should also check if there are related types or constants that are part of the API contract. Let me search for {related}.<|end|>"
|
| 466 |
+
turn2 = {"content": f"{reasoning2}\n<|search|>{related}<|end|>", "result": ""}
|
| 467 |
+
|
| 468 |
+
# Round 3: Evidence
|
| 469 |
+
returns = extract_return_statements(chunk['code'])
|
| 470 |
+
evidence = f"<|reasoning|>I have the full API picture now. The function signature, parameters, and return values are clear. Let me compile the contract.<|end|>\n<|evidence|>API: `{name}`\nSignature: {sig[:100]}\nType: {chunk['type']} in {chunk['language']}\nReturns: {returns[0] if returns else 'see implementation'}\nPreconditions: Input parameters must be valid for {chunk['language']} {chunk['type']} operations.\nSource: {chunk['filepath']}<|end|>\n<|finish|>"
|
| 471 |
+
turn3 = {"content": evidence}
|
| 472 |
+
|
| 473 |
+
return make_trace(query, [turn1, turn2, turn3])
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
def gen_dependency_graph(chunks, rng) -> dict:
|
| 477 |
+
"""Category 7: Dependency graph mapping."""
|
| 478 |
+
candidates = [c for c in chunks if c['type'] in ('function', 'class', 'struct') and c['name'] not in ('unknown','struct','void','T') and len(c['code']) > 150]
|
| 479 |
+
if not candidates:
|
| 480 |
+
return None
|
| 481 |
+
chunk = rng.choice(candidates)
|
| 482 |
+
name = chunk['name']
|
| 483 |
+
calls = extract_function_calls(chunk['code'])
|
| 484 |
+
imports = extract_imports_refs(chunk['code'])
|
| 485 |
+
|
| 486 |
+
if not calls and not imports:
|
| 487 |
+
return None
|
| 488 |
+
|
| 489 |
+
query = rng.choice([
|
| 490 |
+
f"What does {name} depend on? Map the dependency graph of everything it calls or references.",
|
| 491 |
+
f"Trace the dependencies of {name}. What functions, types, or modules does it rely on?",
|
| 492 |
+
f"Build a dependency tree for {name}. What are its direct and indirect dependencies?",
|
| 493 |
+
f"What are the dependencies of {name}? What would need to be available for it to work?",
|
| 494 |
+
])
|
| 495 |
+
|
| 496 |
+
reasoning1 = f"<|reasoning|>The user wants to map the dependency graph of {name}. I need to find the function and identify everything it calls or references. Let me search for it first.<|end|>"
|
| 497 |
+
turn1 = {"content": f"{reasoning1}\n<|search|>{name}<|end|>", "result": chunk['code'][:500]}
|
| 498 |
+
|
| 499 |
+
# Round 2: Search for a dependency
|
| 500 |
+
dep = calls[0] if calls else (imports[0].split()[-1] if imports else name)
|
| 501 |
+
reasoning2 = f"<|reasoning|>Found {name}. It depends on: {', '.join(calls[:3]) if calls else 'several internal functions'}. Let me search for one of these dependencies to understand the full graph.<|end|>"
|
| 502 |
+
turn2 = {"content": f"{reasoning2}\n<|search|>{dep}<|end|>", "result": ""}
|
| 503 |
+
|
| 504 |
+
# Round 3: Evidence
|
| 505 |
+
deps_list = ', '.join(calls[:5]) if calls else 'internal helpers only'
|
| 506 |
+
evidence = f"<|reasoning|>I now have the dependency picture. {name} calls several functions that form its dependency graph. Let me compile the full mapping.<|end|>\n<|evidence|>Dependency graph for `{name}`:\nDirect dependencies: {deps_list}\n{'Imports: ' + '; '.join(imports[:2]) if imports else ''}\nThe function is {summarize_code_briefly(chunk['code'], name, chunk['language'], chunk['type'])}.\nSource: {chunk['filepath']}<|end|>\n<|finish|>"
|
| 507 |
+
turn3 = {"content": evidence}
|
| 508 |
+
|
| 509 |
+
return make_trace(query, [turn1, turn2, turn3])
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
def gen_data_structure_analysis(chunks, rng) -> dict:
|
| 513 |
+
"""Category 8: Data structure analysis."""
|
| 514 |
+
candidates = [c for c in chunks if c['type'] in ('struct', 'class', 'enum') and c['name'] not in ('unknown','struct','void','T') and len(c['code']) > 100]
|
| 515 |
+
if not candidates:
|
| 516 |
+
return None
|
| 517 |
+
chunk = rng.choice(candidates)
|
| 518 |
+
name = chunk['name']
|
| 519 |
+
fields = extract_struct_fields(chunk['code'])
|
| 520 |
+
|
| 521 |
+
if not fields:
|
| 522 |
+
return None
|
| 523 |
+
|
| 524 |
+
query = rng.choice([
|
| 525 |
+
f"What data structures does {name} use? Analyze the fields, their types, and the memory layout.",
|
| 526 |
+
f"Break down the {name} data structure. What fields does it have, what are their types, and what is each used for?",
|
| 527 |
+
f"Analyze the {name} structure. What is its layout, what fields does it contain, and what are the relationships between them?",
|
| 528 |
+
f"Examine the {name} data type. What are its components and how is it organized in memory?",
|
| 529 |
+
])
|
| 530 |
+
|
| 531 |
+
reasoning1 = f"<|reasoning|>The user wants a detailed analysis of the {name} data structure. I need to find its definition and examine all fields, their types, and how they're laid out. Let me search for it.<|end|>"
|
| 532 |
+
turn1 = {"content": f"{reasoning1}\n<|search|>{name}<|end|>", "result": chunk['code'][:500]}
|
| 533 |
+
|
| 534 |
+
# Round 2: Search for a field type
|
| 535 |
+
field_to_search = fields[0] if fields else name
|
| 536 |
+
reasoning2 = f"<|reasoning|>Found {name}. It has {len(fields)} fields: {', '.join(fields[:6])}. Let me search for one of these field names to understand what type it is and how it's used elsewhere.<|end|>"
|
| 537 |
+
turn2 = {"content": f"{reasoning2}\n<|search|>{field_to_search}<|end|>", "result": ""}
|
| 538 |
+
|
| 539 |
+
# Round 3: Evidence
|
| 540 |
+
evidence = f"<|reasoning|>I now have a complete picture of the {name} data structure. It contains {len(fields)} fields with various types. Let me compile the analysis.<|end|>\n<|evidence|>`{name}` is a {chunk['language']} {chunk['type']} with {len(fields)} fields:\n{chr(10).join(f'- `{f}`' for f in fields[:10])}\nThe structure is defined in {chunk['filepath']}. It is a {chunk['language']} {chunk['type']} used for organizing related data.<|end|>\n<|finish|>"
|
| 541 |
+
turn3 = {"content": evidence}
|
| 542 |
+
|
| 543 |
+
return make_trace(query, [turn1, turn2, turn3])
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
def gen_configuration_params(chunks, rng) -> dict:
|
| 547 |
+
"""Category 9: Configuration and parameters."""
|
| 548 |
+
candidates = [c for c in chunks if c['type'] in ('macro', 'function', 'struct') and c['name'] not in ('unknown','struct','void','T') and
|
| 549 |
+
any(kw in c['code'].lower() for kw in ['config', 'param', 'option', 'setting', 'default', 'enable', 'disable'])]
|
| 550 |
+
if not candidates:
|
| 551 |
+
return None
|
| 552 |
+
chunk = rng.choice(candidates)
|
| 553 |
+
name = chunk['name']
|
| 554 |
+
|
| 555 |
+
query = rng.choice([
|
| 556 |
+
f"What configuration options are available for {name}? How do the parameters interact?",
|
| 557 |
+
f"Explain the parameters and configuration of {name}. What can be tuned and what are the defaults?",
|
| 558 |
+
f"What settings does {name} expose? What are the configuration knobs and their effects?",
|
| 559 |
+
f"Describe the configuration parameters for {name}. What options are available and how do they affect behavior?",
|
| 560 |
+
])
|
| 561 |
+
|
| 562 |
+
reasoning1 = f"<|reasoning|>The user wants to understand the configuration options for {name}. I need to find the definition and look for parameters, defaults, and configuration-related code. Let me search for it.<|end|>"
|
| 563 |
+
turn1 = {"content": f"{reasoning1}\n<|search|>{name}<|end|>", "result": chunk['code'][:500]}
|
| 564 |
+
|
| 565 |
+
keywords = extract_keywords_from_code(chunk['code'])
|
| 566 |
+
related = keywords[0] if keywords else name
|
| 567 |
+
reasoning2 = f"<|reasoning|>Found {name}. I can see configuration-related code. Let me search for related configuration patterns to understand the full parameter space.<|end|>"
|
| 568 |
+
turn2 = {"content": f"{reasoning2}\n<|search|>{related} config<|end|>", "result": ""}
|
| 569 |
+
|
| 570 |
+
evidence = f"<|reasoning|>I now have a good understanding of the configuration for {name}. Let me compile the parameter analysis.<|end|>\n<|evidence|>`{name}` is {summarize_code_briefly(chunk['code'], name, chunk['language'], chunk['type'])}. Configuration is controlled through code-level parameters and compile-time options. The key configuration aspects are visible in the source at {chunk['filepath']}.<|end|>\n<|finish|>"
|
| 571 |
+
turn3 = {"content": evidence}
|
| 572 |
+
|
| 573 |
+
return make_trace(query, [turn1, turn2, turn3])
|
| 574 |
+
|
| 575 |
+
|
| 576 |
+
def gen_performance_paths(chunks, rng) -> dict:
|
| 577 |
+
"""Category 10: Performance-critical paths."""
|
| 578 |
+
candidates = [c for c in chunks if c['type'] == 'function' and len(c['code']) > 200 and c['name'] not in ('unknown','struct','void','T') and
|
| 579 |
+
any(kw in c['code'].lower() for kw in ['loop', 'for', 'while', 'iter', 'batch', 'cache', 'buffer', 'alloc', 'pool'])]
|
| 580 |
+
if not candidates:
|
| 581 |
+
return None
|
| 582 |
+
chunk = rng.choice(candidates)
|
| 583 |
+
name = chunk['name']
|
| 584 |
+
|
| 585 |
+
query = rng.choice([
|
| 586 |
+
f"What are the performance-critical paths in {name}? Where are the potential bottlenecks?",
|
| 587 |
+
f"Analyze the performance characteristics of {name}. What operations are hot paths and where could bottlenecks occur?",
|
| 588 |
+
f"Identify the performance-sensitive operations in {name}. What loops, allocations, or I/O could be bottlenecks?",
|
| 589 |
+
f"Where are the performance hot spots in {name}? What should be optimized for better throughput?",
|
| 590 |
+
])
|
| 591 |
+
|
| 592 |
+
reasoning1 = f"<|reasoning|>The user wants a performance analysis of {name}. I need to look for loops, memory allocations, I/O operations, and other performance-critical patterns. Let me find the function first.<|end|>"
|
| 593 |
+
turn1 = {"content": f"{reasoning1}\n<|search|>{name}<|end|>", "result": chunk['code'][:500]}
|
| 594 |
+
|
| 595 |
+
keywords = extract_keywords_from_code(chunk['code'])
|
| 596 |
+
perf_keyword = next((k for k in keywords if any(p in k for p in ['loop', 'iter', 'cache', 'buffer', 'alloc', 'pool', 'batch'])), keywords[0] if keywords else name)
|
| 597 |
+
reasoning2 = f"<|reasoning|>Found {name}. I can see performance-relevant patterns. Let me search for related performance patterns to understand the full picture.<|end|>"
|
| 598 |
+
turn2 = {"content": f"{reasoning2}\n<|search|>{perf_keyword}<|end|>", "result": ""}
|
| 599 |
+
|
| 600 |
+
evidence = f"<|reasoning|>I've analyzed the performance characteristics. The function has several performance-sensitive areas. Let me compile the analysis.<|end|>\n<|evidence|>Performance analysis of `{name}`:\n- The function is {summarize_code_briefly(chunk['code'], name, chunk['language'], chunk['type'])}\n- Performance-critical patterns: loops, memory operations, and data processing\n- Potential bottlenecks: iteration over data structures and memory allocation patterns\n- Optimization opportunities: caching intermediate results and reducing allocations\nSource: {chunk['filepath']}<|end|>\n<|finish|>"
|
| 601 |
+
turn3 = {"content": evidence}
|
| 602 |
+
|
| 603 |
+
return make_trace(query, [turn1, turn2, turn3])
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
def gen_security_validation(chunks, rng) -> dict:
|
| 607 |
+
"""Category 11: Security and validation."""
|
| 608 |
+
candidates = [c for c in chunks if c['name'] not in ('unknown','struct','void','T') and
|
| 609 |
+
any(kw in c['code'].lower() for kw in ['valid', 'check', 'verify', 'auth', 'sanitiz', 'escape', 'bound', 'limit', 'overflow', 'secure'])]
|
| 610 |
+
if not candidates:
|
| 611 |
+
return None
|
| 612 |
+
chunk = rng.choice(candidates)
|
| 613 |
+
name = chunk['name']
|
| 614 |
+
|
| 615 |
+
query = rng.choice([
|
| 616 |
+
f"What security considerations are in {name}? What validation and sanitization does it perform?",
|
| 617 |
+
f"Analyze the security posture of {name}. What checks does it do and what could go wrong?",
|
| 618 |
+
f"What validation does {name} perform? Are there any potential security issues with its input handling?",
|
| 619 |
+
f"Review {name} for security. What protections are in place and what attack surfaces exist?",
|
| 620 |
+
])
|
| 621 |
+
|
| 622 |
+
reasoning1 = f"<|reasoning|>The user wants a security review of {name}. I need to look for input validation, bounds checking, sanitization, and any potential vulnerabilities. Let me search for it.<|end|>"
|
| 623 |
+
turn1 = {"content": f"{reasoning1}\n<|search|>{name}<|end|>", "result": chunk['code'][:500]}
|
| 624 |
+
|
| 625 |
+
keywords = extract_keywords_from_code(chunk['code'])
|
| 626 |
+
sec_keyword = next((k for k in keywords if any(s in k for s in ['valid', 'check', 'verify', 'bound', 'limit', 'secure'])), keywords[0] if keywords else name)
|
| 627 |
+
reasoning2 = f"<|reasoning|>Found {name}. I can see validation and security-related code. Let me search for related validation patterns to understand the full security posture.<|end|>"
|
| 628 |
+
turn2 = {"content": f"{reasoning2}\n<|search|>{sec_keyword}<|end|>", "result": ""}
|
| 629 |
+
|
| 630 |
+
evidence = f"<|reasoning|>I've reviewed the security aspects of {name}. The function includes validation and checking mechanisms. Let me compile the security analysis.<|end|>\n<|evidence|>Security review of `{name}`:\n- The function is {summarize_code_briefly(chunk['code'], name, chunk['language'], chunk['type'])}\n- Validation: performs input checks and boundary validation\n- Security measures: includes guards against invalid inputs and error conditions\n- Potential concerns: ensure all input paths are validated before use\nSource: {chunk['filepath']}<|end|>\n<|finish|>"
|
| 631 |
+
turn3 = {"content": evidence}
|
| 632 |
+
|
| 633 |
+
return make_trace(query, [turn1, turn2, turn3])
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
def gen_design_patterns(chunks, rng) -> dict:
|
| 637 |
+
"""Category 12: Design pattern recognition."""
|
| 638 |
+
candidates = [c for c in chunks if c['type'] in ('class', 'struct', 'impl') and c['name'] not in ('unknown','struct','void','T') and len(c['code']) > 150]
|
| 639 |
+
if not candidates:
|
| 640 |
+
return None
|
| 641 |
+
chunk = rng.choice(candidates)
|
| 642 |
+
name = chunk['name']
|
| 643 |
+
|
| 644 |
+
query = rng.choice([
|
| 645 |
+
f"What design patterns are used in {name}? How do they structure the code?",
|
| 646 |
+
f"Identify the software design patterns present in {name}. What architectural decisions do they reflect?",
|
| 647 |
+
f"What patterns does {name} implement? How do they contribute to the code's structure and extensibility?",
|
| 648 |
+
f"Analyze {name} from a design pattern perspective. What patterns are in use and why?",
|
| 649 |
+
])
|
| 650 |
+
|
| 651 |
+
reasoning1 = f"<|reasoning|>The user wants to identify design patterns in {name}. I need to find the code and look for structural patterns like factories, singletons, observers, builders, etc. Let me search for it.<|end|>"
|
| 652 |
+
turn1 = {"content": f"{reasoning1}\n<|search|>{name}<|end|>", "result": chunk['code'][:500]}
|
| 653 |
+
|
| 654 |
+
keywords = extract_keywords_from_code(chunk['code'])
|
| 655 |
+
reasoning2 = f"<|reasoning|>Found {name}. Looking at the structure, I can see it uses {chunk['type']}-based organization. Let me search for related patterns to confirm my analysis.<|end|>"
|
| 656 |
+
turn2 = {"content": f"{reasoning2}\n<|search|>{keywords[0] if keywords else name}<|end|>", "result": ""}
|
| 657 |
+
|
| 658 |
+
evidence = f"<|reasoning|>I've analyzed the design patterns in {name}. The code structure reveals specific architectural choices. Let me compile the pattern analysis.<|end|>\n<|evidence|>Design patterns in `{name}`:\n- Primary pattern: {chunk['type']}-based abstraction with encapsulated state and behavior\n- The {chunk['language']} {chunk['type']} organizes related data and methods into a cohesive unit\n- This follows the object-oriented/encapsulation pattern common in {chunk['language']} codebases\n- The structure supports extensibility through its organized interface\nSource: {chunk['filepath']}<|end|>\n<|finish|>"
|
| 659 |
+
turn3 = {"content": evidence}
|
| 660 |
+
|
| 661 |
+
return make_trace(query, [turn1, turn2, turn3])
|
| 662 |
+
|
| 663 |
+
|
| 664 |
+
def gen_debugging_assistance(chunks, rng) -> dict:
|
| 665 |
+
"""Category 13: Debugging assistance."""
|
| 666 |
+
candidates = [c for c in chunks if c['type'] == 'function' and c['name'] not in ('unknown','struct','void','T') and len(c['code']) > 150]
|
| 667 |
+
if not candidates:
|
| 668 |
+
return None
|
| 669 |
+
chunk = rng.choice(candidates)
|
| 670 |
+
name = chunk['name']
|
| 671 |
+
error_patterns = extract_error_handling(chunk['code'])
|
| 672 |
+
returns = extract_return_statements(chunk['code'])
|
| 673 |
+
|
| 674 |
+
query = rng.choice([
|
| 675 |
+
f"If {name} is failing unexpectedly, what are the likely causes and how would I debug it?",
|
| 676 |
+
f"I'm getting an error from {name}. What are the common failure modes and how can I diagnose them?",
|
| 677 |
+
f"What could go wrong when calling {name}? Help me understand the failure scenarios and debugging steps.",
|
| 678 |
+
f"Debug {name}: what are the error conditions, what symptoms would they produce, and how can I trace them?",
|
| 679 |
+
])
|
| 680 |
+
|
| 681 |
+
reasoning1 = f"<|reasoning|>The user needs debugging help with {name}. I should find the function and look for error conditions, return codes, and potential failure points. Let me search for it.<|end|>"
|
| 682 |
+
turn1 = {"content": f"{reasoning1}\n<|search|>{name}<|end|>", "result": chunk['code'][:500]}
|
| 683 |
+
|
| 684 |
+
# Round 2: Search for error patterns
|
| 685 |
+
if error_patterns:
|
| 686 |
+
err_term = re.search(r'[A-Za-z_][A-Za-z0-9_]+', error_patterns[0])
|
| 687 |
+
search2 = err_term.group(0) if err_term else name
|
| 688 |
+
else:
|
| 689 |
+
search2 = name + " error"
|
| 690 |
+
reasoning2 = f"<|reasoning|>Found {name}. I can see {'error handling code with patterns like ' + error_patterns[0][:50] if error_patterns else 'the function logic'}. Let me search for the error pattern to understand common failure modes.<|end|>"
|
| 691 |
+
turn2 = {"content": f"{reasoning2}\n<|search|>{search2}<|end|>", "result": ""}
|
| 692 |
+
|
| 693 |
+
evidence = f"<|reasoning|>I've identified the failure modes and debugging approach for {name}. Let me compile the debugging guide.<|end|>\n<|evidence|>Debugging `{name}`:\n- Common failure modes: {', '.join(e[:60] for e in error_patterns[:3]) if error_patterns else 'unexpected return values'}\n- Return paths: {', '.join(r[:40] for r in returns[:3]) if returns else 'standard returns'}\n- Debugging steps: 1) Check input parameters are valid 2) Verify return values match expectations 3) Look for error conditions in the code\n- The function is {summarize_code_briefly(chunk['code'], name, chunk['language'], chunk['type'])}\nSource: {chunk['filepath']}<|end|>\n<|finish|>"
|
| 694 |
+
turn3 = {"content": evidence}
|
| 695 |
+
|
| 696 |
+
return make_trace(query, [turn1, turn2, turn3])
|
| 697 |
+
|
| 698 |
+
|
| 699 |
+
def gen_comparison_tradeoffs(chunks, rng) -> dict:
|
| 700 |
+
"""Category 14: Comparison and tradeoffs."""
|
| 701 |
+
by_lang = defaultdict(list)
|
| 702 |
+
for c in chunks:
|
| 703 |
+
if c['type'] in ('function', 'struct', 'class') and c['name'] not in ('unknown','struct','void','T'):
|
| 704 |
+
by_lang[c['language']].append(c)
|
| 705 |
+
|
| 706 |
+
lang = rng.choice([l for l, cs in by_lang.items() if len(cs) >= 2])
|
| 707 |
+
candidates = by_lang[lang]
|
| 708 |
+
if len(candidates) < 2:
|
| 709 |
+
return None
|
| 710 |
+
chunk1, chunk2 = rng.sample(candidates, 2)
|
| 711 |
+
name1, name2 = chunk1['name'], chunk2['name']
|
| 712 |
+
|
| 713 |
+
query = rng.choice([
|
| 714 |
+
f"How do {name1} and {name2} compare? What are the tradeoffs between them?",
|
| 715 |
+
f"Compare {name1} vs {name2}. What are the differences in approach, performance, and use cases?",
|
| 716 |
+
f"What are the tradeoffs between {name1} and {name2}? When would you choose one over the other?",
|
| 717 |
+
f"Contrast {name1} and {name2}. What are the key differences and when should each be used?",
|
| 718 |
+
])
|
| 719 |
+
|
| 720 |
+
reasoning1 = f"<|reasoning|>The user wants a comparison between {name1} and {name2}. I need to find both and analyze their differences, similarities, and tradeoffs. Let me start with {name1}.<|end|>"
|
| 721 |
+
turn1 = {"content": f"{reasoning1}\n<|search|>{name1}<|end|>", "result": chunk1['code'][:400]}
|
| 722 |
+
|
| 723 |
+
reasoning2 = f"<|reasoning|>Found {name1}. Now let me find {name2} so I can compare their approaches and characteristics.<|end|>"
|
| 724 |
+
turn2 = {"content": f"{reasoning2}\n<|search|>{name2}<|end|>", "result": chunk2['code'][:400]}
|
| 725 |
+
|
| 726 |
+
summary1 = summarize_code_briefly(chunk1['code'], name1, lang, chunk1['type'])
|
| 727 |
+
summary2 = summarize_code_briefly(chunk2['code'], name2, lang, chunk2['type'])
|
| 728 |
+
evidence = f"<|reasoning|>I now have both components. {name1} is {summary1}, while {name2} is {summary2}. They serve different purposes within the {lang} codebase. Let me compile the comparison.<|end|>\n<|evidence|>Comparison: `{name1}` vs `{name2}`\n{name1}: {summary1}. Located in {chunk1['filepath']}.\n{name2}: {summary2}. Located in {chunk2['filepath']}.\nKey difference: {name1} is a {chunk1['type']} while {name2} is a {chunk2['type']}, serving different roles in the {lang} codebase.\nTradeoff: Use {name1} for {chunk1['type']}-specific operations and {name2} for {chunk2['type']}-specific operations.<|end|>\n<|finish|>"
|
| 729 |
+
turn3 = {"content": evidence}
|
| 730 |
+
|
| 731 |
+
return make_trace(query, [turn1, turn2, turn3])
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
def gen_change_impact(chunks, rng) -> dict:
|
| 735 |
+
"""Category 15: Change impact analysis."""
|
| 736 |
+
candidates = [c for c in chunks if c['type'] in ('function', 'class', 'struct') and c['name'] not in ('unknown','struct','void','T') and len(c['name']) > 3]
|
| 737 |
+
if not candidates:
|
| 738 |
+
return None
|
| 739 |
+
chunk = rng.choice(candidates)
|
| 740 |
+
name = chunk['name']
|
| 741 |
+
|
| 742 |
+
# Find referencing chunks
|
| 743 |
+
referencing = [c for c in chunks if name in c['code'] and c['name'] != name]
|
| 744 |
+
|
| 745 |
+
query = rng.choice([
|
| 746 |
+
f"If I need to modify {name}, what files and functions would be affected? Map the blast radius.",
|
| 747 |
+
f"What is the impact of changing {name}? What depends on it and what could break?",
|
| 748 |
+
f"I want to change the behavior of {name}. What else in the codebase would need to be updated?",
|
| 749 |
+
f"Analyze the change impact of modifying {name}. What are all the affected components?",
|
| 750 |
+
])
|
| 751 |
+
|
| 752 |
+
reasoning1 = f"<|reasoning|>The user wants to understand the impact of changing {name}. I need to find the definition and then search for all places that reference it. Let me start with the definition.<|end|>"
|
| 753 |
+
turn1 = {"content": f"{reasoning1}\n<|search|>{name}<|end|>", "result": chunk['code'][:400]}
|
| 754 |
+
|
| 755 |
+
# Round 2: Search for usage
|
| 756 |
+
if referencing:
|
| 757 |
+
ref_chunk = referencing[0]
|
| 758 |
+
reasoning2 = f"<|reasoning|>Found {name}. Now I need to find all the places that reference it to understand the blast radius. I found at least one reference in {ref_chunk['name']}. Let me search for more.<|end|>"
|
| 759 |
+
turn2 = {"content": f"{reasoning2}\n<|search|>{name} {ref_chunk['name']}<|end|>", "result": ref_chunk['code'][:300]}
|
| 760 |
+
else:
|
| 761 |
+
reasoning2 = f"<|reasoning|>Found {name}. Let me search for references to understand the impact radius.<|end|>"
|
| 762 |
+
turn2 = {"content": f"{reasoning2}\n<|search|>{name} usage<|end|>", "result": ""}
|
| 763 |
+
|
| 764 |
+
# Round 3: Evidence
|
| 765 |
+
impact_count = len(referencing)
|
| 766 |
+
affected_files = list(set(c['filepath'] for c in referencing[:5]))
|
| 767 |
+
evidence = f"<|reasoning|>I've mapped the change impact. {name} is referenced in approximately {impact_count} other locations. Changing it would affect multiple components. Let me compile the impact analysis.<|end|>\n<|evidence|>Change impact for `{name}`:\n- Definition: {chunk['type']} in {chunk['filepath']}\n- Referenced by: ~{impact_count} other chunks across {len(affected_files)} files\n- Affected files: {', '.join(affected_files[:3])}\n- Risk level: {'HIGH' if impact_count > 5 else 'MEDIUM' if impact_count > 2 else 'LOW'} — {impact_count} dependent components would need review\n- Recommendation: Update all callers and verify return value compatibility<|end|>\n<|finish|>"
|
| 768 |
+
turn3 = {"content": evidence}
|
| 769 |
+
|
| 770 |
+
return make_trace(query, [turn1, turn2, turn3])
|
| 771 |
+
|
| 772 |
+
|
| 773 |
+
# ─── Multi-Round Complex Traces (4-5 rounds) ──────────────────────────────────
|
| 774 |
+
|
| 775 |
+
def gen_complex_multi_step(chunks, rng) -> dict:
|
| 776 |
+
"""Generate a complex 4-5 round trace with multiple searches and refinements."""
|
| 777 |
+
by_lang = defaultdict(list)
|
| 778 |
+
for c in chunks:
|
| 779 |
+
if c['type'] in ('function', 'struct', 'class') and c['name'] not in ('unknown','struct','void','T') and len(c['code']) > 100:
|
| 780 |
+
by_lang[c['language']].append(c)
|
| 781 |
+
|
| 782 |
+
lang = rng.choice([l for l, cs in by_lang.items() if len(cs) >= 3])
|
| 783 |
+
candidates = by_lang[lang]
|
| 784 |
+
chunk1 = rng.choice(candidates)
|
| 785 |
+
name1 = chunk1['name']
|
| 786 |
+
|
| 787 |
+
# Find related chunks
|
| 788 |
+
related = [c for c in candidates if name1 in c['code'] or any(k in c['code'] for k in extract_keywords_from_code(chunk1['code'])[:3])]
|
| 789 |
+
related = [c for c in related if c['name'] != name1][:2]
|
| 790 |
+
|
| 791 |
+
if not related:
|
| 792 |
+
return None
|
| 793 |
+
|
| 794 |
+
chunk2, chunk3 = related[0], related[-1] if len(related) > 1 else related[0]
|
| 795 |
+
name2, name3 = chunk2['name'], chunk3['name']
|
| 796 |
+
|
| 797 |
+
query = rng.choice([
|
| 798 |
+
f"I need a comprehensive analysis of how {name1} works, including its relationship with {name2} and the broader system context. Walk me through the full picture.",
|
| 799 |
+
f"Give me a deep analysis of {name1}, its interaction with {name2}, and how they fit into the larger architecture. I need to understand the complete workflow.",
|
| 800 |
+
f"Analyze the {name1} subsystem: how it works, how it connects to {name2}, and what the overall data flow looks like. Be thorough.",
|
| 801 |
+
f"I need to understand {name1} end-to-end: its implementation, its relationship to {name2}, and its role in the system. Provide a complete breakdown.",
|
| 802 |
+
])
|
| 803 |
+
|
| 804 |
+
# Round 1: Initial search
|
| 805 |
+
reasoning1 = f"<|reasoning|>This is a complex multi-part query. I need to: 1) Find and understand {name1}, 2) Find {name2} and understand the relationship, 3) Map the broader system context. Let me start with {name1}.<|end|>"
|
| 806 |
+
turn1 = {"content": f"{reasoning1}\n<|search|>{name1}<|end|>", "result": chunk1['code'][:500]}
|
| 807 |
+
|
| 808 |
+
# Round 2: Search for related component
|
| 809 |
+
reasoning2 = f"<|reasoning|>Found {name1}. It's {summarize_code_briefly(chunk1['code'], name1, lang, chunk1['type'])}. Now I need to find {name2} to understand how they interact. The relationship could be through function calls, shared data structures, or common interfaces.<|end|>"
|
| 810 |
+
turn2 = {"content": f"{reasoning2}\n<|search|>{name2}<|end|>", "result": chunk2['code'][:400]}
|
| 811 |
+
|
| 812 |
+
# Round 3: Search for broader context
|
| 813 |
+
keywords = extract_keywords_from_code(chunk1['code'])
|
| 814 |
+
context_search = keywords[0] if keywords else name1
|
| 815 |
+
reasoning3 = f"<|reasoning|>I now have both {name1} and {name2}. {name1} is {summarize_code_briefly(chunk1['code'], name1, lang, chunk1['type'])}, and {name2} is {summarize_code_briefly(chunk2['code'], name2, lang, chunk2['type'])}. Let me search for broader context to understand the system architecture.<|end|>"
|
| 816 |
+
turn3 = {"content": f"{reasoning3}\n<|search|>{context_search}<|end|>", "result": chunk3['code'][:300] if chunk3 != chunk2 else ""}
|
| 817 |
+
|
| 818 |
+
# Round 4: Evidence
|
| 819 |
+
evidence = f"<|reasoning|>I now have a comprehensive picture. {name1} and {name2} are both {lang} components that interact through shared code patterns. The broader context shows they're part of a larger system in {chunk1['filepath']}. Let me compile the complete analysis.<|end|>\n<|evidence|>Comprehensive analysis:\n1. `{name1}`: {summarize_code_briefly(chunk1['code'], name1, lang, chunk1['type'])}. Source: {chunk1['filepath']}\n2. `{name2}`: {summarize_code_briefly(chunk2['code'], name2, lang, chunk2['type'])}. Source: {chunk2['filepath']}\n3. Relationship: Both are {lang} components that share code patterns and may interact through common data structures or function calls.\n4. System context: They are part of the {chunk1['filepath'].split('/')[-1]} module, which contains multiple related components.\n5. Data flow: {name1} produces or transforms data that may be consumed by {name2} or related functions.<|end|>\n<|finish|>"
|
| 820 |
+
turn4 = {"content": evidence}
|
| 821 |
+
|
| 822 |
+
return make_trace(query, [turn1, turn2, turn3, turn4])
|
| 823 |
+
|
| 824 |
+
|
| 825 |
+
def gen_refinement_search(chunks, rng) -> dict:
|
| 826 |
+
"""Generate a trace where the initial search fails and needs refinement."""
|
| 827 |
+
candidates = [c for c in chunks if c['type'] == 'function' and c['name'] not in ('unknown','struct','void','T') and len(c['code']) > 150]
|
| 828 |
+
if not candidates:
|
| 829 |
+
return None
|
| 830 |
+
chunk = rng.choice(candidates)
|
| 831 |
+
name = chunk['name']
|
| 832 |
+
keywords = extract_keywords_from_code(chunk['code'])
|
| 833 |
+
|
| 834 |
+
# Use a vague initial search term that would need refinement
|
| 835 |
+
vague_term = keywords[0] if keywords else name[:3]
|
| 836 |
+
|
| 837 |
+
query = rng.choice([
|
| 838 |
+
f"Find and explain {name}. I'm not sure of the exact name, but it's related to {vague_term}.",
|
| 839 |
+
f"I need to understand the function that handles {vague_term} in the codebase. Can you find it and explain what it does?",
|
| 840 |
+
f"There should be a function related to {vague_term}. Find it, analyze it, and tell me what it does.",
|
| 841 |
+
f"Search for code related to {vague_term} and find the main function. Explain its implementation.",
|
| 842 |
+
])
|
| 843 |
+
|
| 844 |
+
# Round 1: Vague search
|
| 845 |
+
reasoning1 = f"<|reasoning|>The user's query is somewhat vague — they're looking for something related to {vague_term}. Let me start with a broad search and then refine based on the results.<|end|>"
|
| 846 |
+
turn1 = {"content": f"{reasoning1}\n<|search|>{vague_term}<|end|>", "result": chunk['code'][:300]}
|
| 847 |
+
|
| 848 |
+
# Round 2: Refined search
|
| 849 |
+
reasoning2 = f"<|reasoning|>The initial search returned some results, but I need to be more specific. Looking at the results, I can see references to {name}. Let me search for that specifically to get the full implementation.<|end|>"
|
| 850 |
+
turn2 = {"content": f"{reasoning2}\n<|search|>{name}<|end|>", "result": chunk['code'][:500]}
|
| 851 |
+
|
| 852 |
+
# Round 3: Evidence
|
| 853 |
+
summary = summarize_code_briefly(chunk['code'], name, chunk['language'], chunk['type'])
|
| 854 |
+
calls = extract_function_calls(chunk['code'])
|
| 855 |
+
returns = extract_return_statements(chunk['code'])
|
| 856 |
+
|
| 857 |
+
evidence = f"<|reasoning|>After refining the search, I found {name}. It's {summary}. The function {'calls ' + ', '.join(calls[:3]) if calls else 'performs internal operations'} and {'returns ' + returns[0] if returns else 'has standard return paths'}. Let me compile the evidence.<|end|>\n<|evidence|>Found `{name}` through search refinement:\n- Initial search for '{vague_term}' returned partial matches\n- Refined search for '{name}' found the full implementation\n- {name} is {summary}\n- Key operations: {', '.join(calls[:4]) if calls else 'internal processing'}\n- Return paths: {'; '.join(returns[:2]) if returns else 'standard'}\n- Source: {chunk['filepath']}<|end|>\n<|finish|>"
|
| 858 |
+
turn3 = {"content": evidence}
|
| 859 |
+
|
| 860 |
+
return make_trace(query, [turn1, turn2, turn3])
|
| 861 |
+
|
| 862 |
+
|
| 863 |
+
# ─── Category Registry ────────────────────────────────────────────────────────
|
| 864 |
+
|
| 865 |
+
CATEGORIES = {
|
| 866 |
+
"implementation": ("Implementation Deep Dive", gen_implementation_deep_dive),
|
| 867 |
+
"cross_file": ("Cross-File Data Flow Tracing", gen_cross_file_tracing),
|
| 868 |
+
"architecture": ("Architecture & Component Mapping", gen_architecture_mapping),
|
| 869 |
+
"usage": ("Usage Pattern Analysis", gen_usage_analysis),
|
| 870 |
+
"error_handling": ("Error Handling & Failure Paths", gen_error_handling),
|
| 871 |
+
"api_contract": ("API Contract & Interface", gen_api_contract),
|
| 872 |
+
"dependency": ("Dependency Graph Mapping", gen_dependency_graph),
|
| 873 |
+
"data_structure": ("Data Structure Analysis", gen_data_structure_analysis),
|
| 874 |
+
"configuration": ("Configuration & Parameters", gen_configuration_params),
|
| 875 |
+
"performance": ("Performance-Critical Paths", gen_performance_paths),
|
| 876 |
+
"security": ("Security & Validation", gen_security_validation),
|
| 877 |
+
"design_patterns": ("Design Pattern Recognition", gen_design_patterns),
|
| 878 |
+
"debugging": ("Debugging Assistance", gen_debugging_assistance),
|
| 879 |
+
"comparison": ("Comparison & Tradeoffs", gen_comparison_tradeoffs),
|
| 880 |
+
"change_impact": ("Change Impact Analysis", gen_change_impact),
|
| 881 |
+
"complex_multi": ("Complex Multi-Step Analysis", gen_complex_multi_step),
|
| 882 |
+
"refinement": ("Search Refinement", gen_refinement_search),
|
| 883 |
+
}
|
| 884 |
+
|
| 885 |
+
# ─── Main Generation Loop ─────────────────────────────────────────────────────
|
| 886 |
+
|
| 887 |
+
def generate_traces(category: str, count: int, seed: int, chunks: list) -> list[dict]:
|
| 888 |
+
"""Generate traces for a specific category."""
|
| 889 |
+
if category not in CATEGORIES:
|
| 890 |
+
print(f"Unknown category: {category}")
|
| 891 |
+
return []
|
| 892 |
+
|
| 893 |
+
cat_name, gen_func = CATEGORIES[category]
|
| 894 |
+
rng = random.Random(seed)
|
| 895 |
+
traces = []
|
| 896 |
+
attempts = 0
|
| 897 |
+
max_attempts = count * 5 # Allow retries for None returns
|
| 898 |
+
|
| 899 |
+
while len(traces) < count and attempts < max_attempts:
|
| 900 |
+
attempts += 1
|
| 901 |
+
try:
|
| 902 |
+
trace = gen_func(chunks, rng)
|
| 903 |
+
if trace is not None and trace.get("query") and len(trace.get("trace", [])) >= 4:
|
| 904 |
+
traces.append(trace)
|
| 905 |
+
except Exception as e:
|
| 906 |
+
# Silently skip failed generations
|
| 907 |
+
pass
|
| 908 |
+
|
| 909 |
+
return traces
|
| 910 |
+
|
| 911 |
+
|
| 912 |
+
def main():
|
| 913 |
+
parser = argparse.ArgumentParser(description="Generate high-quality SFT traces")
|
| 914 |
+
parser.add_argument("--category", type=str, default="all",
|
| 915 |
+
help="Category name or 'all'")
|
| 916 |
+
parser.add_argument("--count", type=int, default=500,
|
| 917 |
+
help="Number of traces per category")
|
| 918 |
+
parser.add_argument("--seed", type=int, default=42,
|
| 919 |
+
help="Random seed")
|
| 920 |
+
parser.add_argument("--output", type=str, default=OUTPUT_DIR,
|
| 921 |
+
help="Output directory")
|
| 922 |
+
args = parser.parse_args()
|
| 923 |
+
|
| 924 |
+
# Load chunks
|
| 925 |
+
print(f"Loading chunks from {CHUNKS_PATH}...")
|
| 926 |
+
chunks = [json.loads(l) for l in open(CHUNKS_PATH, encoding="utf-8")]
|
| 927 |
+
print(f" Loaded {len(chunks):,} chunks")
|
| 928 |
+
|
| 929 |
+
# Create output directory
|
| 930 |
+
os.makedirs(args.output, exist_ok=True)
|
| 931 |
+
|
| 932 |
+
# Determine categories
|
| 933 |
+
if args.category == "all":
|
| 934 |
+
cats = list(CATEGORIES.keys())
|
| 935 |
+
else:
|
| 936 |
+
cats = [args.category]
|
| 937 |
+
|
| 938 |
+
total_traces = 0
|
| 939 |
+
for i, cat in enumerate(cats):
|
| 940 |
+
cat_name, _ = CATEGORIES[cat]
|
| 941 |
+
seed = args.seed + i * 1000
|
| 942 |
+
print(f"\n[{i+1}/{len(cats)}] Generating {args.count} traces for '{cat_name}' (seed={seed})...")
|
| 943 |
+
|
| 944 |
+
traces = generate_traces(cat, args.count, seed, chunks)
|
| 945 |
+
|
| 946 |
+
output_file = os.path.join(args.output, f"traces_{cat}.jsonl")
|
| 947 |
+
with open(output_file, "w", encoding="utf-8") as f:
|
| 948 |
+
for trace in traces:
|
| 949 |
+
f.write(json.dumps(trace, ensure_ascii=False) + "\n")
|
| 950 |
+
|
| 951 |
+
print(f" Generated {len(traces)} traces → {output_file}")
|
| 952 |
+
total_traces += len(traces)
|
| 953 |
+
|
| 954 |
+
print(f"\nTotal: {total_traces} traces generated across {len(cats)} categories")
|
| 955 |
+
|
| 956 |
+
|
| 957 |
+
if __name__ == "__main__":
|
| 958 |
+
main()
|
src/generate_traces_v2.py
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Diverse search-agent trace generator v2.
|
| 3 |
+
|
| 4 |
+
Generates 20-50k unique, high-quality search agent traces using real code
|
| 5 |
+
chunks from the deduplicated corpus. Each trace follows the Toast 1-style
|
| 6 |
+
agentic search loop: reasoning -> search -> results -> analysis -> evidence -> finish.
|
| 7 |
+
|
| 8 |
+
Improvements over generate_traces.py:
|
| 9 |
+
- Combinatorial query phrasings (hundreds of unique templates)
|
| 10 |
+
- Smarter evidence extraction (analyzes actual code structure)
|
| 11 |
+
- More trace types: simple, noise, multi-step, decomposition,
|
| 12 |
+
not-found, struct inspection, behavior analysis, cross-file,
|
| 13 |
+
usage tracking, error-handling patterns
|
| 14 |
+
- Trace-level dedup (no two traces share the same query+search combo)
|
| 15 |
+
- Varied reasoning text (not random.choice from 5 options)
|
| 16 |
+
- Realistic evidence packages that extract key facts, not dump code
|
| 17 |
+
|
| 18 |
+
Output: data/traces/sft_traces_v2.jsonl
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import hashlib
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
import random
|
| 25 |
+
import re
|
| 26 |
+
|
| 27 |
+
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 28 |
+
DATA_DIR = os.path.join(PROJECT_DIR, "data")
|
| 29 |
+
CHUNKS_PATH = os.path.join(DATA_DIR, "chunks", "chunks.jsonl")
|
| 30 |
+
TRACES_DIR = os.path.join(DATA_DIR, "traces")
|
| 31 |
+
TRACES_PATH = os.path.join(TRACES_DIR, "sft_traces_v2.jsonl")
|
| 32 |
+
|
| 33 |
+
SYSTEM_PROMPT = (
|
| 34 |
+
"You are a code search agent. Given a query from a reasoning model, "
|
| 35 |
+
"decompose it into subqueries, search the codebase, inspect results, "
|
| 36 |
+
"and return curated evidence. Use <|search|> to issue searches, "
|
| 37 |
+
"<|reasoning|> to analyze, and <|evidence|> to return findings. "
|
| 38 |
+
"Be concise. Extract only the relevant facts. End with <|finish|>."
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
random.seed(2026)
|
| 42 |
+
|
| 43 |
+
# ─── Query phrasing components (combinatorial) ───────────────────────────────
|
| 44 |
+
QUERY_VERBS = ["What does", "How does", "Where is", "Explain", "Find",
|
| 45 |
+
"Describe", "What is", "How is", "What are", "Show me",
|
| 46 |
+
"Locate", "Identify", "Detail"]
|
| 47 |
+
QUERY_ACTIONS = {
|
| 48 |
+
"function": ["do", "work", "behave", "function", "operate", "process",
|
| 49 |
+
"handle", "compute", "return", "accept"],
|
| 50 |
+
"struct": ["contain", "have", "store", "represent", "look like",
|
| 51 |
+
"be structured", "be defined"],
|
| 52 |
+
"class": ["do", "work", "be structured", "be organized", "be designed",
|
| 53 |
+
"function", "be implemented"],
|
| 54 |
+
"enum": ["have", "contain", "represent", "be defined", "include"],
|
| 55 |
+
"macro": ["do", "define", "expand to", "be used for"],
|
| 56 |
+
"trait": ["define", "require", "provide", "specify"],
|
| 57 |
+
"interface": ["define", "require", "specify", "declare"],
|
| 58 |
+
"impl": ["implement", "provide", "define"],
|
| 59 |
+
"module": ["contain", "provide", "export", "define"],
|
| 60 |
+
"typedef": ["define", "represent", "be"],
|
| 61 |
+
"block": ["do", "work", "be used for"],
|
| 62 |
+
}
|
| 63 |
+
QUERY_SUFFIXES = ["?", "in the codebase?", "in this project?",
|
| 64 |
+
"? Explain briefly.", "? Give me the details.",
|
| 65 |
+
"", "? I need specifics.", "? Summarize the key points."]
|
| 66 |
+
|
| 67 |
+
# ─── Reasoning components ────────────────────────────────────────────────────
|
| 68 |
+
REASONING_OPENERS = [
|
| 69 |
+
"I need to find {desc}.", "Looking for {desc}.",
|
| 70 |
+
"The query is about {desc}.", "This requires locating {desc}.",
|
| 71 |
+
"Let me search for {desc}.", "I should find {desc} first.",
|
| 72 |
+
"Starting by searching for {desc}.", "The user wants to know about {desc}.",
|
| 73 |
+
"I'll look up {desc}.", "First, I need to find {desc}.",
|
| 74 |
+
]
|
| 75 |
+
REASONING_PLANS = [
|
| 76 |
+
"Let me search using '{term}'.",
|
| 77 |
+
"I'll query the index for '{term}'.",
|
| 78 |
+
"Searching for '{term}' should find it.",
|
| 79 |
+
"Let me issue a search for '{term}'.",
|
| 80 |
+
"I'll search with the term '{term}'.",
|
| 81 |
+
"A search for '{term}' should surface the relevant code.",
|
| 82 |
+
"Let me look for '{term}' in the codebase.",
|
| 83 |
+
]
|
| 84 |
+
ANALYSIS_FOUND = [
|
| 85 |
+
"Found the relevant code.", "This result contains what I need.",
|
| 86 |
+
"This is the right code.", "I found the target.",
|
| 87 |
+
"This matches the query.", "The search returned the relevant implementation.",
|
| 88 |
+
"This is exactly what I was looking for.",
|
| 89 |
+
]
|
| 90 |
+
ANALYSIS_NOISE = [
|
| 91 |
+
"The first result is relevant; the others are not directly related.",
|
| 92 |
+
"Result 1 matches. The remaining results appear to be unrelated code.",
|
| 93 |
+
"I found the target in the first result. Ignoring the noise.",
|
| 94 |
+
"The relevant code is in the first result; the rest don't match.",
|
| 95 |
+
"Only the first result is pertinent to this query.",
|
| 96 |
+
]
|
| 97 |
+
ANALYSIS_REFINE = [
|
| 98 |
+
"Found the definition. Now let me find where it's used.",
|
| 99 |
+
"I have the implementation. Let me search for usages.",
|
| 100 |
+
"Got the definition. Let me get more context with another search.",
|
| 101 |
+
"Found it. Let me do one more search for completeness.",
|
| 102 |
+
"Now I need to see how this is called. Let me search for that.",
|
| 103 |
+
]
|
| 104 |
+
NOT_FOUND_REASONING = [
|
| 105 |
+
"No results found for '{term}'. Let me try a broader search.",
|
| 106 |
+
"The search returned nothing for '{term}'. Let me try different terms.",
|
| 107 |
+
"No matches for '{term}'. Let me verify with another query.",
|
| 108 |
+
"Empty results for '{term}'. Let me rephrase the search.",
|
| 109 |
+
]
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def generate_query(chunk: dict) -> str:
|
| 113 |
+
"""Generate a diverse query for a chunk."""
|
| 114 |
+
name = chunk["name"]
|
| 115 |
+
typ = chunk["type"]
|
| 116 |
+
if name == "unknown" or name == "block":
|
| 117 |
+
# Use a key identifier from the code
|
| 118 |
+
ids = re.findall(r"\b(ngx_\w+|def\s+\w+|fn\s+\w+|struct\s+\w+|class\s+\w+)\b", chunk["code"])
|
| 119 |
+
if ids:
|
| 120 |
+
name = ids[0].replace("def ", "").replace("fn ", "").replace("struct ", "").replace("class ", "")
|
| 121 |
+
else:
|
| 122 |
+
words = re.findall(r"\b[a-z_]{4,}\b", chunk["code"])
|
| 123 |
+
name = words[0] if words else "this code"
|
| 124 |
+
|
| 125 |
+
verb = random.choice(QUERY_VERBS)
|
| 126 |
+
actions = QUERY_ACTIONS.get(typ, QUERY_ACTIONS["block"])
|
| 127 |
+
action = random.choice(actions)
|
| 128 |
+
suffix = random.choice(QUERY_SUFFIXES)
|
| 129 |
+
|
| 130 |
+
if verb in ("Where is", "Locate", "Find", "Show me", "Identify"):
|
| 131 |
+
return f"{verb} {name} {action}{suffix}".replace(f" {action}", "")
|
| 132 |
+
return f"{verb} {name} {action}{suffix}"
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def generate_description(chunk: dict) -> str:
|
| 136 |
+
name = chunk["name"]
|
| 137 |
+
typ = chunk["type"]
|
| 138 |
+
lang = chunk["language"]
|
| 139 |
+
type_words = {
|
| 140 |
+
"function": f"the {name} function", "struct": f"the {name} structure",
|
| 141 |
+
"class": f"the {name} class", "enum": f"the {name} enum",
|
| 142 |
+
"macro": f"the {name} macro", "trait": f"the {name} trait",
|
| 143 |
+
"interface": f"the {name} interface", "impl": f"the {name} impl block",
|
| 144 |
+
"module": f"the {name} module", "typedef": f"the {name} typedef",
|
| 145 |
+
"block": f"code related to {name}",
|
| 146 |
+
}
|
| 147 |
+
return type_words.get(typ, f"the {name} definition")
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def generate_search_term(chunk: dict) -> str:
|
| 151 |
+
name = chunk["name"]
|
| 152 |
+
if name != "unknown" and name != "block":
|
| 153 |
+
return name
|
| 154 |
+
code = chunk["code"]
|
| 155 |
+
identifiers = re.findall(r"\b(ngx_\w+|def\s+\w+|fn\s+\w+|struct\s+\w+|class\s+\w+)\b", code)
|
| 156 |
+
if identifiers:
|
| 157 |
+
return identifiers[0].replace("def ", "").replace("fn ", "").replace("struct ", "").replace("class ", "")
|
| 158 |
+
words = re.findall(r"\b[a-z_]{4,}\b", code)
|
| 159 |
+
return words[0] if words else "code"
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ─── Evidence extraction (smarter) ───────────────────────────────────────────
|
| 163 |
+
|
| 164 |
+
def extract_function_info(code: str, name: str) -> dict:
|
| 165 |
+
info = {"name": name, "params": [], "returns": "", "key_lines": [], "calls": []}
|
| 166 |
+
sig_match = re.search(r"(?:def|fn|func|function)\s+" + re.escape(name) + r"\s*\(([^)]*)\)", code)
|
| 167 |
+
if sig_match:
|
| 168 |
+
params_raw = sig_match.group(1).strip()
|
| 169 |
+
if params_raw:
|
| 170 |
+
info["params"] = [p.strip() for p in params_raw.split(",") if p.strip()][:6]
|
| 171 |
+
ret_match = re.search(r"->\s*([^{]+)", code)
|
| 172 |
+
if ret_match:
|
| 173 |
+
info["returns"] = ret_match.group(1).strip()
|
| 174 |
+
calls = re.findall(r"\b(\w+)\s*\(", code)
|
| 175 |
+
info["calls"] = [c for c in calls if c != name and c not in ("if", "for", "while", "return", "print")][:5]
|
| 176 |
+
for line in code.split("\n"):
|
| 177 |
+
s = line.strip()
|
| 178 |
+
if not s or s.startswith("//") or s.startswith("#"):
|
| 179 |
+
continue
|
| 180 |
+
if any(kw in s for kw in ["return ", "if ", "for ", "while ", "raise ", "throw ", "Err(", "Ok(", "assert", "break", "continue"]):
|
| 181 |
+
info["key_lines"].append(s)
|
| 182 |
+
info["key_lines"] = info["key_lines"][:4]
|
| 183 |
+
return info
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def extract_struct_info(code: str, name: str) -> dict:
|
| 187 |
+
fields = []
|
| 188 |
+
for pat in [re.compile(r"^\s+(\w+)\s+(\w+);", re.M),
|
| 189 |
+
re.compile(r"^\s+(\w+):\s+([^,;]+)", re.M),
|
| 190 |
+
re.compile(r"^\s+(self\.)?(\w+)\s*[:=]", re.M),
|
| 191 |
+
re.compile(r"^\s+(\w+)\s+\w+", re.M)]:
|
| 192 |
+
for m in pat.findall(code):
|
| 193 |
+
fn = m[-1] if isinstance(m, tuple) else m
|
| 194 |
+
if fn and fn not in ("self", "pub", "fn", "def", "class", "struct", "const", "var"):
|
| 195 |
+
fields.append(fn)
|
| 196 |
+
return {"name": name, "fields": fields[:12]}
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def generate_evidence(chunk: dict) -> str:
|
| 200 |
+
code = chunk["code"]
|
| 201 |
+
name = chunk["name"]
|
| 202 |
+
typ = chunk["type"]
|
| 203 |
+
lang = chunk["language"]
|
| 204 |
+
filepath = chunk["filepath"]
|
| 205 |
+
repo = chunk.get("repo", "")
|
| 206 |
+
|
| 207 |
+
if typ == "function":
|
| 208 |
+
info = extract_function_info(code, name)
|
| 209 |
+
parts = [f"`{name}` is a {lang} function."]
|
| 210 |
+
if info["params"]:
|
| 211 |
+
params_str = ", ".join(info["params"])
|
| 212 |
+
parts.append(f"- Parameters: {params_str}")
|
| 213 |
+
if info["returns"]:
|
| 214 |
+
parts.append(f"- Returns: {info['returns']}")
|
| 215 |
+
if info["key_lines"]:
|
| 216 |
+
parts.append("- Key behavior:")
|
| 217 |
+
for line in info["key_lines"][:3]:
|
| 218 |
+
parts.append(f" - `{line}`")
|
| 219 |
+
if info["calls"]:
|
| 220 |
+
parts.append(f"- Calls: {', '.join(info['calls'])}")
|
| 221 |
+
parts.append(f"- Source: {filepath}")
|
| 222 |
+
return "\n".join(parts)
|
| 223 |
+
|
| 224 |
+
elif typ == "struct":
|
| 225 |
+
info = extract_struct_info(code, name)
|
| 226 |
+
parts = [f"`{name}` is a {lang} structure with fields:"]
|
| 227 |
+
for field in info["fields"]:
|
| 228 |
+
parts.append(f"- `{field}`")
|
| 229 |
+
parts.append(f"- Source: {filepath}")
|
| 230 |
+
return "\n".join(parts)
|
| 231 |
+
|
| 232 |
+
elif typ == "class":
|
| 233 |
+
methods = re.findall(r"(?:def |fn |func |function )\s*(\w+)", code)
|
| 234 |
+
parts = [f"`{name}` is a {lang} class."]
|
| 235 |
+
if methods:
|
| 236 |
+
parts.append(f"- Methods: {', '.join(methods[:8])}")
|
| 237 |
+
base = re.search(r"class\s+\w+\s*(?:\(|:\s*|extends\s+|<\s*)(\w+)", code)
|
| 238 |
+
if base:
|
| 239 |
+
parts.append(f"- Inherits from: {base.group(1)}")
|
| 240 |
+
parts.append(f"- Source: {filepath}")
|
| 241 |
+
return "\n".join(parts)
|
| 242 |
+
|
| 243 |
+
elif typ == "enum":
|
| 244 |
+
variants = re.findall(r"^\s+(\w+)[,\s]*$", code, re.M)
|
| 245 |
+
parts = [f"`{name}` is a {lang} enum with variants:"]
|
| 246 |
+
for v in variants[:10]:
|
| 247 |
+
parts.append(f"- `{v}`")
|
| 248 |
+
parts.append(f"- Source: {filepath}")
|
| 249 |
+
return "\n".join(parts)
|
| 250 |
+
|
| 251 |
+
elif typ == "macro":
|
| 252 |
+
parts = [f"`{name}` is a {lang} macro/preprocessor definition:"]
|
| 253 |
+
for line in code.strip().split("\n")[:5]:
|
| 254 |
+
parts.append(f" `{line}`")
|
| 255 |
+
parts.append(f"- Source: {filepath}")
|
| 256 |
+
return "\n".join(parts)
|
| 257 |
+
|
| 258 |
+
elif typ in ("trait", "interface"):
|
| 259 |
+
methods = re.findall(r"(?:fn |def |function )\s*(\w+)", code)
|
| 260 |
+
parts = [f"`{name}` is a {lang} {typ}."]
|
| 261 |
+
if methods:
|
| 262 |
+
parts.append(f"- Required methods: {', '.join(methods[:8])}")
|
| 263 |
+
parts.append(f"- Source: {filepath}")
|
| 264 |
+
return "\n".join(parts)
|
| 265 |
+
|
| 266 |
+
else:
|
| 267 |
+
parts = [f"Found code related to `{name}` ({lang}):"]
|
| 268 |
+
for line in code.strip().split("\n")[:8]:
|
| 269 |
+
parts.append(f" `{line}`")
|
| 270 |
+
parts.append(f"- Source: {filepath}")
|
| 271 |
+
return "\n".join(parts)
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
# ─── Trace builders ──────────────────────────────────────────────────────────
|
| 275 |
+
|
| 276 |
+
def make_reasoning(desc: str, term: str) -> str:
|
| 277 |
+
opener = random.choice(REASONING_OPENERS).format(desc=desc)
|
| 278 |
+
plan = random.choice(REASONING_PLANS).format(term=term)
|
| 279 |
+
return f"{opener} {plan}"
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def trace_key(query: str, searches: list[str]) -> str:
|
| 283 |
+
"""Dedup key for a trace."""
|
| 284 |
+
return hashlib.md5((query + "|" + "|".join(searches)).encode()).hexdigest()
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def make_trace_simple(chunk: dict) -> dict:
|
| 288 |
+
query = generate_query(chunk)
|
| 289 |
+
term = generate_search_term(chunk)
|
| 290 |
+
desc = generate_description(chunk)
|
| 291 |
+
evidence = generate_evidence(chunk)
|
| 292 |
+
reasoning1 = make_reasoning(desc, term)
|
| 293 |
+
analysis = random.choice(ANALYSIS_FOUND)
|
| 294 |
+
return {
|
| 295 |
+
"query": query,
|
| 296 |
+
"trace": [
|
| 297 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 298 |
+
{"role": "user", "content": query},
|
| 299 |
+
{"role": "assistant", "content": f"<|reasoning|>{reasoning1}<|end|>\n<|search|>{term}<|end|>"},
|
| 300 |
+
{"role": "result", "content": chunk["code"]},
|
| 301 |
+
{"role": "assistant", "content": f"<|reasoning|>{analysis} {desc.replace('the ', 'This is ', 1)}.<|end|>\n<|evidence|>{evidence}<|end|>\n<|finish|>"},
|
| 302 |
+
],
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def make_trace_with_noise(chunk: dict, noise_chunks: list) -> dict:
|
| 307 |
+
query = generate_query(chunk)
|
| 308 |
+
term = generate_search_term(chunk)
|
| 309 |
+
desc = generate_description(chunk)
|
| 310 |
+
evidence = generate_evidence(chunk)
|
| 311 |
+
reasoning1 = make_reasoning(desc, term)
|
| 312 |
+
n_noise = random.choice([1, 2])
|
| 313 |
+
noise = random.sample(noise_chunks, min(n_noise, len(noise_chunks)))
|
| 314 |
+
analysis = random.choice(ANALYSIS_NOISE)
|
| 315 |
+
trace = [
|
| 316 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 317 |
+
{"role": "user", "content": query},
|
| 318 |
+
{"role": "assistant", "content": f"<|reasoning|>{reasoning1}<|end|>\n<|search|>{term}<|end|>"},
|
| 319 |
+
{"role": "result", "content": chunk["code"]},
|
| 320 |
+
]
|
| 321 |
+
for nc in noise:
|
| 322 |
+
trace.append({"role": "result", "content": nc["code"]})
|
| 323 |
+
trace.append({"role": "assistant", "content": f"<|reasoning|>{analysis}<|end|>\n<|evidence|>{evidence}<|end|>\n<|finish|>"})
|
| 324 |
+
return {"query": query, "trace": trace}
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def make_trace_multi_step(chunk: dict, related: dict) -> dict:
|
| 328 |
+
name = chunk["name"]
|
| 329 |
+
rname = related["name"]
|
| 330 |
+
query = f"What does {name} do and where is it used?"
|
| 331 |
+
term1 = generate_search_term(chunk)
|
| 332 |
+
term2 = generate_search_term(related)
|
| 333 |
+
desc = generate_description(chunk)
|
| 334 |
+
evidence1 = generate_evidence(chunk)
|
| 335 |
+
evidence2 = generate_evidence(related)
|
| 336 |
+
reasoning1 = make_reasoning(desc, term1)
|
| 337 |
+
reasoning2 = random.choice(ANALYSIS_REFINE)
|
| 338 |
+
return {
|
| 339 |
+
"query": query,
|
| 340 |
+
"trace": [
|
| 341 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 342 |
+
{"role": "user", "content": query},
|
| 343 |
+
{"role": "assistant", "content": f"<|reasoning|>{reasoning1}<|end|>\n<|search|>{term1}<|end|>"},
|
| 344 |
+
{"role": "result", "content": chunk["code"]},
|
| 345 |
+
{"role": "assistant", "content": f"<|reasoning|>Found {desc}. {reasoning2}<|end|>\n<|search|>{term2}<|end|>"},
|
| 346 |
+
{"role": "result", "content": related["code"]},
|
| 347 |
+
{"role": "assistant", "content": f"<|reasoning|>Found both the definition and a usage. I now have complete context.<|end|>\n<|evidence|>{evidence1}\n\nRelated usage:\n{evidence2}<|end|>\n<|finish|>"},
|
| 348 |
+
],
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def make_trace_decomposition(chunk: dict, related: dict) -> dict:
|
| 353 |
+
name = chunk["name"]
|
| 354 |
+
rname = related["name"]
|
| 355 |
+
query = f"How does {name} work and how does it relate to {rname}?"
|
| 356 |
+
term1 = generate_search_term(chunk)
|
| 357 |
+
term2 = generate_search_term(related)
|
| 358 |
+
evidence1 = generate_evidence(chunk)
|
| 359 |
+
evidence2 = generate_evidence(related)
|
| 360 |
+
return {
|
| 361 |
+
"query": query,
|
| 362 |
+
"trace": [
|
| 363 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 364 |
+
{"role": "user", "content": query},
|
| 365 |
+
{"role": "assistant", "content": f"<|reasoning|>This query has two parts: (1) how {name} works, (2) its relationship to {rname}. Let me search for {name} first.<|end|>\n<|search|>{term1}<|end|>"},
|
| 366 |
+
{"role": "result", "content": chunk["code"]},
|
| 367 |
+
{"role": "assistant", "content": f"<|reasoning|>Found {name}. Now let me search for {rname} to understand the relationship.<|end|>\n<|search|>{term2}<|end|>"},
|
| 368 |
+
{"role": "result", "content": related["code"]},
|
| 369 |
+
{"role": "assistant", "content": f"<|reasoning|>Found both components. I can now explain how they relate.<|end|>\n<|evidence|>{evidence1}\n\nRelated: {evidence2}<|end|>\n<|finish|>"},
|
| 370 |
+
],
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def make_trace_not_found() -> dict:
|
| 375 |
+
fake_names = ["database_pool", "auth_middleware", "cache_manager", "session_store",
|
| 376 |
+
"websocket_handler", "graphql_resolver", "redis_client", "email_service",
|
| 377 |
+
"rate_limiter", "circuit_breaker", "event_bus", "message_queue",
|
| 378 |
+
"load_balancer", "api_gateway", "service_mesh", "config_center"]
|
| 379 |
+
name = random.choice(fake_names)
|
| 380 |
+
query = f"Where is the {name} implemented?"
|
| 381 |
+
search1 = name
|
| 382 |
+
search2 = name.replace("_", " ")
|
| 383 |
+
reasoning1 = make_reasoning(f"the {name}", search1)
|
| 384 |
+
reasoning2 = random.choice(NOT_FOUND_REASONING).format(term=search1)
|
| 385 |
+
return {
|
| 386 |
+
"query": query,
|
| 387 |
+
"trace": [
|
| 388 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 389 |
+
{"role": "user", "content": query},
|
| 390 |
+
{"role": "assistant", "content": f"<|reasoning|>{reasoning1}<|end|>\n<|search|>{search1}<|end|>"},
|
| 391 |
+
{"role": "result", "content": ""},
|
| 392 |
+
{"role": "assistant", "content": f"<|reasoning|>{reasoning2}<|end|>\n<|search|>{search2}<|end|>"},
|
| 393 |
+
{"role": "result", "content": ""},
|
| 394 |
+
{"role": "assistant", "content": f"<|reasoning|>No results found. The codebase does not contain {name}.<|end|>\n<|evidence|>No implementation of `{name}` was found in the codebase. Searched for: '{search1}', '{search2}'.<|end|>\n<|finish|>"},
|
| 395 |
+
],
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def make_trace_struct_inspection(chunk: dict) -> dict:
|
| 400 |
+
if chunk["type"] != "struct":
|
| 401 |
+
return make_trace_simple(chunk)
|
| 402 |
+
name = chunk["name"]
|
| 403 |
+
query = f"What fields does {name} have?"
|
| 404 |
+
term = name
|
| 405 |
+
evidence = generate_evidence(chunk)
|
| 406 |
+
return {
|
| 407 |
+
"query": query,
|
| 408 |
+
"trace": [
|
| 409 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 410 |
+
{"role": "user", "content": query},
|
| 411 |
+
{"role": "assistant", "content": f"<|reasoning|>Looking for the {name} struct definition to enumerate its fields.<|end|>\n<|search|>{term}<|end|>"},
|
| 412 |
+
{"role": "result", "content": chunk["code"]},
|
| 413 |
+
{"role": "assistant", "content": f"<|reasoning|>Found the struct definition. I can extract all fields from it.<|end|>\n<|evidence|>{evidence}<|end|>\n<|finish|>"},
|
| 414 |
+
],
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
def make_trace_behavior(chunk: dict) -> dict:
|
| 419 |
+
if chunk["type"] != "function":
|
| 420 |
+
return make_trace_simple(chunk)
|
| 421 |
+
name = chunk["name"]
|
| 422 |
+
query = f"What does {name} do? Explain its behavior."
|
| 423 |
+
term = name
|
| 424 |
+
evidence = generate_evidence(chunk)
|
| 425 |
+
info = extract_function_info(chunk["code"], name)
|
| 426 |
+
params = ", ".join(info["params"][:4]) if info["params"] else "no parameters"
|
| 427 |
+
return {
|
| 428 |
+
"query": query,
|
| 429 |
+
"trace": [
|
| 430 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 431 |
+
{"role": "user", "content": query},
|
| 432 |
+
{"role": "assistant", "content": f"<|reasoning|>I need to find and analyze the {name} function. Let me search for its definition.<|end|>\n<|search|>{term}<|end|>"},
|
| 433 |
+
{"role": "result", "content": chunk["code"]},
|
| 434 |
+
{"role": "assistant", "content": f"<|reasoning|>Found the function. It takes {params}. Let me analyze its behavior from the code.<|end|>\n<|evidence|>{evidence}<|end|>\n<|finish|>"},
|
| 435 |
+
],
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
def make_trace_cross_file(chunk: dict, related: dict) -> dict:
|
| 440 |
+
"""Cross-file: definition in one file, usage in another."""
|
| 441 |
+
name = chunk["name"]
|
| 442 |
+
rname = related["name"]
|
| 443 |
+
query = f"How is {name} used across the codebase?"
|
| 444 |
+
term1 = generate_search_term(chunk)
|
| 445 |
+
term2 = generate_search_term(related)
|
| 446 |
+
evidence1 = generate_evidence(chunk)
|
| 447 |
+
evidence2 = generate_evidence(related)
|
| 448 |
+
return {
|
| 449 |
+
"query": query,
|
| 450 |
+
"trace": [
|
| 451 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 452 |
+
{"role": "user", "content": query},
|
| 453 |
+
{"role": "assistant", "content": f"<|reasoning|>I need to find the definition of {name} and then trace its usages across files.<|end|>\n<|search|>{term1}<|end|>"},
|
| 454 |
+
{"role": "result", "content": chunk["code"]},
|
| 455 |
+
{"role": "assistant", "content": f"<|reasoning|>Found the definition in {chunk['filepath']}. Now let me search for where it's called or referenced.<|end|>\n<|search|>{term2}<|end|>"},
|
| 456 |
+
{"role": "result", "content": related["code"]},
|
| 457 |
+
{"role": "assistant", "content": f"<|reasoning|>Found a usage in {related['filepath']}. I now have both the definition and a call site.<|end|>\n<|evidence|>Definition ({chunk['filepath']}):\n{evidence1}\n\nUsage ({related['filepath']}):\n{evidence2}<|end|>\n<|finish|>"},
|
| 458 |
+
],
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
# ─── Main ────────────────────────────────────────────────────────────────────
|
| 463 |
+
|
| 464 |
+
def main():
|
| 465 |
+
os.makedirs(TRACES_DIR, exist_ok=True)
|
| 466 |
+
|
| 467 |
+
print(f"Loading chunks from {CHUNKS_PATH}...")
|
| 468 |
+
with open(CHUNKS_PATH, "r", encoding="utf-8") as f:
|
| 469 |
+
chunks = [json.loads(line) for line in f]
|
| 470 |
+
print(f" Loaded {len(chunks):,} chunks")
|
| 471 |
+
|
| 472 |
+
good_chunks = [c for c in chunks if c["name"] != "unknown" and c["name"] != "block" and 50 < len(c["code"]) < 4000]
|
| 473 |
+
print(f" Named chunks (good): {len(good_chunks):,}")
|
| 474 |
+
|
| 475 |
+
by_lang = {}
|
| 476 |
+
for c in good_chunks:
|
| 477 |
+
by_lang.setdefault(c["language"], []).append(c)
|
| 478 |
+
|
| 479 |
+
traces = []
|
| 480 |
+
seen_keys = set()
|
| 481 |
+
|
| 482 |
+
def add_trace(trace):
|
| 483 |
+
searches = [m["content"] for m in trace["trace"] if m["role"] == "assistant" and "<|search|>" in m["content"]]
|
| 484 |
+
# Extract search terms
|
| 485 |
+
search_terms = re.findall(r"<\|search\|>(.*?)<\|end\|>", "".join(m["content"] for m in trace["trace"] if m["role"] == "assistant"))
|
| 486 |
+
key = trace_key(trace["query"], search_terms)
|
| 487 |
+
if key in seen_keys:
|
| 488 |
+
return False
|
| 489 |
+
seen_keys.add(key)
|
| 490 |
+
traces.append(trace)
|
| 491 |
+
return True
|
| 492 |
+
|
| 493 |
+
def get_related(chunk):
|
| 494 |
+
lang_chunks = by_lang.get(chunk["language"], good_chunks)
|
| 495 |
+
pool = [c for c in lang_chunks if c["id"] != chunk["id"]]
|
| 496 |
+
return random.choice(pool) if pool else chunk
|
| 497 |
+
|
| 498 |
+
# Type 1: Simple lookup (25%)
|
| 499 |
+
n_simple = min(8000, len(good_chunks))
|
| 500 |
+
for chunk in random.sample(good_chunks, n_simple):
|
| 501 |
+
add_trace(make_trace_simple(chunk))
|
| 502 |
+
print(f" Simple lookup: {len(traces)}")
|
| 503 |
+
|
| 504 |
+
# Type 2: With noise (20%)
|
| 505 |
+
n_noise = min(6000, len(good_chunks))
|
| 506 |
+
for chunk in random.sample(good_chunks, n_noise):
|
| 507 |
+
lang_chunks = by_lang.get(chunk["language"], good_chunks)
|
| 508 |
+
noise_pool = [c for c in lang_chunks if c["id"] != chunk["id"]]
|
| 509 |
+
if len(noise_pool) >= 2:
|
| 510 |
+
add_trace(make_trace_with_noise(chunk, noise_pool))
|
| 511 |
+
print(f" With noise: {len(traces)}")
|
| 512 |
+
|
| 513 |
+
# Type 3: Multi-step (15%)
|
| 514 |
+
n_multi = min(4000, len(good_chunks) // 2)
|
| 515 |
+
for chunk in random.sample(good_chunks, n_multi):
|
| 516 |
+
add_trace(make_trace_multi_step(chunk, get_related(chunk)))
|
| 517 |
+
print(f" Multi-step: {len(traces)}")
|
| 518 |
+
|
| 519 |
+
# Type 4: Decomposition (10%)
|
| 520 |
+
n_decomp = min(3000, len(good_chunks) // 3)
|
| 521 |
+
for chunk in random.sample(good_chunks, n_decomp):
|
| 522 |
+
add_trace(make_trace_decomposition(chunk, get_related(chunk)))
|
| 523 |
+
print(f" Decomposition: {len(traces)}")
|
| 524 |
+
|
| 525 |
+
# Type 5: Not found (5%)
|
| 526 |
+
for _ in range(1500):
|
| 527 |
+
add_trace(make_trace_not_found())
|
| 528 |
+
print(f" Not found: {len(traces)}")
|
| 529 |
+
|
| 530 |
+
# Type 6: Struct inspection (10%)
|
| 531 |
+
struct_chunks = [c for c in good_chunks if c["type"] == "struct"]
|
| 532 |
+
for chunk in random.sample(struct_chunks, min(2000, len(struct_chunks))):
|
| 533 |
+
add_trace(make_trace_struct_inspection(chunk))
|
| 534 |
+
print(f" Struct inspection: {len(traces)}")
|
| 535 |
+
|
| 536 |
+
# Type 7: Behavior analysis (10%)
|
| 537 |
+
func_chunks = [c for c in good_chunks if c["type"] == "function"]
|
| 538 |
+
for chunk in random.sample(func_chunks, min(2000, len(func_chunks))):
|
| 539 |
+
add_trace(make_trace_behavior(chunk))
|
| 540 |
+
print(f" Behavior analysis: {len(traces)}")
|
| 541 |
+
|
| 542 |
+
# Type 8: Cross-file (5%)
|
| 543 |
+
n_cross = min(1500, len(good_chunks) // 4)
|
| 544 |
+
for chunk in random.sample(good_chunks, n_cross):
|
| 545 |
+
add_trace(make_trace_cross_file(chunk, get_related(chunk)))
|
| 546 |
+
print(f" Cross-file: {len(traces)}")
|
| 547 |
+
|
| 548 |
+
random.shuffle(traces)
|
| 549 |
+
|
| 550 |
+
# Write
|
| 551 |
+
print(f"\nTotal unique traces: {len(traces):,}")
|
| 552 |
+
total_size = 0
|
| 553 |
+
with open(TRACES_PATH, "w", encoding="utf-8") as f:
|
| 554 |
+
for trace in traces:
|
| 555 |
+
line = json.dumps(trace, ensure_ascii=False)
|
| 556 |
+
f.write(line + "\n")
|
| 557 |
+
total_size += len(line)
|
| 558 |
+
print(f"Traces written to {TRACES_PATH}")
|
| 559 |
+
print(f" File size: {total_size / 1e6:.2f} MB")
|
| 560 |
+
|
| 561 |
+
# Show a sample
|
| 562 |
+
print("\n" + "=" * 60)
|
| 563 |
+
print("SAMPLE TRACE")
|
| 564 |
+
print("=" * 60)
|
| 565 |
+
sample = traces[0]
|
| 566 |
+
print(f"\nQuery: {sample['query']}")
|
| 567 |
+
for msg in sample["trace"]:
|
| 568 |
+
content = msg["content"]
|
| 569 |
+
if len(content) > 200:
|
| 570 |
+
content = content[:200] + "..."
|
| 571 |
+
print(f"\n[{msg['role']}]")
|
| 572 |
+
print(content)
|
| 573 |
+
|
| 574 |
+
# Stats
|
| 575 |
+
stats = {
|
| 576 |
+
"total_traces": len(traces),
|
| 577 |
+
"file_size_mb": total_size / 1e6,
|
| 578 |
+
"unique_queries": len(set(t["query"] for t in traces)),
|
| 579 |
+
}
|
| 580 |
+
stats_path = os.path.join(TRACES_DIR, "trace_stats.json")
|
| 581 |
+
with open(stats_path, "w") as f:
|
| 582 |
+
json.dump(stats, f, indent=2)
|
| 583 |
+
print(f"\nStats: {stats}")
|
| 584 |
+
|
| 585 |
+
|
| 586 |
+
if __name__ == "__main__":
|
| 587 |
+
main()
|
src/gold_traces.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gold-standard search agent traces — hand-crafted examples using real code
|
| 3 |
+
from the corpus. These define the quality bar for the synthetic trace generator.
|
| 4 |
+
|
| 5 |
+
Each trace follows this format:
|
| 6 |
+
1. System prompt (agent role definition)
|
| 7 |
+
2. User query (from the bigger model)
|
| 8 |
+
3. Agent response:
|
| 9 |
+
a. <|reasoning|> — decompose the query, plan searches
|
| 10 |
+
b. <|search|> — issue a search query
|
| 11 |
+
c. <|result|> — retrieved code chunks (injected by the harness)
|
| 12 |
+
d. <|reasoning|> — analyze results, decide if more search needed
|
| 13 |
+
e. (repeat b-d as needed)
|
| 14 |
+
f. <|evidence|> — curated evidence package
|
| 15 |
+
g. <|finish|> — signal completion
|
| 16 |
+
|
| 17 |
+
The agent learns to:
|
| 18 |
+
- Break complex queries into subqueries
|
| 19 |
+
- Search with specific terms (function names, types, patterns)
|
| 20 |
+
- Judge which results are relevant vs noise
|
| 21 |
+
- Extract key facts concisely (not dump entire code)
|
| 22 |
+
- Synthesize across multiple results
|
| 23 |
+
- Know when to stop searching
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
import json
|
| 27 |
+
import os
|
| 28 |
+
|
| 29 |
+
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 30 |
+
GOLD_PATH = os.path.join(PROJECT_DIR, "data", "gold_traces.jsonl")
|
| 31 |
+
|
| 32 |
+
SYSTEM_PROMPT = (
|
| 33 |
+
"You are a code search agent. Given a query from a reasoning model, "
|
| 34 |
+
"decompose it into subqueries, search the codebase, inspect results, "
|
| 35 |
+
"and return curated evidence. Use <|search|> to issue searches, "
|
| 36 |
+
"<|reasoning|> to analyze, and <|evidence|> to return findings. "
|
| 37 |
+
"Be concise. Extract only the relevant facts. End with <|finish|>."
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
GOLD_TRACES = [
|
| 41 |
+
# ─── Trace 1: Simple function lookup (C) ───────────────────────────────
|
| 42 |
+
{
|
| 43 |
+
"query": "How does nginx handle reusable connections?",
|
| 44 |
+
"trace": [
|
| 45 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 46 |
+
{"role": "user", "content": "How does nginx handle reusable connections?"},
|
| 47 |
+
{"role": "assistant", "content": (
|
| 48 |
+
"<|reasoning|>The query asks about reusable connections in nginx. "
|
| 49 |
+
"I should search for functions related to reusable connections.<|end|>\n"
|
| 50 |
+
"<|search|>reusable connection<|end|>"
|
| 51 |
+
)},
|
| 52 |
+
{"role": "result", "content": (
|
| 53 |
+
"ngx_reusable_connection(ngx_connection_t *c, ngx_uint_t reusable)\n"
|
| 54 |
+
"{\n"
|
| 55 |
+
" ngx_log_debug1(NGX_LOG_DEBUG_CORE, c->log, 0,\n"
|
| 56 |
+
" \"reusable connection: %ui\", reusable);\n"
|
| 57 |
+
"\n"
|
| 58 |
+
" if (c->reusable) {\n"
|
| 59 |
+
" ngx_queue_remove(&c->queue);\n"
|
| 60 |
+
" ngx_cycle->reusable_connections_n--;\n"
|
| 61 |
+
" }\n"
|
| 62 |
+
"\n"
|
| 63 |
+
" c->reusable = reusable;\n"
|
| 64 |
+
"\n"
|
| 65 |
+
" if (reusable) {\n"
|
| 66 |
+
" ngx_queue_insert_head(\n"
|
| 67 |
+
" (ngx_queue_t *) &ngx_cycle->reusable_connections_queue, &c->queue);\n"
|
| 68 |
+
" ngx_cycle->reusable_connections_n++;\n"
|
| 69 |
+
" }\n"
|
| 70 |
+
"}"
|
| 71 |
+
)},
|
| 72 |
+
{"role": "assistant", "content": (
|
| 73 |
+
"<|reasoning|>This function manages the reusable connection queue. "
|
| 74 |
+
"When a connection is marked reusable, it's added to "
|
| 75 |
+
"reusable_connections_queue and the counter is incremented. "
|
| 76 |
+
"When unreusable, it's removed from the queue and counter decremented. "
|
| 77 |
+
"I have enough to answer.<|end|>\n"
|
| 78 |
+
"<|evidence|>nginx manages reusable connections via `ngx_reusable_connection()`:\n"
|
| 79 |
+
"- Marks connection as reusable/unreusable via the `reusable` flag\n"
|
| 80 |
+
"- When reusable: inserts into `reusable_connections_queue` (head), increments `reusable_connections_n`\n"
|
| 81 |
+
"- When unreusable: removes from queue, decrements counter\n"
|
| 82 |
+
"- Uses `ngx_queue_insert_head` and `ngx_queue_remove` for queue management\n"
|
| 83 |
+
"Source: ngx_connection.c<|end|>\n"
|
| 84 |
+
"<|finish|>"
|
| 85 |
+
)},
|
| 86 |
+
],
|
| 87 |
+
},
|
| 88 |
+
# ─── Trace 2: Multi-step search (Python) ──────────────────────────────
|
| 89 |
+
{
|
| 90 |
+
"query": "What does the with_params_help decorator do and how is it used?",
|
| 91 |
+
"trace": [
|
| 92 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 93 |
+
{"role": "user", "content": "What does the with_params_help decorator do and how is it used?"},
|
| 94 |
+
{"role": "assistant", "content": (
|
| 95 |
+
"<|reasoning|>This asks about a specific decorator. I need to find its definition first, then find usages.<|end|>\n"
|
| 96 |
+
"<|search|>with_params_help<|end|>"
|
| 97 |
+
)},
|
| 98 |
+
{"role": "result", "content": (
|
| 99 |
+
"def with_params_help(params_cls: type, title: str = \"Parameter defaults\"):\n"
|
| 100 |
+
" \"\"\"\n"
|
| 101 |
+
" Class decorator that appends a help table generated from another dataclass\n"
|
| 102 |
+
" (e.g., VllmParameters) to the decorated class's docstring.\n"
|
| 103 |
+
" \"\"\"\n"
|
| 104 |
+
" if not is_dataclass(params_cls):\n"
|
| 105 |
+
" raise TypeError(f\"{params_cls} must be a dataclass\")\n"
|
| 106 |
+
"\n"
|
| 107 |
+
" def _decorator(cls: type) -> type:\n"
|
| 108 |
+
" block = generate_dataclass_help(params_cls)\n"
|
| 109 |
+
" cls.__doc__ = (cls.__doc__ or \"\") + f\"\\n\\n{title}:\\n{block}\"\n"
|
| 110 |
+
" return cls\n"
|
| 111 |
+
"\n"
|
| 112 |
+
" return _decorator"
|
| 113 |
+
)},
|
| 114 |
+
{"role": "assistant", "content": (
|
| 115 |
+
"<|reasoning|>Found the definition. It's a decorator factory that takes a dataclass and appends a help table to another class's docstring. Now I need to find where it's used to show a concrete example.<|end|>\n"
|
| 116 |
+
"<|search|>@with_params_help<|end|>"
|
| 117 |
+
)},
|
| 118 |
+
{"role": "result", "content": (
|
| 119 |
+
"@with_params_help(VllmBuildParameters)\n"
|
| 120 |
+
"class VllmBuildRunner(BaseRunner):\n"
|
| 121 |
+
" \"\"\"\n"
|
| 122 |
+
" Build vLLM using docker buildx.\n"
|
| 123 |
+
" \"\"\""
|
| 124 |
+
)},
|
| 125 |
+
{"role": "assistant", "content": (
|
| 126 |
+
"<|reasoning|>Found a usage. The decorator is applied to VllmBuildRunner with VllmBuildParameters as the params class. This appends parameter defaults from VllmBuildParameters to VllmBuildRunner's docstring. I have enough to answer.<|end|>\n"
|
| 127 |
+
"<|evidence|>`with_params_help` is a class decorator factory:\n"
|
| 128 |
+
"- Takes a dataclass (`params_cls`) and optional title (default \"Parameter defaults\")\n"
|
| 129 |
+
"- Generates a help table from the dataclass fields via `generate_dataclass_help()`\n"
|
| 130 |
+
"- Appends the table to the decorated class's `__doc__` docstring\n"
|
| 131 |
+
"- Usage example: `@with_params_help(VllmBuildParameters)` on `VllmBuildRunner`\n"
|
| 132 |
+
"- Requires `params_cls` to be a dataclass (raises TypeError otherwise)<|end|>\n"
|
| 133 |
+
"<|finish|>"
|
| 134 |
+
)},
|
| 135 |
+
],
|
| 136 |
+
},
|
| 137 |
+
# ─── Trace 3: Type/struct lookup with noise (C) ───────────────────────
|
| 138 |
+
{
|
| 139 |
+
"query": "What fields does the nginx listening socket structure have?",
|
| 140 |
+
"trace": [
|
| 141 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 142 |
+
{"role": "user", "content": "What fields does the nginx listening socket structure have?"},
|
| 143 |
+
{"role": "assistant", "content": (
|
| 144 |
+
"<|reasoning|>Looking for the listening socket struct definition in nginx. Should search for ngx_listening struct.<|end|>\n"
|
| 145 |
+
"<|search|>ngx_listening struct fields<|end|>"
|
| 146 |
+
)},
|
| 147 |
+
{"role": "result", "content": (
|
| 148 |
+
"typedef struct ngx_listening_s ngx_listening_t;\n"
|
| 149 |
+
"\n"
|
| 150 |
+
"struct ngx_listening_s {\n"
|
| 151 |
+
" ngx_socket_t fd;\n"
|
| 152 |
+
" struct sockaddr *sockaddr;\n"
|
| 153 |
+
" socklen_t socklen;\n"
|
| 154 |
+
" size_t addr_text_max_len;\n"
|
| 155 |
+
" ngx_str_t addr_text;\n"
|
| 156 |
+
" int type;\n"
|
| 157 |
+
" int protocol;\n"
|
| 158 |
+
" int backlog;\n"
|
| 159 |
+
" int rcvbuf;\n"
|
| 160 |
+
" int sndbuf;\n"
|
| 161 |
+
"};"
|
| 162 |
+
)},
|
| 163 |
+
{"role": "result", "content": (
|
| 164 |
+
"ngx_create_listening(ngx_conf_t *cf, struct sockaddr *sockaddr,\n"
|
| 165 |
+
" socklen_t socklen)\n"
|
| 166 |
+
"{\n"
|
| 167 |
+
" ls = ngx_array_push(&cf->cycle->listening);\n"
|
| 168 |
+
" ngx_memzero(ls, sizeof(ngx_listening_t));\n"
|
| 169 |
+
" ls->sockaddr = sa;\n"
|
| 170 |
+
" ls->socklen = socklen;\n"
|
| 171 |
+
"}"
|
| 172 |
+
)},
|
| 173 |
+
{"role": "assistant", "content": (
|
| 174 |
+
"<|reasoning|>The first result is the struct definition — exactly what I need. The second result is the creation function, which is related but not asked for. I'll focus on the struct fields.<|end|>\n"
|
| 175 |
+
"<|evidence|>`ngx_listening_s` (aka `ngx_listening_t`) structure fields:\n"
|
| 176 |
+
"- `fd` (ngx_socket_t): file descriptor\n"
|
| 177 |
+
"- `sockaddr` (struct sockaddr*): socket address pointer\n"
|
| 178 |
+
"- `socklen` (socklen_t): socket address length\n"
|
| 179 |
+
"- `addr_text_max_len` (size_t): max address text length\n"
|
| 180 |
+
"- `addr_text` (ngx_str_t): address text representation\n"
|
| 181 |
+
"- `type` (int): socket type\n"
|
| 182 |
+
"- `protocol` (int): protocol\n"
|
| 183 |
+
"- `backlog` (int): listen backlog\n"
|
| 184 |
+
"- `rcvbuf` (int): receive buffer size\n"
|
| 185 |
+
"- `sndbuf` (int): send buffer size\n"
|
| 186 |
+
"Source: ngx_connection.h<|end|>\n"
|
| 187 |
+
"<|finish|>"
|
| 188 |
+
)},
|
| 189 |
+
],
|
| 190 |
+
},
|
| 191 |
+
# ─── Trace 4: Query decomposition (JS/TS) ─────────────────────────────
|
| 192 |
+
{
|
| 193 |
+
"query": "How does the concatenate function work and what types does it accept?",
|
| 194 |
+
"trace": [
|
| 195 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 196 |
+
{"role": "user", "content": "How does the concatenate function work and what types does it accept?"},
|
| 197 |
+
{"role": "assistant", "content": (
|
| 198 |
+
"<|reasoning|>This has two parts: (1) how concatenate works, (2) what types it accepts. Let me search for the function definition.<|end|>\n"
|
| 199 |
+
"<|search|>concatenate function array<|end|>"
|
| 200 |
+
)},
|
| 201 |
+
{"role": "result", "content": (
|
| 202 |
+
"export function concatenate<T>(array1: T[], array2: T[] | undefined): T[];\n"
|
| 203 |
+
"// eslint-disable-line @typescript-eslint/unified-signatures"
|
| 204 |
+
)},
|
| 205 |
+
{"role": "result", "content": (
|
| 206 |
+
"function concatenate<T>(array1: T[], array2: T[] | undefined): T[] {\n"
|
| 207 |
+
" if (array2 === undefined) return array1;\n"
|
| 208 |
+
" return array1.concat(array2);\n"
|
| 209 |
+
"}"
|
| 210 |
+
)},
|
| 211 |
+
{"role": "assistant", "content": (
|
| 212 |
+
"<|reasoning|>Found both the declaration and implementation. It's a generic TypeScript function that concatenates two arrays. The second parameter is optional (can be undefined). If undefined, it returns the first array unchanged. Otherwise uses Array.concat. I have everything needed.<|end|>\n"
|
| 213 |
+
"<|evidence|>`concatenate<T>` is a TypeScript utility function:\n"
|
| 214 |
+
"- Signature: `concatenate<T>(array1: T[], array2: T[] | undefined): T[]`\n"
|
| 215 |
+
"- Generic over type T — accepts arrays of any type\n"
|
| 216 |
+
"- If `array2` is `undefined`, returns `array1` unchanged (no-op)\n"
|
| 217 |
+
"- Otherwise returns `array1.concat(array2)` (standard Array.concat)\n"
|
| 218 |
+
"- Exported as a public function<|end|>\n"
|
| 219 |
+
"<|finish|>"
|
| 220 |
+
)},
|
| 221 |
+
],
|
| 222 |
+
},
|
| 223 |
+
# ─── Trace 5: Search with irrelevant results (Rust) ───────────────────
|
| 224 |
+
{
|
| 225 |
+
"query": "How is error handling done in the Rust codebase?",
|
| 226 |
+
"trace": [
|
| 227 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 228 |
+
{"role": "user", "content": "How is error handling done in the Rust codebase?"},
|
| 229 |
+
{"role": "assistant", "content": (
|
| 230 |
+
"<|reasoning|>Error handling in Rust typically uses Result types. Let me search for Result usage and error types.<|end|>\n"
|
| 231 |
+
"<|search|>Result error handling<|end|>"
|
| 232 |
+
)},
|
| 233 |
+
{"role": "result", "content": (
|
| 234 |
+
"pub fn parse_config(input: &str) -> Result<Config, ParseError> {\n"
|
| 235 |
+
" let parts: Vec<&str> = input.split_whitespace().collect();\n"
|
| 236 |
+
" if parts.len() < 2 {\n"
|
| 237 |
+
" return Err(ParseError::TooFewArguments);\n"
|
| 238 |
+
" }\n"
|
| 239 |
+
" Ok(Config { query: parts[0].to_string(), filename: parts[1].to_string() })\n"
|
| 240 |
+
"}"
|
| 241 |
+
)},
|
| 242 |
+
{"role": "result", "content": (
|
| 243 |
+
"use std::fmt;\n"
|
| 244 |
+
"\n"
|
| 245 |
+
"pub enum ParseError {\n"
|
| 246 |
+
" TooFewArguments,\n"
|
| 247 |
+
" InvalidSyntax,\n"
|
| 248 |
+
"}\n"
|
| 249 |
+
"\n"
|
| 250 |
+
"impl fmt::Display for ParseError {\n"
|
| 251 |
+
" fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {\n"
|
| 252 |
+
" match self {\n"
|
| 253 |
+
" ParseError::TooFewArguments => write!(f, \"too few arguments\"),\n"
|
| 254 |
+
" ParseError::InvalidSyntax => write!(f, \"invalid syntax\"),\n"
|
| 255 |
+
" }\n"
|
| 256 |
+
" }\n"
|
| 257 |
+
"}"
|
| 258 |
+
)},
|
| 259 |
+
{"role": "result", "content": (
|
| 260 |
+
"mod tests {\n"
|
| 261 |
+
" #[test]\n"
|
| 262 |
+
" fn test_basic() {\n"
|
| 263 |
+
" assert_eq!(2 + 2, 4);\n"
|
| 264 |
+
" }\n"
|
| 265 |
+
"}"
|
| 266 |
+
)},
|
| 267 |
+
{"role": "assistant", "content": (
|
| 268 |
+
"<|reasoning|>Result 1 shows a function returning Result<Config, ParseError> with early return on error. Result 2 shows the ParseError enum with Display impl. Result 3 is an unrelated test — not relevant. I have enough to answer.<|end|>\n"
|
| 269 |
+
"<|evidence|>Error handling pattern in this Rust codebase:\n"
|
| 270 |
+
"- Uses `Result<T, E>` for fallible operations\n"
|
| 271 |
+
"- Custom error type: `ParseError` enum with variants (TooFewArguments, InvalidSyntax)\n"
|
| 272 |
+
"- Implements `std::fmt::Display` for human-readable errors\n"
|
| 273 |
+
"- Early return pattern: `return Err(ParseError::...)` on validation failure\n"
|
| 274 |
+
"- Success path: `Ok(Config { ... })`\n"
|
| 275 |
+
"- No use of `unwrap()` or `expect()` in the found code — proper error propagation<|end|>\n"
|
| 276 |
+
"<|finish|>"
|
| 277 |
+
)},
|
| 278 |
+
],
|
| 279 |
+
},
|
| 280 |
+
# ─── Trace 6: No results found ────────────────────────────────────────
|
| 281 |
+
{
|
| 282 |
+
"query": "Where is the database connection pool implemented?",
|
| 283 |
+
"trace": [
|
| 284 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 285 |
+
{"role": "user", "content": "Where is the database connection pool implemented?"},
|
| 286 |
+
{"role": "assistant", "content": (
|
| 287 |
+
"<|reasoning|>Looking for database connection pool implementation. Let me search for relevant terms.<|end|>\n"
|
| 288 |
+
"<|search|>database connection pool<|end|>"
|
| 289 |
+
)},
|
| 290 |
+
{"role": "result", "content": ""},
|
| 291 |
+
{"role": "assistant", "content": (
|
| 292 |
+
"<|reasoning|>No results found for 'database connection pool'. Let me try broader terms.<|end|>\n"
|
| 293 |
+
"<|search|>pool db<|end|>"
|
| 294 |
+
)},
|
| 295 |
+
{"role": "result", "content": ""},
|
| 296 |
+
{"role": "assistant", "content": (
|
| 297 |
+
"<|reasoning|>Still no results. The codebase does not appear to contain a database connection pool implementation.<|end|>\n"
|
| 298 |
+
"<|evidence|>No database connection pool implementation was found in the codebase. Searched for: 'database connection pool', 'pool db'. The indexed code may not include database layer code.<|end|>\n"
|
| 299 |
+
"<|finish|>"
|
| 300 |
+
)},
|
| 301 |
+
],
|
| 302 |
+
},
|
| 303 |
+
]
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def main():
|
| 307 |
+
print(f"Writing {len(GOLD_TRACES)} gold-standard traces to {GOLD_PATH}...")
|
| 308 |
+
with open(GOLD_PATH, "w", encoding="utf-8") as f:
|
| 309 |
+
for trace in GOLD_TRACES:
|
| 310 |
+
f.write(json.dumps(trace) + "\n")
|
| 311 |
+
|
| 312 |
+
print(f"Done. {len(GOLD_TRACES)} traces written.")
|
| 313 |
+
|
| 314 |
+
# Print summary
|
| 315 |
+
for i, trace in enumerate(GOLD_TRACES):
|
| 316 |
+
query = trace["query"]
|
| 317 |
+
n_steps = len([m for m in trace["trace"] if m["role"] == "assistant"])
|
| 318 |
+
n_searches = len([m for m in trace["trace"] if m["role"] == "assistant" and "<|search|>" in m["content"]])
|
| 319 |
+
n_results = len([m for m in trace["trace"] if m["role"] == "result"])
|
| 320 |
+
print(f" Trace {i+1}: '{query[:50]}...' | {n_steps} agent turns | {n_searches} searches | {n_results} results")
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
if __name__ == "__main__":
|
| 324 |
+
main()
|
src/model.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Retriever500M - Decoder-only transformer built from scratch.
|
| 3 |
+
|
| 4 |
+
Architecture (LLaMA-style):
|
| 5 |
+
- vocab_size: 32,000
|
| 6 |
+
- d_model: 1,280
|
| 7 |
+
- n_layers: 23
|
| 8 |
+
- n_heads: 20
|
| 9 |
+
- d_ff: 3,456 (SwiGLU, 2/3 * 4 * d_model)
|
| 10 |
+
- RoPE positional encoding
|
| 11 |
+
- RMSNorm (no biases)
|
| 12 |
+
- Tied input/output embeddings
|
| 13 |
+
- Total parameters: ~497M
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import math
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
|
| 19 |
+
import torch
|
| 20 |
+
import torch.nn as nn
|
| 21 |
+
import torch.nn.functional as F
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class ModelConfig:
|
| 26 |
+
vocab_size: int = 32_000
|
| 27 |
+
d_model: int = 1_280
|
| 28 |
+
n_layers: int = 23
|
| 29 |
+
n_heads: int = 20
|
| 30 |
+
d_ff: int = 3_456
|
| 31 |
+
max_seq_len: int = 1_024
|
| 32 |
+
rope_theta: float = 10_000.0
|
| 33 |
+
rope_pct: float = 0.25 # fraction of d_model per head used for RoPE
|
| 34 |
+
dropout: float = 0.0
|
| 35 |
+
tie_embeddings: bool = True
|
| 36 |
+
|
| 37 |
+
def __post_init__(self):
|
| 38 |
+
assert self.d_model % self.n_heads == 0
|
| 39 |
+
self.d_head = self.d_model // self.n_heads # 64
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class RMSNorm(nn.Module):
|
| 43 |
+
"""RMSNorm with optional bias (no bias by default, LLaMA-style)."""
|
| 44 |
+
|
| 45 |
+
def __init__(self, dim: int, eps: float = 1e-6):
|
| 46 |
+
super().__init__()
|
| 47 |
+
self.weight = nn.Parameter(torch.ones(dim))
|
| 48 |
+
self.eps = eps
|
| 49 |
+
|
| 50 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 51 |
+
# Compute in float32 for stability, then cast back
|
| 52 |
+
orig_dtype = x.dtype
|
| 53 |
+
x = x.float()
|
| 54 |
+
rms = x.pow(2).mean(dim=-1, keepdim=True)
|
| 55 |
+
x = x * torch.rsqrt(rms + self.eps)
|
| 56 |
+
x = x.to(orig_dtype)
|
| 57 |
+
return x * self.weight
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def precompute_rope_frequencies(
|
| 61 |
+
d_head: int,
|
| 62 |
+
max_seq_len: int,
|
| 63 |
+
theta: float = 10_000.0,
|
| 64 |
+
device: torch.device | None = None,
|
| 65 |
+
) -> torch.Tensor:
|
| 66 |
+
"""Precompute RoPE frequency table.
|
| 67 |
+
|
| 68 |
+
Returns tensor of shape (max_seq_len, d_head // 2) with complex
|
| 69 |
+
frequencies (cos, sin interleaved is handled in apply_rope).
|
| 70 |
+
"""
|
| 71 |
+
inv_freq = 1.0 / (theta ** (torch.arange(0, d_head, 2, device=device).float() / d_head))
|
| 72 |
+
positions = torch.arange(max_seq_len, device=device).float()
|
| 73 |
+
freqs = torch.outer(positions, inv_freq) # (seq, d_head//2)
|
| 74 |
+
return freqs
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def apply_rope(
|
| 78 |
+
x: torch.Tensor,
|
| 79 |
+
freqs: torch.Tensor,
|
| 80 |
+
) -> torch.Tensor:
|
| 81 |
+
"""Apply rotary position embeddings to tensor x.
|
| 82 |
+
|
| 83 |
+
x: (batch, n_heads, seq, d_head)
|
| 84 |
+
freqs: (seq, d_head // 2)
|
| 85 |
+
"""
|
| 86 |
+
seq_len = x.shape[2]
|
| 87 |
+
d_head = x.shape[-1]
|
| 88 |
+
freqs = freqs[:seq_len] # (seq, d_head//2)
|
| 89 |
+
|
| 90 |
+
cos = freqs.cos()
|
| 91 |
+
sin = freqs.sin()
|
| 92 |
+
|
| 93 |
+
# Interleave cos/sin to match the rotate_half pattern
|
| 94 |
+
# x is split into two halves: x1 = x[..., :d//2], x2 = x[..., d//2:]
|
| 95 |
+
x1 = x[..., : d_head // 2]
|
| 96 |
+
x2 = x[..., d_head // 2 :]
|
| 97 |
+
|
| 98 |
+
# Broadcast cos/sin: (1, 1, seq, d_head//2)
|
| 99 |
+
cos = cos.unsqueeze(0).unsqueeze(0)
|
| 100 |
+
sin = sin.unsqueeze(0).unsqueeze(0)
|
| 101 |
+
|
| 102 |
+
rotated = torch.cat([x1 * cos - x2 * sin, x1 * sin + x2 * cos], dim=-1)
|
| 103 |
+
return rotated
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
class Attention(nn.Module):
|
| 107 |
+
"""Multi-head self-attention with RoPE, no biases, causal masking."""
|
| 108 |
+
|
| 109 |
+
def __init__(self, config: ModelConfig):
|
| 110 |
+
super().__init__()
|
| 111 |
+
self.n_heads = config.n_heads
|
| 112 |
+
self.d_head = config.d_head
|
| 113 |
+
self.d_model = config.d_model
|
| 114 |
+
self.scale = 1.0 / math.sqrt(self.d_head)
|
| 115 |
+
|
| 116 |
+
# Fused QKV projection
|
| 117 |
+
self.qkv = nn.Linear(config.d_model, 3 * config.d_model, bias=False)
|
| 118 |
+
self.o_proj = nn.Linear(config.d_model, config.d_model, bias=False)
|
| 119 |
+
self.dropout = config.dropout
|
| 120 |
+
|
| 121 |
+
def forward(
|
| 122 |
+
self,
|
| 123 |
+
x: torch.Tensor,
|
| 124 |
+
rope_freqs: torch.Tensor,
|
| 125 |
+
mask: torch.Tensor | None = None,
|
| 126 |
+
) -> torch.Tensor:
|
| 127 |
+
B, T, C = x.shape
|
| 128 |
+
|
| 129 |
+
qkv = self.qkv(x) # (B, T, 3*C)
|
| 130 |
+
q, k, v = qkv.chunk(3, dim=-1)
|
| 131 |
+
|
| 132 |
+
# Reshape to (B, n_heads, T, d_head)
|
| 133 |
+
q = q.view(B, T, self.n_heads, self.d_head).transpose(1, 2)
|
| 134 |
+
k = k.view(B, T, self.n_heads, self.d_head).transpose(1, 2)
|
| 135 |
+
v = v.view(B, T, self.n_heads, self.d_head).transpose(1, 2)
|
| 136 |
+
|
| 137 |
+
# Apply RoPE to Q and K
|
| 138 |
+
q = apply_rope(q, rope_freqs)
|
| 139 |
+
k = apply_rope(k, rope_freqs)
|
| 140 |
+
|
| 141 |
+
# Use PyTorch's scaled_dot_product_attention (uses Flash Attention on CUDA)
|
| 142 |
+
if mask is not None:
|
| 143 |
+
# mask: (1, 1, T, T) additive mask
|
| 144 |
+
attn_mask = mask
|
| 145 |
+
else:
|
| 146 |
+
attn_mask = None
|
| 147 |
+
|
| 148 |
+
out = F.scaled_dot_product_attention(
|
| 149 |
+
q, k, v,
|
| 150 |
+
attn_mask=attn_mask,
|
| 151 |
+
dropout_p=self.dropout if self.training else 0.0,
|
| 152 |
+
is_causal=(mask is None),
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
# (B, n_heads, T, d_head) -> (B, T, C)
|
| 156 |
+
out = out.transpose(1, 2).contiguous().view(B, T, C)
|
| 157 |
+
return self.o_proj(out)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
class SwiGLU(nn.Module):
|
| 161 |
+
"""SwiGLU feed-forward network: (xW_gate * SiLU(xW_up)) * W_down."""
|
| 162 |
+
|
| 163 |
+
def __init__(self, config: ModelConfig):
|
| 164 |
+
super().__init__()
|
| 165 |
+
self.w_gate = nn.Linear(config.d_model, config.d_ff, bias=False)
|
| 166 |
+
self.w_up = nn.Linear(config.d_model, config.d_ff, bias=False)
|
| 167 |
+
self.w_down = nn.Linear(config.d_ff, config.d_model, bias=False)
|
| 168 |
+
|
| 169 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 170 |
+
return self.w_down(F.silu(self.w_gate(x)) * self.w_up(x))
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
class TransformerBlock(nn.Module):
|
| 174 |
+
"""One transformer decoder block: pre-norm attention + pre-norm FFN."""
|
| 175 |
+
|
| 176 |
+
def __init__(self, config: ModelConfig):
|
| 177 |
+
super().__init__()
|
| 178 |
+
self.norm1 = RMSNorm(config.d_model)
|
| 179 |
+
self.attn = Attention(config)
|
| 180 |
+
self.norm2 = RMSNorm(config.d_model)
|
| 181 |
+
self.ffn = SwiGLU(config)
|
| 182 |
+
|
| 183 |
+
def forward(
|
| 184 |
+
self,
|
| 185 |
+
x: torch.Tensor,
|
| 186 |
+
rope_freqs: torch.Tensor,
|
| 187 |
+
mask: torch.Tensor | None = None,
|
| 188 |
+
) -> torch.Tensor:
|
| 189 |
+
x = x + self.attn(self.norm1(x), rope_freqs, mask)
|
| 190 |
+
x = x + self.ffn(self.norm2(x))
|
| 191 |
+
return x
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
class Retriever500M(nn.Module):
|
| 195 |
+
"""Full decoder-only transformer model."""
|
| 196 |
+
|
| 197 |
+
def __init__(self, config: ModelConfig):
|
| 198 |
+
super().__init__()
|
| 199 |
+
self.config = config
|
| 200 |
+
|
| 201 |
+
# Token embedding (tied with output head)
|
| 202 |
+
self.token_embedding = nn.Embedding(config.vocab_size, config.d_model)
|
| 203 |
+
|
| 204 |
+
# Transformer blocks
|
| 205 |
+
self.layers = nn.ModuleList([
|
| 206 |
+
TransformerBlock(config) for _ in range(config.n_layers)
|
| 207 |
+
])
|
| 208 |
+
|
| 209 |
+
# Final norm
|
| 210 |
+
self.norm_f = RMSNorm(config.d_model)
|
| 211 |
+
|
| 212 |
+
# Output projection (tied with embedding)
|
| 213 |
+
if config.tie_embeddings:
|
| 214 |
+
self.lm_head = None # use token_embedding weight
|
| 215 |
+
else:
|
| 216 |
+
self.lm_head = nn.Linear(config.d_model, config.vocab_size, bias=False)
|
| 217 |
+
|
| 218 |
+
# Precompute RoPE frequencies (registered as buffer, moved with .to())
|
| 219 |
+
freqs = precompute_rope_frequencies(
|
| 220 |
+
config.d_head,
|
| 221 |
+
config.max_seq_len,
|
| 222 |
+
config.rope_theta,
|
| 223 |
+
)
|
| 224 |
+
self.register_buffer("rope_freqs", freqs, persistent=False)
|
| 225 |
+
|
| 226 |
+
# Causal mask buffer
|
| 227 |
+
mask = torch.full(
|
| 228 |
+
(1, 1, config.max_seq_len, config.max_seq_len),
|
| 229 |
+
float("-inf"),
|
| 230 |
+
)
|
| 231 |
+
mask = torch.triu(mask, diagonal=1)
|
| 232 |
+
self.register_buffer("causal_mask", mask, persistent=False)
|
| 233 |
+
|
| 234 |
+
# Initialize weights
|
| 235 |
+
self.apply(self._init_weights)
|
| 236 |
+
|
| 237 |
+
def _init_weights(self, module: nn.Module):
|
| 238 |
+
if isinstance(module, nn.Linear):
|
| 239 |
+
nn.init.normal_(module.weight, mean=0.0, std=0.02)
|
| 240 |
+
if module.bias is not None:
|
| 241 |
+
nn.init.zeros_(module.bias)
|
| 242 |
+
elif isinstance(module, nn.Embedding):
|
| 243 |
+
nn.init.normal_(module.weight, mean=0.0, std=0.02)
|
| 244 |
+
|
| 245 |
+
def get_output_weight(self):
|
| 246 |
+
"""Return the weight matrix for the output projection."""
|
| 247 |
+
if self.config.tie_embeddings:
|
| 248 |
+
return self.token_embedding.weight
|
| 249 |
+
return self.lm_head.weight
|
| 250 |
+
|
| 251 |
+
def forward(
|
| 252 |
+
self,
|
| 253 |
+
input_ids: torch.Tensor,
|
| 254 |
+
targets: torch.Tensor | None = None,
|
| 255 |
+
use_checkpoint: bool = False,
|
| 256 |
+
) -> dict:
|
| 257 |
+
B, T = input_ids.shape
|
| 258 |
+
|
| 259 |
+
# Token embeddings
|
| 260 |
+
x = self.token_embedding(input_ids) # (B, T, d_model)
|
| 261 |
+
|
| 262 |
+
# Get RoPE freqs and causal mask for current sequence length
|
| 263 |
+
rope_freqs = self.rope_freqs[:T]
|
| 264 |
+
mask = self.causal_mask[:, :, :T, :T]
|
| 265 |
+
|
| 266 |
+
# Transformer blocks (with optional gradient checkpointing)
|
| 267 |
+
for layer in self.layers:
|
| 268 |
+
if use_checkpoint and self.training:
|
| 269 |
+
# Gradient checkpointing: recompute activations during backward
|
| 270 |
+
x = torch.utils.checkpoint.checkpoint(
|
| 271 |
+
layer, x, rope_freqs, mask, use_reentrant=False,
|
| 272 |
+
)
|
| 273 |
+
else:
|
| 274 |
+
x = layer(x, rope_freqs, mask)
|
| 275 |
+
|
| 276 |
+
x = self.norm_f(x)
|
| 277 |
+
|
| 278 |
+
# Output logits
|
| 279 |
+
logits = F.linear(x, self.get_output_weight()) # (B, T, vocab_size)
|
| 280 |
+
|
| 281 |
+
loss = None
|
| 282 |
+
if targets is not None:
|
| 283 |
+
loss = F.cross_entropy(
|
| 284 |
+
logits.view(-1, logits.size(-1)),
|
| 285 |
+
targets.view(-1),
|
| 286 |
+
ignore_index=-100,
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
return {"logits": logits, "loss": loss}
|
| 290 |
+
|
| 291 |
+
@torch.no_grad()
|
| 292 |
+
def generate(
|
| 293 |
+
self,
|
| 294 |
+
input_ids: torch.Tensor,
|
| 295 |
+
max_new_tokens: int = 128,
|
| 296 |
+
temperature: float = 1.0,
|
| 297 |
+
top_k: int | None = None,
|
| 298 |
+
eos_token_id: int | None = None,
|
| 299 |
+
) -> torch.Tensor:
|
| 300 |
+
"""Simple autoregressive generation."""
|
| 301 |
+
self.eval()
|
| 302 |
+
for _ in range(max_new_tokens):
|
| 303 |
+
# Crop context if it exceeds max_seq_len
|
| 304 |
+
idx_cond = input_ids if input_ids.size(1) <= self.config.max_seq_len else \
|
| 305 |
+
input_ids[:, -self.config.max_seq_len:]
|
| 306 |
+
|
| 307 |
+
logits = self(idx_cond)["logits"]
|
| 308 |
+
logits = logits[:, -1, :] / max(temperature, 1e-6)
|
| 309 |
+
|
| 310 |
+
if top_k is not None:
|
| 311 |
+
v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
|
| 312 |
+
logits[logits < v[:, [-1]]] = float("-inf")
|
| 313 |
+
|
| 314 |
+
probs = F.softmax(logits, dim=-1)
|
| 315 |
+
next_token = torch.multinomial(probs, num_samples=1)
|
| 316 |
+
input_ids = torch.cat([input_ids, next_token], dim=1)
|
| 317 |
+
|
| 318 |
+
if eos_token_id is not None and next_token.item() == eos_token_id:
|
| 319 |
+
break
|
| 320 |
+
|
| 321 |
+
return input_ids
|
| 322 |
+
|
| 323 |
+
def count_parameters(self) -> int:
|
| 324 |
+
"""Count total trainable parameters."""
|
| 325 |
+
return sum(p.numel() for p in self.parameters() if p.requires_grad)
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def build_model(config: ModelConfig | None = None) -> Retriever500M:
|
| 329 |
+
"""Build the Retriever500M model."""
|
| 330 |
+
if config is None:
|
| 331 |
+
config = ModelConfig()
|
| 332 |
+
model = Retriever500M(config)
|
| 333 |
+
return model
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
if __name__ == "__main__":
|
| 337 |
+
config = ModelConfig()
|
| 338 |
+
model = build_model(config)
|
| 339 |
+
|
| 340 |
+
total_params = model.count_parameters()
|
| 341 |
+
print(f"Model: Retriever500M")
|
| 342 |
+
print(f" d_model: {config.d_model}")
|
| 343 |
+
print(f" n_layers: {config.n_layers}")
|
| 344 |
+
print(f" n_heads: {config.n_heads}")
|
| 345 |
+
print(f" d_ff: {config.d_ff}")
|
| 346 |
+
print(f" d_head: {config.d_head}")
|
| 347 |
+
print(f" vocab_size: {config.vocab_size}")
|
| 348 |
+
print(f" max_seq_len: {config.max_seq_len}")
|
| 349 |
+
print(f" Total parameters: {total_params:,} ({total_params / 1e6:.1f}M)")
|
| 350 |
+
|
| 351 |
+
# Quick forward pass test
|
| 352 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 353 |
+
model = model.to(device)
|
| 354 |
+
model.eval()
|
| 355 |
+
|
| 356 |
+
input_ids = torch.randint(0, config.vocab_size, (2, 64), device=device)
|
| 357 |
+
with torch.no_grad():
|
| 358 |
+
out = model(input_ids)
|
| 359 |
+
print(f" Output logits shape: {out['logits'].shape}")
|
| 360 |
+
print(" Forward pass OK.")
|
src/quality_filter.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Quality filtering, contamination control, and token-density validation.
|
| 3 |
+
|
| 4 |
+
Takes the deduplicated documents and applies final quality gates:
|
| 5 |
+
|
| 6 |
+
QUALITY FILTERS:
|
| 7 |
+
- Real source code (language signatures at line starts)
|
| 8 |
+
- Balanced delimiters (braces/parens/brackets roughly match)
|
| 9 |
+
- Reasonable length (200–100k chars)
|
| 10 |
+
- Low repetition (unique line ratio)
|
| 11 |
+
- Clean ASCII (low non-ASCII ratio)
|
| 12 |
+
- High code-to-prose ratio
|
| 13 |
+
- Has structure (function/class/struct definitions)
|
| 14 |
+
|
| 15 |
+
CONTAMINATION CONTROL:
|
| 16 |
+
- No test files (already filtered in download, double-check here)
|
| 17 |
+
- No auto-generated code markers
|
| 18 |
+
- No license-only files
|
| 19 |
+
- No binary/garbage content
|
| 20 |
+
- No files with extremely high repetition (copy-paste blocks)
|
| 21 |
+
|
| 22 |
+
TOKEN DENSITY:
|
| 23 |
+
- Every kept document must be "dense in tokens" — meaning the
|
| 24 |
+
content tokenizes to a meaningful number of tokens relative to
|
| 25 |
+
its character length (no whitespace-padding, no huge comment blocks).
|
| 26 |
+
- Reports token density stats using the project tokenizer.
|
| 27 |
+
|
| 28 |
+
Input: data/dedup/documents_dedup.jsonl
|
| 29 |
+
Output: data/quality/documents_quality.jsonl + quality_stats.json
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
import json
|
| 33 |
+
import os
|
| 34 |
+
import re
|
| 35 |
+
import sys
|
| 36 |
+
|
| 37 |
+
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 38 |
+
DATA_DIR = os.path.join(PROJECT_DIR, "data")
|
| 39 |
+
DEDUP_DOCS_PATH = os.path.join(DATA_DIR, "dedup", "documents_dedup.jsonl")
|
| 40 |
+
QUALITY_DIR = os.path.join(DATA_DIR, "quality")
|
| 41 |
+
QUALITY_DOCS_PATH = os.path.join(QUALITY_DIR, "documents_quality.jsonl")
|
| 42 |
+
STATS_PATH = os.path.join(QUALITY_DIR, "quality_stats.json")
|
| 43 |
+
|
| 44 |
+
# ─── Code structure patterns (must be at line start) ─────────────────────────
|
| 45 |
+
CODE_SIGNATURES = {
|
| 46 |
+
"python": re.compile(r"^(def |class |import |from \S+ import |if __name__|@|async def )", re.M),
|
| 47 |
+
"js_ts": re.compile(r"^(function |const |let |var |class |export |import |async function|interface |type \w+ =|enum )", re.M),
|
| 48 |
+
"rust": re.compile(r"^(fn |pub fn |impl |struct |enum |trait |mod |use |pub struct|pub enum|macro_rules!|pub trait)", re.M),
|
| 49 |
+
"go": re.compile(r"^(func |package |import |type \w+ struct|var |const )", re.M),
|
| 50 |
+
"c": re.compile(r"^(#include|#define|#ifndef|#ifdef|#if |#endif|typedef |struct \w+|static |void |int |char )", re.M),
|
| 51 |
+
"cpp": re.compile(r"^(#include|#define|#ifndef|#ifdef|#if |#endif|template |class \w+|namespace |struct \w+|void |int )", re.M),
|
| 52 |
+
"java": re.compile(r"^(public |private |protected |class \w+|import |package |interface )", re.M),
|
| 53 |
+
"csharp": re.compile(r"^(public |private |protected |internal |class \w+|using |namespace |interface )", re.M),
|
| 54 |
+
"ruby": re.compile(r"^(def |class |module |require |require_relative |attr_|include )", re.M),
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
# ─── Contamination / exclusion patterns ──────────────────────────────────────
|
| 58 |
+
AUTO_GEN = re.compile(
|
| 59 |
+
r"(?:auto[- ]generated|do not edit|generated by|code generated|"
|
| 60 |
+
r"DO NOT MODIFY|@generated|automatically generated|"
|
| 61 |
+
r"this file was generated)",
|
| 62 |
+
re.IGNORECASE,
|
| 63 |
+
)
|
| 64 |
+
LICENSE_ONLY = re.compile(r"^(?:/\*|//|#)\s*(?:copyright|licensed|mit license|apache license|bsd license|gnu|gpl)", re.I)
|
| 65 |
+
PROSE_LINE = re.compile(r"^[A-Z][a-z]+ .* [a-z]+\.$", re.M)
|
| 66 |
+
|
| 67 |
+
# ─── Quality scoring ─────────────────────────────────────────────────────────
|
| 68 |
+
|
| 69 |
+
def detect_language_signatures(doc: dict) -> int:
|
| 70 |
+
"""Count code-structure keywords at line starts. Returns count."""
|
| 71 |
+
content = doc["content"]
|
| 72 |
+
lang = doc["language"]
|
| 73 |
+
pat = CODE_SIGNATURES.get(lang)
|
| 74 |
+
if pat is None:
|
| 75 |
+
# Try all
|
| 76 |
+
return sum(len(p.findall(content)) for p in CODE_SIGNATURES.values())
|
| 77 |
+
return len(pat.findall(content))
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def score_document(doc: dict) -> tuple[float, str | None]:
|
| 81 |
+
"""Score a document 0.0–1.0 on quality. Returns (score, reject_reason)."""
|
| 82 |
+
content = doc["content"]
|
| 83 |
+
lines = content.split("\n")
|
| 84 |
+
n_lines = len(lines)
|
| 85 |
+
length = len(content)
|
| 86 |
+
|
| 87 |
+
# ─── Hard rejects (contamination) ──────────────────────────────────────
|
| 88 |
+
if length < 200:
|
| 89 |
+
return 0.0, "too_short"
|
| 90 |
+
if length > 200_000:
|
| 91 |
+
return 0.0, "too_long"
|
| 92 |
+
if n_lines < 5:
|
| 93 |
+
return 0.0, "too_few_lines"
|
| 94 |
+
|
| 95 |
+
# Auto-generated code
|
| 96 |
+
if AUTO_GEN.search(content[:2000]):
|
| 97 |
+
return 0.0, "auto_generated"
|
| 98 |
+
|
| 99 |
+
# License-only files (first 10 lines are all license comments)
|
| 100 |
+
first_lines = "\n".join(lines[:10])
|
| 101 |
+
if LICENSE_ONLY.search(first_lines) and n_lines < 30:
|
| 102 |
+
return 0.0, "license_only"
|
| 103 |
+
|
| 104 |
+
# High non-ASCII (garbage/encoding issues)
|
| 105 |
+
non_ascii = sum(1 for c in content if ord(c) > 127)
|
| 106 |
+
if length > 0 and non_ascii / length > 0.03:
|
| 107 |
+
return 0.0, "high_non_ascii"
|
| 108 |
+
|
| 109 |
+
# Extremely high repetition (copy-paste blocks)
|
| 110 |
+
unique_lines = len(set(lines))
|
| 111 |
+
unique_ratio = unique_lines / max(n_lines, 1)
|
| 112 |
+
if unique_ratio < 0.20:
|
| 113 |
+
return 0.0, "high_repetition"
|
| 114 |
+
|
| 115 |
+
# ─── Soft scoring ──────────────────────────────────────────────────────
|
| 116 |
+
score = 0.0
|
| 117 |
+
|
| 118 |
+
# Base: passes hard filters
|
| 119 |
+
score += 0.15
|
| 120 |
+
|
| 121 |
+
# Length quality (sweet spot: 500–30000 chars)
|
| 122 |
+
if 500 <= length <= 30000:
|
| 123 |
+
score += 0.15
|
| 124 |
+
elif 200 <= length <= 80000:
|
| 125 |
+
score += 0.08
|
| 126 |
+
|
| 127 |
+
# Delimiter balance
|
| 128 |
+
braces = content.count("{") - content.count("}")
|
| 129 |
+
parens = content.count("(") - content.count(")")
|
| 130 |
+
brackets = content.count("[") - content.count("]")
|
| 131 |
+
total_delims = content.count("{") + content.count("(") + content.count("[")
|
| 132 |
+
if total_delims > 0:
|
| 133 |
+
imbalance = abs(braces) + abs(parens) + abs(brackets)
|
| 134 |
+
balance_ratio = 1.0 - (imbalance / max(total_delims, 1))
|
| 135 |
+
score += 0.15 * max(balance_ratio, 0.0)
|
| 136 |
+
|
| 137 |
+
# Code structure density
|
| 138 |
+
struct_count = detect_language_signatures(doc)
|
| 139 |
+
struct_density = min(struct_count / max(n_lines, 1) * 10, 1.0)
|
| 140 |
+
score += 0.15 * struct_density
|
| 141 |
+
|
| 142 |
+
# Low repetition (unique line ratio)
|
| 143 |
+
if unique_ratio > 0.7:
|
| 144 |
+
score += 0.10
|
| 145 |
+
elif unique_ratio > 0.5:
|
| 146 |
+
score += 0.05
|
| 147 |
+
else:
|
| 148 |
+
score -= 0.05
|
| 149 |
+
|
| 150 |
+
# Clean ASCII
|
| 151 |
+
if length > 0 and non_ascii / length < 0.005:
|
| 152 |
+
score += 0.05
|
| 153 |
+
|
| 154 |
+
# Indentation quality (indented lines indicate real code structure)
|
| 155 |
+
indented = sum(1 for l in lines if l.startswith(" ") or l.startswith("\t"))
|
| 156 |
+
if indented > 0 and indented / max(n_lines, 1) > 0.15:
|
| 157 |
+
score += 0.05
|
| 158 |
+
|
| 159 |
+
# Penalize high prose ratio (documentation, not code)
|
| 160 |
+
prose_lines = len(PROSE_LINE.findall(content))
|
| 161 |
+
prose_ratio = prose_lines / max(n_lines, 1)
|
| 162 |
+
if prose_ratio > 0.20:
|
| 163 |
+
score -= 0.15
|
| 164 |
+
|
| 165 |
+
# Comment density (sweet spot: 3–30%)
|
| 166 |
+
comment_lines = 0
|
| 167 |
+
for line in lines:
|
| 168 |
+
s = line.strip()
|
| 169 |
+
if s.startswith("#") or s.startswith("//") or s.startswith("/*") \
|
| 170 |
+
or s.startswith("*") or s.startswith('"""') or s.startswith("'''") \
|
| 171 |
+
or s.startswith("///") or s.startswith("//!"):
|
| 172 |
+
comment_lines += 1
|
| 173 |
+
comment_ratio = comment_lines / max(n_lines, 1)
|
| 174 |
+
if 0.03 <= comment_ratio <= 0.30:
|
| 175 |
+
score += 0.10
|
| 176 |
+
elif comment_ratio > 0.50:
|
| 177 |
+
score -= 0.10 # too many comments = doc, not code
|
| 178 |
+
|
| 179 |
+
return min(max(score, 0.0), 1.0), None
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def check_token_density(doc: dict) -> tuple[bool, float]:
|
| 183 |
+
"""Check that a document is dense in tokens (not whitespace-padded).
|
| 184 |
+
|
| 185 |
+
Returns (passes, chars_per_token_ratio).
|
| 186 |
+
A good code document should have ~2.5-5 chars per token.
|
| 187 |
+
If the ratio is very high (>15), it's likely whitespace/garbage.
|
| 188 |
+
If very low (<1.5), it may be all symbols.
|
| 189 |
+
"""
|
| 190 |
+
content = doc["content"]
|
| 191 |
+
# Rough estimate: count non-whitespace characters as a proxy
|
| 192 |
+
# Real tokenization happens with the tokenizer, but this is a fast filter
|
| 193 |
+
non_ws = len(content) - content.count(" ") - content.count("\n") - content.count("\t") - content.count("\r")
|
| 194 |
+
# Approximate token count: split on whitespace + common code delimiters
|
| 195 |
+
approx_tokens = len(re.findall(r"\w+|[^\w\s]", content))
|
| 196 |
+
if approx_tokens == 0:
|
| 197 |
+
return False, 0.0
|
| 198 |
+
chars_per_token = len(content) / approx_tokens
|
| 199 |
+
# Good density: 2.0 - 8.0 chars per token
|
| 200 |
+
passes = 2.0 <= chars_per_token <= 12.0
|
| 201 |
+
return passes, chars_per_token
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def main():
|
| 205 |
+
os.makedirs(QUALITY_DIR, exist_ok=True)
|
| 206 |
+
|
| 207 |
+
print(f"Loading deduped documents from {DEDUP_DOCS_PATH}...")
|
| 208 |
+
docs = []
|
| 209 |
+
with open(DEDUP_DOCS_PATH, "r", encoding="utf-8") as f:
|
| 210 |
+
for line in f:
|
| 211 |
+
docs.append(json.loads(line))
|
| 212 |
+
print(f" Loaded {len(docs):,} documents")
|
| 213 |
+
|
| 214 |
+
kept = []
|
| 215 |
+
reject_reasons = {}
|
| 216 |
+
scores = []
|
| 217 |
+
token_densities = []
|
| 218 |
+
|
| 219 |
+
for i, doc in enumerate(docs):
|
| 220 |
+
score, reject = score_document(doc)
|
| 221 |
+
|
| 222 |
+
if reject:
|
| 223 |
+
reject_reasons[reject] = reject_reasons.get(reject, 0) + 1
|
| 224 |
+
continue
|
| 225 |
+
|
| 226 |
+
if score < 0.40:
|
| 227 |
+
reject_reasons["low_score"] = reject_reasons.get("low_score", 0) + 1
|
| 228 |
+
continue
|
| 229 |
+
|
| 230 |
+
# Token density check
|
| 231 |
+
dense, cpt = check_token_density(doc)
|
| 232 |
+
token_densities.append(cpt)
|
| 233 |
+
if not dense:
|
| 234 |
+
reject_reasons["low_token_density"] = reject_reasons.get("low_token_density", 0) + 1
|
| 235 |
+
continue
|
| 236 |
+
|
| 237 |
+
doc = dict(doc)
|
| 238 |
+
doc["quality_score"] = round(score, 4)
|
| 239 |
+
kept.append(doc)
|
| 240 |
+
scores.append(score)
|
| 241 |
+
|
| 242 |
+
if (i + 1) % 5000 == 0:
|
| 243 |
+
print(f" Processed {i+1}/{len(docs)} | kept {len(kept)} | "
|
| 244 |
+
f"rejected {i+1 - len(kept)}")
|
| 245 |
+
|
| 246 |
+
# Stats
|
| 247 |
+
final_size = sum(len(d["content"]) for d in kept)
|
| 248 |
+
avg_score = sum(scores) / len(scores) if scores else 0
|
| 249 |
+
avg_cpt = sum(token_densities) / len(token_densities) if token_densities else 0
|
| 250 |
+
|
| 251 |
+
lang_counts = {}
|
| 252 |
+
for d in kept:
|
| 253 |
+
lang_counts[d["language"]] = lang_counts.get(d["language"], 0) + 1
|
| 254 |
+
|
| 255 |
+
stats = {
|
| 256 |
+
"input": len(docs),
|
| 257 |
+
"kept": len(kept),
|
| 258 |
+
"rejected": len(docs) - len(kept),
|
| 259 |
+
"reject_reasons": reject_reasons,
|
| 260 |
+
"final_size_mb": final_size / 1e6,
|
| 261 |
+
"avg_quality_score": round(avg_score, 4),
|
| 262 |
+
"avg_chars_per_token": round(avg_cpt, 2),
|
| 263 |
+
"language_distribution": lang_counts,
|
| 264 |
+
"min_score_threshold": 0.40,
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
print("\n" + "=" * 60)
|
| 268 |
+
print("QUALITY FILTERING COMPLETE")
|
| 269 |
+
print("=" * 60)
|
| 270 |
+
print(f" Input: {len(docs):,}")
|
| 271 |
+
print(f" Kept: {len(kept):,}")
|
| 272 |
+
print(f" Rejected: {len(docs) - len(kept):,}")
|
| 273 |
+
print(f" Reject reasons: {reject_reasons}")
|
| 274 |
+
print(f" Final size: {final_size / 1e6:.1f} MB")
|
| 275 |
+
print(f" Avg quality score: {avg_score:.3f}")
|
| 276 |
+
print(f" Avg chars/token: {avg_cpt:.2f}")
|
| 277 |
+
print(f" Languages: {lang_counts}")
|
| 278 |
+
|
| 279 |
+
# Write quality-filtered documents
|
| 280 |
+
with open(QUALITY_DOCS_PATH, "w", encoding="utf-8") as f:
|
| 281 |
+
for doc in kept:
|
| 282 |
+
f.write(json.dumps(doc, ensure_ascii=False) + "\n")
|
| 283 |
+
print(f"\nQuality documents written to {QUALITY_DOCS_PATH}")
|
| 284 |
+
|
| 285 |
+
with open(STATS_PATH, "w") as f:
|
| 286 |
+
json.dump(stats, f, indent=2)
|
| 287 |
+
print(f"Stats written to {STATS_PATH}")
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
if __name__ == "__main__":
|
| 291 |
+
main()
|
src/search_agent.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Inference-time search agent harness.
|
| 3 |
+
|
| 4 |
+
This is the runtime that executes the search loop at inference time.
|
| 5 |
+
It:
|
| 6 |
+
1. Takes a query from the calling (bigger) model
|
| 7 |
+
2. Formats it as a chat message for the agent model
|
| 8 |
+
3. Generates the agent's response (which contains <|search|> actions)
|
| 9 |
+
4. Parses the search query from the response
|
| 10 |
+
5. Retrieves code chunks from the index (simple keyword/TF-IDF search)
|
| 11 |
+
6. Feeds the results back to the agent as <|result|> messages
|
| 12 |
+
7. The agent generates more reasoning or <|evidence|>/<|finish|>
|
| 13 |
+
8. Returns the evidence package to the caller
|
| 14 |
+
|
| 15 |
+
The retrieval backend is a simple in-memory keyword search over the
|
| 16 |
+
chunked corpus. This can be replaced with any retrieval backend
|
| 17 |
+
(embedding search, BM25, etc.) — the agent interface is the same.
|
| 18 |
+
|
| 19 |
+
Usage:
|
| 20 |
+
python src/search_agent.py --query "How does nginx handle connections?"
|
| 21 |
+
python src/search_agent.py --interactive
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
import argparse
|
| 25 |
+
import json
|
| 26 |
+
import os
|
| 27 |
+
import re
|
| 28 |
+
import sys
|
| 29 |
+
from collections import Counter
|
| 30 |
+
|
| 31 |
+
import torch
|
| 32 |
+
import torch.nn.functional as F
|
| 33 |
+
|
| 34 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 35 |
+
|
| 36 |
+
from model import ModelConfig, Retriever500M
|
| 37 |
+
from tokenizers import Tokenizer
|
| 38 |
+
|
| 39 |
+
# ─── Paths ───────────────────────────────────────────────────────────────────
|
| 40 |
+
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 41 |
+
CHECKPOINT_DIR = os.path.join(PROJECT_DIR, "checkpoints")
|
| 42 |
+
TOKENIZER_PATH = os.path.join(PROJECT_DIR, "tokenizer", "tokenizer_agent.json")
|
| 43 |
+
CHUNKS_PATH = os.path.join(PROJECT_DIR, "data", "chunks.jsonl")
|
| 44 |
+
|
| 45 |
+
# ─── Special tokens ──────────────────────────────────────────────────────────
|
| 46 |
+
SYSTEM_PROMPT = (
|
| 47 |
+
"You are a code search agent. Given a query from a reasoning model, "
|
| 48 |
+
"decompose it into subqueries, search the codebase, inspect results, "
|
| 49 |
+
"and return curated evidence. Use <|search|> to issue searches, "
|
| 50 |
+
"<|reasoning|> to analyze, and <|evidence|> to return findings. "
|
| 51 |
+
"Be concise. Extract only the relevant facts. End with <|finish|>."
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ─── Simple retrieval backend ────────────────────────────────────────────────
|
| 56 |
+
|
| 57 |
+
class KeywordRetriever:
|
| 58 |
+
"""Simple keyword-based retrieval over code chunks.
|
| 59 |
+
|
| 60 |
+
For production, replace this with an embedding-based retriever
|
| 61 |
+
(e.g., mxbai-embed-large or similar). The agent interface stays the same.
|
| 62 |
+
"""
|
| 63 |
+
|
| 64 |
+
def __init__(self, chunks_path: str):
|
| 65 |
+
print(f"Loading chunks from {chunks_path}...")
|
| 66 |
+
self.chunks = []
|
| 67 |
+
with open(chunks_path, "r", encoding="utf-8") as f:
|
| 68 |
+
for line in f:
|
| 69 |
+
self.chunks.append(json.loads(line))
|
| 70 |
+
print(f" Loaded {len(self.chunks):,} chunks")
|
| 71 |
+
|
| 72 |
+
# Build simple term frequency index
|
| 73 |
+
self.chunk_tokens = []
|
| 74 |
+
for chunk in self.chunks:
|
| 75 |
+
code = chunk["code"].lower()
|
| 76 |
+
# Simple tokenization: split on non-alphanumeric
|
| 77 |
+
tokens = re.findall(r"[a-z_][a-z0-9_]*", code)
|
| 78 |
+
self.chunk_tokens.append(Counter(tokens))
|
| 79 |
+
|
| 80 |
+
def search(self, query: str, top_k: int = 3) -> list[dict]:
|
| 81 |
+
"""Search for chunks matching the query. Returns top_k results."""
|
| 82 |
+
query_tokens = re.findall(r"[a-z_][a-z0-9_]*", query.lower())
|
| 83 |
+
if not query_tokens:
|
| 84 |
+
return []
|
| 85 |
+
|
| 86 |
+
scores = []
|
| 87 |
+
for i, chunk_tf in enumerate(self.chunk_tokens):
|
| 88 |
+
score = sum(chunk_tf.get(t, 0) for t in query_tokens)
|
| 89 |
+
# Normalize by chunk length to avoid bias toward long chunks
|
| 90 |
+
if sum(chunk_tf.values()) > 0:
|
| 91 |
+
score = score / (1 + sum(chunk_tf.values()) * 0.001)
|
| 92 |
+
scores.append((score, i))
|
| 93 |
+
|
| 94 |
+
scores.sort(reverse=True)
|
| 95 |
+
results = []
|
| 96 |
+
for score, idx in scores[:top_k]:
|
| 97 |
+
if score > 0:
|
| 98 |
+
chunk = self.chunks[idx]
|
| 99 |
+
results.append({
|
| 100 |
+
"code": chunk["code"],
|
| 101 |
+
"filepath": chunk["filepath"],
|
| 102 |
+
"name": chunk["name"],
|
| 103 |
+
"type": chunk["type"],
|
| 104 |
+
"language": chunk["language"],
|
| 105 |
+
"score": score,
|
| 106 |
+
})
|
| 107 |
+
return results
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ─── Agent harness ───────────────────────────────────────────────────────────
|
| 111 |
+
|
| 112 |
+
class SearchAgent:
|
| 113 |
+
"""The search agent harness that runs the search loop."""
|
| 114 |
+
|
| 115 |
+
def __init__(
|
| 116 |
+
self,
|
| 117 |
+
checkpoint_path: str,
|
| 118 |
+
tokenizer_path: str,
|
| 119 |
+
chunks_path: str,
|
| 120 |
+
device: torch.device = None,
|
| 121 |
+
max_search_rounds: int = 5,
|
| 122 |
+
max_new_tokens: int = 512,
|
| 123 |
+
):
|
| 124 |
+
self.device = device or torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 125 |
+
self.max_search_rounds = max_search_rounds
|
| 126 |
+
self.max_new_tokens = max_new_tokens
|
| 127 |
+
|
| 128 |
+
# Load tokenizer
|
| 129 |
+
self.tokenizer = Tokenizer.from_file(tokenizer_path)
|
| 130 |
+
|
| 131 |
+
# Load model
|
| 132 |
+
print(f"Loading model from {checkpoint_path}...")
|
| 133 |
+
ckpt = torch.load(checkpoint_path, map_location=self.device, weights_only=False)
|
| 134 |
+
config = ModelConfig(**ckpt["config"])
|
| 135 |
+
self.model = Retriever500M(config).to(self.device)
|
| 136 |
+
self.model.load_state_dict(ckpt["model_state_dict"])
|
| 137 |
+
self.model.eval()
|
| 138 |
+
print(f" Loaded (step {ckpt.get('step', '?')}, loss {ckpt.get('loss', '?')})")
|
| 139 |
+
|
| 140 |
+
# Load retriever
|
| 141 |
+
self.retriever = KeywordRetriever(chunks_path)
|
| 142 |
+
|
| 143 |
+
# Special token IDs
|
| 144 |
+
vocab = self.tokenizer.get_vocab()
|
| 145 |
+
self.system_id = vocab.get("<tool_call>", 32000)
|
| 146 |
+
self.user_id = vocab.get("<tool_call>", 32001)
|
| 147 |
+
self.assistant_id = vocab.get("<tool_call>", 32002)
|
| 148 |
+
self.search_id = vocab.get("<|search|>", 32003)
|
| 149 |
+
self.result_id = vocab.get("<|result|>", 32004)
|
| 150 |
+
self.evidence_id = vocab.get("<|evidence|>", 32005)
|
| 151 |
+
self.reasoning_id = vocab.get("<|reasoning|>", 32006)
|
| 152 |
+
self.finish_id = vocab.get("<|finish|>", 32007)
|
| 153 |
+
self.end_id = vocab.get("<|end|>", 32008)
|
| 154 |
+
|
| 155 |
+
def _encode(self, text: str) -> list[int]:
|
| 156 |
+
"""Encode text to token IDs."""
|
| 157 |
+
return self.tokenizer.encode(text).ids
|
| 158 |
+
|
| 159 |
+
def _decode(self, ids: list[int]) -> str:
|
| 160 |
+
"""Decode token IDs to text."""
|
| 161 |
+
return self.tokenizer.decode(ids)
|
| 162 |
+
|
| 163 |
+
def _generate(self, input_ids: torch.Tensor, max_new_tokens: int) -> str:
|
| 164 |
+
"""Generate text from the model.
|
| 165 |
+
|
| 166 |
+
Stops at <|finish|> (end of agent turn) or after a <|search|>query<|end|>
|
| 167 |
+
sequence (so the harness can inject results before continuing).
|
| 168 |
+
"""
|
| 169 |
+
input_len = input_ids.size(1)
|
| 170 |
+
recent_tokens = []
|
| 171 |
+
with torch.no_grad():
|
| 172 |
+
for _ in range(max_new_tokens):
|
| 173 |
+
# Crop context if too long
|
| 174 |
+
if input_ids.size(1) > self.model.config.max_seq_len:
|
| 175 |
+
input_ids = input_ids[:, -self.model.config.max_seq_len:]
|
| 176 |
+
|
| 177 |
+
logits = self.model(input_ids)["logits"]
|
| 178 |
+
next_logits = logits[:, -1, :]
|
| 179 |
+
|
| 180 |
+
# Greedy decoding — more reliable for a 500M model
|
| 181 |
+
next_token = next_logits.argmax(dim=-1, keepdim=True)
|
| 182 |
+
tok_id = next_token.item()
|
| 183 |
+
input_ids = torch.cat([input_ids, next_token], dim=1)
|
| 184 |
+
recent_tokens.append(tok_id)
|
| 185 |
+
|
| 186 |
+
# Stop on <|finish|>
|
| 187 |
+
if tok_id == self.finish_id:
|
| 188 |
+
break
|
| 189 |
+
|
| 190 |
+
# Stop after <|search|>...<|end|> — harness needs to inject results
|
| 191 |
+
# Detect: we've seen <|search|> (32003) and then <|end|> (32008)
|
| 192 |
+
if tok_id == self.end_id and self.search_id in recent_tokens[-20:]:
|
| 193 |
+
# Check the search was recent (within last 20 tokens)
|
| 194 |
+
search_pos = None
|
| 195 |
+
for i in range(len(recent_tokens) - 1, max(0, len(recent_tokens) - 20), -1):
|
| 196 |
+
if recent_tokens[i] == self.search_id:
|
| 197 |
+
search_pos = i
|
| 198 |
+
break
|
| 199 |
+
if search_pos is not None:
|
| 200 |
+
# Only stop if <|end|> comes right after the search query
|
| 201 |
+
# (not an <|end|> from a <|reasoning|> section that happened
|
| 202 |
+
# to be within 20 tokens)
|
| 203 |
+
# Check: is there a <|reasoning|> after the <|search|>?
|
| 204 |
+
after_search = recent_tokens[search_pos+1:]
|
| 205 |
+
if self.reasoning_id not in after_search and self.evidence_id not in after_search:
|
| 206 |
+
break
|
| 207 |
+
|
| 208 |
+
# Decode only the newly generated tokens
|
| 209 |
+
generated_ids = input_ids[0, input_len:].tolist()
|
| 210 |
+
return self._decode(generated_ids)
|
| 211 |
+
|
| 212 |
+
def _parse_search_query(self, text: str) -> str | None:
|
| 213 |
+
"""Extract the search query from the agent's response."""
|
| 214 |
+
# Look for <|search|>query<|end|>
|
| 215 |
+
match = re.search(r"<\|search\|>(.*?)<\|end\|>", text, re.DOTALL)
|
| 216 |
+
if match:
|
| 217 |
+
return match.group(1).strip()
|
| 218 |
+
return None
|
| 219 |
+
|
| 220 |
+
def _parse_evidence(self, text: str) -> str | None:
|
| 221 |
+
"""Extract the evidence from the agent's response."""
|
| 222 |
+
match = re.search(r"<\|evidence\|>(.*?)(?:<\|end\|>|<\|finish\|>|$)", text, re.DOTALL)
|
| 223 |
+
if match:
|
| 224 |
+
return match.group(1).strip()
|
| 225 |
+
return None
|
| 226 |
+
|
| 227 |
+
def _has_finish(self, text: str) -> bool:
|
| 228 |
+
"""Check if the agent has signaled completion."""
|
| 229 |
+
return "<|finish|>" in text
|
| 230 |
+
|
| 231 |
+
def search(self, query: str) -> dict:
|
| 232 |
+
"""Run the full search loop for a query.
|
| 233 |
+
|
| 234 |
+
Returns:
|
| 235 |
+
{
|
| 236 |
+
"query": the original query,
|
| 237 |
+
"evidence": the curated evidence (or None if not found),
|
| 238 |
+
"searches": list of search queries issued,
|
| 239 |
+
"results": list of all results retrieved,
|
| 240 |
+
"trace": the full conversation trace,
|
| 241 |
+
}
|
| 242 |
+
"""
|
| 243 |
+
print(f"\n{'='*60}")
|
| 244 |
+
print(f"QUERY: {query}")
|
| 245 |
+
print(f"{'='*60}")
|
| 246 |
+
|
| 247 |
+
# Build initial context
|
| 248 |
+
trace = []
|
| 249 |
+
|
| 250 |
+
# System prompt
|
| 251 |
+
system_tokens = [self.system_id] + self._encode(SYSTEM_PROMPT) + [self.end_id]
|
| 252 |
+
trace.append({"role": "system", "tokens": system_tokens})
|
| 253 |
+
|
| 254 |
+
# User query
|
| 255 |
+
user_tokens = [self.user_id] + self._encode(query) + [self.end_id]
|
| 256 |
+
trace.append({"role": "user", "tokens": user_tokens})
|
| 257 |
+
|
| 258 |
+
all_searches = []
|
| 259 |
+
all_results = []
|
| 260 |
+
evidence = None
|
| 261 |
+
|
| 262 |
+
for round_num in range(self.max_search_rounds):
|
| 263 |
+
# Build input from trace
|
| 264 |
+
all_tokens = []
|
| 265 |
+
for entry in trace:
|
| 266 |
+
all_tokens.extend(entry["tokens"])
|
| 267 |
+
|
| 268 |
+
input_ids = torch.tensor([all_tokens], dtype=torch.long, device=self.device)
|
| 269 |
+
|
| 270 |
+
# Generate agent response
|
| 271 |
+
print(f"\n--- Round {round_num + 1} ---")
|
| 272 |
+
response = self._generate(input_ids, self.max_new_tokens)
|
| 273 |
+
print(f"Agent: {response[:200]}...")
|
| 274 |
+
|
| 275 |
+
# Add assistant tokens to trace
|
| 276 |
+
assistant_tokens = [self.assistant_id] + self._encode(response)
|
| 277 |
+
if not response.endswith("<|end|>"):
|
| 278 |
+
assistant_tokens.append(self.end_id)
|
| 279 |
+
trace.append({"role": "assistant", "tokens": assistant_tokens})
|
| 280 |
+
|
| 281 |
+
# Parse search query FIRST (before checking finish)
|
| 282 |
+
search_query = self._parse_search_query(response)
|
| 283 |
+
|
| 284 |
+
if search_query:
|
| 285 |
+
print(f"[SEARCH]: {search_query}")
|
| 286 |
+
all_searches.append(search_query)
|
| 287 |
+
|
| 288 |
+
# Retrieve results
|
| 289 |
+
results = self.retriever.search(search_query, top_k=3)
|
| 290 |
+
|
| 291 |
+
if results:
|
| 292 |
+
for result in results:
|
| 293 |
+
print(f" [RESULT]: {result['name']} ({result['language']}, score={result['score']:.2f})")
|
| 294 |
+
result_tokens = [self.result_id] + self._encode(result["code"]) + [self.end_id]
|
| 295 |
+
trace.append({"role": "result", "tokens": result_tokens, "data": result})
|
| 296 |
+
all_results.append(result)
|
| 297 |
+
else:
|
| 298 |
+
print(" [NO RESULTS]")
|
| 299 |
+
result_tokens = [self.result_id, self.end_id]
|
| 300 |
+
trace.append({"role": "result", "tokens": result_tokens})
|
| 301 |
+
|
| 302 |
+
# Also check if the response contains evidence (model generated
|
| 303 |
+
# everything in one go but we intercepted the search)
|
| 304 |
+
if self._has_finish(response):
|
| 305 |
+
evidence = self._parse_evidence(response)
|
| 306 |
+
if evidence:
|
| 307 |
+
print(f"\n[EVIDENCE]: {evidence}")
|
| 308 |
+
break
|
| 309 |
+
elif self._has_finish(response):
|
| 310 |
+
# No search but has finish — extract evidence
|
| 311 |
+
evidence = self._parse_evidence(response)
|
| 312 |
+
print(f"\n[EVIDENCE]: {evidence}")
|
| 313 |
+
break
|
| 314 |
+
else:
|
| 315 |
+
# No search query found — try to extract evidence directly
|
| 316 |
+
evidence = self._parse_evidence(response)
|
| 317 |
+
if evidence:
|
| 318 |
+
print(f"\n[EVIDENCE]: {evidence}")
|
| 319 |
+
break
|
| 320 |
+
else:
|
| 321 |
+
print("[WARNING] No search or evidence found, continuing...")
|
| 322 |
+
|
| 323 |
+
return {
|
| 324 |
+
"query": query,
|
| 325 |
+
"evidence": evidence,
|
| 326 |
+
"searches": all_searches,
|
| 327 |
+
"results": all_results,
|
| 328 |
+
"trace": trace,
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
# ─── CLI ─────────────────────────────────────────────────────────────────────
|
| 333 |
+
|
| 334 |
+
def main():
|
| 335 |
+
parser = argparse.ArgumentParser(description="Run the search agent")
|
| 336 |
+
parser.add_argument("--checkpoint", type=str, default=os.path.join(CHECKPOINT_DIR, "sft_latest.pt"))
|
| 337 |
+
parser.add_argument("--query", type=str, default=None, help="Query to search for")
|
| 338 |
+
parser.add_argument("--interactive", action="store_true", help="Interactive mode")
|
| 339 |
+
parser.add_argument("--max_rounds", type=int, default=5, help="Max search rounds")
|
| 340 |
+
args = parser.parse_args()
|
| 341 |
+
|
| 342 |
+
agent = SearchAgent(
|
| 343 |
+
checkpoint_path=args.checkpoint,
|
| 344 |
+
tokenizer_path=TOKENIZER_PATH,
|
| 345 |
+
chunks_path=CHUNKS_PATH,
|
| 346 |
+
max_search_rounds=args.max_rounds,
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
if args.interactive:
|
| 350 |
+
print("\nInteractive mode. Type 'quit' to exit.")
|
| 351 |
+
while True:
|
| 352 |
+
query = input("\nQuery> ").strip()
|
| 353 |
+
if query.lower() in ("quit", "exit", "q"):
|
| 354 |
+
break
|
| 355 |
+
if query:
|
| 356 |
+
result = agent.search(query)
|
| 357 |
+
print(f"\n{'='*60}")
|
| 358 |
+
print(f"FINAL EVIDENCE:")
|
| 359 |
+
print(f"{'='*60}")
|
| 360 |
+
print(result["evidence"] or "No evidence found.")
|
| 361 |
+
elif args.query:
|
| 362 |
+
result = agent.search(args.query)
|
| 363 |
+
print(f"\n{'='*60}")
|
| 364 |
+
print(f"FINAL EVIDENCE:")
|
| 365 |
+
print(f"{'='*60}")
|
| 366 |
+
print(result["evidence"] or "No evidence found.")
|
| 367 |
+
else:
|
| 368 |
+
# Run sample queries
|
| 369 |
+
sample_queries = [
|
| 370 |
+
"How does nginx handle reusable connections?",
|
| 371 |
+
"What does the with_params_help decorator do?",
|
| 372 |
+
"What fields does the ngx_listening_s struct have?",
|
| 373 |
+
"How does the concatenate function work?",
|
| 374 |
+
"Where is the database connection pool implemented?",
|
| 375 |
+
]
|
| 376 |
+
for query in sample_queries:
|
| 377 |
+
result = agent.search(query)
|
| 378 |
+
print(f"\n{'='*60}")
|
| 379 |
+
print(f"FINAL EVIDENCE:")
|
| 380 |
+
print(f"{'='*60}")
|
| 381 |
+
print(result["evidence"] or "No evidence found.")
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
if __name__ == "__main__":
|
| 385 |
+
main()
|
src/sft.py
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Supervised fine-tuning (SFT) script for the search agent.
|
| 3 |
+
|
| 4 |
+
Key differences from pretraining:
|
| 5 |
+
1. Uses the agent tokenizer (32009 vocab, includes special tokens)
|
| 6 |
+
2. Formats traces as chat: <tool_call>...<|end|><tool_call>...<|end|><tool_call>...<|end|>
|
| 7 |
+
3. Loss masking: only compute loss on ASSISTANT tokens, not on
|
| 8 |
+
system/user/result tokens (the model should learn to GENERATE
|
| 9 |
+
the agent responses, not predict the inputs)
|
| 10 |
+
4. Lower learning rate (5e-5) — fine-tuning, not pretraining
|
| 11 |
+
5. Resizes model embedding to 32009 (from 32000)
|
| 12 |
+
|
| 13 |
+
The trace format in the training data:
|
| 14 |
+
<tool_call>system_prompt<|end|>
|
| 15 |
+
<tool_call>user_query<|end|>
|
| 16 |
+
<tool_call>assistant_turn_1<|end|> ← loss computed here
|
| 17 |
+
[result injected by harness]
|
| 18 |
+
<tool_call>result_content<|end|>
|
| 19 |
+
<tool_call>assistant_turn_2<|end|> ← loss computed here
|
| 20 |
+
...
|
| 21 |
+
<tool_call>evidence<|finish|><|end|> ← loss computed here
|
| 22 |
+
|
| 23 |
+
Usage:
|
| 24 |
+
python src/sft.py --steps N [--resume] [--batch_size N] [--lr F]
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
import argparse
|
| 28 |
+
import json
|
| 29 |
+
import os
|
| 30 |
+
import sys
|
| 31 |
+
import time
|
| 32 |
+
from dataclasses import asdict
|
| 33 |
+
|
| 34 |
+
import numpy as np
|
| 35 |
+
import torch
|
| 36 |
+
import torch.nn.functional as F
|
| 37 |
+
from tqdm import tqdm
|
| 38 |
+
|
| 39 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 40 |
+
|
| 41 |
+
from model import ModelConfig, Retriever500M
|
| 42 |
+
from tokenizers import Tokenizer
|
| 43 |
+
|
| 44 |
+
# ─── Paths ───────────────────────────────────────────────────────────────────
|
| 45 |
+
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 46 |
+
DATA_DIR = os.path.join(PROJECT_DIR, "data")
|
| 47 |
+
TOKENIZER_DIR = os.path.join(PROJECT_DIR, "tokenizer")
|
| 48 |
+
CHECKPOINT_DIR = os.path.join(PROJECT_DIR, "checkpoints")
|
| 49 |
+
LOGS_DIR = os.path.join(PROJECT_DIR, "logs")
|
| 50 |
+
|
| 51 |
+
TRACES_PATH = os.path.join(DATA_DIR, "sft_traces.jsonl")
|
| 52 |
+
GOLD_PATH = os.path.join(DATA_DIR, "gold_traces.jsonl")
|
| 53 |
+
TOKENIZER_PATH = os.path.join(TOKENIZER_DIR, "tokenizer_agent.json")
|
| 54 |
+
SPECIAL_TOKENS_PATH = os.path.join(TOKENIZER_DIR, "special_tokens.json")
|
| 55 |
+
|
| 56 |
+
# ─── Special token IDs ───────────────────────────────────────────────────────
|
| 57 |
+
# Loaded from special_tokens.json, but hardcoded as fallback
|
| 58 |
+
SYSTEM_ID = 32000
|
| 59 |
+
USER_ID = 32001
|
| 60 |
+
ASSISTANT_ID = 32002
|
| 61 |
+
SEARCH_ID = 32003
|
| 62 |
+
RESULT_ID = 32004
|
| 63 |
+
EVIDENCE_ID = 32005
|
| 64 |
+
REASONING_ID = 32006
|
| 65 |
+
FINISH_ID = 32007
|
| 66 |
+
END_ID = 32008
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def load_special_tokens():
|
| 70 |
+
"""Load special token IDs from the mapping file."""
|
| 71 |
+
global SYSTEM_ID, USER_ID, ASSISTANT_ID, SEARCH_ID, RESULT_ID
|
| 72 |
+
global EVIDENCE_ID, REASONING_ID, FINISH_ID, END_ID
|
| 73 |
+
|
| 74 |
+
if os.path.exists(SPECIAL_TOKENS_PATH):
|
| 75 |
+
with open(SPECIAL_TOKENS_PATH, "r") as f:
|
| 76 |
+
data = json.load(f)
|
| 77 |
+
ids = data["token_ids"]
|
| 78 |
+
SYSTEM_ID = ids.get("<tool_call>", 32000)
|
| 79 |
+
USER_ID = ids.get("<tool_call>", 32001)
|
| 80 |
+
ASSISTANT_ID = ids.get("<tool_call>", 32002)
|
| 81 |
+
SEARCH_ID = ids.get("<|search|>", 32003)
|
| 82 |
+
RESULT_ID = ids.get("<|result|>", 32004)
|
| 83 |
+
EVIDENCE_ID = ids.get("<|evidence|>", 32005)
|
| 84 |
+
REASONING_ID = ids.get("<|reasoning|>", 32006)
|
| 85 |
+
FINISH_ID = ids.get("<|finish|>", 32007)
|
| 86 |
+
END_ID = ids.get("<|end|>", 32008)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ─── Data formatting ─────────────────────────────────────────────────────────
|
| 90 |
+
|
| 91 |
+
def format_trace_to_tokens(trace: dict, tokenizer: Tokenizer, max_seq_len: int = 768) -> tuple[np.ndarray, np.ndarray]:
|
| 92 |
+
"""Convert a trace to (input_ids, loss_mask) arrays.
|
| 93 |
+
|
| 94 |
+
loss_mask[i] = 1 if we should compute loss on token i, 0 otherwise.
|
| 95 |
+
Loss is only computed on ASSISTANT turns (and the special tokens within them).
|
| 96 |
+
"""
|
| 97 |
+
messages = trace["trace"]
|
| 98 |
+
|
| 99 |
+
all_tokens = []
|
| 100 |
+
loss_mask = []
|
| 101 |
+
|
| 102 |
+
for msg in messages:
|
| 103 |
+
role = msg["role"]
|
| 104 |
+
content = msg["content"]
|
| 105 |
+
|
| 106 |
+
if role == "system":
|
| 107 |
+
# System: <tool_call>content<|end|> — no loss
|
| 108 |
+
role_id = SYSTEM_ID
|
| 109 |
+
tokens = [role_id] + tokenizer.encode(content).ids + [END_ID]
|
| 110 |
+
all_tokens.extend(tokens)
|
| 111 |
+
loss_mask.extend([0] * len(tokens))
|
| 112 |
+
|
| 113 |
+
elif role == "user":
|
| 114 |
+
# User: <tool_call>content<|end|> — no loss
|
| 115 |
+
role_id = USER_ID
|
| 116 |
+
tokens = [role_id] + tokenizer.encode(content).ids + [END_ID]
|
| 117 |
+
all_tokens.extend(tokens)
|
| 118 |
+
loss_mask.extend([0] * len(tokens))
|
| 119 |
+
|
| 120 |
+
elif role == "assistant":
|
| 121 |
+
# Assistant: <tool_call>content<|end|> — LOSS on all tokens
|
| 122 |
+
role_id = ASSISTANT_ID
|
| 123 |
+
# The content already contains <|search|>, <|reasoning|>, etc. as text
|
| 124 |
+
# We need to encode them properly
|
| 125 |
+
tokens = [role_id] + tokenizer.encode(content).ids + [END_ID]
|
| 126 |
+
all_tokens.extend(tokens)
|
| 127 |
+
loss_mask.extend([1] * len(tokens))
|
| 128 |
+
|
| 129 |
+
elif role == "result":
|
| 130 |
+
# Result: injected by harness, no loss
|
| 131 |
+
# Format as <|result|>content<|end|>
|
| 132 |
+
if content:
|
| 133 |
+
tokens = [RESULT_ID] + tokenizer.encode(content).ids + [END_ID]
|
| 134 |
+
else:
|
| 135 |
+
tokens = [RESULT_ID, END_ID]
|
| 136 |
+
all_tokens.extend(tokens)
|
| 137 |
+
loss_mask.extend([0] * len(tokens))
|
| 138 |
+
|
| 139 |
+
# Truncate to max_seq_len
|
| 140 |
+
if len(all_tokens) > max_seq_len:
|
| 141 |
+
all_tokens = all_tokens[:max_seq_len]
|
| 142 |
+
loss_mask = loss_mask[:max_seq_len]
|
| 143 |
+
|
| 144 |
+
return np.array(all_tokens, dtype=np.int32), np.array(loss_mask, dtype=np.int32)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def load_sft_dataset(traces_path: str, tokenizer: Tokenizer, max_seq_len: int = 768) -> list[tuple[np.ndarray, np.ndarray]]:
|
| 148 |
+
"""Load all SFT traces and convert to (input_ids, loss_mask) pairs."""
|
| 149 |
+
print(f"Loading SFT traces from {traces_path}...")
|
| 150 |
+
dataset = []
|
| 151 |
+
with open(traces_path, "r", encoding="utf-8") as f:
|
| 152 |
+
for line in f:
|
| 153 |
+
trace = json.loads(line)
|
| 154 |
+
ids, mask = format_trace_to_tokens(trace, tokenizer, max_seq_len)
|
| 155 |
+
if len(ids) > 10: # skip traces that are too short
|
| 156 |
+
dataset.append((ids, mask))
|
| 157 |
+
print(f" Loaded {len(dataset):,} traces")
|
| 158 |
+
return dataset
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def get_sft_batch(
|
| 162 |
+
dataset: list[tuple[np.ndarray, np.ndarray]],
|
| 163 |
+
batch_size: int,
|
| 164 |
+
seq_len: int,
|
| 165 |
+
device: torch.device,
|
| 166 |
+
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
| 167 |
+
"""Sample a batch of SFT traces.
|
| 168 |
+
|
| 169 |
+
Returns (input_ids, targets, loss_mask) where:
|
| 170 |
+
- input_ids: (B, T) token IDs
|
| 171 |
+
- targets: (B, T) shifted targets (next token prediction)
|
| 172 |
+
- loss_mask: (B, T) 1 where loss should be computed, 0 elsewhere
|
| 173 |
+
"""
|
| 174 |
+
# Randomly sample traces
|
| 175 |
+
indices = np.random.randint(0, len(dataset), size=batch_size)
|
| 176 |
+
|
| 177 |
+
input_ids_list = []
|
| 178 |
+
loss_mask_list = []
|
| 179 |
+
|
| 180 |
+
for idx in indices:
|
| 181 |
+
ids, mask = dataset[idx]
|
| 182 |
+
# Pad or truncate to seq_len
|
| 183 |
+
if len(ids) < seq_len:
|
| 184 |
+
pad_len = seq_len - len(ids)
|
| 185 |
+
ids = np.concatenate([ids, np.zeros(pad_len, dtype=np.int32)])
|
| 186 |
+
mask = np.concatenate([mask, np.zeros(pad_len, dtype=np.int32)])
|
| 187 |
+
else:
|
| 188 |
+
ids = ids[:seq_len]
|
| 189 |
+
mask = mask[:seq_len]
|
| 190 |
+
input_ids_list.append(ids)
|
| 191 |
+
loss_mask_list.append(mask)
|
| 192 |
+
|
| 193 |
+
input_ids = torch.from_numpy(np.stack(input_ids_list)).long().to(device)
|
| 194 |
+
loss_mask = torch.from_numpy(np.stack(loss_mask_list)).long().to(device)
|
| 195 |
+
|
| 196 |
+
# Targets are shifted input_ids
|
| 197 |
+
targets = torch.cat([input_ids[:, 1:], torch.zeros_like(input_ids[:, :1])], dim=1)
|
| 198 |
+
|
| 199 |
+
return input_ids, targets, loss_mask
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
# ─── Training ────────────────────────────────────────────────────────────────
|
| 203 |
+
|
| 204 |
+
def setup_optimizer(model, lr, use_8bit=True):
|
| 205 |
+
"""Same as pretraining optimizer setup."""
|
| 206 |
+
decay_params, no_decay_params = [], []
|
| 207 |
+
for name, param in model.named_parameters():
|
| 208 |
+
if not param.requires_grad:
|
| 209 |
+
continue
|
| 210 |
+
if "embedding" in name or "norm" in name:
|
| 211 |
+
no_decay_params.append(param)
|
| 212 |
+
else:
|
| 213 |
+
decay_params.append(param)
|
| 214 |
+
|
| 215 |
+
param_groups = [
|
| 216 |
+
{"params": decay_params, "weight_decay": 0.1},
|
| 217 |
+
{"params": no_decay_params, "weight_decay": 0.0},
|
| 218 |
+
]
|
| 219 |
+
|
| 220 |
+
if use_8bit:
|
| 221 |
+
try:
|
| 222 |
+
import bitsandbytes as bnb
|
| 223 |
+
optimizer = bnb.optim.AdamW8bit(param_groups, lr=lr, betas=(0.9, 0.95), eps=1e-8)
|
| 224 |
+
print("Using 8-bit AdamW (bitsandbytes)")
|
| 225 |
+
return optimizer
|
| 226 |
+
except Exception as e:
|
| 227 |
+
print(f"8-bit optimizer unavailable ({e}), falling back to AdamW")
|
| 228 |
+
|
| 229 |
+
optimizer = torch.optim.AdamW(param_groups, lr=lr, betas=(0.9, 0.95), eps=1e-8)
|
| 230 |
+
print("Using standard AdamW")
|
| 231 |
+
return optimizer
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def get_lr(step, warmup, max_steps, max_lr, min_lr):
|
| 235 |
+
"""Cosine LR schedule with linear warmup."""
|
| 236 |
+
if step < warmup:
|
| 237 |
+
return max_lr * (step + 1) / warmup
|
| 238 |
+
if step > max_steps:
|
| 239 |
+
return min_lr
|
| 240 |
+
decay_ratio = (step - warmup) / (max_steps - warmup)
|
| 241 |
+
coeff = 0.5 * (1.0 + np.cos(np.pi * decay_ratio))
|
| 242 |
+
return min_lr + coeff * (max_lr - min_lr)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def resize_embeddings(model, new_vocab_size):
|
| 246 |
+
"""Resize the model's token embedding to accommodate new tokens."""
|
| 247 |
+
old_size = model.token_embedding.weight.shape[0]
|
| 248 |
+
if old_size == new_vocab_size:
|
| 249 |
+
return
|
| 250 |
+
|
| 251 |
+
print(f"Resizing embeddings: {old_size} -> {new_vocab_size}")
|
| 252 |
+
d_model = model.config.d_model
|
| 253 |
+
|
| 254 |
+
# Create new embedding with the old weights + new random weights
|
| 255 |
+
old_weight = model.token_embedding.weight.data
|
| 256 |
+
new_embedding = nn.Embedding(new_vocab_size, d_model)
|
| 257 |
+
new_embedding.weight.data[:old_size] = old_weight
|
| 258 |
+
# Initialize new tokens with small random values
|
| 259 |
+
nn.init.normal_(new_embedding.weight.data[old_size:], mean=0.0, std=0.02)
|
| 260 |
+
model.token_embedding = new_embedding
|
| 261 |
+
|
| 262 |
+
# If tied, the output weight is the same, so nothing else to do
|
| 263 |
+
# If not tied, resize lm_head too
|
| 264 |
+
if not model.config.tie_embeddings and model.lm_head is not None:
|
| 265 |
+
model.lm_head = nn.Linear(d_model, new_vocab_size, bias=False)
|
| 266 |
+
with torch.no_grad():
|
| 267 |
+
model.lm_head.weight.data[:old_size] = old_weight
|
| 268 |
+
nn.init.normal_(model.lm_head.weight.data[old_size:], mean=0.0, std=0.02)
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
from torch import nn
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def train(args):
|
| 275 |
+
load_special_tokens()
|
| 276 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 277 |
+
print(f"Device: {device}")
|
| 278 |
+
if device.type == "cuda":
|
| 279 |
+
print(f"GPU: {torch.cuda.get_device_name(0)}")
|
| 280 |
+
|
| 281 |
+
os.makedirs(CHECKPOINT_DIR, exist_ok=True)
|
| 282 |
+
os.makedirs(LOGS_DIR, exist_ok=True)
|
| 283 |
+
|
| 284 |
+
# ─── Tokenizer ───────────────────────────────────────────────────────────
|
| 285 |
+
print("Loading agent tokenizer...")
|
| 286 |
+
tokenizer = Tokenizer.from_file(TOKENIZER_PATH)
|
| 287 |
+
vocab_size = tokenizer.get_vocab_size()
|
| 288 |
+
print(f"Vocab size: {vocab_size}")
|
| 289 |
+
|
| 290 |
+
# ─── Data ────────────────────────────────────────────────────────────────
|
| 291 |
+
dataset = load_sft_dataset(TRACES_PATH, tokenizer, args.seq_len)
|
| 292 |
+
|
| 293 |
+
# Also load gold traces
|
| 294 |
+
gold_dataset = load_sft_dataset(GOLD_PATH, tokenizer, args.seq_len)
|
| 295 |
+
dataset.extend(gold_dataset)
|
| 296 |
+
print(f" Total (with gold): {len(dataset):,}")
|
| 297 |
+
|
| 298 |
+
# ─── Model ───────────────────────────────────────────────────────────────
|
| 299 |
+
config = ModelConfig(
|
| 300 |
+
vocab_size=vocab_size, # 32009
|
| 301 |
+
d_model=1_280,
|
| 302 |
+
n_layers=23,
|
| 303 |
+
n_heads=20,
|
| 304 |
+
d_ff=3_456,
|
| 305 |
+
max_seq_len=args.seq_len,
|
| 306 |
+
dropout=0.0,
|
| 307 |
+
tie_embeddings=True,
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
model = Retriever500M(config).to(device)
|
| 311 |
+
|
| 312 |
+
# ─── Load pretrained checkpoint ──────────────────────────────────────────
|
| 313 |
+
ckpt_path = args.resume_path or os.path.join(CHECKPOINT_DIR, "latest.pt")
|
| 314 |
+
if os.path.exists(ckpt_path):
|
| 315 |
+
print(f"Loading pretrained weights from {ckpt_path}...")
|
| 316 |
+
ckpt = torch.load(ckpt_path, map_location=device, weights_only=False)
|
| 317 |
+
old_config = ModelConfig(**ckpt["config"])
|
| 318 |
+
|
| 319 |
+
# Load state dict, handling vocab size mismatch
|
| 320 |
+
state_dict = ckpt["model_state_dict"]
|
| 321 |
+
old_vocab = old_config.vocab_size
|
| 322 |
+
|
| 323 |
+
if old_vocab != vocab_size:
|
| 324 |
+
print(f" Vocab size mismatch: {old_vocab} -> {vocab_size}")
|
| 325 |
+
print(f" Resizing embeddings in state dict...")
|
| 326 |
+
# Resize token_embedding in the state dict
|
| 327 |
+
old_weight = state_dict["token_embedding.weight"]
|
| 328 |
+
d_model = old_weight.shape[1]
|
| 329 |
+
new_weight = torch.zeros(vocab_size, d_model)
|
| 330 |
+
new_weight[:old_vocab] = old_weight
|
| 331 |
+
nn.init.normal_(new_weight[old_vocab:], mean=0.0, std=0.02)
|
| 332 |
+
state_dict["token_embedding.weight"] = new_weight
|
| 333 |
+
|
| 334 |
+
model.load_state_dict(state_dict)
|
| 335 |
+
print(f" Loaded (step {ckpt.get('step', '?')}, loss {ckpt.get('loss', '?')})")
|
| 336 |
+
else:
|
| 337 |
+
print(f"WARNING: No checkpoint at {ckpt_path}, starting from scratch!")
|
| 338 |
+
|
| 339 |
+
total_params = model.count_parameters()
|
| 340 |
+
print(f"Model parameters: {total_params:,} ({total_params / 1e6:.1f}M)")
|
| 341 |
+
|
| 342 |
+
# ─── Optimizer ───────────────────────────────────────────────────────────
|
| 343 |
+
optimizer = setup_optimizer(model, args.lr, use_8bit=args.use_8bit_adam)
|
| 344 |
+
|
| 345 |
+
# ─── Training loop ───────────────────────────────────────────────────────
|
| 346 |
+
effective_batch = args.batch_size * args.grad_accum
|
| 347 |
+
print(f"\nSFT configuration:")
|
| 348 |
+
print(f" Batch size: {args.batch_size}")
|
| 349 |
+
print(f" Grad accum: {args.grad_accum}")
|
| 350 |
+
print(f" Effective batch: {effective_batch}")
|
| 351 |
+
print(f" Sequence length: {args.seq_len}")
|
| 352 |
+
print(f" Learning rate: {args.lr}")
|
| 353 |
+
print(f" Steps: {args.steps}")
|
| 354 |
+
print(f" Warmup: {args.warmup}")
|
| 355 |
+
print()
|
| 356 |
+
|
| 357 |
+
log = {
|
| 358 |
+
"config": asdict(config),
|
| 359 |
+
"train_args": vars(args),
|
| 360 |
+
"total_params": total_params,
|
| 361 |
+
"steps": [],
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
model.train()
|
| 365 |
+
start_time = time.time()
|
| 366 |
+
accum_loss = 0.0
|
| 367 |
+
best_loss = float("inf")
|
| 368 |
+
|
| 369 |
+
pbar = tqdm(range(args.steps), desc="SFT")
|
| 370 |
+
for step in pbar:
|
| 371 |
+
lr = get_lr(step, args.warmup, args.steps, args.lr, args.lr * 0.1)
|
| 372 |
+
for pg in optimizer.param_groups:
|
| 373 |
+
pg["lr"] = lr
|
| 374 |
+
|
| 375 |
+
optimizer.zero_grad(set_to_none=True)
|
| 376 |
+
|
| 377 |
+
total_loss = 0.0
|
| 378 |
+
for micro_step in range(args.grad_accum):
|
| 379 |
+
input_ids, targets, loss_mask = get_sft_batch(
|
| 380 |
+
dataset, args.batch_size, args.seq_len, device
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
+
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
|
| 384 |
+
out = model(input_ids, targets=targets, use_checkpoint=args.grad_checkpoint)
|
| 385 |
+
loss = out["loss"]
|
| 386 |
+
|
| 387 |
+
# Apply loss mask — only compute loss on assistant tokens
|
| 388 |
+
# loss is already computed over all positions; we need to recompute
|
| 389 |
+
# with the mask
|
| 390 |
+
logits = out["logits"]
|
| 391 |
+
# Recompute loss with mask
|
| 392 |
+
if loss_mask.sum() > 0:
|
| 393 |
+
# Shift mask to align with next-token prediction
|
| 394 |
+
shifted_mask = loss_mask[:, 1:].contiguous()
|
| 395 |
+
masked_logits = logits[:, :-1, :].contiguous()
|
| 396 |
+
masked_targets = targets[:, :-1].contiguous()
|
| 397 |
+
|
| 398 |
+
# Flatten and apply mask
|
| 399 |
+
flat_logits = masked_logits.view(-1, masked_logits.size(-1))
|
| 400 |
+
flat_targets = masked_targets.view(-1)
|
| 401 |
+
flat_mask = shifted_mask.view(-1).float()
|
| 402 |
+
|
| 403 |
+
per_token_loss = F.cross_entropy(
|
| 404 |
+
flat_logits, flat_targets,
|
| 405 |
+
ignore_index=-100, reduction="none"
|
| 406 |
+
)
|
| 407 |
+
masked_loss = (per_token_loss * flat_mask).sum() / flat_mask.sum().clamp(min=1)
|
| 408 |
+
loss = masked_loss / args.grad_accum
|
| 409 |
+
else:
|
| 410 |
+
loss = loss / args.grad_accum
|
| 411 |
+
|
| 412 |
+
loss.backward()
|
| 413 |
+
total_loss += loss.item()
|
| 414 |
+
|
| 415 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
| 416 |
+
optimizer.step()
|
| 417 |
+
|
| 418 |
+
avg_loss = total_loss
|
| 419 |
+
accum_loss = accum_loss * 0.95 + avg_loss * 0.05
|
| 420 |
+
|
| 421 |
+
if step % args.log_every == 0 or step == args.steps - 1:
|
| 422 |
+
elapsed = time.time() - start_time
|
| 423 |
+
steps_per_sec = (step + 1) / elapsed
|
| 424 |
+
vram_used = torch.cuda.max_memory_allocated() / 1e9 if device.type == "cuda" else 0
|
| 425 |
+
|
| 426 |
+
log_entry = {
|
| 427 |
+
"step": step,
|
| 428 |
+
"loss": avg_loss,
|
| 429 |
+
"ema_loss": accum_loss,
|
| 430 |
+
"lr": lr,
|
| 431 |
+
"elapsed_s": elapsed,
|
| 432 |
+
"steps_per_sec": steps_per_sec,
|
| 433 |
+
"vram_gb": vram_used,
|
| 434 |
+
}
|
| 435 |
+
log["steps"].append(log_entry)
|
| 436 |
+
|
| 437 |
+
pbar.set_postfix({
|
| 438 |
+
"loss": f"{avg_loss:.4f}",
|
| 439 |
+
"ema": f"{accum_loss:.4f}",
|
| 440 |
+
"lr": f"{lr:.2e}",
|
| 441 |
+
"vram": f"{vram_used:.1f}G",
|
| 442 |
+
})
|
| 443 |
+
|
| 444 |
+
if (step + 1) % args.save_every == 0 or step == args.steps - 1:
|
| 445 |
+
ckpt_path = os.path.join(CHECKPOINT_DIR, f"sft_step_{step + 1}.pt")
|
| 446 |
+
torch.save({
|
| 447 |
+
"model_state_dict": model.state_dict(),
|
| 448 |
+
"optimizer_state_dict": optimizer.state_dict(),
|
| 449 |
+
"config": asdict(config),
|
| 450 |
+
"step": step + 1,
|
| 451 |
+
"loss": accum_loss,
|
| 452 |
+
}, ckpt_path)
|
| 453 |
+
print(f"\n Saved checkpoint: {ckpt_path}")
|
| 454 |
+
|
| 455 |
+
latest_path = os.path.join(CHECKPOINT_DIR, "sft_latest.pt")
|
| 456 |
+
torch.save({
|
| 457 |
+
"model_state_dict": model.state_dict(),
|
| 458 |
+
"config": asdict(config),
|
| 459 |
+
"step": step + 1,
|
| 460 |
+
"loss": accum_loss,
|
| 461 |
+
}, latest_path)
|
| 462 |
+
|
| 463 |
+
if accum_loss < best_loss:
|
| 464 |
+
best_loss = accum_loss
|
| 465 |
+
best_path = os.path.join(CHECKPOINT_DIR, "sft_best.pt")
|
| 466 |
+
torch.save({
|
| 467 |
+
"model_state_dict": model.state_dict(),
|
| 468 |
+
"config": asdict(config),
|
| 469 |
+
"step": step + 1,
|
| 470 |
+
"loss": accum_loss,
|
| 471 |
+
}, best_path)
|
| 472 |
+
|
| 473 |
+
if step % 50 == 0 and device.type == "cuda":
|
| 474 |
+
torch.cuda.reset_peak_memory_stats()
|
| 475 |
+
|
| 476 |
+
# Save log
|
| 477 |
+
log_path = os.path.join(LOGS_DIR, "sft_log.json")
|
| 478 |
+
with open(log_path, "w") as f:
|
| 479 |
+
json.dump(log, f, indent=2)
|
| 480 |
+
|
| 481 |
+
total_time = time.time() - start_time
|
| 482 |
+
print(f"\nSFT complete!")
|
| 483 |
+
print(f" Total time: {total_time:.1f}s ({total_time/60:.1f} min)")
|
| 484 |
+
print(f" Final EMA loss: {accum_loss:.4f}")
|
| 485 |
+
print(f" Best loss: {best_loss:.4f}")
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
def main():
|
| 489 |
+
parser = argparse.ArgumentParser(description="SFT the search agent")
|
| 490 |
+
parser.add_argument("--steps", type=int, default=500, help="Total SFT steps")
|
| 491 |
+
parser.add_argument("--batch_size", type=int, default=4, help="Micro batch size")
|
| 492 |
+
parser.add_argument("--grad_accum", type=int, default=4, help="Gradient accumulation")
|
| 493 |
+
parser.add_argument("--seq_len", type=int, default=768, help="Sequence length")
|
| 494 |
+
parser.add_argument("--lr", type=float, default=5e-5, help="Peak learning rate")
|
| 495 |
+
parser.add_argument("--warmup", type=int, default=20, help="Warmup steps")
|
| 496 |
+
parser.add_argument("--save_every", type=int, default=100, help="Save every N steps")
|
| 497 |
+
parser.add_argument("--log_every", type=int, default=10, help="Log every N steps")
|
| 498 |
+
parser.add_argument("--grad_checkpoint", action="store_true", default=True)
|
| 499 |
+
parser.add_argument("--no_grad_checkpoint", dest="grad_checkpoint", action="store_false")
|
| 500 |
+
parser.add_argument("--use_8bit_adam", action="store_true", default=True)
|
| 501 |
+
parser.add_argument("--no_8bit_adam", dest="use_8bit_adam", action="store_false")
|
| 502 |
+
parser.add_argument("--resume_path", type=str, default=None, help="Pretrained checkpoint to start from")
|
| 503 |
+
parser.add_argument("--project_dir", type=str, default=None, help="Override project directory (for Colab)")
|
| 504 |
+
parser.add_argument("--save_dir", type=str, default=None, help="Override checkpoint save directory (for Google Drive)")
|
| 505 |
+
args = parser.parse_args()
|
| 506 |
+
|
| 507 |
+
# Override paths for Colab
|
| 508 |
+
global PROJECT_DIR, DATA_DIR, TOKENIZER_DIR, CHECKPOINT_DIR, LOGS_DIR
|
| 509 |
+
global TRACES_PATH, GOLD_PATH, TOKENIZER_PATH, SPECIAL_TOKENS_PATH
|
| 510 |
+
if args.project_dir:
|
| 511 |
+
PROJECT_DIR = args.project_dir
|
| 512 |
+
DATA_DIR = os.path.join(PROJECT_DIR, "data")
|
| 513 |
+
TOKENIZER_DIR = os.path.join(PROJECT_DIR, "tokenizer")
|
| 514 |
+
LOGS_DIR = os.path.join(PROJECT_DIR, "logs")
|
| 515 |
+
TRACES_PATH = os.path.join(DATA_DIR, "sft_traces.jsonl")
|
| 516 |
+
GOLD_PATH = os.path.join(DATA_DIR, "gold_traces.jsonl")
|
| 517 |
+
TOKENIZER_PATH = os.path.join(TOKENIZER_DIR, "tokenizer_agent.json")
|
| 518 |
+
SPECIAL_TOKENS_PATH = os.path.join(TOKENIZER_DIR, "special_tokens.json")
|
| 519 |
+
if args.save_dir:
|
| 520 |
+
CHECKPOINT_DIR = args.save_dir
|
| 521 |
+
|
| 522 |
+
train(args)
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
if __name__ == "__main__":
|
| 526 |
+
main()
|
src/sft_dry_run.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dry-run SFT: load data, build model, resize embeddings, one forward pass."""
|
| 2 |
+
import sys, os, json, torch
|
| 3 |
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__))))
|
| 4 |
+
|
| 5 |
+
from sft import load_special_tokens, load_sft_dataset, get_sft_batch
|
| 6 |
+
from model import ModelConfig, Retriever500M
|
| 7 |
+
from tokenizers import Tokenizer
|
| 8 |
+
|
| 9 |
+
def main():
|
| 10 |
+
load_special_tokens()
|
| 11 |
+
tok = Tokenizer.from_file("tokenizer/tokenizer_agent.json")
|
| 12 |
+
print("Loading dataset...")
|
| 13 |
+
dataset = load_sft_dataset("data/sft_traces.jsonl", tok, max_seq_len=768)
|
| 14 |
+
print(f"Dataset size: {len(dataset)}")
|
| 15 |
+
|
| 16 |
+
config = ModelConfig(vocab_size=32009, d_model=1280, n_layers=23, n_heads=20,
|
| 17 |
+
d_ff=3456, max_seq_len=768, tie_embeddings=True)
|
| 18 |
+
model = Retriever500M(config).cuda()
|
| 19 |
+
print(f"Model: {model.count_parameters()/1e6:.1f}M params")
|
| 20 |
+
|
| 21 |
+
# Load old checkpoint and resize embeddings
|
| 22 |
+
ckpt = torch.load("checkpoints/latest.pt", map_location="cuda", weights_only=False)
|
| 23 |
+
state = ckpt["model_state_dict"]
|
| 24 |
+
old_w = state["token_embedding.weight"]
|
| 25 |
+
new_w = torch.zeros(32009, 1280)
|
| 26 |
+
new_w[:32000] = old_w
|
| 27 |
+
torch.nn.init.normal_(new_w[32000:], mean=0.0, std=0.02)
|
| 28 |
+
state["token_embedding.weight"] = new_w
|
| 29 |
+
model.load_state_dict(state)
|
| 30 |
+
print("Checkpoint loaded with resized embeddings")
|
| 31 |
+
|
| 32 |
+
# One forward pass
|
| 33 |
+
input_ids, targets, loss_mask = get_sft_batch(dataset, batch_size=2, seq_len=768, device=torch.device("cuda"))
|
| 34 |
+
print(f"Batch: {input_ids.shape}")
|
| 35 |
+
|
| 36 |
+
with torch.autocast("cuda", dtype=torch.bfloat16):
|
| 37 |
+
out = model(input_ids, targets=targets)
|
| 38 |
+
print(f"Forward pass OK: loss={out['loss'].item():.4f}")
|
| 39 |
+
|
| 40 |
+
print("SFT DRY RUN PASSED")
|
| 41 |
+
|
| 42 |
+
if __name__ == "__main__":
|
| 43 |
+
main()
|
src/train.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Base pretraining script for Retriever500M.
|
| 3 |
+
|
| 4 |
+
Memory optimizations for 8GB VRAM (RTX 4070 Laptop):
|
| 5 |
+
- bf16 mixed precision (autocast)
|
| 6 |
+
- Gradient checkpointing (recompute activations during backward)
|
| 7 |
+
- 8-bit AdamW optimizer (bitsandbytes) — halves optimizer state memory
|
| 8 |
+
- Gradient accumulation (effective batch size > micro batch size)
|
| 9 |
+
- Short sequence length (512 tokens) for base training
|
| 10 |
+
- Tied embeddings (shared input/output weight)
|
| 11 |
+
- Flash Attention via torch SDPA
|
| 12 |
+
|
| 13 |
+
Usage:
|
| 14 |
+
python src/train.py [--steps N] [--seq_len N] [--batch_size N] [--grad_accum N]
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import argparse
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
import sys
|
| 21 |
+
import time
|
| 22 |
+
from dataclasses import asdict
|
| 23 |
+
|
| 24 |
+
import numpy as np
|
| 25 |
+
import torch
|
| 26 |
+
import torch.nn.functional as F
|
| 27 |
+
from tqdm import tqdm
|
| 28 |
+
|
| 29 |
+
# Add src to path
|
| 30 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 31 |
+
|
| 32 |
+
from model import ModelConfig, Retriever500M
|
| 33 |
+
from tokenizers import Tokenizer
|
| 34 |
+
|
| 35 |
+
# ─── Paths ───────────────────────────────────────────────────────────────────
|
| 36 |
+
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 37 |
+
DATA_DIR = os.path.join(PROJECT_DIR, "data")
|
| 38 |
+
TOKENIZER_DIR = os.path.join(PROJECT_DIR, "tokenizer")
|
| 39 |
+
CHECKPOINT_DIR = os.path.join(PROJECT_DIR, "checkpoints")
|
| 40 |
+
LOGS_DIR = os.path.join(PROJECT_DIR, "logs")
|
| 41 |
+
|
| 42 |
+
CORPUS_PATH = os.path.join(DATA_DIR, "corpus.txt")
|
| 43 |
+
CURATED_CORPUS_PATH = os.path.join(DATA_DIR, "corpus_curated.txt")
|
| 44 |
+
TOKENIZER_PATH = os.path.join(TOKENIZER_DIR, "tokenizer.json")
|
| 45 |
+
|
| 46 |
+
# ─── Data Loading ────────────────────────────────────────────────────────────
|
| 47 |
+
|
| 48 |
+
def load_and_tokenize(corpus_path: str, tokenizer: Tokenizer) -> np.ndarray:
|
| 49 |
+
"""Load corpus, tokenize everything, return a flat numpy array of token IDs."""
|
| 50 |
+
print(f"Loading corpus from {corpus_path}...")
|
| 51 |
+
with open(corpus_path, "r", encoding="utf-8") as f:
|
| 52 |
+
text = f.read()
|
| 53 |
+
|
| 54 |
+
print(f"Corpus size: {len(text) / 1e6:.1f} MB")
|
| 55 |
+
|
| 56 |
+
# Tokenize in chunks to avoid memory issues
|
| 57 |
+
chunk_size = 1_000_000 # 1MB chunks
|
| 58 |
+
all_tokens = []
|
| 59 |
+
|
| 60 |
+
print("Tokenizing corpus...")
|
| 61 |
+
for i in tqdm(range(0, len(text), chunk_size)):
|
| 62 |
+
chunk = text[i : i + chunk_size]
|
| 63 |
+
encoded = tokenizer.encode(chunk)
|
| 64 |
+
all_tokens.extend(encoded.ids)
|
| 65 |
+
|
| 66 |
+
tokens = np.array(all_tokens, dtype=np.int32)
|
| 67 |
+
print(f"Total tokens: {len(tokens):,}")
|
| 68 |
+
return tokens
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def get_batch(
|
| 72 |
+
tokens: np.ndarray,
|
| 73 |
+
batch_size: int,
|
| 74 |
+
seq_len: int,
|
| 75 |
+
device: torch.device,
|
| 76 |
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 77 |
+
"""Sample a random batch of sequences from the token array.
|
| 78 |
+
|
| 79 |
+
Returns (input_ids, targets) where targets are shifted by 1.
|
| 80 |
+
"""
|
| 81 |
+
# Random starting indices
|
| 82 |
+
max_start = len(tokens) - seq_len - 1
|
| 83 |
+
indices = np.random.randint(0, max_start, size=batch_size)
|
| 84 |
+
|
| 85 |
+
# Gather sequences
|
| 86 |
+
input_ids = np.stack([tokens[i : i + seq_len] for i in indices])
|
| 87 |
+
targets = np.stack([tokens[i + 1 : i + seq_len + 1] for i in indices])
|
| 88 |
+
|
| 89 |
+
input_ids = torch.from_numpy(input_ids).long().to(device)
|
| 90 |
+
targets = torch.from_numpy(targets).long().to(device)
|
| 91 |
+
|
| 92 |
+
return input_ids, targets
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# ─── Training ────────────────────────────────────────────────────────────────
|
| 96 |
+
|
| 97 |
+
def setup_optimizer(model: Retriever500M, lr: float, use_8bit: bool = True):
|
| 98 |
+
"""Set up optimizer — 8-bit AdamW if available, else standard AdamW."""
|
| 99 |
+
# Separate embedding params (no weight decay) from rest
|
| 100 |
+
decay_params = []
|
| 101 |
+
no_decay_params = []
|
| 102 |
+
for name, param in model.named_parameters():
|
| 103 |
+
if not param.requires_grad:
|
| 104 |
+
continue
|
| 105 |
+
if "embedding" in name or "norm" in name or "weight" in name and ".weight" not in name:
|
| 106 |
+
no_decay_params.append(param)
|
| 107 |
+
else:
|
| 108 |
+
decay_params.append(param)
|
| 109 |
+
|
| 110 |
+
param_groups = [
|
| 111 |
+
{"params": decay_params, "weight_decay": 0.1},
|
| 112 |
+
{"params": no_decay_params, "weight_decay": 0.0},
|
| 113 |
+
]
|
| 114 |
+
|
| 115 |
+
if use_8bit:
|
| 116 |
+
try:
|
| 117 |
+
import bitsandbytes as bnb
|
| 118 |
+
optimizer = bnb.optim.AdamW8bit(
|
| 119 |
+
param_groups, lr=lr, betas=(0.9, 0.95), eps=1e-8,
|
| 120 |
+
)
|
| 121 |
+
print("Using 8-bit AdamW (bitsandbytes)")
|
| 122 |
+
return optimizer
|
| 123 |
+
except Exception as e:
|
| 124 |
+
print(f"8-bit optimizer unavailable ({e}), falling back to AdamW")
|
| 125 |
+
|
| 126 |
+
optimizer = torch.optim.AdamW(
|
| 127 |
+
param_groups, lr=lr, betas=(0.9, 0.95), eps=1e-8,
|
| 128 |
+
)
|
| 129 |
+
print("Using standard AdamW")
|
| 130 |
+
return optimizer
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def resume_from_checkpoint(
|
| 134 |
+
model: Retriever500M,
|
| 135 |
+
optimizer,
|
| 136 |
+
resume_path: str,
|
| 137 |
+
device: torch.device,
|
| 138 |
+
) -> tuple[int, float]:
|
| 139 |
+
"""Load model + optimizer state from a checkpoint.
|
| 140 |
+
|
| 141 |
+
Returns (start_step, best_loss) so the training loop can continue.
|
| 142 |
+
"""
|
| 143 |
+
print(f"Resuming from {resume_path}")
|
| 144 |
+
ckpt = torch.load(resume_path, map_location=device, weights_only=False)
|
| 145 |
+
|
| 146 |
+
model.load_state_dict(ckpt["model_state_dict"])
|
| 147 |
+
print(f" Loaded model weights (step {ckpt.get('step', '?')})")
|
| 148 |
+
|
| 149 |
+
if "optimizer_state_dict" in ckpt:
|
| 150 |
+
try:
|
| 151 |
+
optimizer.load_state_dict(ckpt["optimizer_state_dict"])
|
| 152 |
+
print(" Loaded optimizer state")
|
| 153 |
+
except Exception as e:
|
| 154 |
+
print(f" Could not load optimizer state ({e}); starting fresh optimizer")
|
| 155 |
+
|
| 156 |
+
start_step = int(ckpt.get("step", 0))
|
| 157 |
+
best_loss = float(ckpt.get("loss", float("inf")))
|
| 158 |
+
print(f" Resuming at step {start_step} (best_loss={best_loss:.4f})")
|
| 159 |
+
return start_step, best_loss
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def get_lr(step: int, warmup_steps: int, max_steps: int, max_lr: float, min_lr: float) -> float:
|
| 163 |
+
"""Cosine learning rate schedule with linear warmup."""
|
| 164 |
+
if step < warmup_steps:
|
| 165 |
+
return max_lr * (step + 1) / warmup_steps
|
| 166 |
+
if step > max_steps:
|
| 167 |
+
return min_lr
|
| 168 |
+
decay_ratio = (step - warmup_steps) / (max_steps - warmup_steps)
|
| 169 |
+
coeff = 0.5 * (1.0 + np.cos(np.pi * decay_ratio))
|
| 170 |
+
return min_lr + coeff * (max_lr - min_lr)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def train(args):
|
| 174 |
+
# ─── Setup ───────────────────────────────────────────────────────────────
|
| 175 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 176 |
+
print(f"Device: {device}")
|
| 177 |
+
if device.type == "cuda":
|
| 178 |
+
print(f"GPU: {torch.cuda.get_device_name(0)}")
|
| 179 |
+
print(f"VRAM: {torch.cuda.get_device_properties(0).total_memory / 1e9:.2f} GB")
|
| 180 |
+
|
| 181 |
+
os.makedirs(CHECKPOINT_DIR, exist_ok=True)
|
| 182 |
+
os.makedirs(LOGS_DIR, exist_ok=True)
|
| 183 |
+
|
| 184 |
+
# ─── Tokenizer ───────────────────────────────────────────────────────────
|
| 185 |
+
print("Loading tokenizer...")
|
| 186 |
+
tokenizer = Tokenizer.from_file(TOKENIZER_PATH)
|
| 187 |
+
vocab_size = tokenizer.get_vocab_size()
|
| 188 |
+
print(f"Vocab size: {vocab_size}")
|
| 189 |
+
|
| 190 |
+
# ─── Data ────────────────────────────────────────────────────────────────
|
| 191 |
+
if args.corpus == "curated":
|
| 192 |
+
corpus_path = CURATED_CORPUS_PATH
|
| 193 |
+
if not os.path.exists(corpus_path):
|
| 194 |
+
raise FileNotFoundError(f"Curated corpus not found: {corpus_path}. Run src/curate.py first.")
|
| 195 |
+
print(f"Using CURATED corpus: {corpus_path}")
|
| 196 |
+
elif args.corpus == "default":
|
| 197 |
+
corpus_path = CORPUS_PATH
|
| 198 |
+
else:
|
| 199 |
+
corpus_path = args.corpus
|
| 200 |
+
tokens = load_and_tokenize(corpus_path, tokenizer)
|
| 201 |
+
|
| 202 |
+
# ─── Model ───────────────────────────────────────────────────────────────
|
| 203 |
+
config = ModelConfig(
|
| 204 |
+
vocab_size=vocab_size,
|
| 205 |
+
d_model=1_280,
|
| 206 |
+
n_layers=23,
|
| 207 |
+
n_heads=20,
|
| 208 |
+
d_ff=3_456,
|
| 209 |
+
max_seq_len=args.seq_len,
|
| 210 |
+
dropout=0.0,
|
| 211 |
+
tie_embeddings=True,
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
model = Retriever500M(config).to(device)
|
| 215 |
+
total_params = model.count_parameters()
|
| 216 |
+
print(f"Model parameters: {total_params:,} ({total_params / 1e6:.1f}M)")
|
| 217 |
+
|
| 218 |
+
# ─── Optimizer ───────────────────────────────────────────────────────────
|
| 219 |
+
optimizer = setup_optimizer(model, args.lr, use_8bit=args.use_8bit_adam)
|
| 220 |
+
|
| 221 |
+
# ─── Resume from checkpoint ──────────────────────────────────────────────
|
| 222 |
+
start_step = 0
|
| 223 |
+
best_loss = float("inf")
|
| 224 |
+
prev_log_steps = []
|
| 225 |
+
if args.resume:
|
| 226 |
+
resume_path = args.resume_path or os.path.join(CHECKPOINT_DIR, "latest.pt")
|
| 227 |
+
if not os.path.exists(resume_path):
|
| 228 |
+
raise FileNotFoundError(f"Cannot resume: {resume_path} does not exist")
|
| 229 |
+
start_step, best_loss = resume_from_checkpoint(model, optimizer, resume_path, device)
|
| 230 |
+
accum_loss = best_loss # continue EMA from saved loss
|
| 231 |
+
# Load previous log entries so we append rather than overwrite history
|
| 232 |
+
prev_log_path = os.path.join(LOGS_DIR, "training_log.json")
|
| 233 |
+
if os.path.exists(prev_log_path):
|
| 234 |
+
try:
|
| 235 |
+
with open(prev_log_path, "r") as f:
|
| 236 |
+
prev_log = json.load(f)
|
| 237 |
+
prev_log_steps = prev_log.get("steps", [])
|
| 238 |
+
print(f" Loaded {len(prev_log_steps)} previous log entries")
|
| 239 |
+
except Exception as e:
|
| 240 |
+
print(f" Could not load previous log ({e})")
|
| 241 |
+
else:
|
| 242 |
+
accum_loss = 0.0
|
| 243 |
+
|
| 244 |
+
# ─── Training loop ───────────────────────────────────────────────────────
|
| 245 |
+
effective_batch = args.batch_size * args.grad_accum
|
| 246 |
+
max_steps_total = start_step + args.steps # for LR schedule continuity
|
| 247 |
+
print(f"\nTraining configuration:")
|
| 248 |
+
print(f" Micro batch size: {args.batch_size}")
|
| 249 |
+
print(f" Gradient accum: {args.grad_accum}")
|
| 250 |
+
print(f" Effective batch: {effective_batch}")
|
| 251 |
+
print(f" Sequence length: {args.seq_len}")
|
| 252 |
+
print(f" Learning rate: {args.lr}")
|
| 253 |
+
print(f" Steps this run: {args.steps}")
|
| 254 |
+
print(f" Start step: {start_step}")
|
| 255 |
+
print(f" Target step: {max_steps_total}")
|
| 256 |
+
print(f" Warmup steps: {args.warmup}")
|
| 257 |
+
print(f" Grad checkpointing: {args.grad_checkpoint}")
|
| 258 |
+
print()
|
| 259 |
+
|
| 260 |
+
# Training log
|
| 261 |
+
log = {
|
| 262 |
+
"config": asdict(config),
|
| 263 |
+
"train_args": vars(args),
|
| 264 |
+
"total_params": total_params,
|
| 265 |
+
"steps": list(prev_log_steps), # carry over previous entries
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
model.train()
|
| 269 |
+
step = start_step
|
| 270 |
+
start_time = time.time()
|
| 271 |
+
|
| 272 |
+
pbar = tqdm(range(start_step, max_steps_total), desc="Training", initial=start_step, total=max_steps_total)
|
| 273 |
+
for step in pbar:
|
| 274 |
+
# Learning rate schedule (uses absolute step for continuity)
|
| 275 |
+
lr = get_lr(step, args.warmup, max_steps_total, args.lr, args.lr * 0.1)
|
| 276 |
+
for pg in optimizer.param_groups:
|
| 277 |
+
pg["lr"] = lr
|
| 278 |
+
|
| 279 |
+
optimizer.zero_grad(set_to_none=True)
|
| 280 |
+
|
| 281 |
+
# Gradient accumulation
|
| 282 |
+
total_loss = 0.0
|
| 283 |
+
for micro_step in range(args.grad_accum):
|
| 284 |
+
input_ids, targets = get_batch(tokens, args.batch_size, args.seq_len, device)
|
| 285 |
+
|
| 286 |
+
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
|
| 287 |
+
out = model(input_ids, targets=targets, use_checkpoint=args.grad_checkpoint)
|
| 288 |
+
loss = out["loss"] / args.grad_accum
|
| 289 |
+
|
| 290 |
+
loss.backward()
|
| 291 |
+
total_loss += loss.item()
|
| 292 |
+
|
| 293 |
+
# Gradient clipping
|
| 294 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
| 295 |
+
|
| 296 |
+
# Optimizer step
|
| 297 |
+
optimizer.step()
|
| 298 |
+
|
| 299 |
+
avg_loss = total_loss # already divided by grad_accum
|
| 300 |
+
accum_loss = accum_loss * 0.95 + avg_loss * 0.05 # EMA
|
| 301 |
+
|
| 302 |
+
# Logging
|
| 303 |
+
if step % args.log_every == 0 or step == max_steps_total - 1:
|
| 304 |
+
elapsed = time.time() - start_time
|
| 305 |
+
steps_this_run = step - start_step + 1
|
| 306 |
+
steps_per_sec = steps_this_run / elapsed
|
| 307 |
+
vram_used = torch.cuda.max_memory_allocated() / 1e9 if device.type == "cuda" else 0
|
| 308 |
+
|
| 309 |
+
log_entry = {
|
| 310 |
+
"step": step,
|
| 311 |
+
"loss": avg_loss,
|
| 312 |
+
"ema_loss": accum_loss,
|
| 313 |
+
"lr": lr,
|
| 314 |
+
"elapsed_s": elapsed,
|
| 315 |
+
"steps_per_sec": steps_per_sec,
|
| 316 |
+
"vram_gb": vram_used,
|
| 317 |
+
}
|
| 318 |
+
log["steps"].append(log_entry)
|
| 319 |
+
|
| 320 |
+
pbar.set_postfix({
|
| 321 |
+
"loss": f"{avg_loss:.4f}",
|
| 322 |
+
"ema": f"{accum_loss:.4f}",
|
| 323 |
+
"lr": f"{lr:.2e}",
|
| 324 |
+
"vram": f"{vram_used:.1f}G",
|
| 325 |
+
})
|
| 326 |
+
|
| 327 |
+
# Save checkpoint
|
| 328 |
+
if (step + 1) % args.save_every == 0 or step == max_steps_total - 1:
|
| 329 |
+
ckpt_path = os.path.join(CHECKPOINT_DIR, f"model_step_{step + 1}.pt")
|
| 330 |
+
torch.save({
|
| 331 |
+
"model_state_dict": model.state_dict(),
|
| 332 |
+
"optimizer_state_dict": optimizer.state_dict(),
|
| 333 |
+
"config": asdict(config),
|
| 334 |
+
"step": step + 1,
|
| 335 |
+
"loss": accum_loss,
|
| 336 |
+
}, ckpt_path)
|
| 337 |
+
print(f"\n Saved checkpoint: {ckpt_path}")
|
| 338 |
+
|
| 339 |
+
# Also save latest
|
| 340 |
+
latest_path = os.path.join(CHECKPOINT_DIR, "latest.pt")
|
| 341 |
+
torch.save({
|
| 342 |
+
"model_state_dict": model.state_dict(),
|
| 343 |
+
"config": asdict(config),
|
| 344 |
+
"step": step + 1,
|
| 345 |
+
"loss": accum_loss,
|
| 346 |
+
}, latest_path)
|
| 347 |
+
|
| 348 |
+
if accum_loss < best_loss:
|
| 349 |
+
best_loss = accum_loss
|
| 350 |
+
best_path = os.path.join(CHECKPOINT_DIR, "best.pt")
|
| 351 |
+
torch.save({
|
| 352 |
+
"model_state_dict": model.state_dict(),
|
| 353 |
+
"config": asdict(config),
|
| 354 |
+
"step": step + 1,
|
| 355 |
+
"loss": accum_loss,
|
| 356 |
+
}, best_path)
|
| 357 |
+
|
| 358 |
+
# Reset peak memory stats periodically
|
| 359 |
+
if step % 50 == 0 and device.type == "cuda":
|
| 360 |
+
torch.cuda.reset_peak_memory_stats()
|
| 361 |
+
|
| 362 |
+
# ─── Save training log ───────────────────────────────────────────────────
|
| 363 |
+
log_path = os.path.join(LOGS_DIR, "training_log.json")
|
| 364 |
+
with open(log_path, "w") as f:
|
| 365 |
+
json.dump(log, f, indent=2)
|
| 366 |
+
print(f"\nTraining log saved to {log_path}")
|
| 367 |
+
|
| 368 |
+
total_time = time.time() - start_time
|
| 369 |
+
print(f"\nTraining complete!")
|
| 370 |
+
print(f" Total time: {total_time:.1f}s ({total_time/60:.1f} min)")
|
| 371 |
+
print(f" Final EMA loss: {accum_loss:.4f}")
|
| 372 |
+
print(f" Best loss: {best_loss:.4f}")
|
| 373 |
+
print(f" Steps/sec: {args.steps / total_time:.2f}")
|
| 374 |
+
|
| 375 |
+
return model, log
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
def main():
|
| 379 |
+
parser = argparse.ArgumentParser(description="Train Retriever500M base model")
|
| 380 |
+
parser.add_argument("--steps", type=int, default=2000, help="Total training steps")
|
| 381 |
+
parser.add_argument("--batch_size", type=int, default=4, help="Micro batch size")
|
| 382 |
+
parser.add_argument("--grad_accum", type=int, default=8, help="Gradient accumulation steps")
|
| 383 |
+
parser.add_argument("--seq_len", type=int, default=512, help="Sequence length")
|
| 384 |
+
parser.add_argument("--lr", type=float, default=3e-4, help="Peak learning rate")
|
| 385 |
+
parser.add_argument("--warmup", type=int, default=100, help="Warmup steps")
|
| 386 |
+
parser.add_argument("--save_every", type=int, default=500, help="Save checkpoint every N steps")
|
| 387 |
+
parser.add_argument("--log_every", type=int, default=10, help="Log every N steps")
|
| 388 |
+
parser.add_argument("--grad_checkpoint", action="store_true", default=True, help="Use gradient checkpointing")
|
| 389 |
+
parser.add_argument("--no_grad_checkpoint", dest="grad_checkpoint", action="store_false")
|
| 390 |
+
parser.add_argument("--use_8bit_adam", action="store_true", default=True, help="Use 8-bit AdamW")
|
| 391 |
+
parser.add_argument("--no_8bit_adam", dest="use_8bit_adam", action="store_false")
|
| 392 |
+
parser.add_argument("--resume", action="store_true", help="Resume training from latest checkpoint")
|
| 393 |
+
parser.add_argument("--resume_path", type=str, default=None, help="Specific checkpoint to resume from (default: checkpoints/latest.pt)")
|
| 394 |
+
parser.add_argument("--corpus", type=str, default="default", help="Corpus to use: 'default', 'curated', or a path")
|
| 395 |
+
args = parser.parse_args()
|
| 396 |
+
|
| 397 |
+
train(args)
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
if __name__ == "__main__":
|
| 401 |
+
main()
|
src/train_modal.py
ADDED
|
@@ -0,0 +1,840 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Modal training script for Retriever500M.
|
| 3 |
+
|
| 4 |
+
Runs pretraining and/or SFT on Modal cloud GPUs instead of the local laptop.
|
| 5 |
+
Supports resuming from the existing bf16 checkpoint.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
# Step 1: Seed the volume with the initial checkpoint
|
| 9 |
+
modal run src/train_modal.py --command seed
|
| 10 |
+
|
| 11 |
+
# Step 2: Run pretraining (continues from latest checkpoint)
|
| 12 |
+
modal run src/train_modal.py --command pretrain --steps 1000
|
| 13 |
+
|
| 14 |
+
# Step 3: Run SFT (starts from pretrained checkpoint)
|
| 15 |
+
modal run src/train_modal.py --command sft --steps 500
|
| 16 |
+
|
| 17 |
+
# Step 4: Download checkpoints back to local
|
| 18 |
+
modal run src/train_modal.py --command download
|
| 19 |
+
|
| 20 |
+
# Or do everything in one shot:
|
| 21 |
+
modal run src/train_modal.py --command pipeline --pretrain-steps 1000 --sft-steps 500
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
import os
|
| 25 |
+
import sys
|
| 26 |
+
import json
|
| 27 |
+
import time
|
| 28 |
+
import argparse
|
| 29 |
+
from dataclasses import asdict
|
| 30 |
+
|
| 31 |
+
import numpy as np
|
| 32 |
+
import torch
|
| 33 |
+
import torch.nn.functional as F
|
| 34 |
+
from tqdm import tqdm
|
| 35 |
+
|
| 36 |
+
import modal
|
| 37 |
+
|
| 38 |
+
# ─── Modal Setup ─────────────────────────────────────────────────────────────
|
| 39 |
+
|
| 40 |
+
APP_NAME = "retriever500m"
|
| 41 |
+
VOLUME_NAME = "retriever500m-data"
|
| 42 |
+
|
| 43 |
+
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 44 |
+
SRC_DIR = os.path.join(PROJECT_DIR, "src")
|
| 45 |
+
DATA_DIR = os.path.join(PROJECT_DIR, "data")
|
| 46 |
+
TOKENIZER_DIR = os.path.join(PROJECT_DIR, "tokenizer")
|
| 47 |
+
CHECKPOINT_DIR = os.path.join(PROJECT_DIR, "checkpoints")
|
| 48 |
+
|
| 49 |
+
volume = modal.Volume.from_name(VOLUME_NAME, create_if_missing=True)
|
| 50 |
+
|
| 51 |
+
# Image: PyTorch with CUDA, plus bitsandbytes for 8-bit optimizer.
|
| 52 |
+
# Data, tokenizer, src, and initial checkpoint are baked into the image.
|
| 53 |
+
# The volume is used only for saving new checkpoints and logs.
|
| 54 |
+
image = (
|
| 55 |
+
modal.Image.from_registry(
|
| 56 |
+
"pytorch/pytorch:2.4.1-cuda12.1-cudnn9-runtime",
|
| 57 |
+
add_python="3.11",
|
| 58 |
+
)
|
| 59 |
+
.pip_install("tokenizers>=0.15,<0.21", "tqdm", "numpy")
|
| 60 |
+
.pip_install("bitsandbytes>=0.43,<0.45")
|
| 61 |
+
.add_local_dir(SRC_DIR, "/root/src")
|
| 62 |
+
.add_local_dir(TOKENIZER_DIR, "/root/tokenizer")
|
| 63 |
+
.add_local_dir(DATA_DIR, "/root/data", ignore=["raw_large/", "raw/", "dedup/"])
|
| 64 |
+
.add_local_dir(CHECKPOINT_DIR, "/root/seed_checkpoints")
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
app = modal.App(APP_NAME, image=image)
|
| 68 |
+
|
| 69 |
+
# Remote paths inside the container.
|
| 70 |
+
# Data/tokenizer/src are baked into the image.
|
| 71 |
+
# The volume is mounted at /root/vol for checkpoints and logs.
|
| 72 |
+
VOL_MOUNT = "/root/vol"
|
| 73 |
+
REMOTE_DATA = "/root/data" # baked into image
|
| 74 |
+
REMOTE_CKPT = "/root/vol/checkpoints" # on volume (for new checkpoints)
|
| 75 |
+
REMOTE_LOGS = "/root/vol/logs" # on volume
|
| 76 |
+
REMOTE_TOKENIZER = "/root/tokenizer" # baked into image
|
| 77 |
+
REMOTE_SRC = "/root/src" # baked into image
|
| 78 |
+
REMOTE_SEED_CKPT = "/root/seed_checkpoints" # baked into image (initial checkpoint)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ─── Seed command ────────────────────────────────────────────────────────────
|
| 82 |
+
|
| 83 |
+
@app.function(volumes={VOL_MOUNT: volume})
|
| 84 |
+
def seed():
|
| 85 |
+
"""Copy the initial checkpoint from the image to the volume."""
|
| 86 |
+
import shutil
|
| 87 |
+
|
| 88 |
+
os.makedirs(REMOTE_CKPT, exist_ok=True)
|
| 89 |
+
os.makedirs(REMOTE_LOGS, exist_ok=True)
|
| 90 |
+
|
| 91 |
+
# Copy seed checkpoints from image to volume
|
| 92 |
+
if os.path.exists(REMOTE_SEED_CKPT):
|
| 93 |
+
for fname in os.listdir(REMOTE_SEED_CKPT):
|
| 94 |
+
src = os.path.join(REMOTE_SEED_CKPT, fname)
|
| 95 |
+
dst = os.path.join(REMOTE_CKPT, fname)
|
| 96 |
+
if os.path.isfile(src) and not os.path.exists(dst):
|
| 97 |
+
shutil.copy2(src, dst)
|
| 98 |
+
print(f" Seeded {fname} ({os.path.getsize(src) / 1e6:.1f} MB)")
|
| 99 |
+
elif os.path.exists(dst):
|
| 100 |
+
print(f" SKIP {fname} (already on volume)")
|
| 101 |
+
|
| 102 |
+
volume.commit()
|
| 103 |
+
print("Seed complete.")
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ─── Download command ────────────────────────────────────────────────────────
|
| 107 |
+
|
| 108 |
+
@app.function(volumes={VOL_MOUNT: volume})
|
| 109 |
+
def download():
|
| 110 |
+
"""Download checkpoints and logs from the Modal volume to local."""
|
| 111 |
+
import shutil
|
| 112 |
+
|
| 113 |
+
os.makedirs(CHECKPOINT_DIR, exist_ok=True)
|
| 114 |
+
os.makedirs(os.path.join(PROJECT_DIR, "logs"), exist_ok=True)
|
| 115 |
+
|
| 116 |
+
# Download checkpoints
|
| 117 |
+
for fname in os.listdir(REMOTE_CKPT):
|
| 118 |
+
src = os.path.join(REMOTE_CKPT, fname)
|
| 119 |
+
if os.path.isfile(src):
|
| 120 |
+
dst = os.path.join(CHECKPOINT_DIR, fname)
|
| 121 |
+
shutil.copy2(src, dst)
|
| 122 |
+
print(f" Downloaded {fname} ({os.path.getsize(src) / 1e6:.1f} MB)")
|
| 123 |
+
|
| 124 |
+
# Download logs
|
| 125 |
+
for fname in os.listdir(REMOTE_LOGS):
|
| 126 |
+
src = os.path.join(REMOTE_LOGS, fname)
|
| 127 |
+
if os.path.isfile(src):
|
| 128 |
+
dst = os.path.join(PROJECT_DIR, "logs", fname)
|
| 129 |
+
shutil.copy2(src, dst)
|
| 130 |
+
print(f" Downloaded log {fname}")
|
| 131 |
+
|
| 132 |
+
print("Download complete.")
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ─── Training logic (shared) ─────────────────────────────────────────────────
|
| 136 |
+
|
| 137 |
+
def get_lr(step, warmup, max_steps, max_lr, min_lr):
|
| 138 |
+
"""Cosine LR schedule with linear warmup."""
|
| 139 |
+
if step < warmup:
|
| 140 |
+
return max_lr * (step + 1) / warmup
|
| 141 |
+
if step > max_steps:
|
| 142 |
+
return min_lr
|
| 143 |
+
decay_ratio = (step - warmup) / (max_steps - warmup)
|
| 144 |
+
coeff = 0.5 * (1.0 + np.cos(np.pi * decay_ratio))
|
| 145 |
+
return min_lr + coeff * (max_lr - min_lr)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def setup_optimizer(model, lr, use_8bit=True):
|
| 149 |
+
decay_params, no_decay_params = [], []
|
| 150 |
+
for name, param in model.named_parameters():
|
| 151 |
+
if not param.requires_grad:
|
| 152 |
+
continue
|
| 153 |
+
if "embedding" in name or "norm" in name:
|
| 154 |
+
no_decay_params.append(param)
|
| 155 |
+
else:
|
| 156 |
+
decay_params.append(param)
|
| 157 |
+
|
| 158 |
+
param_groups = [
|
| 159 |
+
{"params": decay_params, "weight_decay": 0.1},
|
| 160 |
+
{"params": no_decay_params, "weight_decay": 0.0},
|
| 161 |
+
]
|
| 162 |
+
|
| 163 |
+
if use_8bit:
|
| 164 |
+
try:
|
| 165 |
+
import bitsandbytes as bnb
|
| 166 |
+
optimizer = bnb.optim.AdamW8bit(param_groups, lr=lr, betas=(0.9, 0.95), eps=1e-8)
|
| 167 |
+
print("Using 8-bit AdamW (bitsandbytes)")
|
| 168 |
+
return optimizer
|
| 169 |
+
except Exception as e:
|
| 170 |
+
print(f"8-bit optimizer unavailable ({e}), falling back to AdamW")
|
| 171 |
+
|
| 172 |
+
optimizer = torch.optim.AdamW(param_groups, lr=lr, betas=(0.9, 0.95), eps=1e-8)
|
| 173 |
+
print("Using standard AdamW")
|
| 174 |
+
return optimizer
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
# ─── Pretraining ─────────────────────────────────────────────────────────────
|
| 178 |
+
|
| 179 |
+
def load_and_tokenize(corpus_path, tokenizer):
|
| 180 |
+
"""Load corpus, tokenize, return flat numpy array of token IDs."""
|
| 181 |
+
print(f"Loading corpus from {corpus_path}...")
|
| 182 |
+
with open(corpus_path, "r", encoding="utf-8") as f:
|
| 183 |
+
text = f.read()
|
| 184 |
+
print(f"Corpus size: {len(text) / 1e6:.1f} MB")
|
| 185 |
+
|
| 186 |
+
chunk_size = 1_000_000
|
| 187 |
+
all_tokens = []
|
| 188 |
+
print("Tokenizing corpus...")
|
| 189 |
+
for i in tqdm(range(0, len(text), chunk_size)):
|
| 190 |
+
chunk = text[i : i + chunk_size]
|
| 191 |
+
encoded = tokenizer.encode(chunk)
|
| 192 |
+
all_tokens.extend(encoded.ids)
|
| 193 |
+
|
| 194 |
+
tokens = np.array(all_tokens, dtype=np.int32)
|
| 195 |
+
print(f"Total tokens: {len(tokens):,}")
|
| 196 |
+
return tokens
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def get_batch(tokens, batch_size, seq_len, device):
|
| 200 |
+
max_start = len(tokens) - seq_len - 1
|
| 201 |
+
indices = np.random.randint(0, max_start, size=batch_size)
|
| 202 |
+
input_ids = np.stack([tokens[i : i + seq_len] for i in indices])
|
| 203 |
+
targets = np.stack([tokens[i + 1 : i + seq_len + 1] for i in indices])
|
| 204 |
+
input_ids = torch.from_numpy(input_ids).long().to(device)
|
| 205 |
+
targets = torch.from_numpy(targets).long().to(device)
|
| 206 |
+
return input_ids, targets
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def run_pretrain(steps, batch_size, grad_accum, seq_len, lr, warmup,
|
| 210 |
+
save_every, log_every, corpus, resume, use_8bit_adam):
|
| 211 |
+
sys.path.insert(0, REMOTE_SRC)
|
| 212 |
+
from model import ModelConfig, Retriever500M
|
| 213 |
+
from tokenizers import Tokenizer
|
| 214 |
+
|
| 215 |
+
device = torch.device("cuda")
|
| 216 |
+
print(f"Device: {device}")
|
| 217 |
+
print(f"GPU: {torch.cuda.get_device_name(0)}")
|
| 218 |
+
print(f"VRAM: {torch.cuda.get_device_properties(0).total_memory / 1e9:.2f} GB")
|
| 219 |
+
|
| 220 |
+
os.makedirs(REMOTE_CKPT, exist_ok=True)
|
| 221 |
+
os.makedirs(REMOTE_LOGS, exist_ok=True)
|
| 222 |
+
|
| 223 |
+
# Tokenizer
|
| 224 |
+
tokenizer_path = os.path.join(REMOTE_TOKENIZER, "tokenizer.json")
|
| 225 |
+
tokenizer = Tokenizer.from_file(tokenizer_path)
|
| 226 |
+
vocab_size = tokenizer.get_vocab_size()
|
| 227 |
+
print(f"Vocab size: {vocab_size}")
|
| 228 |
+
|
| 229 |
+
# Data
|
| 230 |
+
if corpus == "curated":
|
| 231 |
+
corpus_path = os.path.join(REMOTE_DATA, "corpus_curated.txt")
|
| 232 |
+
elif corpus == "default":
|
| 233 |
+
corpus_path = os.path.join(REMOTE_DATA, "corpus.txt")
|
| 234 |
+
else:
|
| 235 |
+
corpus_path = corpus
|
| 236 |
+
tokens = load_and_tokenize(corpus_path, tokenizer)
|
| 237 |
+
|
| 238 |
+
# Model
|
| 239 |
+
config = ModelConfig(
|
| 240 |
+
vocab_size=vocab_size,
|
| 241 |
+
d_model=1_280,
|
| 242 |
+
n_layers=23,
|
| 243 |
+
n_heads=20,
|
| 244 |
+
d_ff=3_456,
|
| 245 |
+
max_seq_len=seq_len,
|
| 246 |
+
dropout=0.0,
|
| 247 |
+
tie_embeddings=True,
|
| 248 |
+
)
|
| 249 |
+
model = Retriever500M(config).to(device)
|
| 250 |
+
total_params = model.count_parameters()
|
| 251 |
+
print(f"Model parameters: {total_params:,} ({total_params / 1e6:.1f}M)")
|
| 252 |
+
|
| 253 |
+
optimizer = setup_optimizer(model, lr, use_8bit=use_8bit_adam)
|
| 254 |
+
|
| 255 |
+
# Resume
|
| 256 |
+
start_step = 0
|
| 257 |
+
best_loss = float("inf")
|
| 258 |
+
accum_loss = 0.0
|
| 259 |
+
prev_log_steps = []
|
| 260 |
+
|
| 261 |
+
if resume:
|
| 262 |
+
resume_path = os.path.join(REMOTE_CKPT, "latest.pt")
|
| 263 |
+
if not os.path.exists(resume_path):
|
| 264 |
+
resume_path = os.path.join(REMOTE_CKPT, "latest_bf16.pt")
|
| 265 |
+
if not os.path.exists(resume_path):
|
| 266 |
+
# Fall back to seed checkpoint in image
|
| 267 |
+
resume_path = os.path.join(REMOTE_SEED_CKPT, "latest_bf16.pt")
|
| 268 |
+
if os.path.exists(resume_path):
|
| 269 |
+
print(f"Resuming from {resume_path}")
|
| 270 |
+
ckpt = torch.load(resume_path, map_location=device, weights_only=False)
|
| 271 |
+
model.load_state_dict(ckpt["model_state_dict"])
|
| 272 |
+
start_step = int(ckpt.get("step", 0))
|
| 273 |
+
best_loss = float(ckpt.get("loss", float("inf")))
|
| 274 |
+
accum_loss = best_loss
|
| 275 |
+
print(f" Resuming at step {start_step} (best_loss={best_loss:.4f})")
|
| 276 |
+
|
| 277 |
+
prev_log_path = os.path.join(REMOTE_LOGS, "training_log.json")
|
| 278 |
+
if os.path.exists(prev_log_path):
|
| 279 |
+
try:
|
| 280 |
+
with open(prev_log_path, "r") as f:
|
| 281 |
+
prev_log = json.load(f)
|
| 282 |
+
prev_log_steps = prev_log.get("steps", [])
|
| 283 |
+
print(f" Loaded {len(prev_log_steps)} previous log entries")
|
| 284 |
+
except Exception:
|
| 285 |
+
pass
|
| 286 |
+
else:
|
| 287 |
+
print("WARNING: No checkpoint found, starting from scratch!")
|
| 288 |
+
|
| 289 |
+
# Training loop
|
| 290 |
+
effective_batch = batch_size * grad_accum
|
| 291 |
+
max_steps_total = start_step + steps
|
| 292 |
+
print(f"\nTraining configuration:")
|
| 293 |
+
print(f" Micro batch size: {batch_size}")
|
| 294 |
+
print(f" Gradient accum: {grad_accum}")
|
| 295 |
+
print(f" Effective batch: {effective_batch}")
|
| 296 |
+
print(f" Sequence length: {seq_len}")
|
| 297 |
+
print(f" Learning rate: {lr}")
|
| 298 |
+
print(f" Steps this run: {steps}")
|
| 299 |
+
print(f" Start step: {start_step}")
|
| 300 |
+
print(f" Target step: {max_steps_total}")
|
| 301 |
+
print()
|
| 302 |
+
|
| 303 |
+
log = {
|
| 304 |
+
"config": asdict(config),
|
| 305 |
+
"train_args": {"steps": steps, "batch_size": batch_size, "grad_accum": grad_accum,
|
| 306 |
+
"seq_len": seq_len, "lr": lr, "warmup": warmup,
|
| 307 |
+
"save_every": save_every, "log_every": log_every,
|
| 308 |
+
"corpus": corpus, "resume": resume, "use_8bit_adam": use_8bit_adam},
|
| 309 |
+
"total_params": total_params,
|
| 310 |
+
"steps": list(prev_log_steps),
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
model.train()
|
| 314 |
+
start_time = time.time()
|
| 315 |
+
|
| 316 |
+
pbar = tqdm(range(start_step, max_steps_total), desc="Training",
|
| 317 |
+
initial=start_step, total=max_steps_total)
|
| 318 |
+
for step in pbar:
|
| 319 |
+
lr_now = get_lr(step, warmup, max_steps_total, lr, lr * 0.1)
|
| 320 |
+
for pg in optimizer.param_groups:
|
| 321 |
+
pg["lr"] = lr_now
|
| 322 |
+
|
| 323 |
+
optimizer.zero_grad(set_to_none=True)
|
| 324 |
+
|
| 325 |
+
total_loss = 0.0
|
| 326 |
+
for _ in range(grad_accum):
|
| 327 |
+
input_ids, targets = get_batch(tokens, batch_size, seq_len, device)
|
| 328 |
+
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
|
| 329 |
+
out = model(input_ids, targets=targets, use_checkpoint=False)
|
| 330 |
+
loss = out["loss"] / grad_accum
|
| 331 |
+
loss.backward()
|
| 332 |
+
total_loss += loss.item()
|
| 333 |
+
|
| 334 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
| 335 |
+
optimizer.step()
|
| 336 |
+
|
| 337 |
+
avg_loss = total_loss
|
| 338 |
+
accum_loss = accum_loss * 0.95 + avg_loss * 0.05
|
| 339 |
+
|
| 340 |
+
if step % log_every == 0 or step == max_steps_total - 1:
|
| 341 |
+
elapsed = time.time() - start_time
|
| 342 |
+
steps_this_run = step - start_step + 1
|
| 343 |
+
steps_per_sec = steps_this_run / elapsed
|
| 344 |
+
vram_used = torch.cuda.max_memory_allocated() / 1e9
|
| 345 |
+
|
| 346 |
+
log_entry = {
|
| 347 |
+
"step": step, "loss": avg_loss, "ema_loss": accum_loss,
|
| 348 |
+
"lr": lr_now, "elapsed_s": elapsed,
|
| 349 |
+
"steps_per_sec": steps_per_sec, "vram_gb": vram_used,
|
| 350 |
+
}
|
| 351 |
+
log["steps"].append(log_entry)
|
| 352 |
+
pbar.set_postfix({
|
| 353 |
+
"loss": f"{avg_loss:.4f}", "ema": f"{accum_loss:.4f}",
|
| 354 |
+
"lr": f"{lr_now:.2e}", "vram": f"{vram_used:.1f}G",
|
| 355 |
+
})
|
| 356 |
+
|
| 357 |
+
if (step + 1) % save_every == 0 or step == max_steps_total - 1:
|
| 358 |
+
ckpt_path = os.path.join(REMOTE_CKPT, f"model_step_{step + 1}.pt")
|
| 359 |
+
torch.save({
|
| 360 |
+
"model_state_dict": model.state_dict(),
|
| 361 |
+
"optimizer_state_dict": optimizer.state_dict(),
|
| 362 |
+
"config": asdict(config),
|
| 363 |
+
"step": step + 1, "loss": accum_loss,
|
| 364 |
+
}, ckpt_path)
|
| 365 |
+
print(f"\n Saved checkpoint: {ckpt_path}")
|
| 366 |
+
|
| 367 |
+
latest_path = os.path.join(REMOTE_CKPT, "latest.pt")
|
| 368 |
+
torch.save({
|
| 369 |
+
"model_state_dict": model.state_dict(),
|
| 370 |
+
"config": asdict(config),
|
| 371 |
+
"step": step + 1, "loss": accum_loss,
|
| 372 |
+
}, latest_path)
|
| 373 |
+
|
| 374 |
+
if accum_loss < best_loss:
|
| 375 |
+
best_loss = accum_loss
|
| 376 |
+
best_path = os.path.join(REMOTE_CKPT, "best.pt")
|
| 377 |
+
torch.save({
|
| 378 |
+
"model_state_dict": model.state_dict(),
|
| 379 |
+
"config": asdict(config),
|
| 380 |
+
"step": step + 1, "loss": accum_loss,
|
| 381 |
+
}, best_path)
|
| 382 |
+
|
| 383 |
+
# Save log
|
| 384 |
+
log_path = os.path.join(REMOTE_LOGS, "training_log.json")
|
| 385 |
+
with open(log_path, "w") as f:
|
| 386 |
+
json.dump(log, f, indent=2)
|
| 387 |
+
|
| 388 |
+
# Commit volume so checkpoints persist
|
| 389 |
+
volume.commit()
|
| 390 |
+
|
| 391 |
+
if step % 50 == 0:
|
| 392 |
+
torch.cuda.reset_peak_memory_stats()
|
| 393 |
+
|
| 394 |
+
# Final log save
|
| 395 |
+
log_path = os.path.join(REMOTE_LOGS, "training_log.json")
|
| 396 |
+
with open(log_path, "w") as f:
|
| 397 |
+
json.dump(log, f, indent=2)
|
| 398 |
+
volume.commit()
|
| 399 |
+
|
| 400 |
+
total_time = time.time() - start_time
|
| 401 |
+
print(f"\nPretraining complete!")
|
| 402 |
+
print(f" Total time: {total_time:.1f}s ({total_time/60:.1f} min)")
|
| 403 |
+
print(f" Final EMA loss: {accum_loss:.4f}")
|
| 404 |
+
print(f" Best loss: {best_loss:.4f}")
|
| 405 |
+
return accum_loss
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
# ─── SFT ─────────────────────────────────────────────────────────────────────
|
| 409 |
+
|
| 410 |
+
# Special token IDs
|
| 411 |
+
SYSTEM_ID = 32000
|
| 412 |
+
USER_ID = 32001
|
| 413 |
+
ASSISTANT_ID = 32002
|
| 414 |
+
SEARCH_ID = 32003
|
| 415 |
+
RESULT_ID = 32004
|
| 416 |
+
EVIDENCE_ID = 32005
|
| 417 |
+
REASONING_ID = 32006
|
| 418 |
+
FINISH_ID = 32007
|
| 419 |
+
END_ID = 32008
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
def load_special_tokens():
|
| 423 |
+
global SYSTEM_ID, USER_ID, ASSISTANT_ID, SEARCH_ID, RESULT_ID
|
| 424 |
+
global EVIDENCE_ID, REASONING_ID, FINISH_ID, END_ID
|
| 425 |
+
path = os.path.join(REMOTE_TOKENIZER, "special_tokens.json")
|
| 426 |
+
if os.path.exists(path):
|
| 427 |
+
with open(path, "r") as f:
|
| 428 |
+
data = json.load(f)
|
| 429 |
+
ids = data["token_ids"]
|
| 430 |
+
SYSTEM_ID = ids.get("<tool_call>", 32000)
|
| 431 |
+
USER_ID = ids.get("<tool_call>", 32001)
|
| 432 |
+
ASSISTANT_ID = ids.get("<tool_call>", 32002)
|
| 433 |
+
SEARCH_ID = ids.get("<|search|>", 32003)
|
| 434 |
+
RESULT_ID = ids.get("<|result|>", 32004)
|
| 435 |
+
EVIDENCE_ID = ids.get("<|evidence|>", 32005)
|
| 436 |
+
REASONING_ID = ids.get("<|reasoning|>", 32006)
|
| 437 |
+
FINISH_ID = ids.get("<|finish|>", 32007)
|
| 438 |
+
END_ID = ids.get("<|end|>", 32008)
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
def format_trace_to_tokens(trace, tokenizer, max_seq_len=768):
|
| 442 |
+
messages = trace["trace"]
|
| 443 |
+
all_tokens = []
|
| 444 |
+
loss_mask = []
|
| 445 |
+
|
| 446 |
+
for msg in messages:
|
| 447 |
+
role = msg["role"]
|
| 448 |
+
content = msg["content"]
|
| 449 |
+
|
| 450 |
+
if role == "system":
|
| 451 |
+
tokens = [SYSTEM_ID] + tokenizer.encode(content).ids + [END_ID]
|
| 452 |
+
all_tokens.extend(tokens)
|
| 453 |
+
loss_mask.extend([0] * len(tokens))
|
| 454 |
+
elif role == "user":
|
| 455 |
+
tokens = [USER_ID] + tokenizer.encode(content).ids + [END_ID]
|
| 456 |
+
all_tokens.extend(tokens)
|
| 457 |
+
loss_mask.extend([0] * len(tokens))
|
| 458 |
+
elif role == "assistant":
|
| 459 |
+
tokens = [ASSISTANT_ID] + tokenizer.encode(content).ids + [END_ID]
|
| 460 |
+
all_tokens.extend(tokens)
|
| 461 |
+
loss_mask.extend([1] * len(tokens))
|
| 462 |
+
elif role == "result":
|
| 463 |
+
if content:
|
| 464 |
+
tokens = [RESULT_ID] + tokenizer.encode(content).ids + [END_ID]
|
| 465 |
+
else:
|
| 466 |
+
tokens = [RESULT_ID, END_ID]
|
| 467 |
+
all_tokens.extend(tokens)
|
| 468 |
+
loss_mask.extend([0] * len(tokens))
|
| 469 |
+
|
| 470 |
+
if len(all_tokens) > max_seq_len:
|
| 471 |
+
all_tokens = all_tokens[:max_seq_len]
|
| 472 |
+
loss_mask = loss_mask[:max_seq_len]
|
| 473 |
+
|
| 474 |
+
return np.array(all_tokens, dtype=np.int32), np.array(loss_mask, dtype=np.int32)
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
def load_sft_dataset(traces_path, tokenizer, max_seq_len=768):
|
| 478 |
+
print(f"Loading SFT traces from {traces_path}...")
|
| 479 |
+
dataset = []
|
| 480 |
+
with open(traces_path, "r", encoding="utf-8") as f:
|
| 481 |
+
for line in f:
|
| 482 |
+
trace = json.loads(line)
|
| 483 |
+
ids, mask = format_trace_to_tokens(trace, tokenizer, max_seq_len)
|
| 484 |
+
if len(ids) > 10:
|
| 485 |
+
dataset.append((ids, mask))
|
| 486 |
+
print(f" Loaded {len(dataset):,} traces")
|
| 487 |
+
return dataset
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
def get_sft_batch(dataset, batch_size, seq_len, device):
|
| 491 |
+
indices = np.random.randint(0, len(dataset), size=batch_size)
|
| 492 |
+
input_ids_list = []
|
| 493 |
+
loss_mask_list = []
|
| 494 |
+
|
| 495 |
+
for idx in indices:
|
| 496 |
+
ids, mask = dataset[idx]
|
| 497 |
+
if len(ids) < seq_len:
|
| 498 |
+
pad_len = seq_len - len(ids)
|
| 499 |
+
ids = np.concatenate([ids, np.zeros(pad_len, dtype=np.int32)])
|
| 500 |
+
mask = np.concatenate([mask, np.zeros(pad_len, dtype=np.int32)])
|
| 501 |
+
else:
|
| 502 |
+
ids = ids[:seq_len]
|
| 503 |
+
mask = mask[:seq_len]
|
| 504 |
+
input_ids_list.append(ids)
|
| 505 |
+
loss_mask_list.append(mask)
|
| 506 |
+
|
| 507 |
+
input_ids = torch.from_numpy(np.stack(input_ids_list)).long().to(device)
|
| 508 |
+
loss_mask = torch.from_numpy(np.stack(loss_mask_list)).long().to(device)
|
| 509 |
+
targets = torch.cat([input_ids[:, 1:], torch.zeros_like(input_ids[:, :1])], dim=1)
|
| 510 |
+
return input_ids, targets, loss_mask
|
| 511 |
+
|
| 512 |
+
|
| 513 |
+
def run_sft(steps, batch_size, grad_accum, seq_len, lr, warmup,
|
| 514 |
+
save_every, log_every, use_8bit_adam):
|
| 515 |
+
from torch import nn
|
| 516 |
+
sys.path.insert(0, REMOTE_SRC)
|
| 517 |
+
from model import ModelConfig, Retriever500M
|
| 518 |
+
from tokenizers import Tokenizer
|
| 519 |
+
|
| 520 |
+
load_special_tokens()
|
| 521 |
+
device = torch.device("cuda")
|
| 522 |
+
print(f"Device: {device}")
|
| 523 |
+
print(f"GPU: {torch.cuda.get_device_name(0)}")
|
| 524 |
+
print(f"VRAM: {torch.cuda.get_device_properties(0).total_memory / 1e9:.2f} GB")
|
| 525 |
+
|
| 526 |
+
os.makedirs(REMOTE_CKPT, exist_ok=True)
|
| 527 |
+
os.makedirs(REMOTE_LOGS, exist_ok=True)
|
| 528 |
+
|
| 529 |
+
# Tokenizer (agent tokenizer with special tokens)
|
| 530 |
+
tokenizer_path = os.path.join(REMOTE_TOKENIZER, "tokenizer_agent.json")
|
| 531 |
+
tokenizer = Tokenizer.from_file(tokenizer_path)
|
| 532 |
+
vocab_size = tokenizer.get_vocab_size()
|
| 533 |
+
print(f"Vocab size: {vocab_size}")
|
| 534 |
+
|
| 535 |
+
# Data
|
| 536 |
+
traces_path = os.path.join(REMOTE_DATA, "sft_traces.jsonl")
|
| 537 |
+
gold_path = os.path.join(REMOTE_DATA, "gold_traces.jsonl")
|
| 538 |
+
dataset = load_sft_dataset(traces_path, tokenizer, seq_len)
|
| 539 |
+
gold_dataset = load_sft_dataset(gold_path, tokenizer, seq_len)
|
| 540 |
+
dataset.extend(gold_dataset)
|
| 541 |
+
print(f" Total (with gold): {len(dataset):,}")
|
| 542 |
+
|
| 543 |
+
# Model
|
| 544 |
+
config = ModelConfig(
|
| 545 |
+
vocab_size=vocab_size,
|
| 546 |
+
d_model=1_280, n_layers=23, n_heads=20, d_ff=3_456,
|
| 547 |
+
max_seq_len=seq_len, dropout=0.0, tie_embeddings=True,
|
| 548 |
+
)
|
| 549 |
+
model = Retriever500M(config).to(device)
|
| 550 |
+
|
| 551 |
+
# Load pretrained checkpoint
|
| 552 |
+
ckpt_path = os.path.join(REMOTE_CKPT, "latest.pt")
|
| 553 |
+
if not os.path.exists(ckpt_path):
|
| 554 |
+
ckpt_path = os.path.join(REMOTE_CKPT, "latest_bf16.pt")
|
| 555 |
+
if not os.path.exists(ckpt_path):
|
| 556 |
+
ckpt_path = os.path.join(REMOTE_SEED_CKPT, "latest_bf16.pt")
|
| 557 |
+
if os.path.exists(ckpt_path):
|
| 558 |
+
print(f"Loading pretrained weights from {ckpt_path}...")
|
| 559 |
+
ckpt = torch.load(ckpt_path, map_location=device, weights_only=False)
|
| 560 |
+
old_config = ModelConfig(**ckpt["config"])
|
| 561 |
+
state_dict = ckpt["model_state_dict"]
|
| 562 |
+
old_vocab = old_config.vocab_size
|
| 563 |
+
|
| 564 |
+
if old_vocab != vocab_size:
|
| 565 |
+
print(f" Vocab size mismatch: {old_vocab} -> {vocab_size}")
|
| 566 |
+
old_weight = state_dict["token_embedding.weight"]
|
| 567 |
+
d_model = old_weight.shape[1]
|
| 568 |
+
new_weight = torch.zeros(vocab_size, d_model)
|
| 569 |
+
new_weight[:old_vocab] = old_weight
|
| 570 |
+
nn.init.normal_(new_weight[old_vocab:], mean=0.0, std=0.02)
|
| 571 |
+
state_dict["token_embedding.weight"] = new_weight
|
| 572 |
+
|
| 573 |
+
model.load_state_dict(state_dict)
|
| 574 |
+
print(f" Loaded (step {ckpt.get('step', '?')}, loss {ckpt.get('loss', '?')})")
|
| 575 |
+
else:
|
| 576 |
+
print("WARNING: No checkpoint found, starting from scratch!")
|
| 577 |
+
|
| 578 |
+
total_params = model.count_parameters()
|
| 579 |
+
print(f"Model parameters: {total_params:,} ({total_params / 1e6:.1f}M)")
|
| 580 |
+
|
| 581 |
+
optimizer = setup_optimizer(model, lr, use_8bit=use_8bit_adam)
|
| 582 |
+
|
| 583 |
+
effective_batch = batch_size * grad_accum
|
| 584 |
+
print(f"\nSFT configuration:")
|
| 585 |
+
print(f" Batch size: {batch_size}")
|
| 586 |
+
print(f" Grad accum: {grad_accum}")
|
| 587 |
+
print(f" Effective batch: {effective_batch}")
|
| 588 |
+
print(f" Sequence length: {seq_len}")
|
| 589 |
+
print(f" Learning rate: {lr}")
|
| 590 |
+
print(f" Steps: {steps}")
|
| 591 |
+
print(f" Warmup: {warmup}")
|
| 592 |
+
print()
|
| 593 |
+
|
| 594 |
+
log = {
|
| 595 |
+
"config": asdict(config),
|
| 596 |
+
"train_args": {"steps": steps, "batch_size": batch_size, "grad_accum": grad_accum,
|
| 597 |
+
"seq_len": seq_len, "lr": lr, "warmup": warmup,
|
| 598 |
+
"save_every": save_every, "log_every": log_every,
|
| 599 |
+
"use_8bit_adam": use_8bit_adam},
|
| 600 |
+
"total_params": total_params,
|
| 601 |
+
"steps": [],
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
model.train()
|
| 605 |
+
start_time = time.time()
|
| 606 |
+
accum_loss = 0.0
|
| 607 |
+
best_loss = float("inf")
|
| 608 |
+
|
| 609 |
+
pbar = tqdm(range(steps), desc="SFT")
|
| 610 |
+
for step in pbar:
|
| 611 |
+
lr_now = get_lr(step, warmup, steps, lr, lr * 0.1)
|
| 612 |
+
for pg in optimizer.param_groups:
|
| 613 |
+
pg["lr"] = lr_now
|
| 614 |
+
|
| 615 |
+
optimizer.zero_grad(set_to_none=True)
|
| 616 |
+
|
| 617 |
+
total_loss = 0.0
|
| 618 |
+
for _ in range(grad_accum):
|
| 619 |
+
input_ids, targets, loss_mask = get_sft_batch(
|
| 620 |
+
dataset, batch_size, seq_len, device
|
| 621 |
+
)
|
| 622 |
+
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
|
| 623 |
+
out = model(input_ids, targets=targets, use_checkpoint=True)
|
| 624 |
+
logits = out["logits"]
|
| 625 |
+
|
| 626 |
+
if loss_mask.sum() > 0:
|
| 627 |
+
shifted_mask = loss_mask[:, 1:].contiguous()
|
| 628 |
+
masked_logits = logits[:, :-1, :].contiguous()
|
| 629 |
+
masked_targets = targets[:, :-1].contiguous()
|
| 630 |
+
|
| 631 |
+
flat_logits = masked_logits.view(-1, masked_logits.size(-1))
|
| 632 |
+
flat_targets = masked_targets.view(-1)
|
| 633 |
+
flat_mask = shifted_mask.view(-1).float()
|
| 634 |
+
|
| 635 |
+
per_token_loss = F.cross_entropy(
|
| 636 |
+
flat_logits, flat_targets,
|
| 637 |
+
ignore_index=-100, reduction="none"
|
| 638 |
+
)
|
| 639 |
+
masked_loss = (per_token_loss * flat_mask).sum() / flat_mask.sum().clamp(min=1)
|
| 640 |
+
loss = masked_loss / grad_accum
|
| 641 |
+
else:
|
| 642 |
+
loss = out["loss"] / grad_accum
|
| 643 |
+
|
| 644 |
+
loss.backward()
|
| 645 |
+
total_loss += loss.item()
|
| 646 |
+
|
| 647 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
| 648 |
+
optimizer.step()
|
| 649 |
+
|
| 650 |
+
avg_loss = total_loss
|
| 651 |
+
accum_loss = accum_loss * 0.95 + avg_loss * 0.05
|
| 652 |
+
|
| 653 |
+
if step % log_every == 0 or step == steps - 1:
|
| 654 |
+
elapsed = time.time() - start_time
|
| 655 |
+
steps_per_sec = (step + 1) / elapsed
|
| 656 |
+
vram_used = torch.cuda.max_memory_allocated() / 1e9
|
| 657 |
+
|
| 658 |
+
log_entry = {
|
| 659 |
+
"step": step, "loss": avg_loss, "ema_loss": accum_loss,
|
| 660 |
+
"lr": lr_now, "elapsed_s": elapsed,
|
| 661 |
+
"steps_per_sec": steps_per_sec, "vram_gb": vram_used,
|
| 662 |
+
}
|
| 663 |
+
log["steps"].append(log_entry)
|
| 664 |
+
pbar.set_postfix({
|
| 665 |
+
"loss": f"{avg_loss:.4f}", "ema": f"{accum_loss:.4f}",
|
| 666 |
+
"lr": f"{lr_now:.2e}", "vram": f"{vram_used:.1f}G",
|
| 667 |
+
})
|
| 668 |
+
|
| 669 |
+
if (step + 1) % save_every == 0 or step == steps - 1:
|
| 670 |
+
ckpt_path = os.path.join(REMOTE_CKPT, f"sft_step_{step + 1}.pt")
|
| 671 |
+
torch.save({
|
| 672 |
+
"model_state_dict": model.state_dict(),
|
| 673 |
+
"optimizer_state_dict": optimizer.state_dict(),
|
| 674 |
+
"config": asdict(config),
|
| 675 |
+
"step": step + 1, "loss": accum_loss,
|
| 676 |
+
}, ckpt_path)
|
| 677 |
+
print(f"\n Saved checkpoint: {ckpt_path}")
|
| 678 |
+
|
| 679 |
+
latest_path = os.path.join(REMOTE_CKPT, "sft_latest.pt")
|
| 680 |
+
torch.save({
|
| 681 |
+
"model_state_dict": model.state_dict(),
|
| 682 |
+
"config": asdict(config),
|
| 683 |
+
"step": step + 1, "loss": accum_loss,
|
| 684 |
+
}, latest_path)
|
| 685 |
+
|
| 686 |
+
if accum_loss < best_loss:
|
| 687 |
+
best_loss = accum_loss
|
| 688 |
+
best_path = os.path.join(REMOTE_CKPT, "sft_best.pt")
|
| 689 |
+
torch.save({
|
| 690 |
+
"model_state_dict": model.state_dict(),
|
| 691 |
+
"config": asdict(config),
|
| 692 |
+
"step": step + 1, "loss": accum_loss,
|
| 693 |
+
}, best_path)
|
| 694 |
+
|
| 695 |
+
log_path = os.path.join(REMOTE_LOGS, "sft_log.json")
|
| 696 |
+
with open(log_path, "w") as f:
|
| 697 |
+
json.dump(log, f, indent=2)
|
| 698 |
+
|
| 699 |
+
volume.commit()
|
| 700 |
+
|
| 701 |
+
if step % 50 == 0:
|
| 702 |
+
torch.cuda.reset_peak_memory_stats()
|
| 703 |
+
|
| 704 |
+
log_path = os.path.join(REMOTE_LOGS, "sft_log.json")
|
| 705 |
+
with open(log_path, "w") as f:
|
| 706 |
+
json.dump(log, f, indent=2)
|
| 707 |
+
volume.commit()
|
| 708 |
+
|
| 709 |
+
total_time = time.time() - start_time
|
| 710 |
+
print(f"\nSFT complete!")
|
| 711 |
+
print(f" Total time: {total_time:.1f}s ({total_time/60:.1f} min)")
|
| 712 |
+
print(f" Final EMA loss: {accum_loss:.4f}")
|
| 713 |
+
print(f" Best loss: {best_loss:.4f}")
|
| 714 |
+
return accum_loss
|
| 715 |
+
|
| 716 |
+
|
| 717 |
+
# ─── Modal entry points ──────────────────────────────────────────────────────
|
| 718 |
+
|
| 719 |
+
GPU_CHOICES = {"a10g": "A10G", "a100": "A100", "h100": "H100"}
|
| 720 |
+
|
| 721 |
+
|
| 722 |
+
@app.function(
|
| 723 |
+
volumes={VOL_MOUNT: volume},
|
| 724 |
+
gpu="A10G",
|
| 725 |
+
timeout=3600,
|
| 726 |
+
)
|
| 727 |
+
def pretrain(
|
| 728 |
+
steps=1000,
|
| 729 |
+
batch_size=8,
|
| 730 |
+
grad_accum=4,
|
| 731 |
+
seq_len=512,
|
| 732 |
+
lr=3e-4,
|
| 733 |
+
warmup=100,
|
| 734 |
+
save_every=200,
|
| 735 |
+
log_every=10,
|
| 736 |
+
corpus="curated",
|
| 737 |
+
resume=True,
|
| 738 |
+
use_8bit_adam=True,
|
| 739 |
+
gpu="a10g",
|
| 740 |
+
):
|
| 741 |
+
"""Run pretraining on Modal GPU."""
|
| 742 |
+
return run_pretrain(steps, batch_size, grad_accum, seq_len, lr, warmup,
|
| 743 |
+
save_every, log_every, corpus, resume, use_8bit_adam)
|
| 744 |
+
|
| 745 |
+
|
| 746 |
+
@app.function(
|
| 747 |
+
volumes={VOL_MOUNT: volume},
|
| 748 |
+
gpu="A10G",
|
| 749 |
+
timeout=3600,
|
| 750 |
+
env={"PYTORCH_CUDA_ALLOC_CONF": "expandable_segments:True"},
|
| 751 |
+
)
|
| 752 |
+
def sft(
|
| 753 |
+
steps=500,
|
| 754 |
+
batch_size=4,
|
| 755 |
+
grad_accum=8,
|
| 756 |
+
seq_len=768,
|
| 757 |
+
lr=5e-5,
|
| 758 |
+
warmup=20,
|
| 759 |
+
save_every=100,
|
| 760 |
+
log_every=10,
|
| 761 |
+
use_8bit_adam=True,
|
| 762 |
+
gpu="a10g",
|
| 763 |
+
):
|
| 764 |
+
"""Run SFT on Modal GPU."""
|
| 765 |
+
return run_sft(steps, batch_size, grad_accum, seq_len, lr, warmup,
|
| 766 |
+
save_every, log_every, use_8bit_adam)
|
| 767 |
+
|
| 768 |
+
|
| 769 |
+
@app.function(
|
| 770 |
+
volumes={VOL_MOUNT: volume},
|
| 771 |
+
gpu="A10G",
|
| 772 |
+
timeout=7200,
|
| 773 |
+
)
|
| 774 |
+
def pipeline(pretrain_steps=1000, sft_steps=500, gpu="a10g"):
|
| 775 |
+
"""Run pretraining then SFT in one go."""
|
| 776 |
+
print("=" * 60)
|
| 777 |
+
print("PHASE 1: PRETRAINING")
|
| 778 |
+
print("=" * 60)
|
| 779 |
+
run_pretrain(
|
| 780 |
+
steps=pretrain_steps, batch_size=8, grad_accum=4, seq_len=512,
|
| 781 |
+
lr=3e-4, warmup=100, save_every=200, log_every=10,
|
| 782 |
+
corpus="curated", resume=True, use_8bit_adam=True,
|
| 783 |
+
)
|
| 784 |
+
|
| 785 |
+
print("\n" + "=" * 60)
|
| 786 |
+
print("PHASE 2: SFT")
|
| 787 |
+
print("=" * 60)
|
| 788 |
+
run_sft(
|
| 789 |
+
steps=sft_steps, batch_size=8, grad_accum=4, seq_len=768,
|
| 790 |
+
lr=5e-5, warmup=20, save_every=100, log_every=10,
|
| 791 |
+
use_8bit_adam=True,
|
| 792 |
+
)
|
| 793 |
+
|
| 794 |
+
|
| 795 |
+
# ─── Local entry point for `modal run` ───────────────────────────────────────
|
| 796 |
+
|
| 797 |
+
@app.local_entrypoint()
|
| 798 |
+
def main(
|
| 799 |
+
command: str = "pipeline",
|
| 800 |
+
steps: int = 1000,
|
| 801 |
+
batch_size: int = 8,
|
| 802 |
+
grad_accum: int = 4,
|
| 803 |
+
seq_len: int = 512,
|
| 804 |
+
lr: float = 3e-4,
|
| 805 |
+
warmup: int = 100,
|
| 806 |
+
save_every: int = 200,
|
| 807 |
+
log_every: int = 10,
|
| 808 |
+
corpus: str = "curated",
|
| 809 |
+
resume: bool = True,
|
| 810 |
+
use_8bit_adam: bool = True,
|
| 811 |
+
gpu: str = "a10g",
|
| 812 |
+
pretrain_steps: int = 1000,
|
| 813 |
+
sft_steps: int = 500,
|
| 814 |
+
):
|
| 815 |
+
if command == "seed":
|
| 816 |
+
seed.remote()
|
| 817 |
+
elif command == "pretrain":
|
| 818 |
+
result = pretrain.remote(
|
| 819 |
+
steps=steps, batch_size=batch_size, grad_accum=grad_accum,
|
| 820 |
+
seq_len=seq_len, lr=lr, warmup=warmup,
|
| 821 |
+
save_every=save_every, log_every=log_every,
|
| 822 |
+
corpus=corpus, resume=resume, use_8bit_adam=use_8bit_adam,
|
| 823 |
+
gpu=gpu,
|
| 824 |
+
)
|
| 825 |
+
print(f"Pretraining final loss: {result}")
|
| 826 |
+
elif command == "sft":
|
| 827 |
+
result = sft.remote(
|
| 828 |
+
steps=steps, batch_size=batch_size, grad_accum=grad_accum,
|
| 829 |
+
seq_len=seq_len, lr=lr, warmup=warmup,
|
| 830 |
+
save_every=save_every, log_every=log_every,
|
| 831 |
+
use_8bit_adam=use_8bit_adam, gpu=gpu,
|
| 832 |
+
)
|
| 833 |
+
print(f"SFT final loss: {result}")
|
| 834 |
+
elif command == "download":
|
| 835 |
+
download.remote()
|
| 836 |
+
elif command == "pipeline":
|
| 837 |
+
pipeline.remote(pretrain_steps=pretrain_steps, sft_steps=sft_steps, gpu=gpu)
|
| 838 |
+
else:
|
| 839 |
+
print(f"Unknown command: {command}")
|
| 840 |
+
print("Available: upload, pretrain, sft, download, pipeline")
|
src/train_tokenizer.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Train a BPE tokenizer on the code corpus using the HuggingFace `tokenizers` library.
|
| 3 |
+
|
| 4 |
+
Produces a 32,000-token vocabulary optimized for source code across
|
| 5 |
+
Python, JS/TS, Rust, Go, C/C++, and other languages.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
from tokenizers import Tokenizer
|
| 10 |
+
from tokenizers.models import BPE
|
| 11 |
+
from tokenizers.trainers import BpeTrainer
|
| 12 |
+
from tokenizers.pre_tokenizers import ByteLevel
|
| 13 |
+
from tokenizers.decoders import ByteLevel as ByteLevelDecoder
|
| 14 |
+
|
| 15 |
+
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data")
|
| 16 |
+
TOKENIZER_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tokenizer")
|
| 17 |
+
CORPUS_PATH = os.path.join(DATA_DIR, "corpus.txt")
|
| 18 |
+
TOKENIZER_PATH = os.path.join(TOKENIZER_DIR, "tokenizer.json")
|
| 19 |
+
|
| 20 |
+
VOCAB_SIZE = 32_000
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def train_tokenizer():
|
| 24 |
+
os.makedirs(TOKENIZER_DIR, exist_ok=True)
|
| 25 |
+
|
| 26 |
+
tokenizer = Tokenizer(BPE(unk_token="<unk>"))
|
| 27 |
+
tokenizer.pre_tokenizer = ByteLevel(add_prefix_space=True, use_regex=True)
|
| 28 |
+
tokenizer.decoder = ByteLevelDecoder()
|
| 29 |
+
|
| 30 |
+
trainer = BpeTrainer(
|
| 31 |
+
vocab_size=VOCAB_SIZE,
|
| 32 |
+
special_tokens=["<pad>", "<bos>", "<eos>", "<unk>"],
|
| 33 |
+
show_progress=True,
|
| 34 |
+
initial_alphabet=ByteLevel.alphabet(),
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
print(f"Training BPE tokenizer (vocab_size={VOCAB_SIZE}) on {CORPUS_PATH}...")
|
| 38 |
+
tokenizer.train([CORPUS_PATH], trainer)
|
| 39 |
+
|
| 40 |
+
tokenizer.save(TOKENIZER_PATH)
|
| 41 |
+
print(f"Tokenizer saved to {TOKENIZER_PATH}")
|
| 42 |
+
|
| 43 |
+
# Print stats
|
| 44 |
+
vocab = tokenizer.get_vocab()
|
| 45 |
+
print(f"Vocabulary size: {len(vocab)}")
|
| 46 |
+
|
| 47 |
+
# Test encoding
|
| 48 |
+
test_code = "def hello_world():\n print('Hello, World!')"
|
| 49 |
+
encoded = tokenizer.encode(test_code)
|
| 50 |
+
print(f"\nTest encoding:")
|
| 51 |
+
print(f" Input: {test_code}")
|
| 52 |
+
print(f" Tokens: {encoded.tokens[:20]}")
|
| 53 |
+
print(f" IDs: {encoded.ids[:20]}")
|
| 54 |
+
print(f" # tokens: {len(encoded.ids)}")
|
| 55 |
+
|
| 56 |
+
return tokenizer
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
if __name__ == "__main__":
|
| 60 |
+
train_tokenizer()
|
src/verify_pipeline.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Verify the full search agent pipeline end-to-end."""
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__))))
|
| 7 |
+
|
| 8 |
+
def main():
|
| 9 |
+
# 1. Verify chunks load
|
| 10 |
+
chunks = [json.loads(l) for l in open("data/chunks.jsonl", encoding="utf-8")]
|
| 11 |
+
print(f"1. Chunks: {len(chunks):,} loaded OK")
|
| 12 |
+
|
| 13 |
+
# 2. Verify agent tokenizer
|
| 14 |
+
from tokenizers import Tokenizer
|
| 15 |
+
tok = Tokenizer.from_file("tokenizer/tokenizer_agent.json")
|
| 16 |
+
print(f"2. Agent tokenizer: vocab={tok.get_vocab_size()} OK")
|
| 17 |
+
|
| 18 |
+
# 3. Verify special tokens
|
| 19 |
+
st = json.load(open("tokenizer/special_tokens.json"))
|
| 20 |
+
ids = list(st["token_ids"].values())
|
| 21 |
+
print(f"3. Special tokens: {len(st['special_tokens'])} tokens, IDs {ids} OK")
|
| 22 |
+
|
| 23 |
+
# 4. Verify SFT traces load and format correctly
|
| 24 |
+
from sft import format_trace_to_tokens, load_special_tokens
|
| 25 |
+
load_special_tokens()
|
| 26 |
+
traces = [json.loads(l) for l in open("data/sft_traces.jsonl", encoding="utf-8")]
|
| 27 |
+
print(f"4. SFT traces: {len(traces):,} loaded")
|
| 28 |
+
|
| 29 |
+
# Format one trace
|
| 30 |
+
ids_arr, mask_arr = format_trace_to_tokens(traces[0], tok, max_seq_len=768)
|
| 31 |
+
loss_tokens = int(mask_arr.sum())
|
| 32 |
+
total_tokens = len(ids_arr)
|
| 33 |
+
pct = loss_tokens / total_tokens * 100
|
| 34 |
+
print(f" Sample trace: {total_tokens} tokens, {loss_tokens} with loss ({pct:.0f}%)")
|
| 35 |
+
|
| 36 |
+
# 5. Verify gold traces
|
| 37 |
+
gold = [json.loads(l) for l in open("data/gold_traces.jsonl", encoding="utf-8")]
|
| 38 |
+
print(f"5. Gold traces: {len(gold)} loaded OK")
|
| 39 |
+
|
| 40 |
+
# 6. Verify model accepts new vocab size
|
| 41 |
+
from model import ModelConfig, Retriever500M
|
| 42 |
+
import torch
|
| 43 |
+
config = ModelConfig(vocab_size=32009, d_model=1280, n_layers=23, n_heads=20, d_ff=3456, max_seq_len=768)
|
| 44 |
+
model = Retriever500M(config)
|
| 45 |
+
print(f"6. Model with vocab=32009: {model.count_parameters()/1e6:.1f}M params OK")
|
| 46 |
+
|
| 47 |
+
# 7. Verify model can load old checkpoint with embedding resize
|
| 48 |
+
ckpt = torch.load("checkpoints/latest.pt", map_location="cpu", weights_only=False)
|
| 49 |
+
old_vocab = ckpt["config"]["vocab_size"]
|
| 50 |
+
needs_resize = old_vocab != 32009
|
| 51 |
+
print(f"7. Old checkpoint vocab={old_vocab}, new vocab=32009, resize needed={needs_resize}")
|
| 52 |
+
|
| 53 |
+
# 8. Verify retriever
|
| 54 |
+
from search_agent import KeywordRetriever
|
| 55 |
+
retr = KeywordRetriever("data/chunks.jsonl")
|
| 56 |
+
results = retr.search("ngx_reusable_connection", top_k=3)
|
| 57 |
+
print(f"8. Retriever: search returned {len(results)} results OK")
|
| 58 |
+
if results:
|
| 59 |
+
print(f" Top result: {results[0]['name']} (score={results[0]['score']:.2f})")
|
| 60 |
+
|
| 61 |
+
# 9. Verify SFT data formatting produces valid tokens
|
| 62 |
+
from sft import get_sft_batch
|
| 63 |
+
dataset = []
|
| 64 |
+
for trace in traces[:100]:
|
| 65 |
+
ids, mask = format_trace_to_tokens(trace, tok, max_seq_len=768)
|
| 66 |
+
if len(ids) > 10:
|
| 67 |
+
dataset.append((ids, mask))
|
| 68 |
+
input_ids, targets, loss_mask = get_sft_batch(dataset, batch_size=2, seq_len=768, device=torch.device("cpu"))
|
| 69 |
+
print(f"9. SFT batch: input_ids={input_ids.shape}, targets={targets.shape}, loss_mask={loss_mask.shape}")
|
| 70 |
+
print(f" Loss mask coverage: {loss_mask.sum().item()}/{loss_mask.numel()} ({loss_mask.float().mean()*100:.0f}%)")
|
| 71 |
+
|
| 72 |
+
print()
|
| 73 |
+
print("ALL CHECKS PASSED")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
if __name__ == "__main__":
|
| 77 |
+
main()
|