JacobLinCool Codex commited on
Commit
e0cdb73
·
verified ·
1 Parent(s): e86200e

feat: add lora training kit

Browse files

Co-authored-by: Codex <noreply@openai.com>

README.md CHANGED
@@ -82,6 +82,14 @@ turns. Each included turn yields a tool-call example and an advisor-response exa
82
  selected targets, parsed XML tool call, tool observations, and score context preserved. This prepares the Well-Tuned
83
  path without claiming that the adapter has already been trained or published.
84
 
 
 
 
 
 
 
 
 
85
  ## Submission Packet
86
 
87
  The `submission_packet` Gradio API endpoint and `Packet` button export a Markdown submission bundle for the current
@@ -100,8 +108,8 @@ Packet, and PNG exports.
100
 
101
  `/api/demo-bundle.zip` and the `Bundle` button download a server-built ZIP for the deterministic demo session. The
102
  bundle includes a manifest, demo session JSON, Prize Ledger JSON, trace JSONL, Field Notes, Almanac chapter, LoRA SFT
103
- JSONL, Submission Packet, and a PNG export note. This gives judges or collaborators one auditable package without
104
- depending on browser `localStorage`.
105
 
106
  ## Prize Ledger
107
 
 
82
  selected targets, parsed XML tool call, tool observations, and score context preserved. This prepares the Well-Tuned
83
  path without claiming that the adapter has already been trained or published.
84
 
85
+ ## LoRA Training Kit
86
+
87
+ `/api/lora-training-kit.zip` and the `Train` button export a training kit for the deterministic demo session: SFT JSONL,
88
+ training recipe, adapter model-card draft, and the exact training command. The included
89
+ `scripts/train_minicpm_lora.py` entrypoint supports a dependency-light `--dry-run` validation path and a real
90
+ `transformers + PEFT` training path after installing `pip install -e '.[train]'`. The Prize Ledger still marks
91
+ Well-Tuned as training-kit-ready until a real adapter is trained and published.
92
+
93
  ## Submission Packet
94
 
95
  The `submission_packet` Gradio API endpoint and `Packet` button export a Markdown submission bundle for the current
 
108
 
109
  `/api/demo-bundle.zip` and the `Bundle` button download a server-built ZIP for the deterministic demo session. The
110
  bundle includes a manifest, demo session JSON, Prize Ledger JSON, trace JSONL, Field Notes, Almanac chapter, LoRA SFT
111
+ JSONL, LoRA training kit, Submission Packet, and a PNG export note. This gives judges or collaborators one auditable
112
+ package without depending on browser `localStorage`.
113
 
114
  ## Prize Ledger
115
 
app.py CHANGED
@@ -15,6 +15,7 @@ from hackathon_advisor.data import ProjectIndex
15
  from hackathon_advisor.demo_rehearsal import build_demo_rehearsal
16
  from hackathon_advisor.field_notes import build_field_notes_markdown
17
  from hackathon_advisor.lora_dataset import build_lora_dataset_jsonl
 
18
  from hackathon_advisor.prize_ledger import prize_ledger
19
  from hackathon_advisor.submission_packet import build_submission_packet_markdown
20
  from hackathon_advisor.tool_contracts import resolve_tool_call, tool_schemas
@@ -115,6 +116,24 @@ def demo_bundle() -> Response:
115
  )
116
 
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  @app.api(name="tool_contract_check", concurrency_limit=8)
119
  def tool_contract_check(model_output: str, fallback_query: str = "") -> dict:
120
  return resolve_tool_call(model_output, fallback_query=fallback_query).to_dict()
 
15
  from hackathon_advisor.demo_rehearsal import build_demo_rehearsal
16
  from hackathon_advisor.field_notes import build_field_notes_markdown
17
  from hackathon_advisor.lora_dataset import build_lora_dataset_jsonl
18
+ from hackathon_advisor.lora_training_kit import TRAINING_KIT_FILENAME, build_lora_training_kit_zip
19
  from hackathon_advisor.prize_ledger import prize_ledger
20
  from hackathon_advisor.submission_packet import build_submission_packet_markdown
21
  from hackathon_advisor.tool_contracts import resolve_tool_call, tool_schemas
 
116
  )
117
 
118
 
119
+ @app.get("/api/lora-training-kit.zip")
120
+ def lora_training_kit() -> Response:
121
+ runtime_status = engine.runtime_status()
122
+ ledger = prize_ledger(runtime_status)
123
+ metadata = {
124
+ **trace_metadata(index),
125
+ "project_count": len(index.projects),
126
+ }
127
+ demo = build_demo_rehearsal(engine)
128
+ session = demo.get("session") if isinstance(demo.get("session"), dict) else {}
129
+ content = build_lora_training_kit_zip(session, metadata, ledger)
130
+ return Response(
131
+ content=content,
132
+ media_type="application/zip",
133
+ headers={"Content-Disposition": f'attachment; filename="{TRAINING_KIT_FILENAME}"'},
134
+ )
135
+
136
+
137
  @app.api(name="tool_contract_check", concurrency_limit=8)
