File size: 26,737 Bytes
481fbb6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
#!/usr/bin/env python3
"""Generate the Memory-LoRA training corpus via OpenRouter.

Produces two local parquet-backed artifacts under ``data/``:

  data/docs/documents.jsonl   -- one row per document (id, category, topic,
                                  cross-corpus split, list of (section, text))
  data/qna/qna.jsonl          -- one row per recall QA pair (doc_id, split,
                                  qna_split, question, prefix, target)

Four document categories, all seeded to contain specific, checkable facts
(numbers, names, claims) so recall is gradeable by exact-match, mirroring
Code2LoRA's assertion-completion targets:

  paper              -- the REAL Code2LoRA paper, chunked into sections
                        (not synthetic -- hand-authored below from the
                        paper we already read in full).
  coding_agent_harness -- synthetic docs about capabilities that help coding
                        agents / CLI tools (Claude Code, Codex, etc.) handle
                        large codebases: context injection, repo indexing,
                        diffing, static analysis, self-improvement loops.
  agile_pm           -- synthetic docs about agile project tracking: Jira
                        ticket lifecycles, sprints, story points, velocity,
                        epics, standups, retrospectives, burndown.
  general            -- broad diverse synthetic fact-sheets, needed so the
                        hypernetwork's document->LoRA mapping generalizes
                        (breadth requirement, same reason Code2LoRA needed
                        400+ repos rather than 1).

OpenRouter is OpenAI-API compatible -- plain ``openai`` client,
base_url=https://openrouter.ai/api/v1. Key read from OPENROUTER_API_KEY
(loaded from a local .env, never hardcoded/committed).

Usage:
    python scripts/generate_synthetic_dataset.py --limit 3     # smoke test
    python scripts/generate_synthetic_dataset.py --n-per-category 60
"""

from __future__ import annotations

import argparse
import hashlib
import json
import os
import random
import re
import sys
import time
from pathlib import Path
from typing import Any, Dict, List, Optional

from openai import OpenAI

HERE = Path(__file__).resolve().parent
REPO_ROOT = HERE.parent
sys.path.insert(0, str(REPO_ROOT))
from memory_lora.data_paths import DOCS_DIR, QNA_DIR, CACHE_DIR, ensure_dirs  # noqa: E402


def _load_dotenv(path: Path) -> None:
    if not path.exists():
        return
    for line in path.read_text().splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        k, v = line.split("=", 1)
        os.environ.setdefault(k.strip(), v.strip())


_load_dotenv(REPO_ROOT / ".env")

DEFAULT_GEN_MODEL = "qwen/qwen-2.5-7b-instruct"
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"


# ---------------------------------------------------------------------------
# Cache-wrapped OpenRouter call
# ---------------------------------------------------------------------------

def _cache_key(model: str, messages: List[Dict[str, str]], **kwargs) -> str:
    payload = json.dumps({"model": model, "messages": messages, **kwargs},
                          sort_keys=True)
    return hashlib.sha256(payload.encode()).hexdigest()[:24]


class CachedClient:
    def __init__(self, client: OpenAI, cache_dir: Path):
        self.client = client
        self.cache_dir = cache_dir

    def chat(self, model: str, messages: List[Dict[str, str]],
             temperature: float = 0.9, max_tokens: int = 2000,
             retries: int = 4) -> str:
        key = _cache_key(model, messages, temperature=temperature,
                          max_tokens=max_tokens)
        cache_file = self.cache_dir / f"{key}.txt"
        if cache_file.exists():
            return cache_file.read_text()
        last_err: Optional[Exception] = None
        for attempt in range(retries):
            try:
                resp = self.client.chat.completions.create(
                    model=model, messages=messages,
                    temperature=temperature, max_tokens=max_tokens,
                )
                text = resp.choices[0].message.content or ""
                cache_file.write_text(text)
                return text
            except Exception as e:  # noqa: BLE001
                last_err = e
                wait = 2 ** attempt
                print(f"  [warn] OpenRouter call failed ({e}); retry in {wait}s",
                      flush=True)
                time.sleep(wait)
        raise RuntimeError(f"OpenRouter call failed after {retries} retries: {last_err}")


