Spaces:
Running on Zero
Running on Zero
feat: add lora training kit
Browse filesCo-authored-by: Codex <noreply@openai.com>
- README.md +10 -2
- app.py +19 -0
- hackathon_advisor/artifact_bundle.py +4 -2
- hackathon_advisor/lora_training_kit.py +165 -0
- hackathon_advisor/prize_ledger.py +9 -2
- pyproject.toml +6 -0
- scripts/train_minicpm_lora.py +168 -0
- static/app.js +6 -0
- static/index.html +1 -0
- static/styles.css +4 -0
- tests/test_app.py +17 -0
- tests/test_artifact_bundle.py +2 -1
- tests/test_lora_training_kit.py +101 -0
- tests/test_prize_ledger.py +2 -1
- tests/test_submission_packet.py +1 -1
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
|
| 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": "
|
| 67 |
-
"evidence": "LoRA SFT dataset export
|
| 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"] == "
|
| 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"] == "
|
| 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 |
|
| 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
|