138
  def tool_contract_check(model_output: str, fallback_query: str = "") -> dict:
139
  return resolve_tool_call(model_output, fallback_query=fallback_query).to_dict()
hackathon_advisor/artifact_bundle.py CHANGED
@@ -9,6 +9,7 @@ from zipfile import ZIP_DEFLATED, ZipFile
9
  from hackathon_advisor.chapter import build_chapter_markdown
10
  from hackathon_advisor.field_notes import build_field_notes_markdown
11
  from hackathon_advisor.lora_dataset import build_lora_dataset_jsonl
 
12
  from hackathon_advisor.submission_packet import build_submission_packet_markdown
13
  from hackathon_advisor.trace_export import build_trace_jsonl
14
 
@@ -39,7 +40,7 @@ def _bundle_files(
39
  metadata: dict[str, Any],
40
  ledger: dict[str, Any],
41
  demo: dict[str, Any],
42
- ) -> dict[str, str]:
43
  return {
44
  "demo-session.json": json.dumps(demo, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
45
  "prize-ledger.json": json.dumps(ledger, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
@@ -47,13 +48,14 @@ def _bundle_files(
47
  "field-notes.md": build_field_notes_markdown(session, metadata),
48
  "almanac-chapter.md": build_chapter_markdown(session, metadata),
49
  "lora-sft.jsonl": build_lora_dataset_jsonl(session, metadata),
 
50
  "submission-packet.md": build_submission_packet_markdown(session, metadata, ledger),
51
  "png-export-note.md": _png_note(demo),
52
  }
53
 
54
 
55
  def _manifest(
56
- files: dict[str, str],
57
  metadata: dict[str, Any],
58
  ledger: dict[str, Any],
59
  demo: dict[str, Any],
 
9
  from hackathon_advisor.chapter import build_chapter_markdown
10
  from hackathon_advisor.field_notes import build_field_notes_markdown
11
  from hackathon_advisor.lora_dataset import build_lora_dataset_jsonl
12
+ from hackathon_advisor.lora_training_kit import build_lora_training_kit_zip
13
  from hackathon_advisor.submission_packet import build_submission_packet_markdown
14
  from hackathon_advisor.trace_export import build_trace_jsonl
15
 
 
40
  metadata: dict[str, Any],
41
  ledger: dict[str, Any],
42
  demo: dict[str, Any],
43
+ ) -> dict[str, str | bytes]:
44
  return {
45
  "demo-session.json": json.dumps(demo, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
46
  "prize-ledger.json": json.dumps(ledger, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
 
48
  "field-notes.md": build_field_notes_markdown(session, metadata),
49
  "almanac-chapter.md": build_chapter_markdown(session, metadata),
50
  "lora-sft.jsonl": build_lora_dataset_jsonl(session, metadata),
51
+ "lora-training-kit.zip": build_lora_training_kit_zip(session, metadata, ledger),
52
  "submission-packet.md": build_submission_packet_markdown(session, metadata, ledger),
53
  "png-export-note.md": _png_note(demo),
54
  }
55
 
56
 
57
  def _manifest(
58
+ files: dict[str, str | bytes],
59
  metadata: dict[str, Any],
60
  ledger: dict[str, Any],
61
  demo: dict[str, Any],
hackathon_advisor/lora_training_kit.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from io import BytesIO
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from zipfile import ZIP_DEFLATED, ZipFile
9
+
10
+ from hackathon_advisor.lora_dataset import BASE_MODEL, build_lora_dataset_jsonl
11
+
12
+
13
+ TRAINING_RECIPE_SCHEMA_VERSION = 1
14
+ TRAINING_KIT_FILENAME = "hackathon-advisor-lora-training-kit.zip"
15
+ ADAPTER_REPO = "build-small-hackathon/hackathon-advisor-minicpm5-lora"
16
+
17
+
18
+ def parse_lora_dataset_jsonl(text: str) -> tuple[dict[str, Any], list[dict[str, Any]]]:
19
+ records = [json.loads(line) for line in text.splitlines() if line.strip()]
20
+ if not records:
21
+ raise ValueError("LoRA dataset is empty")
22
+ manifest = records[0]
23
+ examples = records[1:]
24
+ if manifest.get("type") != "lora_sft_manifest":
25
+ raise ValueError("first LoRA dataset row must be a lora_sft_manifest")
26
+ for index, example in enumerate(examples, start=1):
27
+ if example.get("type") != "lora_sft_example":
28
+ raise ValueError(f"record {index} is not a lora_sft_example")
29
+ messages = example.get("messages")
30
+ if not isinstance(messages, list) or len(messages) < 2:
31
+ raise ValueError(f"record {index} has no chat messages")
32
+ for message in messages:
33
+ if not isinstance(message, dict) or not message.get("role") or not message.get("content"):
34
+ raise ValueError(f"record {index} has an invalid chat message")
35
+ return manifest, examples
36
+
37
+
38
+ def build_training_recipe(
39
+ dataset_manifest: dict[str, Any],
40
+ example_count: int,
41
+ *,
42
+ max_steps: int = 120,
43
+ ) -> dict[str, Any]:
44
+ return {
45
+ "type": "lora_training_recipe",
46
+ "schema_version": TRAINING_RECIPE_SCHEMA_VERSION,
47
+ "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
48
+ "base_model": dataset_manifest.get("base_model") or BASE_MODEL,
49
+ "adapter_repo": ADAPTER_REPO,
50
+ "adapter_task": dataset_manifest.get("adapter_task") or "hackathon_advisor_tool_call_and_voice",
51
+ "dataset_format": dataset_manifest.get("format") or "chat-jsonl",
52
+ "example_count": example_count,
53
+ "method": "LoRA SFT",
54
+ "runtime": "transformers + PEFT",
55
+ "max_steps": max_steps,
56
+ "rank": 16,
57
+ "alpha": 32,
58
+ "dropout": 0.05,
59
+ "learning_rate": 0.0002,
60
+ "max_seq_length": 1024,
61
+ "target_modules": "discovered torch.nn.Linear module suffixes at training runtime",
62
+ "publish_status": "not-published",
63
+ }
64
+
65
+
66
+ def build_training_model_card(recipe: dict[str, Any], dataset_manifest: dict[str, Any], ledger: dict[str, Any]) -> str:
67
+ badges = ledger.get("badges") if isinstance(ledger.get("badges"), list) else []
68
+ lines = [
69
+ "# Hackathon Advisor MiniCPM5 LoRA",
70
+ "",
71
+ "This is the prepared model card for the Well-Tuned adapter candidate. The checked-in app can export the SFT "
72
+ "dataset and this training kit; the adapter is not claimed as published until a real Hub repo and training run "
73
+ "exist.",
74
+ "",
75
+ "## Recipe",
76
+ "",
77
+ f"- Base model: `{recipe['base_model']}`",
78
+ f"- Adapter repo target: `{recipe['adapter_repo']}`",
79
+ f"- Task: `{recipe['adapter_task']}`",
80
+ f"- Method: {recipe['method']}",
81
+ f"- Examples: {recipe['example_count']}",
82
+ f"- Max steps: {recipe['max_steps']}",
83
+ f"- LoRA rank: {recipe['rank']}",
84
+ f"- LoRA alpha: {recipe['alpha']}",
85
+ "",
86
+ "## Dataset Provenance",
87
+ "",
88
+ f"- Source: {dataset_manifest.get('source', 'exact_session_trace')}",
89
+ f"- Turn count: {dataset_manifest.get('turn_count', 0)}",
90
+ f"- Index digest: `{(dataset_manifest.get('index') or {}).get('snapshot_digest', '')}`",
91
+ "",
92
+ "## Badge Ledger",
93
+ "",
94
+ ]
95
+ for badge in badges:
96
+ if not isinstance(badge, dict):
97
+ continue
98
+ lines.append(f"- {badge.get('name')}: {badge.get('status')} - {badge.get('evidence')}")
99
+ return "\n".join(lines).rstrip() + "\n"
100
+
101
+
102
+ def build_train_command(recipe: dict[str, Any]) -> str:
103
+ return (
104
+ "pip install -e '.[train]'\n"
105
+ "python scripts/train_minicpm_lora.py \\\n"
106
+ " --dataset lora-sft.jsonl \\\n"
107
+ " --output-dir ./minicpm5-hackathon-advisor-lora \\\n"
108
+ f" --base-model {recipe['base_model']} \\\n"
109
+ f" --max-steps {recipe['max_steps']}\n"
110
+ )
111
+
112
+
113
+ def build_lora_training_kit_zip(session: dict[str, Any], metadata: dict[str, Any], ledger: dict[str, Any]) -> bytes:
114
+ dataset_text = build_lora_dataset_jsonl(session, metadata)
115
+ dataset_manifest, examples = parse_lora_dataset_jsonl(dataset_text)
116
+ recipe = build_training_recipe(dataset_manifest, len(examples))
117
+ model_card = build_training_model_card(recipe, dataset_manifest, ledger)
118
+ command = build_train_command(recipe)
119
+ files = {
120
+ "lora-sft.jsonl": dataset_text,
121
+ "training-recipe.json": json.dumps(recipe, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
122
+ "adapter-model-card.md": model_card,
123
+ "train-command.txt": command,
124
+ "README.md": _kit_readme(recipe),
125
+ }
126
+ manifest = {
127
+ "type": "lora_training_kit_manifest",
128
+ "schema_version": TRAINING_RECIPE_SCHEMA_VERSION,
129
+ "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
130
+ "file_count": len(files),
131
+ "files": list(files),
132
+ "example_count": len(examples),
133
+ "adapter_repo": recipe["adapter_repo"],
134
+ "publish_status": recipe["publish_status"],
135
+ }
136
+
137
+ buffer = BytesIO()
138
+ with ZipFile(buffer, "w", compression=ZIP_DEFLATED) as archive:
139
+ archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n")
140
+ for filename, content in files.items():
141
+ archive.writestr(filename, content)
142
+ return buffer.getvalue()
143
+
144
+
145
+ def write_lora_training_dry_run(dataset_path: Path, output_dir: Path, *, max_steps: int = 120) -> dict[str, Any]:
146
+ dataset_text = dataset_path.read_text(encoding="utf-8")
147
+ dataset_manifest, examples = parse_lora_dataset_jsonl(dataset_text)
148
+ recipe = build_training_recipe(dataset_manifest, len(examples), max_steps=max_steps)
149
+ output_dir.mkdir(parents=True, exist_ok=True)
150
+ (output_dir / "training-recipe.json").write_text(
151
+ json.dumps(recipe, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
152
+ encoding="utf-8",
153
+ )
154
+ (output_dir / "train-command.txt").write_text(build_train_command(recipe), encoding="utf-8")
155
+ return recipe
156
+
157
+
158
+ def _kit_readme(recipe: dict[str, Any]) -> str:
159
+ return (
160
+ "# Hackathon Advisor LoRA Training Kit\n\n"
161
+ "This kit prepares the Well-Tuned path without claiming the adapter has already been trained or published.\n\n"
162
+ "Run `train-command.txt` in an environment with the `train` extra installed. The training script validates the "
163
+ "dataset, loads the base model, discovers LoRA target modules from the loaded model, and saves the PEFT adapter.\n\n"
164
+ f"Adapter repo target: `{recipe['adapter_repo']}`\n"
165
+ )
hackathon_advisor/prize_ledger.py CHANGED
@@ -63,8 +63,8 @@ BADGE_LEDGER = [
63
  },
64
  {
65
  "name": "Well-Tuned",
66
- "status": "dataset-ready",
67
- "evidence": "LoRA SFT dataset export is generated from exact session traces; adapter publication remains a separate build milestone.",
68
  },
69
  {
70
  "name": "Llama Champion",
@@ -81,6 +81,13 @@ TRAINING_ARTIFACTS = [
81
  "endpoint": "lora_dataset",
82
  "format": "chat-jsonl",
83
  "base_model": "openbmb/MiniCPM5-1B",
 
 
 
 
 
 
 
84
  }
85
  ]
86
 
 
63
  },
64
  {
65
  "name": "Well-Tuned",
66
+ "status": "training-kit-ready",
67
+ "evidence": "LoRA SFT dataset and training kit export are generated from exact session traces; adapter publication remains a separate build milestone.",
68
  },
69
  {
70
  "name": "Llama Champion",
 
81
  "endpoint": "lora_dataset",
82
  "format": "chat-jsonl",
83
  "base_model": "openbmb/MiniCPM5-1B",
84
+ },
85
+ {
86
+ "name": "MiniCPM5 LoRA training kit",
87
+ "status": "export-ready",
88
+ "endpoint": "/api/lora-training-kit.zip",
89
+ "format": "zip",
90
+ "base_model": "openbmb/MiniCPM5-1B",
91
  }
92
  ]
93
 
pyproject.toml CHANGED
@@ -19,6 +19,12 @@ model = [
19
  "torch>=2.8,<3",
20
  "transformers>=4.55,<5",
21
  ]
 
 
 
 
 
 
22
 
23
  [tool.pytest.ini_options]
24
  testpaths = ["tests"]
 
19
  "torch>=2.8,<3",
20
  "transformers>=4.55,<5",
21
  ]
22
+ train = [
23
+ "accelerate>=1.0,<2",
24
+ "peft>=0.13,<1",
25
+ "torch>=2.8,<3",
26
+ "transformers>=4.55,<5",
27
+ ]
28
 
29
  [tool.pytest.ini_options]
30
  testpaths = ["tests"]
scripts/train_minicpm_lora.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+ import sys
7
+ from typing import Any
8
+
9
+ ROOT = Path(__file__).resolve().parents[1]
10
+ if str(ROOT) not in sys.path:
11
+ sys.path.insert(0, str(ROOT))
12
+
13
+ from hackathon_advisor.lora_training_kit import (
14
+ build_training_recipe,
15
+ parse_lora_dataset_jsonl,
16
+ write_lora_training_dry_run,
17
+ )
18
+
19
+
20
+ def main() -> None:
21
+ parser = argparse.ArgumentParser(description="Train or dry-run the Hackathon Advisor MiniCPM5 LoRA adapter.")
22
+ parser.add_argument("--dataset", required=True, type=Path, help="LoRA SFT JSONL exported by the app.")
23
+ parser.add_argument("--output-dir", required=True, type=Path, help="Directory for adapter or dry-run artifacts.")
24
+ parser.add_argument("--base-model", default="openbmb/MiniCPM5-1B", help="Base model id.")
25
+ parser.add_argument("--max-steps", default=120, type=int, help="Maximum training steps.")
26
+ parser.add_argument("--rank", default=16, type=int, help="LoRA rank.")
27
+ parser.add_argument("--alpha", default=32, type=int, help="LoRA alpha.")
28
+ parser.add_argument("--dropout", default=0.05, type=float, help="LoRA dropout.")
29
+ parser.add_argument("--learning-rate", default=2e-4, type=float, help="Learning rate.")
30
+ parser.add_argument("--max-seq-length", default=1024, type=int, help="Maximum tokenized sequence length.")
31
+ parser.add_argument("--dry-run", action="store_true", help="Validate dataset and write recipe without training.")
32
+ args = parser.parse_args()
33
+
34
+ if args.dry_run:
35
+ recipe = write_lora_training_dry_run(args.dataset, args.output_dir, max_steps=args.max_steps)
36
+ print(f"dry-run ok: {recipe['example_count']} examples -> {args.output_dir}")
37
+ return
38
+
39
+ train_lora(
40
+ dataset_path=args.dataset,
41
+ output_dir=args.output_dir,
42
+ base_model=args.base_model,
43
+ max_steps=args.max_steps,
44
+ rank=args.rank,
45
+ alpha=args.alpha,
46
+ dropout=args.dropout,
47
+ learning_rate=args.learning_rate,
48
+ max_seq_length=args.max_seq_length,
49
+ )
50
+
51
+
52
+ def train_lora(
53
+ *,
54
+ dataset_path: Path,
55
+ output_dir: Path,
56
+ base_model: str,
57
+ max_steps: int,
58
+ rank: int,
59
+ alpha: int,
60
+ dropout: float,
61
+ learning_rate: float,
62
+ max_seq_length: int,
63
+ ) -> None:
64
+ try:
65
+ import torch
66
+ from peft import LoraConfig, TaskType, get_peft_model
67
+ from transformers import AutoModelForCausalLM, AutoTokenizer, Trainer, TrainingArguments
68
+ except ImportError as error:
69
+ raise SystemExit("Install training dependencies first: pip install -e '.[train]'") from error
70
+
71
+ dataset_text = dataset_path.read_text(encoding="utf-8")
72
+ dataset_manifest, examples = parse_lora_dataset_jsonl(dataset_text)
73
+ tokenizer = AutoTokenizer.from_pretrained(base_model, trust_remote_code=True)
74
+ if tokenizer.pad_token is None:
75
+ tokenizer.pad_token = tokenizer.eos_token
76
+ model = AutoModelForCausalLM.from_pretrained(
77
+ base_model,
78
+ torch_dtype="auto",
79
+ device_map="auto",
80
+ trust_remote_code=True,
81
+ )
82
+ target_modules = _discover_lora_targets(model, torch)
83
+ if not target_modules:
84
+ raise RuntimeError("No torch.nn.Linear modules were found for LoRA target discovery.")
85
+ lora_config = LoraConfig(
86
+ r=rank,
87
+ lora_alpha=alpha,
88
+ lora_dropout=dropout,
89
+ target_modules=target_modules,
90
+ task_type=TaskType.CAUSAL_LM,
91
+ )
92
+ model = get_peft_model(model, lora_config)
93
+ train_dataset = _ChatDataset(examples, tokenizer, max_seq_length)
94
+ recipe = build_training_recipe(dataset_manifest, len(examples), max_steps=max_steps)
95
+ training_args = TrainingArguments(
96
+ output_dir=str(output_dir),
97
+ max_steps=max_steps,
98
+ per_device_train_batch_size=1,
99
+ gradient_accumulation_steps=4,
100
+ learning_rate=learning_rate,
101
+ logging_steps=5,
102
+ save_steps=max(20, max_steps),
103
+ save_total_limit=1,
104
+ report_to=[],
105
+ )
106
+ trainer = Trainer(
107
+ model=model,
108
+ args=training_args,
109
+ train_dataset=train_dataset,
110
+ data_collator=_causal_lm_collate(tokenizer),
111
+ )
112
+ trainer.train()
113
+ output_dir.mkdir(parents=True, exist_ok=True)
114
+ model.save_pretrained(output_dir)
115
+ tokenizer.save_pretrained(output_dir)
116
+ (output_dir / "training-recipe.json").write_text(
117
+ json.dumps(recipe, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
118
+ encoding="utf-8",
119
+ )
120
+
121
+
122
+ def _discover_lora_targets(model: Any, torch_module: Any) -> list[str]:
123
+ targets: set[str] = set()
124
+ for name, module in model.named_modules():
125
+ if not isinstance(module, torch_module.nn.Linear):
126
+ continue
127
+ suffix = name.rsplit(".", 1)[-1]
128
+ if suffix in {"lm_head", "embed_tokens"}:
129
+ continue
130
+ targets.add(suffix)
131
+ return sorted(targets)
132
+
133
+
134
+ class _ChatDataset:
135
+ def __init__(self, examples: list[dict[str, Any]], tokenizer: Any, max_seq_length: int) -> None:
136
+ self.examples = examples
137
+ self.tokenizer = tokenizer
138
+ self.max_seq_length = max_seq_length
139
+
140
+ def __len__(self) -> int:
141
+ return len(self.examples)
142
+
143
+ def __getitem__(self, index: int) -> dict[str, Any]:
144
+ messages = self.examples[index]["messages"]
145
+ text = self.tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
146
+ encoded = self.tokenizer(
147
+ text,
148
+ max_length=self.max_seq_length,
149
+ truncation=True,
150
+ padding=False,
151
+ )
152
+ input_ids = encoded["input_ids"]
153
+ return {
154
+ "input_ids": input_ids,
155
+ "attention_mask": encoded["attention_mask"],
156
+ "labels": list(input_ids),
157
+ }
158
+
159
+
160
+ def _causal_lm_collate(tokenizer: Any):
161
+ def collate(batch: list[dict[str, Any]]) -> dict[str, Any]:
162
+ return tokenizer.pad(batch, padding=True, return_tensors="pt")
163
+
164
+ return collate
165
+
166
+
167
+ if __name__ == "__main__":
168
+ main()
static/app.js CHANGED
@@ -24,6 +24,7 @@ const exportTraceButton = document.querySelector("#export-trace");
24
  const exportNotesButton = document.querySelector("#export-notes");
25
  const exportChapterButton = document.querySelector("#export-chapter");
26
  const exportLoraButton = document.querySelector("#export-lora");
 
27
  const exportPacketButton = document.querySelector("#export-packet");
28
  const exportBundleButton = document.querySelector("#export-bundle");
29
  const resetButton = document.querySelector("#reset-session");
@@ -78,6 +79,10 @@ exportLoraButton.addEventListener("click", async () => {
78
  await exportLoraDataset();
79
  });
80
 
 
 
 
 
81
  exportPacketButton.addEventListener("click", async () => {
82
  await exportSubmissionPacket();
83
  });
@@ -618,6 +623,7 @@ function renderTrace(trace) {
618
 
619
  function setCommandDisabled(disabled) {
620
  document.querySelectorAll(".command-row button").forEach((button) => {
 
621
  if (button.id === "export-bundle") return;
622
  const isArtifact = button.id === "export-artifact";
623
  const isTrace = button.id === "export-trace";
 
24
  const exportNotesButton = document.querySelector("#export-notes");
25
  const exportChapterButton = document.querySelector("#export-chapter");
26
  const exportLoraButton = document.querySelector("#export-lora");
27
+ const exportTrainKitButton = document.querySelector("#export-train-kit");
28
  const exportPacketButton = document.querySelector("#export-packet");
29
  const exportBundleButton = document.querySelector("#export-bundle");
30
  const resetButton = document.querySelector("#reset-session");
 
79
  await exportLoraDataset();
80
  });
81
 
82
+ exportTrainKitButton.addEventListener("click", () => {
83
+ window.location.assign("/api/lora-training-kit.zip");
84
+ });
85
+
86
  exportPacketButton.addEventListener("click", async () => {
87
  await exportSubmissionPacket();
88
  });
 
623
 
624
  function setCommandDisabled(disabled) {
625
  document.querySelectorAll(".command-row button").forEach((button) => {
626
+ if (button.id === "export-train-kit") return;
627
  if (button.id === "export-bundle") return;
628
  const isArtifact = button.id === "export-artifact";
629
  const isTrace = button.id === "export-trace";
static/index.html CHANGED
@@ -37,6 +37,7 @@
37
  <button type="button" id="export-notes" title="Export Field Notes" disabled>Notes</button>
38
  <button type="button" id="export-chapter" title="Export the Almanac chapter" disabled>Chapter</button>
39
  <button type="button" id="export-lora" title="Export the LoRA SFT dataset" disabled>LoRA</button>
 
40
  <button type="button" id="export-packet" title="Export the submission packet" disabled>Packet</button>
41
  <button type="button" id="export-bundle" title="Download the demo evidence bundle">Bundle</button>
42
  <button type="button" id="export-artifact" title="Export the current fate page" disabled>PNG</button>
 
37
  <button type="button" id="export-notes" title="Export Field Notes" disabled>Notes</button>
38
  <button type="button" id="export-chapter" title="Export the Almanac chapter" disabled>Chapter</button>
39
  <button type="button" id="export-lora" title="Export the LoRA SFT dataset" disabled>LoRA</button>
40
+ <button type="button" id="export-train-kit" title="Download the LoRA training kit">Train</button>
41
  <button type="button" id="export-packet" title="Export the submission packet" disabled>Packet</button>
42
  <button type="button" id="export-bundle" title="Download the demo evidence bundle">Bundle</button>
43
  <button type="button" id="export-artifact" title="Export the current fate page" disabled>PNG</button>
static/styles.css CHANGED
@@ -473,6 +473,10 @@ button:disabled {
473
  border-left-color: #5f6d38;
474
  }
475
 
 
 
 
 
476
  .badge-item.planned {
477
  border-left-color: var(--muted-ink);
478
  }
 
473
  border-left-color: #5f6d38;
474
  }
475
 
476
+ .badge-item.training-kit-ready {
477
+ border-left-color: #5f6d38;
478
+ }
479
+
480
  .badge-item.planned {
481
  border-left-color: var(--muted-ink);
482
  }
tests/test_app.py CHANGED
@@ -12,6 +12,7 @@ from app import (
12
  health,
13
  index,
14
  lora_dataset_artifact,
 
15
  prize_ledger_endpoint,
16
  runtime,
17
  submission_packet_artifact,
@@ -143,9 +144,24 @@ def test_demo_bundle_endpoint_returns_zip_attachment() -> None:
143
 
144
  assert "submission-packet.md" in names
145
  assert "lora-sft.jsonl" in names
 
146
  assert manifest["turn_count"] == 2
147
 
148
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  def test_tool_contract_check_endpoint_defaults_safely() -> None:
150
  payload = tool_contract_check("broken", "family archive")
151
 
@@ -168,3 +184,4 @@ def test_prize_ledger_endpoint_reports_submission_evidence() -> None:
168
  assert payload["tiny_titan_eligible"] is True
169
  assert any(badge["name"] == "Sharing is Caring" for badge in payload["badges"])
170
  assert payload["training_artifacts"][0]["endpoint"] == "lora_dataset"
 
 
12
  health,
13
  index,
14
  lora_dataset_artifact,
15
+ lora_training_kit,
16
  prize_ledger_endpoint,
17
  runtime,
18
  submission_packet_artifact,
 
144
 
145
  assert "submission-packet.md" in names
146
  assert "lora-sft.jsonl" in names
147
+ assert "lora-training-kit.zip" in names
148
  assert manifest["turn_count"] == 2
149
 
150
 
151
+ def test_lora_training_kit_endpoint_returns_zip_attachment() -> None:
152
+ response = lora_training_kit()
153
+
154
+ assert response.media_type == "application/zip"
155
+ assert "hackathon-advisor-lora-training-kit.zip" in response.headers["content-disposition"]
156
+ with ZipFile(BytesIO(response.body)) as archive:
157
+ names = set(archive.namelist())
158
+ recipe = json.loads(archive.read("training-recipe.json"))
159
+
160
+ assert "adapter-model-card.md" in names
161
+ assert "train-command.txt" in names
162
+ assert recipe["publish_status"] == "not-published"
163
+
164
+
165
  def test_tool_contract_check_endpoint_defaults_safely() -> None:
166
  payload = tool_contract_check("broken", "family archive")
167
 
 
184
  assert payload["tiny_titan_eligible"] is True
185
  assert any(badge["name"] == "Sharing is Caring" for badge in payload["badges"])
186
  assert payload["training_artifacts"][0]["endpoint"] == "lora_dataset"
187
+ assert payload["training_artifacts"][1]["endpoint"] == "/api/lora-training-kit.zip"
tests/test_artifact_bundle.py CHANGED
@@ -34,11 +34,12 @@ def test_demo_bundle_contains_submission_evidence_files() -> None:
34
  "field-notes.md",
35
  "almanac-chapter.md",
36
  "lora-sft.jsonl",
 
37
  "submission-packet.md",
38
  "png-export-note.md",
39
  }
40
  assert manifest["type"] == "demo_bundle_manifest"
41
  assert manifest["turn_count"] == 2
42
- assert manifest["badge_status"]["Well-Tuned"] == "dataset-ready"
43
  assert "agent_turn" in trace
44
  assert "## Prize Evidence" in packet
 
34
  "field-notes.md",
35
  "almanac-chapter.md",
36
  "lora-sft.jsonl",
37
+ "lora-training-kit.zip",
38
  "submission-packet.md",
39
  "png-export-note.md",
40
  }
41
  assert manifest["type"] == "demo_bundle_manifest"
42
  assert manifest["turn_count"] == 2
43
+ assert manifest["badge_status"]["Well-Tuned"] == "training-kit-ready"
44
  assert "agent_turn" in trace
45
  assert "## Prize Evidence" in packet
tests/test_lora_training_kit.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import subprocess
3
+ import sys
4
+ from io import BytesIO
5
+ from pathlib import Path
6
+ from zipfile import ZipFile
7
+
8
+ from hackathon_advisor.agent import AdvisorEngine
9
+ from hackathon_advisor.data import ProjectIndex
10
+ from hackathon_advisor.demo_rehearsal import build_demo_rehearsal
11
+ from hackathon_advisor.lora_dataset import build_lora_dataset_jsonl
12
+ from hackathon_advisor.lora_training_kit import (
13
+ build_lora_training_kit_zip,
14
+ parse_lora_dataset_jsonl,
15
+ )
16
+ from hackathon_advisor.prize_ledger import prize_ledger
17
+ from hackathon_advisor.trace_export import trace_metadata
18
+
19
+
20
+ def test_lora_training_kit_contains_recipe_and_model_card() -> None:
21
+ index = ProjectIndex.from_files(Path("data/projects.json"), Path("data/project_index.json"))
22
+ engine = AdvisorEngine(index)
23
+ metadata = {
24
+ **trace_metadata(index),
25
+ "project_count": len(index.projects),
26
+ }
27
+ demo = build_demo_rehearsal(engine)
28
+ content = build_lora_training_kit_zip(
29
+ demo["session"],
30
+ metadata,
31
+ prize_ledger(engine.runtime_status()),
32
+ )
33
+
34
+ with ZipFile(BytesIO(content)) as archive:
35
+ names = set(archive.namelist())
36
+ manifest = json.loads(archive.read("manifest.json"))
37
+ recipe = json.loads(archive.read("training-recipe.json"))
38
+ model_card = archive.read("adapter-model-card.md").decode("utf-8")
39
+ command = archive.read("train-command.txt").decode("utf-8")
40
+
41
+ assert names == {
42
+ "manifest.json",
43
+ "lora-sft.jsonl",
44
+ "training-recipe.json",
45
+ "adapter-model-card.md",
46
+ "train-command.txt",
47
+ "README.md",
48
+ }
49
+ assert manifest["type"] == "lora_training_kit_manifest"
50
+ assert manifest["publish_status"] == "not-published"
51
+ assert recipe["base_model"] == "openbmb/MiniCPM5-1B"
52
+ assert recipe["example_count"] == manifest["example_count"]
53
+ assert "adapter is not claimed as published" in model_card
54
+ assert "scripts/train_minicpm_lora.py" in command
55
+
56
+
57
+ def test_parse_lora_dataset_jsonl_rejects_empty_payload() -> None:
58
+ try:
59
+ parse_lora_dataset_jsonl("")
60
+ except ValueError as error:
61
+ assert "empty" in str(error)
62
+ else:
63
+ raise AssertionError("empty dataset should be rejected")
64
+
65
+
66
+ def test_train_minicpm_lora_dry_run_writes_recipe(tmp_path: Path) -> None:
67
+ index = ProjectIndex.from_files(Path("data/projects.json"), Path("data/project_index.json"))
68
+ engine = AdvisorEngine(index)
69
+ metadata = {
70
+ **trace_metadata(index),
71
+ "project_count": len(index.projects),
72
+ }
73
+ dataset_path = tmp_path / "lora-sft.jsonl"
74
+ output_dir = tmp_path / "dry-run"
75
+ dataset_path.write_text(
76
+ build_lora_dataset_jsonl(build_demo_rehearsal(engine)["session"], metadata),
77
+ encoding="utf-8",
78
+ )
79
+
80
+ result = subprocess.run(
81
+ [
82
+ sys.executable,
83
+ "scripts/train_minicpm_lora.py",
84
+ "--dataset",
85
+ str(dataset_path),
86
+ "--output-dir",
87
+ str(output_dir),
88
+ "--max-steps",
89
+ "7",
90
+ "--dry-run",
91
+ ],
92
+ check=True,
93
+ capture_output=True,
94
+ text=True,
95
+ )
96
+ recipe = json.loads((output_dir / "training-recipe.json").read_text(encoding="utf-8"))
97
+
98
+ assert "dry-run ok" in result.stdout
99
+ assert recipe["example_count"] > 0
100
+ assert recipe["max_steps"] == 7
101
+ assert (output_dir / "train-command.txt").is_file()
tests/test_prize_ledger.py CHANGED
@@ -10,5 +10,6 @@ def test_prize_ledger_tracks_param_budget_and_badges() -> None:
10
  assert payload["largest_model"]["model"] == "openbmb/MiniCPM5-1B"
11
  badges = {badge["name"]: badge["status"] for badge in payload["badges"]}
12
  assert badges["Off the Grid"] == "ready"
13
- assert badges["Well-Tuned"] == "dataset-ready"
14
  assert payload["training_artifacts"][0]["base_model"] == "openbmb/MiniCPM5-1B"
 
 
10
  assert payload["largest_model"]["model"] == "openbmb/MiniCPM5-1B"
11
  badges = {badge["name"]: badge["status"] for badge in payload["badges"]}
12
  assert badges["Off the Grid"] == "ready"
13
+ assert badges["Well-Tuned"] == "training-kit-ready"
14
  assert payload["training_artifacts"][0]["base_model"] == "openbmb/MiniCPM5-1B"
15
+ assert payload["training_artifacts"][1]["format"] == "zip"
tests/test_submission_packet.py CHANGED
@@ -30,7 +30,7 @@ def test_submission_packet_contains_demo_and_prize_evidence() -> None:
30
  assert "## Model Budget" in markdown
31
  assert "## Social Post Draft" in markdown
32
  assert "Hackathon Advisor" in markdown
33
- assert "Well-Tuned | dataset-ready" in markdown
34
  assert "MiniCPM5 LoRA SFT JSONL | ready | lora_dataset" in markdown
35
  assert "Ready badges and planned badges are separated" in markdown
36
  assert "A local-first archive cartographer for family photos" in markdown
 
30
  assert "## Model Budget" in markdown
31
  assert "## Social Post Draft" in markdown
32
  assert "Hackathon Advisor" in markdown
33
+ assert "Well-Tuned | training-kit-ready" in markdown
34
  assert "MiniCPM5 LoRA SFT JSONL | ready | lora_dataset" in markdown
35
  assert "Ready badges and planned badges are separated" in markdown
36
  assert "A local-first archive cartographer for family photos" in markdown