# ---------------------------------------------------------------------------
# Topic seeds per category (kept diverse -> the hypernetwork sees breadth)
# ---------------------------------------------------------------------------

CODING_AGENT_TOPICS = [
    "repository-level context injection strategies for LLM coding agents",
    "incremental static analysis caching for large monorepos",
    "test-impact analysis to select which tests to rerun after a diff",
    "dependency graph indexing for cross-file code navigation",
    "safe automated refactoring patterns for renaming across a codebase",
    "context window budget management when an agent reads many files",
    "self-improvement loops where a coding agent revises its own tool use",
    "diffing and patch-application strategies for multi-file edits",
    "detecting and avoiding regressions when an agent edits shared utilities",
    "code search ranking heuristics for retrieval-augmented coding agents",
    "sandboxing and permission models for autonomous coding agents",
    "long-horizon planning for agents building complex multi-module programs",
    "memory architectures that let an agent recall project conventions",
    "strategies for agents to keep a mental model of a large codebase in sync",
    "CLI tool design patterns for developer-facing coding agents",
    "evaluating coding agent reliability on multi-step programming tasks",
    "handling build system and dependency resolution errors autonomously",
    "techniques for agents to summarize large pull requests for review",
    "version-control-aware agent workflows (branches, rebases, conflicts)",
    "strategies for agents to write and maintain their own regression tests",
]

AGILE_PM_TOPICS = [
    "Jira ticket lifecycle states and transition rules",
    "sprint planning and story point estimation techniques",
    "velocity tracking and forecasting sprint capacity",
    "epic and subtask hierarchy conventions in agile tracking tools",
    "daily standup meeting structure and anti-patterns",
    "sprint retrospective formats and action item follow-through",
    "burndown and burnup chart interpretation",
    "backlog grooming and prioritization frameworks (MoSCoW, WSJF)",
    "definition of done and acceptance criteria best practices",
    "kanban WIP limits and flow efficiency metrics",
    "cross-team dependency tracking in scaled agile (SAFe, LeSS)",
    "bug triage severity/priority labeling conventions",
    "release planning and versioning cadences",
    "stakeholder reporting cadences and status update formats",
    "agile ceremonies for distributed/remote teams",
]

PROJECT_STATUS_TOPICS = [
    "a web app team's sprint status: open tickets, in-progress work, recent commits",
    "a data pipeline team's current sprint: blocked tickets, recent diffs, on-call rotation",
    "a mobile app team's release cycle: feature tickets, QA status, code review queue",
    "an API service team's incident + sprint status: hotfix tickets, recent deploys",
    "an ML training infra team's sprint: experiment tickets, recent config diffs",
    "a platform team's migration project: tracked subtasks, rollout percentage, blockers",
    "a devtools team's backlog grooming outcome: prioritized tickets, recent PRs merged",
    "a security team's remediation sprint: CVE tickets, patch status, recent commits",
    "a frontend team's design-system rollout: component tickets, adoption tracking",
    "a backend team's database migration sprint: schema tickets, rollback plan, diffs",
]

PROJECT_STATUS_SYSTEM = (
    "You write a realistic internal project-status snapshot for a software "
    "team, combining a Jira-style ticket board with recent code activity. "
    "Invent a plausible project/repo name, then include: (1) 6-10 tickets, "
    "each with a ticket key (e.g. PROJ-1234), status (To Do/In Progress/In "
    "Review/Done/Blocked), assignee name, story points, and a one-line "
    "description; (2) a sprint summary (sprint number, dates, velocity, "
    "burndown status); (3) a 'recent changes' section describing 2-4 "
    "specific code changes (file names, what changed, why) as if summarizing "
    "recent commits/diffs; (4) any current blockers or risks. Every fact "
    "(ticket key, status, assignee, points, sprint number, file name) must "
    "be specific and consistent so it can be tested for recall. 400-700 "
    "words. No markdown headers, structured prose with clear labels."
)


def gen_project_status_document(client: CachedClient, model: str, topic: str) -> str:
    prompt = f"Write a project-status snapshot for: {topic}."
    return client.chat(
        model=model,
        messages=[
            {"role": "system", "content": PROJECT_STATUS_SYSTEM},
            {"role": "user", "content": prompt},
        ],
        temperature=0.9,
        max_tokens=1400,
    )


GENERAL_TOPICS = [
    "the history and mechanics of a fictional national park's geology",
    "the biology of a deep-sea bioluminescent organism",
    "the engineering of a suspension bridge's cable system",
    "the brewing process and quality control of specialty coffee",
    "the orbital mechanics of a hypothetical exoplanet system",
    "the supply chain logistics of a regional produce cooperative",
    "the architecture of a public transit signaling system",
    "the culinary traditions of a fictional coastal fishing village",
    "the manufacturing process of a specific alloy used in aerospace",
    "the ecology of a wetland restoration project",
    "the governance structure of a municipal water utility",
    "the training regimen of competitive long-distance cyclists",
    "the archival practices of a rare-book conservation lab",
    "the acoustics engineering of a concert hall renovation",
    "the logistics of a regional disaster-relief supply network",
    "the taxonomy and care requirements of a rare orchid genus",
    "the operations of a small-batch letterpress printing studio",
    "the hydrology of an urban stormwater management system",
    "the production pipeline of a stop-motion animation studio",
    "the maintenance schedule of a commercial wind turbine farm",
]

DOC_GEN_SYSTEM = (
    "You write dense, factual reference documents. Every document must contain "
    "15-30 SPECIFIC, VERIFIABLE facts: exact numbers, named entities, precise "
    "claims, thresholds, or procedures. Avoid vague generalities. Write "
    "300-800 words. Do not use markdown headers; write flowing prose "
    "paragraphs. Invent plausible specifics (names, numbers, dates) when the "
    "topic is fictional/hypothetical -- consistency within the document "
    "matters more than real-world accuracy."
)

QA_GEN_SYSTEM = (
    "You extract recall test questions from a reference document. Given the "
    "document, produce a JSON array of 15-25 objects, each with keys "
    "'question' and 'answer'. Each question must test recall of ONE specific "
    "fact stated in the document (a number, name, threshold, or precise "
    "claim). The answer must be SHORT (1-8 words, ideally a number, name, or "
    "short phrase) and must be copyable verbatim or near-verbatim from the "
    "document. Do not ask yes/no questions. Do not ask questions requiring "
    "reasoning beyond direct recall. Output ONLY the JSON array, no prose."
)


def gen_document(client: CachedClient, model: str, category: str, topic: str) -> str:
    prompt = (
        f"Write a reference document about: {topic}.\n"
        f"Category: {category}."
    )
    return client.chat(
        model=model,
        messages=[
            {"role": "system", "content": DOC_GEN_SYSTEM},
            {"role": "user", "content": prompt},
        ],
        temperature=0.9,
        max_tokens=1200,
    )


def gen_qna(client: CachedClient, model: str, doc_text: str) -> List[Dict[str, str]]:
    raw = client.chat(
        model=model,
        messages=[
            {"role": "system", "content": QA_GEN_SYSTEM},
            {"role": "user", "content": doc_text},
        ],
        temperature=0.3,
        max_tokens=2000,
    )
    match = re.search(r"\[.*\]", raw, re.DOTALL)
    if not match:
        return []
    try:
        items = json.loads(match.group(0))
    except json.JSONDecodeError:
        return []
    out = []
    for it in items:
        q = (it.get("question") or "").strip()
        a = (it.get("answer") or "").strip()
        if q and a and len(a) < 200:
            out.append({"question": q, "answer": a})
    return out


# ---------------------------------------------------------------------------
# The Code2LoRA paper -- real document, hand-chunked into sections (from the
# paper we already read in full; not regenerated by an LLM).
# ---------------------------------------------------------------------------

def code2lora_paper_sections() -> List[Dict[str, str]]:
    return [
        {"name": "abstract", "text": (
            "Code2LoRA is a hypernetwork framework that generates repository-"
            "specific LoRA adapters, effectively injecting repository "
            "knowledge with zero inference-time token overhead. Code2LoRA "
            "supports two usage scenarios: Code2LoRA-Static converts a "
            "single repository snapshot into an adapter; Code2LoRA-Evo "
            "maintains an adapter backed by a GRU hidden state updated per "
            "code diff. The authors build RepoPeftBench, a benchmark of 604 "
            "Python repositories with two tracks: a static track with 40K "
            "training and 12K test assertion-completion tasks, and an "
            "evolution track with 215K commit-derived training and 87K "
            "commit-derived test tasks. On the static track, Code2LoRA-"
            "Static achieves 63.8% cross-repo and 66.2% in-repo exact match, "
            "matching the per-repository LoRA upper bound; on the evolution "
            "track, Code2LoRA-Evo achieves 60.3% cross-repo exact match, "
            "+5.2 percentage points over a single shared LoRA."
        )},
        {"name": "method_architecture", "text": (
            "Code2LoRA has three components: a shared repository encoder "
            "that maps repository-level context to dense embeddings, a "
            "hypernetwork that maps those embeddings to LoRA weights, and a "
            "base LLM that receives the generated adapter. Only the "
            "hypernetwork is trained. The repository encoder uses a frozen "
            "Qwen3-Embedding-0.6B model: each file is divided into 4096-"
            "token chunks with 512-token overlap, embedded, and mean-pooled "
            "to produce a file vector of dimension 1024. The repository "
            "embedding is the concatenation of a weighted mean and a max "
            "pool of file vectors, giving a 2048-dimensional vector. "
            "Code2LoRA-Static's hypernetwork has a 2-layer MLP trunk with "
            "GELU activation, hidden dimension 1024, followed by dedicated "
            "output heads per module type. LoRA matrices use rank r=16 and "
            "alpha=32, targeting seven module types (q_proj, k_proj, "
            "v_proj, o_proj, gate_proj, up_proj, down_proj) shared across "
            "all 28 transformer layers of the base model. Code2LoRA-Static "
            "has approximately 720 million trainable parameters. Code2LoRA-"
            "Evo adds a 1-layer GRU with hidden size 2048 that aggregates "
            "sequential diff embeddings into a hidden state, which "
            "substitutes for the static embedding in the same shared head; "
            "Code2LoRA-Evo has approximately 745 million trainable "
            "parameters, using truncated backpropagation through time with "
            "a window of K=16 steps."
        )},
        {"name": "benchmark_repopeftbench", "text": (
            "RepoPeftBench comprises 604 Python repositories drawn from "
            "GitHub: 512 in-distribution repositories (requiring at least "
            "300 stars) and a 92-repository temporal out-of-distribution "
            "holdout created strictly after the 2025-04-01 scrape cutoff. "
            "The in-distribution set is partitioned into cross-repo (103 "
            "held-out repositories: 51 validation, 52 test) and in-repo "
            "(409 training repositories) splits. The task is assertion "
            "completion: given a structured prefix from a test file "
            "(imports, enclosing class, helper methods, test body up to the "
            "assertion), the model predicts the expected value of the "
            "assertion. The static track draws 39,612 training and 11,636 "
            "test tasks from repository snapshots. The evolution track "
            "replays commit history, yielding 215,129 training and 86,793 "
            "test tasks derived from commits. Evaluation metrics are Exact "
            "Match (EM), Edit Similarity, and CodeBLEU. The base LLM used "
            "in all experiments is Qwen2.5-Coder-1.5B, loaded in bfloat16, "
            "trained on a single H100 80GB GPU using the TRL library."
        )},
        {"name": "results_static_track", "text": (
            "On RepoPeftBench's static track, Code2LoRA-Static achieves "
            "63.8% cross-repo exact match, 9.9 percentage points above the "
            "strongest baseline (full fine-tuning plus RAG, at 53.9%). "
            "Other baselines score lower: RAG with k=3 reaches 39.7% EM, "
            "Dependency-Resolved Context reaches 48.2% EM, full fine-tuning "
            "alone reaches 51.4% EM, and a single shared LoRA reaches 47.4% "
            "EM. On in-repo evaluation, Code2LoRA-Static reaches 66.2% EM, "
            "matching the per-repository LoRA upper bound of 64.0% EM "
            "without any per-repository training. A strengthened Text2LoRA "
            "baseline, matched on input modality and target-module "
            "coverage, reaches only 45.8% EM on cross-repo, isolating the "
            "Text2LoRA hypernetwork head itself as the bottleneck."
        )},
        {"name": "results_evolution_track", "text": (
            "On the evolution track, which evaluates on commit-derived "
            "prefixes, Code2LoRA-Evo is the strongest method on both "
            "splits: 60.3% cross-repo EM and 64.5% in-repo EM, a gain of "
            "5.2 percentage points over a single shared LoRA on cross-repo. "
            "Code2LoRA-Evo's in-repo EM of 64.5% exceeds the per-repository "
            "LoRA upper bound of 64.2% without any per-repository training. "
            "Code2LoRA-Static, evaluated on the same commit-derived inputs "
            "as a within-framework reference, drops to 55.7% cross-repo EM "
            "and 60.6% in-repo EM, markedly below its static-track "
            "performance, showing that snapshot-based adaptation goes "
            "stale as a repository accumulates commits. On the 92-"
            "repository temporal out-of-distribution holdout, Code2LoRA-"
            "Evo achieves the highest exact match at 74.1%, ahead of "
            "Code2LoRA-Static at 72.2% and a single shared LoRA at 72.3%."
        )},
        {"name": "efficiency", "text": (
            "Code2LoRA-Static and Code2LoRA-Evo generate a repository-"
            "specific adapter in under 10 milliseconds per repository with "
            "zero extra inference tokens, versus approximately 1,500 extra "
            "tokens per query for RAG with k=3, and approximately 500 to "
            "2,000 extra tokens per query for Dependency-Resolved Context. "
            "Full fine-tuning requires about 4 hours of training and adds "
            "3.1 gigabytes of storage per repository; per-repository LoRA "
            "requires about 5 minutes of training and 32 megabytes of "
            "storage per repository. In contrast, Code2LoRA-Static's "
            "hypernetwork adds a fixed 679 megabytes of storage shared "
            "across all repositories, and Code2LoRA-Evo adds 65 megabytes, "
            "independent of how many repositories are served."
        )},
        {"name": "limitations", "text": (
            "The Code2LoRA paper's limitations section notes the evaluation "
            "is restricted to Python repositories, a single frozen backbone "
            "(Qwen2.5-Coder-1.5B), and one downstream task (assertion "
            "completion). The reported 74.1% out-of-distribution exact "
            "match may be partially inflated because assertion targets in "
            "the post-cutoff OOD repositories are systematically shorter "
            "(median 7 characters) than in the cross-repo and in-repo test "
            "sets (median 12-13 characters). The LoRA-generation "
            "hypernetwork dominates the trainable parameter count -- "
            "approximately 720 million for Code2LoRA-Static and 745 "
            "million for Code2LoRA-Evo -- so the evolution-track finding "
            "is most directly supported at the 1.5-billion-parameter "
            "backbone scale."
        )},
    ]


# ---------------------------------------------------------------------------
# Splits + orchestration
# ---------------------------------------------------------------------------

def assign_cross_corpus_split(rng: random.Random) -> str:
    r = rng.random()
    if r < 0.8:
        return "train"
    if r < 0.9:
        return "cr_val"
    return "cr_test"


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--gen-model", default=DEFAULT_GEN_MODEL)
    ap.add_argument("--n-per-category", type=int, default=60,
                     help="Docs to generate per synthetic category "
                          "(coding_agent_harness, agile_pm, general).")
    ap.add_argument("--limit", type=int, default=0,
                     help="If set, overrides --n-per-category to a small "
                          "number for a cheap smoke test.")
    ap.add_argument("--only-categories", nargs="+", default=[],
                     help="Restrict generation to these categories (e.g. "
                          "--only-categories project_status), instead of "
                          "regenerating the whole corpus. Also skips the "
                          "paper doc when set.")
    ap.add_argument("--skip-paper", action="store_true")
    ap.add_argument("--seed", type=int, default=3407)
    args = ap.parse_args()

    ensure_dirs()

    api_key = os.environ.get("OPENROUTER_API_KEY")
    if not api_key:
        raise SystemExit("OPENROUTER_API_KEY not set (expected in .env)")

    client = CachedClient(
        OpenAI(base_url=OPENROUTER_BASE_URL, api_key=api_key),
        cache_dir=CACHE_DIR,
    )
    rng = random.Random(args.seed)

    n_per_cat = args.limit if args.limit else args.n_per_category

    docs_path = DOCS_DIR / "documents.jsonl"
    qna_path = QNA_DIR / "qna.jsonl"
    docs_f = docs_path.open("a")
    qna_f = qna_path.open("a")

    doc_counter = 0
    qna_counter = 0

    def emit_doc(doc_id: str, category: str, topic: str,
                 sections: List[Dict[str, str]], split: str) -> None:
        nonlocal doc_counter, qna_counter
        docs_f.write(json.dumps({
            "doc_id": doc_id, "doc_version": "v1", "category": category,
            "topic": topic, "split": split, "sections": sections,
        }) + "\n")
        doc_counter += 1

        full_text = "\n\n".join(s["text"] for s in sections)
        qnas = gen_qna(client, args.gen_model, full_text)
        n_qna = len(qnas)
        for i, qa in enumerate(qnas):
            qna_split = "train" if rng.random() < 0.8 else "held_out"
            qna_f.write(json.dumps({
                "doc_id": doc_id, "doc_version": "v1", "split": split,
                "qna_split": qna_split,
                "question": qa["question"],
                "prefix": f"Q: {qa['question']}\nA:",
                "target": " " + qa["answer"],
            }) + "\n")
            qna_counter += 1
        print(f"  [{category}] {doc_id} ({topic[:50]}...) -> {n_qna} QAs, split={split}",
              flush=True)

    # 1. The real Code2LoRA paper -- always included, always in train split
    # (we WANT it memorized, not held out for cross-corpus generalization
    # testing -- that's the whole point of this build).
    if not args.skip_paper and not args.only_categories:
        print("=== paper (real, hand-authored sections) ===", flush=True)
        emit_doc("code2lora_paper", "paper", "Code2LoRA paper",
                 code2lora_paper_sections(), split="train")

    # 2. Synthetic categories
    topic_lists = {
        "coding_agent_harness": CODING_AGENT_TOPICS,
        "agile_pm": AGILE_PM_TOPICS,
        "project_status": PROJECT_STATUS_TOPICS,
        "general": GENERAL_TOPICS,
    }
    if args.only_categories:
        topic_lists = {k: v for k, v in topic_lists.items() if k in args.only_categories}
    for category, topics in topic_lists.items():
        print(f"=== {category} ({n_per_cat} docs) ===", flush=True)
        for i in range(n_per_cat):
            topic = topics[i % len(topics)]
            if i >= len(topics):
                topic = f"{topic} (variant {i // len(topics) + 1}, different specifics)"
            doc_id = f"{category}_{i:04d}"
            split = assign_cross_corpus_split(rng)
            try:
                if category == "project_status":
                    text = gen_project_status_document(client, args.gen_model, topic)
                else:
                    text = gen_document(client, args.gen_model, category, topic)
            except RuntimeError as e:
                print(f"  [error] skipping {doc_id}: {e}", flush=True)
                continue
            sections = [{"name": "body", "text": text}]
            emit_doc(doc_id, category, topic, sections, split)

    docs_f.close()
    qna_f.close()
    print(f"\nDone. {doc_counter} documents, {qna_counter} QA pairs written to:")
    print(f"  {docs_path}")
    print(f"  {qna_path}")


if __name__ == "__main__":
    main()