MSG msgencrypted-auto Cursor commited on
Commit
727cb75
·
1 Parent(s): 6cea344

Feat/monday sprint 2 (#19)

Browse files

* plan sprint

* model config loading

* model config

* llama studio config

* common fix

* lm eval fix

* llama studio config

* llama studio model

* modal fix

* modal fix eval

* fix

* fix config and stuff model switch

* common fix

* server app modal

* server and orgin fix

* Pin torch 2.11 for HF ZeroGPU Space compatibility.

ZeroGPU rejects torch 2.12; supported versions are 2.8–2.11.

Co-authored-by: Cursor <cursoragent@cursor.com>

* finetuning stuff

* fix common and experiment

* readme md

* experiment

* fix experiment

* finetuning stuff

* finetuning

* fix experiment and stuff

* adaptaters + config fix

---------

Co-authored-by: msgencrypted-auto <msgencrypted.auto@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

.cursor/plans/llama_backend_model_switching_77de87de.plan.md ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Llama backend model switching
3
+ overview: Add the official MiniCPM-V-4.6 GGUF preset from openbmb/MiniCPM-V-4.6-gguf for the llama.cpp / Llama Champion path, then wire a shared runtime model selector so local dev can switch between transformers and llama.cpp backends (and other presets) from Gradio Settings and Studio — not just the Chat debug tab.
4
+ todos:
5
+ - id: add-gguf-preset
6
+ content: Add minicpm-v-4.6-gguf preset to models.yaml (openbmb/MiniCPM-V-4.6-gguf) and document in .env.example
7
+ status: completed
8
+ - id: runtime-model-state
9
+ content: Add set_runtime_model_key() and make get_active_model_key() runtime-aware in model_loading.py
10
+ status: completed
11
+ - id: classic-ui-sync
12
+ content: Wire Settings + Chat dropdowns to set_runtime_model_key; reload on change
13
+ status: completed
14
+ - id: studio-api-sync
15
+ content: Add api_set_active_model + studio.js settings dropdown handler; sync debug picker
16
+ status: completed
17
+ - id: tests-docs
18
+ content: Test preset parsing + runtime key override; document local switching in USAGE.md
19
+ status: completed
20
+ isProject: false
21
+ ---
22
+
23
+ # Llama backend + runtime model switching (local dev)
24
+
25
+ ## What already exists
26
+
27
+ Your repo already has **two inference backends** behind one factory — no new backend code is required for **text** inference:
28
+
29
+ ```mermaid
30
+ flowchart LR
31
+ GradioUI[Gradio Classic + Studio]
32
+ ModelLoading[model_loading.py]
33
+ Factory[factory.py]
34
+ LlamaCpp[LlamaCppBackend]
35
+ Transformers[TransformersBackend]
36
+ GradioUI --> ModelLoading --> Factory
37
+ Factory -->|preset.backend=llama_cpp| LlamaCpp
38
+ Factory -->|preset.backend=transformers| Transformers
39
+ ```
40
+
41
+ - Presets live in [`models.yaml`](models.yaml); backend is chosen **per preset**, not via a separate toggle.
42
+ - Switching transformers → llama.cpp means switching preset, e.g. `minicpm-v-4.6` → `minicpm-v-4.6-gguf` (to be added).
43
+ - [`libs/inference/src/inference/llama_cpp.py`](libs/inference/src/inference/llama_cpp.py) downloads GGUF from Hub and runs `create_chat_completion`.
44
+ - [`ALLOW_MODEL_SWITCH`](libs/inference/src/inference/config.py) gates dropdowns in Settings, Chat, and Studio debug — but **only Chat/Debug actually pass the selected key to inference**.
45
+
46
+ ### Current gap (why switching feels broken)
47
+
48
+ [`get_active_model_key()`](apps/gradio-space/src/gradio_space/model_loading.py) always returns the **startup** preset from env/`models.yaml`:
49
+
50
+ ```12:13:apps/gradio-space/src/gradio_space/model_loading.py
51
+ def get_active_model_key() -> str:
52
+ return _app_config.active_model
53
+ ```
54
+
55
+ Lesson slides, ResearchMind, EchoCoach, TeacherVoice, and Studio Research/Slides all call `get_active_model_key()` — so changing the Settings dropdown only updates the status panel, not the model used by those tabs.
56
+
57
+ ---
58
+
59
+ ## Step 1 — Add MiniCPM-V-4.6 GGUF preset (OpenBMB + llama.cpp)
60
+
61
+ Official GGUF is published at [`openbmb/MiniCPM-V-4.6-gguf`](https://huggingface.co/openbmb/MiniCPM-V-4.6-gguf). This is the **quantized llama.cpp build** of the same ~0.8B multimodal model already registered as `minicpm-v-4.6` (transformers). Recommended quant for local dev: **Q4_K_M** (~529 MB).
62
+
63
+ Add to [`models.yaml`](models.yaml):
64
+
65
+ ```yaml
66
+ minicpm-v-4.6-gguf:
67
+ label: MiniCPM-V 4.6 (GGUF / llama.cpp)
68
+ backend: llama_cpp
69
+ model_repo: openbmb/MiniCPM-V-4.6-gguf
70
+ model_file: MiniCPM-V-4.6-Q4_K_M.gguf
71
+ multimodal: true
72
+ n_ctx: 8192
73
+ n_gpu_layers: 0
74
+ ```
75
+
76
+ Pair with the existing transformers preset for A/B comparison:
77
+
78
+ | Preset key | Backend | Hub source | Use case |
79
+ |------------|---------|------------|----------|
80
+ | `minicpm-v-4.6` | transformers | `openbmb/MiniCPM-V-4.6` | Full multimodal (image/video) via HF processor |
81
+ | `minicpm-v-4.6-gguf` | llama_cpp | `openbmb/MiniCPM-V-4.6-gguf` | Llama Champion / Off-the-Grid; text chat + future image via llama.cpp |
82
+
83
+ Also update [`.env.example`](.env.example) with a commented dev block:
84
+
85
+ ```bash
86
+ ALLOW_MODEL_SWITCH=true
87
+ ACTIVE_MODEL=minicpm-v-4.6 # transformers default (or minicpm5-1b)
88
+ # switch in UI to minicpm-v-4.6-gguf for llama.cpp
89
+ ```
90
+
91
+ Prefetch locally (optional, speeds first load):
92
+
93
+ ```bash
94
+ uv run python scripts/download_model.py --preset minicpm-v-4.6-gguf
95
+ ```
96
+
97
+ Per the [model card](https://huggingface.co/openbmb/MiniCPM-V-4.6-gguf), llama.cpp loads it directly — no custom fork:
98
+
99
+ ```bash
100
+ llama-cli -hf openbmb/MiniCPM-V-4.6-gguf:Q4_K_M
101
+ ```
102
+
103
+ This satisfies the **Llama Champion** badge (llama.cpp runtime) while keeping the **OpenBMB / Tiny Titan** story (same MiniCPM-V 4.6 model family). LoRA/merged lesson presets on MiniCPM5-1B remain **transformers-only**.
104
+
105
+ ### Multimodal caveat (text vs image)
106
+
107
+ - **Text-only tabs** (Lesson slides, ResearchMind, Chat, EchoCoach) work immediately — `LlamaCppBackend.chat()` passes string messages to `create_chat_completion`.
108
+ - **Image input via llama.cpp** requires OpenAI-style message content arrays (`type: image_url`). Current `LlamaCppBackend.chat()` types messages as `list[dict[str, str]]` and does not forward images. Defer image support to a follow-up unless a tab needs it now; keep `minicpm-v-4.6` (transformers) for full VLM demos.
109
+
110
+ ---
111
+
112
+ ## Step 2 — Shared runtime model state
113
+
114
+ Extend [`model_loading.py`](apps/gradio-space/src/gradio_space/model_loading.py):
115
+
116
+ | Function | Behavior |
117
+ |----------|----------|
118
+ | `set_runtime_model_key(key: str) -> str` | Validate key exists; if changed, call `reset_backend()` and clear load cache for old key; return label for UI |
119
+ | `get_active_model_key()` | Return `_runtime_model_key` if set, else `_app_config.active_model` |
120
+ | `reload_model(key)` | Also call `set_runtime_model_key(key)` so reload pins the selection app-wide |
121
+
122
+ This is a small, centralized change — every tab that already calls `get_active_model_key()` will automatically respect the runtime selection once Settings updates it.
123
+
124
+ ---
125
+
126
+ ## Step 3 — Classic Gradio UI wiring
127
+
128
+ ### Settings panel ([`settings_panel.py`](apps/gradio-space/src/gradio_space/ui/settings_panel.py))
129
+
130
+ On dropdown `.change`:
131
+ 1. Call `set_runtime_model_key(selected_key)`
132
+ 2. Update status markdown (existing `model_status`)
133
+ 3. Optionally auto-reload weights (or keep explicit "Reload model" button — recommend **reload on change** for dev UX)
134
+
135
+ Return `model_dropdown` from `build_settings_panel()` (already does) and expose it to [`app.py`](apps/gradio-space/src/gradio_space/app.py) if needed for cross-tab sync.
136
+
137
+ ### Chat tab ([`tabs/chat.py`](apps/gradio-space/src/gradio_space/tabs/chat.py))
138
+
139
+ When `allow_model_switch` is on:
140
+ - On Chat model dropdown change → `set_runtime_model_key(mkey)` so Chat and Settings stay in sync
141
+ - Default dropdown value = `get_active_model_key()` (runtime-aware)
142
+
143
+ ### App header badge (small UX win)
144
+
145
+ When `allow_model_switch` is false, keep current read-only badge. When true, show active preset + backend in Settings accordion header so devs always know which backend is live.
146
+
147
+ ---
148
+
149
+ ## Step 4 — Studio UI wiring
150
+
151
+ In [`api/studio.py`](apps/gradio-space/src/gradio_space/api/studio.py):
152
+
153
+ - Add `api_set_active_model(model_key: str)` → calls `set_runtime_model_key`, returns updated `model_status`
154
+ - Register as `@server.api(name="set_active_model")`
155
+ - `api_model_choices()` should report `active_model=get_active_model_key()` (runtime-aware)
156
+ - `api_reload_model()` already accepts `model_key`; ensure it calls `set_runtime_model_key` too
157
+
158
+ In [`static/studio/studio.js`](apps/gradio-space/static/studio/studio.js) `initSettings()`:
159
+ - On `#settings-model-key` change → `callApi("set_active_model", [key])` then refresh status
160
+ - Keep debug chat dropdown in sync with settings dropdown
161
+
162
+ Studio Research + Slides already delegate to helpers that use `get_active_model_key()` — no per-endpoint `model_key` param needed once runtime state exists.
163
+
164
+ ---
165
+
166
+ ## Step 5 — Dev workflow (how you use it)
167
+
168
+ ```bash
169
+ # .env
170
+ ALLOW_MODEL_SWITCH=true
171
+ ACTIVE_MODEL=minicpm-v-4.6
172
+ ```
173
+
174
+ ```bash
175
+ uv sync --all-packages
176
+ uv run --package gradio-space python -m gradio_space.server
177
+ ```
178
+
179
+ | Goal | Action |
180
+ |------|--------|
181
+ | Transformers MiniCPM-V 4.6 (full VLM) | Select `minicpm-v-4.6` in Settings (or leave startup default) |
182
+ | llama.cpp MiniCPM-V 4.6 (Llama track) | Select `minicpm-v-4.6-gguf` — backend switches automatically |
183
+ | Text-only MiniCPM5 | Select `minicpm5-1b` |
184
+ | Fine-tuned lesson LoRA | Select `minicpm5-1b-lesson-lora` (transformers only) |
185
+ | Compare Qwen GGUF baseline | Select `qwen3b-gguf` |
186
+
187
+ **There is no separate "backend" dropdown** — backend follows the preset. Dropdown labels already include backend hints; optionally prefix choices with `[llama.cpp]` / `[transformers]` in `model_choices()` for clarity.
188
+
189
+ ### Compatibility notes to surface in Settings status
190
+
191
+ - `minicpm-v-4.6-gguf` is text-ready on all tabs; image/video input needs transformers `minicpm-v-4.6` until llama.cpp multimodal messages are wired
192
+ - LoRA/merged local presets require transformers
193
+ - First llama.cpp load downloads ~529 MB GGUF from Hub (subsequent loads use cache)
194
+
195
+ ---
196
+
197
+ ## Step 6 — Tests and docs
198
+
199
+ - Extend [`libs/inference/tests/test_config.py`](libs/inference/tests/test_config.py) to assert `minicpm-v-4.6-gguf` parses with `backend=llama_cpp` and `multimodal=true`
200
+ - Add a small unit test for `set_runtime_model_key` / `get_active_model_key` override in gradio-space tests (or inference tests if kept in `model_loading.py`)
201
+ - Add a short "Switching models locally" subsection to [`USAGE.md`](USAGE.md) and [`apps/gradio-space/README.md`](apps/gradio-space/README.md)
202
+ - Update [`TODO.md`](TODO.md) Llama Champion checklist to reference `minicpm-v-4.6-gguf` instead of generic MiniCPM5 GGUF
203
+
204
+ ---
205
+
206
+ ## Architecture after changes
207
+
208
+ ```mermaid
209
+ sequenceDiagram
210
+ participant Dev as Dev_UI_Settings
211
+ participant ML as model_loading
212
+ participant Factory as inference_factory
213
+ participant Tab as Any_Tab_or_Studio_API
214
+
215
+ Dev->>ML: set_runtime_model_key("minicpm-v-4.6-gguf")
216
+ ML->>Factory: reset_backend()
217
+ Tab->>ML: get_active_model_key()
218
+ ML-->>Tab: "minicpm-v-4.6-gguf"
219
+ Tab->>ML: ensure_model_loaded(key)
220
+ ML->>Factory: get_backend(key).load()
221
+ Note over Factory: LlamaCppBackend loads MiniCPM-V-4.6-Q4_K_M.gguf
222
+ ```
223
+
224
+ ---
225
+
226
+ ## Out of scope (per your choices)
227
+
228
+ - Pinning HF Space to Llama GGUF for judges (deployment config only — set `ACTIVE_MODEL=minicpm-v-4.6-gguf` in Space secrets; keep `ALLOW_MODEL_SWITCH=false`)
229
+ - llama.cpp multimodal image message plumbing in `LlamaCppBackend` (defer; transformers preset covers VLM demos)
230
+ - Converting fine-tuned LoRA weights to GGUF
231
+ - Separate backend-only toggle (preset-based switching is simpler and already matches factory design)
.cursor/plans/todo_last_sprint_tracks_b100b17b.plan.md ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: TODO Last Sprint Tracks
3
+ overview: "Close remaining hackathon badge gaps after the completed llama backend sprint: publish a gated Well-Tuned adapter via Modal, finish Llama Champion README/TODO hygiene (preset + runtime switching already shipped as minicpm-v-4.6-gguf), and ship Field Notes as in-repo report plus HF blog draft."
4
+ todos:
5
+ - id: well-tuned-publish
6
+ content: Run Modal teaching-lora pipeline (smoke → full publish) and verify MSGEncrypted/minicpm5-1b-teaching-lora is public on Hub
7
+ status: pending
8
+ - id: gguf-preset
9
+ content: "DONE via llama_backend plan: minicpm-v-4.6-gguf preset in models.yaml + .env.example + tests"
10
+ status: completed
11
+ - id: runtime-switching
12
+ content: "DONE via llama_backend plan: set_runtime_model_key, Classic Settings + Studio api_set_active_model"
13
+ status: completed
14
+ - id: llama-docs
15
+ content: "Add Llama Champion to README.md badge targets; cross-link USAGE.md switching section (already written)"
16
+ status: pending
17
+ - id: llama-space-optional
18
+ content: "Optional: pin ACTIVE_MODEL=minicpm-v-4.6-gguf on a dev Space or document local-only verify for judges"
19
+ status: pending
20
+ - id: field-notes-md
21
+ content: Write research/docs/field-notes.md covering skill-matrix QLoRA → lm-eval → gate → Hub publish (include teaching-lora results)
22
+ status: pending
23
+ - id: hf-blog
24
+ content: Adapt field-notes.md into HF blog post and link from README when published
25
+ status: pending
26
+ - id: readme-scorecard
27
+ content: Update README badge targets + TODO.md checkboxes; confirm social post URL
28
+ status: pending
29
+ isProject: false
30
+ ---
31
+
32
+ # TODO.md Last Sprint Tracks Plan (updated)
33
+
34
+ Target: **6 merit badges + Bonus Quest Champion** per [TODO.md](TODO.md).
35
+
36
+ **Prerequisite completed:** [llama_backend_model_switching plan](llama_backend_model_switching_77de87de.plan.md) — all todos marked done.
37
+
38
+ ```mermaid
39
+ flowchart LR
40
+ subgraph done [Already done]
41
+ OffGrid[Off the Grid]
42
+ OffBrand[Off Brand]
43
+ Sharing[Sharing is Caring]
44
+ LlamaCore["Llama Champion core: minicpm-v-4.6-gguf + runtime switching"]
45
+ end
46
+ subgraph sprint [Remaining sprint]
47
+ WellTuned[Well-Tuned: Modal publish]
48
+ LlamaDocs["Llama Champion: README + TODO hygiene"]
49
+ FieldNotes[Field Notes: blog]
50
+ Hygiene[README scorecard + social]
51
+ end
52
+ done --> Bonus[Bonus Quest Champion]
53
+ WellTuned --> Bonus
54
+ LlamaDocs --> Bonus
55
+ FieldNotes --> Bonus
56
+ Hygiene --> Bonus
57
+ ```
58
+
59
+ **Strategy unchanged:** Main HF Space stays on `ACTIVE_MODEL=minicpm5-1b` (transformers) for the live teacher demo + Well-Tuned LoRA story. Llama Champion is satisfied via **local llama.cpp preset + runtime switching** (already implemented), not a Space switch.
60
+
61
+ ---
62
+
63
+ ## Completed by llama backend sprint (remove from scope)
64
+
65
+ The original Track 2 assumed `minicpm5-1b-gguf`. The implemented approach uses **`minicpm-v-4.6-gguf`** instead — same OpenBMB / Tiny Titan story, official [`openbmb/MiniCPM-V-4.6-gguf`](https://huggingface.co/openbmb/MiniCPM-V-4.6-gguf), and pairs with the existing transformers VLM preset for A/B comparison.
66
+
67
+ | Deliverable | Status | Where |
68
+ |-------------|--------|-------|
69
+ | GGUF preset | Done | [`models.yaml`](models.yaml) `minicpm-v-4.6-gguf` |
70
+ | `.env.example` dev block | Done | Commented `ALLOW_MODEL_SWITCH` + preset hint |
71
+ | Runtime model switching | Done | [`model_loading.py`](apps/gradio-space/src/gradio_space/model_loading.py) `set_runtime_model_key()` |
72
+ | Classic Settings + Chat sync | Done | [`settings_panel.py`](apps/gradio-space/src/gradio_space/ui/settings_panel.py), [`tabs/chat.py`](apps/gradio-space/src/gradio_space/tabs/chat.py) |
73
+ | Studio API + JS sync | Done | [`api/studio.py`](apps/gradio-space/src/gradio_space/api/studio.py), [`studio.js`](apps/gradio-space/static/studio/studio.js) |
74
+ | Local switching docs | Done | [`USAGE.md`](USAGE.md) "Switching models locally", [`apps/gradio-space/README.md`](apps/gradio-space/README.md) |
75
+ | Tests | Done | [`test_config.py`](libs/inference/tests/test_config.py), [`test_model_loading.py`](apps/gradio-space/tests/test_model_loading.py) |
76
+ | TODO.md preset item | Done | `[x] Add minicpm-v-4.6-gguf preset` |
77
+
78
+ **Do not add `minicpm5-1b-gguf`** unless you explicitly want a second GGUF preset for the 1B lesson model — the llama sprint chose V4.6 GGUF deliberately (multimodal family parity, smaller download ~529 MB).
79
+
80
+ ---
81
+
82
+ ## Track 1 — Well-Tuned: publish one public adapter (unchanged)
83
+
84
+ **Status:** Pipeline complete; needs operational GPU run.
85
+
86
+ **Primary job:** `teaching-lora` → `MSGEncrypted/minicpm5-1b-teaching-lora`
87
+
88
+ **Run sequence:**
89
+
90
+ 1. Smoke: `modal run research/modal/finetune_app.py --job teaching-lora --max-steps 20 --no-publish`
91
+ 2. Full publish: `modal run research/modal/finetune_app.py --job teaching-lora` (or `::publish_only` if artifacts exist)
92
+ 3. Verify public Hub repo + model card tags from [`render_model_card`](research/modal/_common.py)
93
+
94
+ **Optional Space tie-in:**
95
+
96
+ ```bash
97
+ modal volume get slm-finetune teaching-lora ./models/finetuned/minicpm5-1b-lora
98
+ # ACTIVE_MODEL=minicpm5-1b-lesson-lora
99
+ ```
100
+
101
+ ---
102
+
103
+ ## Track 2 — Llama Champion: finish docs + badge closure (reduced scope)
104
+
105
+ **What remains** (README + TODO hygiene only — no new preset or switching code):
106
+
107
+ 1. **README.md** — add **Llama Champion** to Badge targets section:
108
+ - Preset: `minicpm-v-4.6-gguf` (llama.cpp backend)
109
+ - Local verify: `ALLOW_MODEL_SWITCH=true`, select preset in Settings — link to [USAGE.md switching section](USAGE.md)
110
+ - Note: LoRA lesson presets remain transformers-only; main Space stays `minicpm5-1b`
111
+
112
+ 2. **TODO.md** — check off completed items, narrow open ones:
113
+ - `[x]` Add preset (already done)
114
+ - `[ ]` Document llama.cpp path in README (USAGE already done; README pending)
115
+ - `[ ]` "Run Space on llama.cpp" — **optional**: either pin `ACTIVE_MODEL=minicpm-v-4.6-gguf` on a dev Space, or document that local runtime switching satisfies the badge (see llama plan out-of-scope note)
116
+
117
+ 3. **Optional polish:** 30s demo clip showing Settings → `minicpm-v-4.6-gguf` → Chat response on llama.cpp
118
+
119
+ **Acceptance:** README documents Llama Champion; TODO scorecard `[ ]` → `[x]` for Llama Champion.
120
+
121
+ ---
122
+
123
+ ## Track 3 — Field Notes: in-repo report + HF blog draft (unchanged)
124
+
125
+ Create [`research/docs/field-notes.md`](research/docs/field-notes.md):
126
+
127
+ | Section | Content source |
128
+ |---------|----------------|
129
+ | Problem & stack | README + lesson agent narrative |
130
+ | Skill-matrix design | [`experiments.yaml`](research/modal/experiments.yaml) |
131
+ | Pipeline | train → lm-eval → gate → Hub publish |
132
+ | Modal ops | [`research/modal/README.md`](research/modal/README.md) |
133
+ | Results | `teaching-lora` gate output (after Track 1) |
134
+ | Local inference story | Brief mention of llama.cpp switching (completed sprint) |
135
+ | Repro | Modal commands + Hub adapter link |
136
+
137
+ HF blog: adapt same content (~800–1200 words), link from README.
138
+
139
+ ---
140
+
141
+ ## Track 4 — README + submission hygiene (merged with Track 2)
142
+
143
+ Update [`README.md`](README.md) Badge targets:
144
+
145
+ - **Llama Champion** — `minicpm-v-4.6-gguf`, link to USAGE switching docs
146
+ - **Field Notes** — link to `research/docs/field-notes.md` (+ HF blog when live)
147
+ - **Bonus Quest Champion** — all 6 merit badges qualify
148
+
149
+ Update [`TODO.md`](TODO.md) checkboxes as tracks complete.
150
+
151
+ **Manual:** confirm social post URL in README; Community Choice share.
152
+
153
+ ---
154
+
155
+ ## Revised execution order
156
+
157
+ | Step | Type | Est. | Unblocks |
158
+ |------|------|------|----------|
159
+ | 1. Modal `teaching-lora` publish | GPU ops | 1–3 hr | Well-Tuned + Field Notes results |
160
+ | 2. README Llama Champion + badge table | Docs | ~20 min | Llama Champion closure |
161
+ | 3. `research/docs/field-notes.md` | Docs | ~2 hr | Field Notes badge |
162
+ | 4. README/TODO full scorecard | Docs | ~15 min | Submission completeness |
163
+ | 5. HF blog adaptation | Docs | ~1 hr | Judge visibility |
164
+ | 6. Optional Space GGUF pin or demo clip | Ops | ~30 min | Stronger "Run on llama.cpp" checkbox |
165
+
166
+ **Estimated remaining:** ~3–5 hours (Modal GPU dominates; Llama code work is done).
167
+
168
+ ---
169
+
170
+ ## Out of scope
171
+
172
+ - `minicpm5-1b-gguf` preset (superseded by `minicpm-v-4.6-gguf` decision)
173
+ - New runtime switching code (done)
174
+ - OpenAI / Nemotron / Thousand Token Wood tracks
175
+ - llama.cpp multimodal image plumbing in `LlamaCppBackend`
176
+ - LoRA → GGUF conversion
177
+ - Model verification pipeline — post-hackathon
178
+
179
+ ---
180
+
181
+ ## Risk mitigations
182
+
183
+ | Risk | Mitigation |
184
+ |------|------------|
185
+ | Gate fails on `teaching-lora` | Try `math-lora`; increase `max_steps`; inspect Volume before re-run |
186
+ | Judges expect Space on llama.cpp | README + optional dev Space with `ACTIVE_MODEL=minicpm-v-4.6-gguf`; local switching demo clip |
187
+ | HF blog time crunch | In-repo `field-notes.md` first |
.env.example CHANGED
@@ -1,7 +1,7 @@
1
  # --- Preset selection (models.yaml is the source of truth) ---
2
  ACTIVE_MODEL=minicpm5-1b
3
- # Dev: enable dropdown in Gradio. Space: leave false to pin one model for visitors.
4
- ALLOW_MODEL_SWITCH=false
5
  # MODEL_PRESETS_PATH=./models.yaml
6
 
7
  # --- Agent outputs ---
@@ -24,7 +24,13 @@ ALLOW_MODEL_SWITCH=false
24
  # MODEL_ID=openbmb/MiniCPM5-1B
25
  # TRUST_REMOTE_CODE=true
26
 
 
 
 
 
 
27
  # --- llama.cpp presets (optional) ---
 
28
  # ACTIVE_MODEL=qwen3b-gguf
29
  # INFERENCE_BACKEND=llama_cpp
30
  # MODEL_REPO=Qwen/Qwen2.5-3B-Instruct-GGUF
 
1
  # --- Preset selection (models.yaml is the source of truth) ---
2
  ACTIVE_MODEL=minicpm5-1b
3
+ # Defaults to true when unset (models.yaml). Space: set false to pin one model for visitors.
4
+ # ALLOW_MODEL_SWITCH=false
5
  # MODEL_PRESETS_PATH=./models.yaml
6
 
7
  # --- Agent outputs ---
 
24
  # MODEL_ID=openbmb/MiniCPM5-1B
25
  # TRUST_REMOTE_CODE=true
26
 
27
+ # --- Local dev: switch backends/models in Gradio Settings (Classic + Studio) ---
28
+ # ALLOW_MODEL_SWITCH=true
29
+ # ACTIVE_MODEL=minicpm-v-4.6 # transformers default (or minicpm5-1b)
30
+ # switch in UI to minicpm-v-4.6-gguf for llama.cpp / Llama Champion track
31
+
32
  # --- llama.cpp presets (optional) ---
33
+ # ACTIVE_MODEL=minicpm-v-4.6-gguf
34
  # ACTIVE_MODEL=qwen3b-gguf
35
  # INFERENCE_BACKEND=llama_cpp
36
  # MODEL_REPO=Qwen/Qwen2.5-3B-Instruct-GGUF
README.md CHANGED
@@ -31,6 +31,8 @@ See **[USAGE.md](USAGE.md)** for local run, Gradio SDK / ZeroGPU Space deploymen
31
 
32
  **Demo video:** [https://www.youtube.com/watch?v=bwtOiZvJ-7k](https://www.youtube.com/watch?v=bwtOiZvJ-7k)
33
 
 
 
34
  ## Prerequisites
35
 
36
  - [uv](https://docs.astral.sh/uv/)
@@ -80,6 +82,38 @@ modal run research/modal/server_app.py --pipeline # full sweep
80
 
81
  Pull a passing adapter into the Space: `modal volume get slm-finetune math-lora ./models/finetuned/minicpm5-1b-lora`, then set `ACTIVE_MODEL=minicpm5-1b-lesson-lora`.
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  ## How it works
84
 
85
  1. **Skill** — `skills/education-pptx/SKILL.md` (Hermes / agentskills.io format)
@@ -109,7 +143,8 @@ Optional research tooling (not required for the Space): see [research/USAGE.md](
109
 
110
  | Variable | Default | Description |
111
  | -------- | ------- | ----------- |
112
- | `ACTIVE_MODEL` | `minicpm5-1b` | Preset key from `models.yaml` |
 
113
  | `AGENT_OUTPUTS_DIR` | `/tmp/agent_outputs` | Generated `.pptx` files |
114
  | `AGENT_TRACES_DIR` | `outputs/traces` | Agent trace JSON |
115
  | `SKILLS_DIR` | `./skills` | Skill definitions root |
@@ -124,7 +159,7 @@ See [`.env.example`](.env.example) and [`models.yaml`](models.yaml) for model pr
124
  1. Create a Space under [build-small-hackathon](https://huggingface.co/build-small-hackathon) with **Gradio** SDK (Blank template).
125
  2. Link this repository — HF builds from root `app.py` + `requirements.txt` (README YAML above).
126
  3. Hardware: **ZeroGPU** for burst GPU inference, or **GPU basic** for always-on GPU.
127
- 4. Set `ACTIVE_MODEL=minicpm5-1b`, `ALLOW_MODEL_SWITCH=false`, `RESEARCHMIND_DATA_DIR=/tmp/researchmind`.
128
 
129
  A root `Dockerfile` is kept for a later **Docker SDK** deploy (flip README to `sdk: docker`). See [USAGE.md](USAGE.md).
130
 
@@ -136,10 +171,11 @@ A root `Dockerfile` is kept for a later **Docker SDK** deploy (flip README to `s
136
  | **Off Brand** | Custom Studio UI at `/` (Gradio 6 Server mode, not default Gradio chrome) |
137
  | **Modal** (partner) | GPU `train → eval → gate → publish` on [Modal](https://modal.com) — [`research/modal/`](research/modal/) |
138
  | **Well-Tuned** (finetuning) | Skill-matrix QLoRA adapters on MiniCPM5-1B, lm-eval gates, Hub publish |
 
139
 
140
  - Space live under build-small-hackathon
141
  - Demo video: [YouTube](https://www.youtube.com/watch?v=bwtOiZvJ-7k) — real user enters topic → download `.pptx` → show agent trace
142
- - Social post published
143
  - Submission by **June 15, 2026**
144
 
145
  ### Badge targets
@@ -149,6 +185,7 @@ A root `Dockerfile` is kept for a later **Docker SDK** deploy (flip README to `s
149
  - **OpenBMB** — `openbmb/MiniCPM5-1B`
150
  - **Sharing is Caring** — upload traces with `scripts/upload_trace.py`
151
  - **Off-the-Grid** — local inference only (no cloud LLM API)
 
152
  - **Well-Tuned** — per-skill QLoRA adapters trained + gated + published via the [Modal + Fine-tuning track](#modal--fine-tuning-track-well-tuned)
153
  - **Modal** — same pipeline; see [`research/modal/README.md`](research/modal/README.md)
154
 
 
31
 
32
  **Demo video:** [https://www.youtube.com/watch?v=bwtOiZvJ-7k](https://www.youtube.com/watch?v=bwtOiZvJ-7k)
33
 
34
+ **X post:** [https://x.com/MSG_Encrypted/status/2066570320861921748](https://x.com/MSG_Encrypted/status/2066570320861921748)
35
+
36
  ## Prerequisites
37
 
38
  - [uv](https://docs.astral.sh/uv/)
 
82
 
83
  Pull a passing adapter into the Space: `modal volume get slm-finetune math-lora ./models/finetuned/minicpm5-1b-lora`, then set `ACTIVE_MODEL=minicpm5-1b-lesson-lora`.
84
 
85
+ ### Llama track (Llama Champion + Off-the-Grid)
86
+
87
+ The same OpenBMB **MiniCPM-V 4.6** model runs on **llama.cpp** via the [`minicpm-v-4.6-gguf`](models.yaml) preset — GGUF weights from [`openbmb/MiniCPM-V-4.6-gguf`](https://huggingface.co/openbmb/MiniCPM-V-4.6-gguf) (~529 MB Q4_K_M). No cloud LLM API; inference stays fully local through [`libs/inference/src/inference/llama_cpp.py`](libs/inference/src/inference/llama_cpp.py).
88
+
89
+ | Preset | Backend | Use case |
90
+ | ------ | ------- | -------- |
91
+ | `minicpm-v-4.6` | transformers | Full VLM (image/video) via Hugging Face |
92
+ | `minicpm-v-4.6-gguf` | llama.cpp | **Llama Champion** badge; text on all tabs today |
93
+
94
+ **Space (judges):** pin the GGUF preset — no runtime switching for visitors.
95
+
96
+ ```bash
97
+ ACTIVE_MODEL=minicpm-v-4.6-gguf
98
+ ALLOW_MODEL_SWITCH=false
99
+ ```
100
+
101
+ **Local dev:** switch backends at runtime without restarting.
102
+
103
+ ```bash
104
+ ALLOW_MODEL_SWITCH=true
105
+ ACTIVE_MODEL=minicpm-v-4.6 # transformers startup default
106
+ # Settings or Chat → select minicpm-v-4.6-gguf for llama.cpp
107
+ ```
108
+
109
+ Prefetch weights (optional):
110
+
111
+ ```bash
112
+ uv run python scripts/download_model.py --preset minicpm-v-4.6-gguf
113
+ ```
114
+
115
+ See [USAGE.md](USAGE.md) (section *Switching models locally*) for Classic and Studio UI details.
116
+
117
  ## How it works
118
 
119
  1. **Skill** — `skills/education-pptx/SKILL.md` (Hermes / agentskills.io format)
 
143
 
144
  | Variable | Default | Description |
145
  | -------- | ------- | ----------- |
146
+ | `ACTIVE_MODEL` | `minicpm5-1b` | Preset key from `models.yaml` (use `minicpm-v-4.6-gguf` for Llama track) |
147
+ | `ALLOW_MODEL_SWITCH` | `false` | Set `true` locally to switch presets in Settings / Chat |
148
  | `AGENT_OUTPUTS_DIR` | `/tmp/agent_outputs` | Generated `.pptx` files |
149
  | `AGENT_TRACES_DIR` | `outputs/traces` | Agent trace JSON |
150
  | `SKILLS_DIR` | `./skills` | Skill definitions root |
 
159
  1. Create a Space under [build-small-hackathon](https://huggingface.co/build-small-hackathon) with **Gradio** SDK (Blank template).
160
  2. Link this repository — HF builds from root `app.py` + `requirements.txt` (README YAML above).
161
  3. Hardware: **ZeroGPU** for burst GPU inference, or **GPU basic** for always-on GPU.
162
+ 4. Set `ACTIVE_MODEL=minicpm5-1b` (or `minicpm-v-4.6-gguf` for [Llama track](#llama-track-llama-champion--off-the-grid)), `ALLOW_MODEL_SWITCH=false`, `RESEARCHMIND_DATA_DIR=/tmp/researchmind`.
163
 
164
  A root `Dockerfile` is kept for a later **Docker SDK** deploy (flip README to `sdk: docker`). See [USAGE.md](USAGE.md).
165
 
 
171
  | **Off Brand** | Custom Studio UI at `/` (Gradio 6 Server mode, not default Gradio chrome) |
172
  | **Modal** (partner) | GPU `train → eval → gate → publish` on [Modal](https://modal.com) — [`research/modal/`](research/modal/) |
173
  | **Well-Tuned** (finetuning) | Skill-matrix QLoRA adapters on MiniCPM5-1B, lm-eval gates, Hub publish |
174
+ | **Llama Champion** | `minicpm-v-4.6-gguf` on llama.cpp — same OpenBMB VLM family, local GGUF inference |
175
 
176
  - Space live under build-small-hackathon
177
  - Demo video: [YouTube](https://www.youtube.com/watch?v=bwtOiZvJ-7k) — real user enters topic → download `.pptx` → show agent trace
178
+ - Social post published: [X](https://x.com/MSG_Encrypted/status/2066570320861921748)
179
  - Submission by **June 15, 2026**
180
 
181
  ### Badge targets
 
185
  - **OpenBMB** — `openbmb/MiniCPM5-1B`
186
  - **Sharing is Caring** — upload traces with `scripts/upload_trace.py`
187
  - **Off-the-Grid** — local inference only (no cloud LLM API)
188
+ - **Llama Champion** — llama.cpp backend with [`openbmb/MiniCPM-V-4.6-gguf`](https://huggingface.co/openbmb/MiniCPM-V-4.6-gguf); see [Llama track](#llama-track-llama-champion--off-the-grid)
189
  - **Well-Tuned** — per-skill QLoRA adapters trained + gated + published via the [Modal + Fine-tuning track](#modal--fine-tuning-track-well-tuned)
190
  - **Modal** — same pipeline; see [`research/modal/README.md`](research/modal/README.md)
191
 
TODO.md CHANGED
@@ -15,8 +15,8 @@ below is parked for follow-up PRs.
15
 
16
  ## 🦙 Llama Champion badge (cheap, high value)
17
  - [ ] Run the Space on the **llama.cpp / GGUF** backend (`libs/inference/src/inference/llama_cpp.py`).
18
- - [ ] Confirm MiniCPM5-1B has a GGUF (or convert/quantize one) — keep OpenBMB story intact.
19
- - [ ] Document the llama.cpp path in README + Space (which `ACTIVE_MODEL` preset).
20
 
21
  ## 📓 Field Notes badge (cheapest miss — no blog exists yet)
22
  - [ ] Write a blog post / report on the fine-tuning + Modal pipeline:
 
15
 
16
  ## 🦙 Llama Champion badge (cheap, high value)
17
  - [ ] Run the Space on the **llama.cpp / GGUF** backend (`libs/inference/src/inference/llama_cpp.py`).
18
+ - [x] Add `minicpm-v-4.6-gguf` preset (`openbmb/MiniCPM-V-4.6-gguf`) — OpenBMB multimodal on llama.cpp.
19
+ - [ ] Document the llama.cpp path in README + Space (`ACTIVE_MODEL=minicpm-v-4.6-gguf`).
20
 
21
  ## 📓 Field Notes badge (cheapest miss — no blog exists yet)
22
  - [ ] Write a blog post / report on the fine-tuning + Modal pipeline:
USAGE.md CHANGED
@@ -58,6 +58,37 @@ The header in Classic includes a link back to Studio UI.
58
 
59
  The model loads on the **first Generate** (Lesson slides) or chat message. Agent traces are written to `outputs/traces/`. After code changes, restart the process to pick up updates.
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  ### Lesson slides — research sources
62
 
63
  The **Lesson slides** tab can ground outlines on external sources before building the deck:
 
58
 
59
  The model loads on the **first Generate** (Lesson slides) or chat message. Agent traces are written to `outputs/traces/`. After code changes, restart the process to pick up updates.
60
 
61
+ ### Switching models locally (transformers ↔ llama.cpp)
62
+
63
+ For local dev you can switch presets at runtime without restarting:
64
+
65
+ ```bash
66
+ # .env
67
+ ALLOW_MODEL_SWITCH=true
68
+ ACTIVE_MODEL=minicpm-v-4.6 # startup default (transformers)
69
+ ```
70
+
71
+ | UI | Where to switch |
72
+ |----|-----------------|
73
+ | **Classic** (`/classic`) | **Settings** accordion → Model preset dropdown (reloads on change) |
74
+ | **Classic** Chat tab | Model preset dropdown (syncs app-wide) |
75
+ | **Studio** (`/`) | Settings drawer → Model preset; Debug tab has the same list |
76
+
77
+ | Goal | Preset key |
78
+ |------|------------|
79
+ | MiniCPM-V 4.6 transformers (full VLM) | `minicpm-v-4.6` |
80
+ | MiniCPM-V 4.6 llama.cpp / Llama Champion | `minicpm-v-4.6-gguf` |
81
+ | MiniCPM5 1B text | `minicpm5-1b` |
82
+ | Lesson LoRA (transformers only) | `minicpm5-1b-lesson-lora` |
83
+
84
+ Prefetch the GGUF weights (optional):
85
+
86
+ ```bash
87
+ uv run python scripts/download_model.py --preset minicpm-v-4.6-gguf
88
+ ```
89
+
90
+ On Hugging Face Space, keep `ALLOW_MODEL_SWITCH=false` and pin one preset via `ACTIVE_MODEL`.
91
+
92
  ### Lesson slides — research sources
93
 
94
  The **Lesson slides** tab can ground outlines on external sources before building the deck:
apps/gradio-space/README.md CHANGED
@@ -41,10 +41,20 @@ This package uses **Gradio 6 Server mode** (`gradio.Server`):
41
 
42
  **Settings & debug**
43
 
44
- - `model_status`, `model_choices`, `reload_model`
45
  - `debug_chat`
46
  - `save_upload`
47
 
 
 
 
 
 
 
 
 
 
 
48
  ## Demo script (judges) — Language lessons + Cohere stack
49
 
50
  **Badge line:** Cohere Labs — Transcribe + Tiny Aya on one local Language lessons page.
 
41
 
42
  **Settings & debug**
43
 
44
+ - `model_status`, `model_choices`, `set_active_model`, `reload_model`
45
  - `debug_chat`
46
  - `save_upload`
47
 
48
+ ### Switching models locally
49
+
50
+ Set `ALLOW_MODEL_SWITCH=true` in `.env` (see [USAGE.md](../../USAGE.md)). The Settings drawer and Classic **Settings** accordion share one runtime preset — changing it reloads weights and applies to Lesson slides, Research, and voice tabs (not just Chat debug).
51
+
52
+ | Preset | Backend |
53
+ |--------|---------|
54
+ | `minicpm-v-4.6` | transformers (full VLM) |
55
+ | `minicpm-v-4.6-gguf` | llama.cpp (Llama Champion track) |
56
+ | `minicpm5-1b` | transformers |
57
+
58
  ## Demo script (judges) — Language lessons + Cohere stack
59
 
60
  **Badge line:** Cohere Labs — Transcribe + Tiny Aya on one local Language lessons page.
apps/gradio-space/src/gradio_space/api/studio.py CHANGED
@@ -26,6 +26,7 @@ from gradio_space.model_loading import (
26
  get_active_model_key,
27
  model_status,
28
  reload_model,
 
29
  )
30
  from gradio_space.research_helpers import (
31
  list_session_choices,
@@ -873,7 +874,8 @@ def api_model_status() -> dict[str, Any]:
873
 
874
 
875
  def api_model_choices() -> dict[str, Any]:
876
- active = _app_config.active
 
877
  allow_switch = bool(
878
  _app_config.allow_model_switch and len(_app_config.models) > 1
879
  )
@@ -881,7 +883,7 @@ def api_model_choices() -> dict[str, Any]:
881
  if allow_switch:
882
  choices = [{"key": k, "label": label} for label, k in _app_config.model_choices()]
883
  return ok(
884
- active_model=_app_config.active_model,
885
  active_label=active.label,
886
  active_backend=active.backend,
887
  allow_model_switch=allow_switch,
@@ -891,6 +893,17 @@ def api_model_choices() -> dict[str, Any]:
891
  )
892
 
893
 
 
 
 
 
 
 
 
 
 
 
 
894
  def api_reload_model(model_key: str = "") -> dict[str, Any]:
895
  key = (model_key or "").strip() or get_active_model_key()
896
  status_md = reload_model(key)
@@ -1211,6 +1224,10 @@ def register_studio_apis(server: gr.Server) -> None:
1211
  def _model_choices() -> dict[str, Any]:
1212
  return api_model_choices()
1213
 
 
 
 
 
1214
  @server.api(name="reload_model")
1215
  def _reload_model(model_key: str = "") -> dict[str, Any]:
1216
  return api_reload_model(model_key)
 
26
  get_active_model_key,
27
  model_status,
28
  reload_model,
29
+ select_and_reload_model,
30
  )
31
  from gradio_space.research_helpers import (
32
  list_session_choices,
 
874
 
875
 
876
  def api_model_choices() -> dict[str, Any]:
877
+ key = get_active_model_key()
878
+ active = _app_config.get_model(key)
879
  allow_switch = bool(
880
  _app_config.allow_model_switch and len(_app_config.models) > 1
881
  )
 
883
  if allow_switch:
884
  choices = [{"key": k, "label": label} for label, k in _app_config.model_choices()]
885
  return ok(
886
+ active_model=key,
887
  active_label=active.label,
888
  active_backend=active.backend,
889
  allow_model_switch=allow_switch,
 
893
  )
894
 
895
 
896
+ def api_set_active_model(model_key: str = "") -> dict[str, Any]:
897
+ key = (model_key or "").strip() or get_active_model_key()
898
+ try:
899
+ status_md = select_and_reload_model(key)
900
+ except KeyError as exc:
901
+ return err(str(exc), model_key=key)
902
+ if status_md.lower().startswith("error") or "failed" in status_md.lower():
903
+ return err(status_md, status_markdown=status_md, model_key=key)
904
+ return ok(status_markdown=status_md, model_key=key)
905
+
906
+
907
  def api_reload_model(model_key: str = "") -> dict[str, Any]:
908
  key = (model_key or "").strip() or get_active_model_key()
909
  status_md = reload_model(key)
 
1224
  def _model_choices() -> dict[str, Any]:
1225
  return api_model_choices()
1226
 
1227
+ @server.api(name="set_active_model")
1228
+ def _set_active_model(model_key: str = "") -> dict[str, Any]:
1229
+ return api_set_active_model(model_key)
1230
+
1231
  @server.api(name="reload_model")
1232
  def _reload_model(model_key: str = "") -> dict[str, Any]:
1233
  return api_reload_model(model_key)
apps/gradio-space/src/gradio_space/model_loading.py CHANGED
@@ -4,13 +4,29 @@ from inference.factory import get_backend, reset_backend
4
  from inference.response_clean import strip_reasoning_output
5
 
6
  _app_config = get_app_config()
 
7
  _current_model_key: str | None = None
8
  _load_state: dict[str, bool] = {}
9
  _load_errors: dict[str, str] = {}
10
 
11
 
12
  def get_active_model_key() -> str:
13
- return _app_config.active_model
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
 
16
  def ensure_model_loaded(model_key: str) -> str | None:
@@ -53,7 +69,7 @@ def runtime_device_hint(model_key: str) -> str:
53
 
54
 
55
  def warmup(model_key: str | None = None) -> str:
56
- key = model_key or _app_config.active_model
57
  model = get_model_config(key)
58
 
59
  if _load_state.get(key):
@@ -80,7 +96,8 @@ def reload_model(model_key: str) -> str:
80
  """Clear cached backend and reload weights for settings panel."""
81
  global _current_model_key
82
 
83
- key = model_key or _app_config.active_model
 
84
  reset_backend()
85
  _current_model_key = None
86
  _load_state.pop(key, None)
@@ -91,6 +108,11 @@ def reload_model(model_key: str) -> str:
91
  return warmup(key)
92
 
93
 
 
 
 
 
 
94
  def preload_active_model() -> str:
95
  """Load the active preset at startup so the first request is fast."""
96
  key = get_active_model_key()
@@ -106,7 +128,16 @@ def preload_active_model() -> str:
106
 
107
  def model_status(model_key: str) -> str:
108
  model = get_model_config(model_key)
109
- return f"**{model.label}**\n\n- Backend: `{model.backend}`\n- {warmup(model_key)}"
 
 
 
 
 
 
 
 
 
110
 
111
 
112
  def _history_to_messages(history: list) -> list[dict[str, str]]:
 
4
  from inference.response_clean import strip_reasoning_output
5
 
6
  _app_config = get_app_config()
7
+ _runtime_model_key: str | None = None
8
  _current_model_key: str | None = None
9
  _load_state: dict[str, bool] = {}
10
  _load_errors: dict[str, str] = {}
11
 
12
 
13
  def get_active_model_key() -> str:
14
+ return _runtime_model_key or _app_config.active_model
15
+
16
+
17
+ def set_runtime_model_key(key: str) -> str:
18
+ """Pin the active preset for all tabs until process restart."""
19
+ global _runtime_model_key, _current_model_key
20
+
21
+ model = get_model_config(key)
22
+ previous = get_active_model_key()
23
+ if key != previous:
24
+ reset_backend()
25
+ _current_model_key = None
26
+ _load_state.pop(previous, None)
27
+ _load_errors.pop(previous, None)
28
+ _runtime_model_key = key
29
+ return model.label
30
 
31
 
32
  def ensure_model_loaded(model_key: str) -> str | None:
 
69
 
70
 
71
  def warmup(model_key: str | None = None) -> str:
72
+ key = model_key or get_active_model_key()
73
  model = get_model_config(key)
74
 
75
  if _load_state.get(key):
 
96
  """Clear cached backend and reload weights for settings panel."""
97
  global _current_model_key
98
 
99
+ key = model_key or get_active_model_key()
100
+ set_runtime_model_key(key)
101
  reset_backend()
102
  _current_model_key = None
103
  _load_state.pop(key, None)
 
108
  return warmup(key)
109
 
110
 
111
+ def select_and_reload_model(model_key: str) -> str:
112
+ """Switch runtime preset and load weights (Settings dropdown)."""
113
+ return reload_model(model_key)
114
+
115
+
116
  def preload_active_model() -> str:
117
  """Load the active preset at startup so the first request is fast."""
118
  key = get_active_model_key()
 
128
 
129
  def model_status(model_key: str) -> str:
130
  model = get_model_config(model_key)
131
+ notes = ""
132
+ if model.backend == "llama_cpp" and model.multimodal:
133
+ notes = (
134
+ "\n- Note: text-only on llama.cpp; use transformers preset for image/video input."
135
+ )
136
+ return (
137
+ f"**{model.label}**\n\n"
138
+ f"- Backend: `{model.backend}`\n"
139
+ f"- {warmup(model_key)}{notes}"
140
+ )
141
 
142
 
143
  def _history_to_messages(history: list) -> list[dict[str, str]]:
apps/gradio-space/src/gradio_space/server.py CHANGED
@@ -23,7 +23,7 @@ from gradio_space.ui.theme import get_theme, load_css
23
  _PKG_ROOT = Path(__file__).resolve().parent
24
  _APP_ROOT = _PKG_ROOT.parents[1]
25
  _STATIC_DIR = _APP_ROOT / "static" / "studio"
26
- _STUDIO_ASSET_VERSION = "20260615"
27
  _STUDIO_INDEX_HTML = _STATIC_DIR / "index.html"
28
 
29
 
 
23
  _PKG_ROOT = Path(__file__).resolve().parent
24
  _APP_ROOT = _PKG_ROOT.parents[1]
25
  _STATIC_DIR = _APP_ROOT / "static" / "studio"
26
+ _STUDIO_ASSET_VERSION = "20260615b"
27
  _STUDIO_INDEX_HTML = _STATIC_DIR / "index.html"
28
 
29
 
apps/gradio-space/src/gradio_space/tabs/chat.py CHANGED
@@ -1,5 +1,6 @@
1
  import gradio as gr
2
 
 
3
  from gradio_space.research_helpers import (
4
  list_session_choices,
5
  rag_aware_chat,
@@ -29,7 +30,7 @@ def build_chat_tab(workspace: WorkspaceWidgets) -> None:
29
  "Plain chat or corpus-grounded answers — traces appear in Advanced when RAG is on."
30
  )
31
 
32
- model_key = _app_config.active_model
33
 
34
  with gr.Group():
35
  gr.Markdown("#### RAG scope (override workspace defaults)")
@@ -57,11 +58,17 @@ def build_chat_tab(workspace: WorkspaceWidgets) -> None:
57
  if _app_config.allow_model_switch and len(_app_config.models) > 1:
58
  model_dropdown = gr.Dropdown(
59
  choices=_app_config.model_choices(),
60
- value=_app_config.active_model,
61
  label="Model preset (debug override)",
62
  )
63
 
 
 
 
 
 
64
  def _chat(message, history, mkey, use_rag_flag, sid, docs, ws_sid, ws_docs):
 
65
  sid = resolve_session(sid, ws_sid)
66
  docs = resolve_doc_ids(docs, ws_docs)
67
  reply, trace_json, trace_summary = rag_aware_chat(
@@ -83,7 +90,7 @@ def build_chat_tab(workspace: WorkspaceWidgets) -> None:
83
  examples=[
84
  [
85
  "What do my ingested sources say about AI agents?",
86
- _app_config.active_model,
87
  True,
88
  "",
89
  [],
@@ -92,7 +99,7 @@ def build_chat_tab(workspace: WorkspaceWidgets) -> None:
92
  ],
93
  [
94
  "Hello! What can you help me with?",
95
- _app_config.active_model,
96
  False,
97
  "",
98
  [],
 
1
  import gradio as gr
2
 
3
+ from gradio_space.model_loading import get_active_model_key, set_runtime_model_key
4
  from gradio_space.research_helpers import (
5
  list_session_choices,
6
  rag_aware_chat,
 
30
  "Plain chat or corpus-grounded answers — traces appear in Advanced when RAG is on."
31
  )
32
 
33
+ model_key = get_active_model_key()
34
 
35
  with gr.Group():
36
  gr.Markdown("#### RAG scope (override workspace defaults)")
 
58
  if _app_config.allow_model_switch and len(_app_config.models) > 1:
59
  model_dropdown = gr.Dropdown(
60
  choices=_app_config.model_choices(),
61
+ value=get_active_model_key(),
62
  label="Model preset (debug override)",
63
  )
64
 
65
+ def _on_model_change(mkey: str) -> None:
66
+ set_runtime_model_key(mkey)
67
+
68
+ model_dropdown.change(fn=_on_model_change, inputs=model_dropdown)
69
+
70
  def _chat(message, history, mkey, use_rag_flag, sid, docs, ws_sid, ws_docs):
71
+ set_runtime_model_key(mkey)
72
  sid = resolve_session(sid, ws_sid)
73
  docs = resolve_doc_ids(docs, ws_docs)
74
  reply, trace_json, trace_summary = rag_aware_chat(
 
90
  examples=[
91
  [
92
  "What do my ingested sources say about AI agents?",
93
+ get_active_model_key(),
94
  True,
95
  "",
96
  [],
 
99
  ],
100
  [
101
  "Hello! What can you help me with?",
102
+ get_active_model_key(),
103
  False,
104
  "",
105
  [],
apps/gradio-space/src/gradio_space/ui/settings_panel.py CHANGED
@@ -3,7 +3,12 @@ from __future__ import annotations
3
  import gradio as gr
4
 
5
  from echocoach.config import get_echo_coach_config
6
- from gradio_space.model_loading import model_status, reload_model
 
 
 
 
 
7
  from inference.config import get_app_config
8
  from researchmind.config import get_config as get_research_config
9
 
@@ -39,11 +44,17 @@ def _paths_summary() -> str:
39
  def build_settings_panel() -> tuple[gr.Dropdown | None, gr.Markdown, gr.Button]:
40
  """Build settings accordion contents. Returns (model_dropdown or None, status_md, reload_btn)."""
41
  model_dropdown: gr.Dropdown | None = None
 
42
 
43
  if _app_config.allow_model_switch and len(_app_config.models) > 1:
 
 
 
 
 
44
  model_dropdown = gr.Dropdown(
45
  choices=_app_config.model_choices(),
46
- value=_app_config.active_model,
47
  label="Model preset",
48
  )
49
  else:
@@ -53,7 +64,7 @@ def build_settings_panel() -> tuple[gr.Dropdown | None, gr.Markdown, gr.Button]:
53
  f"**Backend:** `{active.backend}`"
54
  )
55
 
56
- status_md = gr.Markdown(value=model_status(_app_config.active_model))
57
  gr.Markdown("#### Voice stack")
58
  gr.Markdown(_voice_stack_summary())
59
  with gr.Accordion("Paths & files", open=False):
@@ -62,13 +73,17 @@ def build_settings_panel() -> tuple[gr.Dropdown | None, gr.Markdown, gr.Button]:
62
  reload_btn = gr.Button("Reload model", variant="secondary", size="sm")
63
 
64
  if model_dropdown is not None:
65
- model_dropdown.change(fn=model_status, inputs=model_dropdown, outputs=status_md)
 
 
 
 
66
 
67
  if model_dropdown is not None:
68
  reload_btn.click(fn=reload_model, inputs=[model_dropdown], outputs=status_md)
69
  else:
70
  reload_btn.click(
71
- fn=lambda: reload_model(_app_config.active_model),
72
  outputs=status_md,
73
  )
74
 
 
3
  import gradio as gr
4
 
5
  from echocoach.config import get_echo_coach_config
6
+ from gradio_space.model_loading import (
7
+ get_active_model_key,
8
+ model_status,
9
+ reload_model,
10
+ select_and_reload_model,
11
+ )
12
  from inference.config import get_app_config
13
  from researchmind.config import get_config as get_research_config
14
 
 
44
  def build_settings_panel() -> tuple[gr.Dropdown | None, gr.Markdown, gr.Button]:
45
  """Build settings accordion contents. Returns (model_dropdown or None, status_md, reload_btn)."""
46
  model_dropdown: gr.Dropdown | None = None
47
+ active_key = get_active_model_key()
48
 
49
  if _app_config.allow_model_switch and len(_app_config.models) > 1:
50
+ active = _app_config.get_model(active_key)
51
+ gr.Markdown(
52
+ f"**Runtime model:** `{active.key}` — {active.label} \n"
53
+ f"**Backend:** `{active.backend}`"
54
+ )
55
  model_dropdown = gr.Dropdown(
56
  choices=_app_config.model_choices(),
57
+ value=active_key,
58
  label="Model preset",
59
  )
60
  else:
 
64
  f"**Backend:** `{active.backend}`"
65
  )
66
 
67
+ status_md = gr.Markdown(value=model_status(active_key))
68
  gr.Markdown("#### Voice stack")
69
  gr.Markdown(_voice_stack_summary())
70
  with gr.Accordion("Paths & files", open=False):
 
73
  reload_btn = gr.Button("Reload model", variant="secondary", size="sm")
74
 
75
  if model_dropdown is not None:
76
+ model_dropdown.change(
77
+ fn=select_and_reload_model,
78
+ inputs=model_dropdown,
79
+ outputs=status_md,
80
+ )
81
 
82
  if model_dropdown is not None:
83
  reload_btn.click(fn=reload_model, inputs=[model_dropdown], outputs=status_md)
84
  else:
85
  reload_btn.click(
86
+ fn=lambda: reload_model(get_active_model_key()),
87
  outputs=status_md,
88
  )
89
 
apps/gradio-space/static/studio/studio.js CHANGED
@@ -26,9 +26,21 @@ function toggleTheme() {
26
  applyTheme(getPreferredTheme());
27
 
28
  function appOrigin() {
29
- const { protocol, hostname } = window.location;
30
- const secureProto = protocol === "http:" ? "https:" : protocol;
31
- return `${secureProto}//${hostname}`;
 
 
 
 
 
 
 
 
 
 
 
 
32
  }
33
 
34
  const SLIDE_PIPELINE_STEPS = [
@@ -1125,6 +1137,15 @@ async function initVoicePresets() {
1125
  return initLanguageLessons();
1126
  }
1127
 
 
 
 
 
 
 
 
 
 
1128
  async function initSettings() {
1129
  const data = await callApi("model_choices", []);
1130
  state.modelChoices = data;
@@ -1147,10 +1168,20 @@ async function initSettings() {
1147
  if (select) {
1148
  select.innerHTML = options;
1149
  select.value = data.active_model;
 
 
 
 
 
1150
  }
1151
  if (debugSelect) {
1152
  debugSelect.innerHTML = options;
1153
  debugSelect.value = data.active_model;
 
 
 
 
 
1154
  }
1155
  }
1156
  }
 
26
  applyTheme(getPreferredTheme());
27
 
28
  function appOrigin() {
29
+ const { protocol, hostname, port } = window.location;
30
+ if (protocol === "https:") {
31
+ return window.location.origin;
32
+ }
33
+ const isLocal =
34
+ hostname === "localhost" ||
35
+ hostname === "127.0.0.1" ||
36
+ hostname === "[::1]" ||
37
+ hostname === "0.0.0.0";
38
+ if (isLocal) {
39
+ return window.location.origin;
40
+ }
41
+ // HF Spaces: TLS terminates at the edge; Gradio client must use https.
42
+ const portSuffix = port ? `:${port}` : "";
43
+ return `https://${hostname}${portSuffix}`;
44
  }
45
 
46
  const SLIDE_PIPELINE_STEPS = [
 
1137
  return initLanguageLessons();
1138
  }
1139
 
1140
+ async function selectActiveModel(key) {
1141
+ const data = await callApi("set_active_model", [key]);
1142
+ $("#settings-status").innerHTML = renderMarkdownLite(data.status_markdown || "");
1143
+ const fresh = await callApi("model_choices", []);
1144
+ state.modelChoices = fresh;
1145
+ $("#settings-active-model").textContent = `${fresh.active_label} (${fresh.active_backend})`;
1146
+ return data;
1147
+ }
1148
+
1149
  async function initSettings() {
1150
  const data = await callApi("model_choices", []);
1151
  state.modelChoices = data;
 
1168
  if (select) {
1169
  select.innerHTML = options;
1170
  select.value = data.active_model;
1171
+ select.onchange = () => {
1172
+ const key = select.value;
1173
+ if (debugSelect) debugSelect.value = key;
1174
+ selectActiveModel(key).catch(() => {});
1175
+ };
1176
  }
1177
  if (debugSelect) {
1178
  debugSelect.innerHTML = options;
1179
  debugSelect.value = data.active_model;
1180
+ debugSelect.onchange = () => {
1181
+ const key = debugSelect.value;
1182
+ if (select) select.value = key;
1183
+ selectActiveModel(key).catch(() => {});
1184
+ };
1185
  }
1186
  }
1187
  }
apps/gradio-space/tests/test_model_loading.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import importlib
2
+
3
+ import pytest
4
+
5
+
6
+ @pytest.fixture
7
+ def model_loading_module(monkeypatch, tmp_path):
8
+ presets = tmp_path / "models.yaml"
9
+ presets.write_text(
10
+ """
11
+ defaults:
12
+ active_model: alpha
13
+ allow_model_switch: true
14
+ models:
15
+ alpha:
16
+ label: Alpha
17
+ backend: transformers
18
+ model_id: openbmb/MiniCPM5-1B
19
+ beta:
20
+ label: Beta GGUF
21
+ backend: llama_cpp
22
+ model_repo: openbmb/MiniCPM-V-4.6-gguf
23
+ model_file: MiniCPM-V-4.6-Q4_K_M.gguf
24
+ multimodal: true
25
+ """
26
+ )
27
+ monkeypatch.chdir(tmp_path)
28
+ monkeypatch.delenv("ACTIVE_MODEL", raising=False)
29
+
30
+ import inference.config as inference_config
31
+ import gradio_space.model_loading as model_loading
32
+
33
+ importlib.reload(inference_config)
34
+ importlib.reload(model_loading)
35
+ return model_loading
36
+
37
+
38
+ def test_runtime_model_key_override(model_loading_module):
39
+ ml = model_loading_module
40
+ assert ml.get_active_model_key() == "alpha"
41
+ ml.set_runtime_model_key("beta")
42
+ assert ml.get_active_model_key() == "beta"
43
+
44
+
45
+ def test_set_runtime_model_key_unknown_raises(model_loading_module):
46
+ ml = model_loading_module
47
+ with pytest.raises(KeyError):
48
+ ml.set_runtime_model_key("missing")
libs/inference/src/inference/config.py CHANGED
@@ -74,7 +74,7 @@ class AppConfig:
74
 
75
  active_model: str
76
  models: dict[str, ModelConfig]
77
- allow_model_switch: bool = False
78
  model_cache_dir: str | None = None
79
  presets_path: Path | None = None
80
 
@@ -238,7 +238,7 @@ def load_app_config() -> AppConfig:
238
 
239
  allow_model_switch = os.environ.get("ALLOW_MODEL_SWITCH")
240
  if allow_model_switch is None:
241
- allow_switch = bool(defaults.get("allow_model_switch", False))
242
  else:
243
  allow_switch = allow_model_switch.lower() in {"1", "true", "yes"}
244
 
 
74
 
75
  active_model: str
76
  models: dict[str, ModelConfig]
77
+ allow_model_switch: bool = True
78
  model_cache_dir: str | None = None
79
  presets_path: Path | None = None
80
 
 
238
 
239
  allow_model_switch = os.environ.get("ALLOW_MODEL_SWITCH")
240
  if allow_model_switch is None:
241
+ allow_switch = bool(defaults.get("allow_model_switch", True))
242
  else:
243
  allow_switch = allow_model_switch.lower() in {"1", "true", "yes"}
244
 
libs/inference/src/inference/transformers.py CHANGED
@@ -72,16 +72,26 @@ class TransformersBackend:
72
  )
73
 
74
  if self._config.adapter_path:
 
75
  from pathlib import Path
76
 
77
  from peft import PeftModel
78
 
79
- adapter = Path(self._config.adapter_path)
80
- if not adapter.is_dir():
 
 
 
 
 
 
 
 
81
  raise FileNotFoundError(
82
- f"LoRA adapter not found for preset {self._config.key!r}: {adapter}"
 
83
  )
84
- self._model = PeftModel.from_pretrained(self._model, str(adapter))
85
 
86
  if plan.device == "cpu":
87
  assert self._model is not None
 
72
  )
73
 
74
  if self._config.adapter_path:
75
+ import re
76
  from pathlib import Path
77
 
78
  from peft import PeftModel
79
 
80
+ adapter = self._config.adapter_path
81
+ adapter_dir = Path(adapter)
82
+ if adapter_dir.is_dir():
83
+ # Local adapter (e.g. pulled from the Modal Volume).
84
+ adapter_src = str(adapter_dir)
85
+ elif re.fullmatch(r"[\w.-]+/[\w.-]+", adapter):
86
+ # Hugging Face Hub repo id (e.g. the Modal-published adapter) —
87
+ # PeftModel fetches it remotely; no manual pull required.
88
+ adapter_src = adapter
89
+ else:
90
  raise FileNotFoundError(
91
+ f"LoRA adapter not found for preset {self._config.key!r}: "
92
+ f"{adapter} (expected a local dir or a Hub repo id 'org/name')"
93
  )
94
+ self._model = PeftModel.from_pretrained(self._model, adapter_src)
95
 
96
  if plan.device == "cpu":
97
  assert self._model is not None
libs/inference/tests/test_config.py CHANGED
@@ -30,6 +30,51 @@ models:
30
  assert config.get_model("demo").model_repo == "org/model-GGUF"
31
 
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  def test_legacy_env_overrides_active_preset(tmp_path, monkeypatch):
34
  presets = tmp_path / "models.yaml"
35
  presets.write_text(
@@ -54,6 +99,24 @@ models:
54
  assert model.model_file == "override.gguf"
55
 
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  def test_resolve_relative_model_path(tmp_path, monkeypatch):
58
  local_dir = tmp_path / "gemma_merged_model"
59
  local_dir.mkdir()
 
30
  assert config.get_model("demo").model_repo == "org/model-GGUF"
31
 
32
 
33
+ def test_allow_model_switch_defaults_true_without_env(tmp_path, monkeypatch):
34
+ presets = tmp_path / "models.yaml"
35
+ presets.write_text(
36
+ """
37
+ defaults:
38
+ active_model: demo
39
+ models:
40
+ demo:
41
+ label: Demo preset
42
+ backend: llama_cpp
43
+ model_repo: org/model-GGUF
44
+ model_file: demo.gguf
45
+ """
46
+ )
47
+ monkeypatch.chdir(tmp_path)
48
+ monkeypatch.delenv("ALLOW_MODEL_SWITCH", raising=False)
49
+
50
+ config = load_app_config()
51
+
52
+ assert config.allow_model_switch is True
53
+
54
+
55
+ def test_allow_model_switch_env_false_overrides(tmp_path, monkeypatch):
56
+ presets = tmp_path / "models.yaml"
57
+ presets.write_text(
58
+ """
59
+ defaults:
60
+ active_model: demo
61
+ allow_model_switch: true
62
+ models:
63
+ demo:
64
+ label: Demo preset
65
+ backend: llama_cpp
66
+ model_repo: org/model-GGUF
67
+ model_file: demo.gguf
68
+ """
69
+ )
70
+ monkeypatch.chdir(tmp_path)
71
+ monkeypatch.setenv("ALLOW_MODEL_SWITCH", "false")
72
+
73
+ config = load_app_config()
74
+
75
+ assert config.allow_model_switch is False
76
+
77
+
78
  def test_legacy_env_overrides_active_preset(tmp_path, monkeypatch):
79
  presets = tmp_path / "models.yaml"
80
  presets.write_text(
 
99
  assert model.model_file == "override.gguf"
100
 
101
 
102
+ def test_minicpm_v_gguf_preset_from_repo(monkeypatch):
103
+ repo_root = Path(__file__).resolve().parents[3]
104
+ models_yaml = repo_root / "models.yaml"
105
+ if not models_yaml.is_file():
106
+ pytest.skip("repo models.yaml not found")
107
+
108
+ monkeypatch.chdir(repo_root)
109
+ monkeypatch.delenv("ACTIVE_MODEL", raising=False)
110
+ monkeypatch.delenv("ALLOW_MODEL_SWITCH", raising=False)
111
+
112
+ model = load_app_config().get_model("minicpm-v-4.6-gguf")
113
+
114
+ assert model.backend == "llama_cpp"
115
+ assert model.multimodal is True
116
+ assert model.model_repo == "openbmb/MiniCPM-V-4.6-gguf"
117
+ assert model.model_file == "MiniCPM-V-4.6-Q4_K_M.gguf"
118
+
119
+
120
  def test_resolve_relative_model_path(tmp_path, monkeypatch):
121
  local_dir = tmp_path / "gemma_merged_model"
122
  local_dir.mkdir()
models.yaml CHANGED
@@ -5,9 +5,8 @@ defaults:
5
  # active_model: minicpm-v-4.6
6
  active_model: minicpm5-1b
7
 
8
- # Dev: set ALLOW_MODEL_SWITCH=true in .env to expose a dropdown in Gradio.
9
- # Space: keep false so visitors use one pinned model.
10
- allow_model_switch: false
11
 
12
  models:
13
  minicpm-v-4.6:
@@ -17,6 +16,15 @@ models:
17
  trust_remote_code: true
18
  multimodal: true
19
 
 
 
 
 
 
 
 
 
 
20
  qwen3b-gguf:
21
  label: Qwen 2.5 3B Instruct (GGUF)
22
  backend: llama_cpp
@@ -68,6 +76,24 @@ models:
68
  model_id: ./models/finetuned/minicpm5-1b-lora-merged
69
  trust_remote_code: true
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  tiny-aya-global:
72
  label: Tiny Aya Global 3.3B (multilingual coach)
73
  backend: transformers
 
5
  # active_model: minicpm-v-4.6
6
  active_model: minicpm5-1b
7
 
8
+ # Default true for local dev (dropdown in Gradio). Space: set ALLOW_MODEL_SWITCH=false.
9
+ allow_model_switch: true
 
10
 
11
  models:
12
  minicpm-v-4.6:
 
16
  trust_remote_code: true
17
  multimodal: true
18
 
19
+ minicpm-v-4.6-gguf:
20
+ label: MiniCPM-V 4.6 (GGUF / llama.cpp)
21
+ backend: llama_cpp
22
+ model_repo: openbmb/MiniCPM-V-4.6-gguf
23
+ model_file: MiniCPM-V-4.6-Q4_K_M.gguf
24
+ multimodal: true
25
+ n_ctx: 8192
26
+ n_gpu_layers: 0
27
+
28
  qwen3b-gguf:
29
  label: Qwen 2.5 3B Instruct (GGUF)
30
  backend: llama_cpp
 
76
  model_id: ./models/finetuned/minicpm5-1b-lora-merged
77
  trust_remote_code: true
78
 
79
+ # Well-Tuned track: base MiniCPM5-1B + a Modal-published LoRA adapter pulled
80
+ # straight from the Hub (no local files needed). These point at the repos the
81
+ # finetune pipeline publishes once a job clears its lm-eval gate
82
+ # (research/modal/experiments.yaml -> publish.hub_repo).
83
+ minicpm5-1b-teaching-hub:
84
+ label: MiniCPM5 1B teaching LoRA (Hub, fine-tuned)
85
+ backend: transformers
86
+ model_id: openbmb/MiniCPM5-1B
87
+ adapter_path: MSGEncrypted/minicpm5-1b-teaching-lora
88
+ trust_remote_code: true
89
+
90
+ minicpm5-1b-math-hub:
91
+ label: MiniCPM5 1B math LoRA (Hub, fine-tuned)
92
+ backend: transformers
93
+ model_id: openbmb/MiniCPM5-1B
94
+ adapter_path: MSGEncrypted/minicpm5-1b-math-lora
95
+ trust_remote_code: true
96
+
97
  tiny-aya-global:
98
  label: Tiny Aya Global 3.3B (multilingual coach)
99
  backend: transformers
requirements.txt CHANGED
@@ -3,8 +3,9 @@
3
 
4
  # Pinned runtime deps (do not pin gradio, spaces, or huggingface_hub — HF preinstalls them)
5
  accelerate==1.13.0
6
- torch==2.12.0
7
- torchvision==0.27.0
 
8
  transformers==5.10.2
9
  peft==0.19.1
10
  # llama-cpp-python omitted — compiles from source on HF (10+ min / timeout).
 
3
 
4
  # Pinned runtime deps (do not pin gradio, spaces, or huggingface_hub — HF preinstalls them)
5
  accelerate==1.13.0
6
+ # ZeroGPU supports torch 2.8–2.11 only (not 2.12).
7
+ torch==2.11.0
8
+ torchvision==0.26.0
9
  transformers==5.10.2
10
  peft==0.19.1
11
  # llama-cpp-python omitted — compiles from source on HF (10+ min / timeout).
research/evals/configs/lm_eval_code.yaml CHANGED
@@ -8,7 +8,11 @@ claim: Better code generation
8
  tasks:
9
  - humaneval
10
  - mbpp
 
 
11
 
 
 
12
  num_fewshot: 0
13
  limit: 50
14
  seed: 42
 
8
  tasks:
9
  - humaneval
10
  - mbpp
11
+ - hellaswag # general-capability guard (catch regression from skill tuning)
12
+ - piqa # general-capability guard
13
 
14
+ # humaneval/mbpp execute model-generated code; opt in explicitly.
15
+ confirm_run_unsafe_code: true
16
  num_fewshot: 0
17
  limit: 50
18
  seed: 42
research/evals/configs/lm_eval_math.yaml CHANGED
@@ -7,6 +7,8 @@ claim: Better math reasoning
7
  tasks:
8
  - gsm8k
9
  - arc_challenge
 
 
10
 
11
  num_fewshot: 5
12
  limit: 100
 
7
  tasks:
8
  - gsm8k
9
  - arc_challenge
10
+ - hellaswag # general-capability guard (catch regression from skill tuning)
11
+ - piqa # general-capability guard
12
 
13
  num_fewshot: 5
14
  limit: 100
research/evals/src/slm_evals/run_lm_eval.py CHANGED
@@ -16,6 +16,7 @@ from __future__ import annotations
16
  import argparse
17
  import datetime
18
  import json
 
19
  import subprocess
20
  import sys
21
  from pathlib import Path
@@ -52,6 +53,17 @@ _METRIC_PRIORITY = (
52
  "bleu,none",
53
  )
54
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
  def parse_args() -> argparse.Namespace:
57
  parser = argparse.ArgumentParser(
@@ -347,6 +359,19 @@ def main() -> int:
347
 
348
  _ensure_lm_eval_models_registered()
349
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
  seed = int(cfg.get("seed", 42))
351
  model_args = dict(spec.model_args)
352
  eval_device = cfg.get("device")
@@ -367,6 +392,7 @@ def main() -> int:
367
  numpy_random_seed=seed,
368
  torch_random_seed=seed,
369
  fewshot_random_seed=seed,
 
370
  log_samples=False,
371
  )
372
 
 
16
  import argparse
17
  import datetime
18
  import json
19
+ import os
20
  import subprocess
21
  import sys
22
  from pathlib import Path
 
53
  "bleu,none",
54
  )
55
 
56
+ # lm-eval tasks that execute model-generated code (pass@k). lm-eval refuses to
57
+ # run them unless confirm_run_unsafe_code=True, and the HF `evaluate` code_eval
58
+ # metric additionally requires HF_ALLOW_CODE_EVAL=1.
59
+ _CODE_EXEC_TASK_PREFIXES = ("humaneval", "mbpp")
60
+
61
+
62
+ def _requires_code_execution(tasks: list[str], override: bool | None) -> bool:
63
+ if override is not None:
64
+ return bool(override)
65
+ return any(str(t).lower().startswith(_CODE_EXEC_TASK_PREFIXES) for t in tasks)
66
+
67
 
68
  def parse_args() -> argparse.Namespace:
69
  parser = argparse.ArgumentParser(
 
359
 
360
  _ensure_lm_eval_models_registered()
361
 
362
+ confirm_unsafe_code = _requires_code_execution(
363
+ cfg["tasks"], cfg.get("confirm_run_unsafe_code")
364
+ )
365
+ if confirm_unsafe_code:
366
+ # Required by the HF `evaluate` code_eval metric to compute pass@k.
367
+ os.environ.setdefault("HF_ALLOW_CODE_EVAL", "1")
368
+ print(
369
+ "Enabling code execution for tasks "
370
+ f"{[t for t in cfg['tasks'] if str(t).lower().startswith(_CODE_EXEC_TASK_PREFIXES)]} "
371
+ "(confirm_run_unsafe_code=True, HF_ALLOW_CODE_EVAL=1)",
372
+ file=sys.stderr,
373
+ )
374
+
375
  seed = int(cfg.get("seed", 42))
376
  model_args = dict(spec.model_args)
377
  eval_device = cfg.get("device")
 
392
  numpy_random_seed=seed,
393
  torch_random_seed=seed,
394
  fewshot_random_seed=seed,
395
+ confirm_run_unsafe_code=confirm_unsafe_code,
396
  log_samples=False,
397
  )
398
 
research/finetune.py CHANGED
@@ -92,7 +92,6 @@ from datasets import load_dataset
92
  from transformers import (
93
  AutoModelForCausalLM,
94
  AutoTokenizer,
95
- DataCollatorForLanguageModeling,
96
  Trainer,
97
  TrainingArguments,
98
  )
@@ -247,12 +246,36 @@ def parse_args():
247
  else None,
248
  help="Cap examples after loading (useful for Hub smoke tests)",
249
  )
 
 
 
 
 
 
 
 
 
 
 
250
  p.add_argument(
251
  "--format",
252
  type=str,
253
  default=os.environ.get("FINETUNE_FORMAT", "chat"),
254
  choices=["alpaca", "chat", "prompt", "text"],
255
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  p.add_argument("--mode", type=str, default="lora",
257
  choices=["full", "lora", "qlora"])
258
  p.add_argument(
@@ -273,6 +296,22 @@ def parse_args():
273
  p.add_argument("--mask_prompt", action="store_true", default=True,
274
  help="compute loss only on the response tokens")
275
  p.add_argument("--no_mask_prompt", dest="mask_prompt", action="store_false")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  # lora hparams
277
  p.add_argument("--lora_r", type=int, default=16)
278
  p.add_argument("--lora_alpha", type=int, default=32)
@@ -398,6 +437,7 @@ def save_training_results(
398
  "dataset": args.dataset,
399
  "dataset_config": args.dataset_config,
400
  "dataset_split": args.dataset_split,
 
401
  "format": args.format,
402
  "mode": args.mode,
403
  "output_dir": out_dir,
@@ -431,23 +471,29 @@ def save_training_results(
431
  return path
432
 
433
 
434
- def to_prompt_response(example, fmt, tokenizer):
435
  """Normalize any supported format into a single training string,
436
- returning (full_text, prompt_text). prompt_text is None for raw text."""
 
 
 
 
437
  if fmt == "text":
438
- return example["text"], None
439
 
440
  if fmt == "alpaca":
441
- instr = example.get("instruction", "")
442
- inp = example.get("input", "") or ""
443
- out = example.get("output", "")
444
  user = instr if not inp else f"{instr}\n\n{inp}"
445
  messages = [{"role": "user", "content": user},
446
  {"role": "assistant", "content": out}]
447
 
448
  elif fmt == "prompt":
449
- prompt = example.get("prompt", "")
450
- resp = example.get("completion", example.get("response", ""))
 
 
451
  messages = [{"role": "user", "content": prompt},
452
  {"role": "assistant", "content": resp}]
453
 
@@ -471,9 +517,9 @@ def to_prompt_response(example, fmt, tokenizer):
471
  return full, prompt_only
472
 
473
 
474
- def build_tokenize_fn(tokenizer, fmt, max_len, mask_prompt):
475
  def fn(example):
476
- full, prompt = to_prompt_response(example, fmt, tokenizer)
477
  ids = tokenizer(full, truncation=True, max_length=max_len,
478
  add_special_tokens=(fmt == "text"))["input_ids"]
479
  labels = list(ids)
@@ -485,6 +531,82 @@ def build_tokenize_fn(tokenizer, fmt, max_len, mask_prompt):
485
  return fn
486
 
487
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488
  class CausalCollator:
489
  """Pads input_ids with pad_token and labels with IGNORE_INDEX."""
490
  def __init__(self, tokenizer):
@@ -795,7 +917,10 @@ def main():
795
  print(f"Base model: {args.model}")
796
  if preset_key:
797
  print(f"Preset: {preset_key}")
798
- print(f"Dataset: {args.dataset}")
 
 
 
799
  print(f"Output: {args.out}")
800
  print(f"Device: {args.device}")
801
 
@@ -813,17 +938,7 @@ def main():
813
  if _training_uses_cuda(args):
814
  print(f"GPU after model load: {_gpu_memory_summary()}")
815
 
816
- ds = load_raw_dataset(
817
- args.dataset,
818
- config=args.dataset_config,
819
- split=args.dataset_split,
820
- max_samples=args.dataset_max_samples,
821
- )
822
- ds = ds.shuffle(seed=args.seed)
823
- tokenize = build_tokenize_fn(tokenizer, args.format, args.max_len,
824
- args.mask_prompt)
825
- ds = ds.map(tokenize, remove_columns=ds.column_names, desc="tokenizing")
826
- ds = ds.filter(lambda e: len(e["input_ids"]) > 1)
827
 
828
  if args.val_split > 0:
829
  split = ds.train_test_split(test_size=args.val_split, seed=args.seed)
@@ -831,6 +946,16 @@ def main():
831
  else:
832
  train_ds, eval_ds = ds, None
833
 
 
 
 
 
 
 
 
 
 
 
834
  targs = TrainingArguments(
835
  output_dir=args.out,
836
  num_train_epochs=args.epochs,
@@ -839,28 +964,41 @@ def main():
839
  per_device_eval_batch_size=args.batch_size,
840
  gradient_accumulation_steps=args.grad_accum,
841
  learning_rate=lr,
842
- lr_scheduler_type="cosine",
843
  warmup_ratio=args.warmup_ratio,
844
- weight_decay=0.01,
845
- logging_steps=10,
 
846
  eval_strategy="steps" if eval_ds is not None else "no",
847
- eval_steps=200,
848
  save_strategy="steps",
849
- save_steps=500,
850
- save_total_limit=2,
 
 
 
851
  bf16=bf16_ok,
852
  fp16=(not bf16_ok and _training_uses_cuda(args)),
853
  gradient_checkpointing=args.gradient_checkpointing,
854
- report_to="none",
 
855
  seed=args.seed,
856
  )
857
 
 
 
 
 
 
 
 
858
  trainer = Trainer(
859
  model=model,
860
  args=targs,
861
  train_dataset=train_ds,
862
  eval_dataset=eval_ds,
863
  data_collator=CausalCollator(tokenizer),
 
864
  )
865
 
866
  train_result = trainer.train(resume_from_checkpoint=args.resume)
@@ -894,7 +1032,7 @@ def main():
894
  eval_metrics=eval_metrics,
895
  )
896
  m = json.loads(results_path.read_text())["metrics"]
897
- print(f"\n--- scores ---")
898
  print(f"loss_score = {m['loss_score']} (lower is better)")
899
  print(f"result_score = {m['result_score']} (0–100, higher is better)")
900
  print(f"Saved to {results_path}")
 
92
  from transformers import (
93
  AutoModelForCausalLM,
94
  AutoTokenizer,
 
95
  Trainer,
96
  TrainingArguments,
97
  )
 
246
  else None,
247
  help="Cap examples after loading (useful for Hub smoke tests)",
248
  )
249
+ p.add_argument(
250
+ "--mix-json",
251
+ type=str,
252
+ default=os.environ.get("FINETUNE_MIX_JSON"),
253
+ help=(
254
+ "JSON list of dataset source specs to mix/replay; overrides "
255
+ "--dataset/--format. Each spec: "
256
+ '{"dataset":..,"format":..,"columns":{..},"dataset_config":..,'
257
+ '"dataset_split":..,"max_samples":..,"max_len":..,"weight":..}'
258
+ ),
259
+ )
260
  p.add_argument(
261
  "--format",
262
  type=str,
263
  default=os.environ.get("FINETUNE_FORMAT", "chat"),
264
  choices=["alpaca", "chat", "prompt", "text"],
265
  )
266
+ # Column-name overrides: let a dataset's own columns map onto a --format
267
+ # without preprocessing (e.g. MetaMathQA query/response -> prompt format,
268
+ # orca-math question/answer -> prompt format).
269
+ p.add_argument("--prompt-key", default=None,
270
+ help="column to use as the prompt (prompt format)")
271
+ p.add_argument("--response-key", default=None,
272
+ help="column to use as the response (prompt format)")
273
+ p.add_argument("--instruction-key", default=None,
274
+ help="column to use as instruction (alpaca format)")
275
+ p.add_argument("--input-key", default=None,
276
+ help="column to use as optional input (alpaca format)")
277
+ p.add_argument("--output-key", default=None,
278
+ help="column to use as output (alpaca format)")
279
  p.add_argument("--mode", type=str, default="lora",
280
  choices=["full", "lora", "qlora"])
281
  p.add_argument(
 
296
  p.add_argument("--mask_prompt", action="store_true", default=True,
297
  help="compute loss only on the response tokens")
298
  p.add_argument("--no_mask_prompt", dest="mask_prompt", action="store_false")
299
+ # training schedule / regularization (previously hardcoded)
300
+ p.add_argument("--lr_scheduler", type=str, default="cosine",
301
+ help="LR scheduler type: cosine, linear, constant, ...")
302
+ p.add_argument("--weight_decay", type=float, default=0.01)
303
+ p.add_argument("--max_grad_norm", type=float, default=1.0)
304
+ p.add_argument("--logging_steps", type=int, default=10)
305
+ p.add_argument("--eval_steps", type=int, default=None,
306
+ help="eval every N steps (default: max_steps//5, else 200)")
307
+ p.add_argument("--save_steps", type=int, default=500)
308
+ p.add_argument("--save_total_limit", type=int, default=2)
309
+ p.add_argument("--early_stopping_patience", type=int, default=0,
310
+ help=">0 enables early stopping + load_best_model_at_end on eval_loss")
311
+ p.add_argument("--neftune_noise_alpha", type=float, default=None,
312
+ help="NEFTune noise alpha (e.g. 5) — quick instruction-tuning gain")
313
+ p.add_argument("--report_to", type=str, default="none",
314
+ help="trainer reporting: none, wandb, tensorboard, ...")
315
  # lora hparams
316
  p.add_argument("--lora_r", type=int, default=16)
317
  p.add_argument("--lora_alpha", type=int, default=32)
 
437
  "dataset": args.dataset,
438
  "dataset_config": args.dataset_config,
439
  "dataset_split": args.dataset_split,
440
+ "mix": json.loads(args.mix_json) if args.mix_json else None,
441
  "format": args.format,
442
  "mode": args.mode,
443
  "output_dir": out_dir,
 
471
  return path
472
 
473
 
474
+ def to_prompt_response(example, fmt, tokenizer, keys=None):
475
  """Normalize any supported format into a single training string,
476
+ returning (full_text, prompt_text). prompt_text is None for raw text.
477
+
478
+ `keys` optionally remaps a dataset's column names onto the format's
479
+ expected fields (e.g. {"prompt": "query"} for MetaMathQA)."""
480
+ keys = keys or {}
481
  if fmt == "text":
482
+ return example[keys.get("text", "text")], None
483
 
484
  if fmt == "alpaca":
485
+ instr = example.get(keys.get("instruction", "instruction"), "")
486
+ inp = example.get(keys.get("input", "input"), "") or ""
487
+ out = example.get(keys.get("output", "output"), "")
488
  user = instr if not inp else f"{instr}\n\n{inp}"
489
  messages = [{"role": "user", "content": user},
490
  {"role": "assistant", "content": out}]
491
 
492
  elif fmt == "prompt":
493
+ prompt = example.get(keys.get("prompt", "prompt"), "")
494
+ rkey = keys.get("response")
495
+ resp = example.get(rkey, "") if rkey else example.get(
496
+ "completion", example.get("response", ""))
497
  messages = [{"role": "user", "content": prompt},
498
  {"role": "assistant", "content": resp}]
499
 
 
517
  return full, prompt_only
518
 
519
 
520
+ def build_tokenize_fn(tokenizer, fmt, max_len, mask_prompt, keys=None):
521
  def fn(example):
522
+ full, prompt = to_prompt_response(example, fmt, tokenizer, keys)
523
  ids = tokenizer(full, truncation=True, max_length=max_len,
524
  add_special_tokens=(fmt == "text"))["input_ids"]
525
  labels = list(ids)
 
531
  return fn
532
 
533
 
534
+ def _source_specs(args) -> list[dict]:
535
+ """Return the list of dataset source specs to train on.
536
+
537
+ With --mix-json, parse the JSON list verbatim. Otherwise synthesize a
538
+ single source from the top-level --dataset/--format/--*-key args."""
539
+ if args.mix_json:
540
+ specs = json.loads(args.mix_json)
541
+ if not isinstance(specs, list) or not specs:
542
+ raise SystemExit("--mix-json must be a non-empty JSON list of source specs")
543
+ return specs
544
+ return [{
545
+ "dataset": args.dataset,
546
+ "format": args.format,
547
+ "dataset_config": args.dataset_config,
548
+ "dataset_split": args.dataset_split,
549
+ "max_samples": args.dataset_max_samples,
550
+ "columns": {k: v for k, v in {
551
+ "prompt": args.prompt_key, "response": args.response_key,
552
+ "instruction": args.instruction_key, "input": args.input_key,
553
+ "output": args.output_key,
554
+ }.items() if v},
555
+ }]
556
+
557
+
558
+ def _apply_weight(ds, weight):
559
+ """Up-sample (weight > 1, with repeats) or sub-sample (weight < 1) a source."""
560
+ if not weight or weight == 1.0 or len(ds) == 0:
561
+ return ds
562
+ target = max(0, int(round(len(ds) * float(weight))))
563
+ if target == 0:
564
+ return ds.select([])
565
+ n = len(ds)
566
+ return ds.select([i % n for i in range(target)]) # repeats when target > n
567
+
568
+
569
+ def build_training_dataset(args, tokenizer):
570
+ """Load, tokenize, weight and concatenate every source into one dataset.
571
+
572
+ Each source carries its own format / columns / split / max_len so a skill
573
+ dataset can be mixed with a general-data replay slice in one run."""
574
+ from datasets import concatenate_datasets
575
+
576
+ specs = _source_specs(args)
577
+ multi = len(specs) > 1
578
+ if multi:
579
+ print(f"Mixing {len(specs)} dataset source(s):")
580
+
581
+ parts = []
582
+ for i, spec in enumerate(specs):
583
+ dataset = spec.get("dataset")
584
+ if not dataset:
585
+ raise SystemExit(f"mix source #{i} is missing 'dataset'")
586
+ fmt = spec.get("format", args.format)
587
+ raw = load_raw_dataset(
588
+ dataset,
589
+ config=spec.get("dataset_config"),
590
+ split=spec.get("dataset_split", "train"),
591
+ max_samples=spec.get("max_samples"),
592
+ )
593
+ raw = raw.shuffle(seed=args.seed)
594
+ keys = spec.get("columns") or {}
595
+ max_len = spec.get("max_len", args.max_len)
596
+ tokenize = build_tokenize_fn(tokenizer, fmt, max_len, args.mask_prompt, keys)
597
+ tok = raw.map(tokenize, remove_columns=raw.column_names,
598
+ desc=f"tokenizing {dataset}")
599
+ tok = tok.filter(lambda e: len(e["input_ids"]) > 1)
600
+ tok = _apply_weight(tok, spec.get("weight"))
601
+ if multi:
602
+ wnote = f" (weight {spec['weight']})" if spec.get("weight") else ""
603
+ print(f" - {dataset} [{fmt}] -> {len(tok)} examples{wnote}")
604
+ parts.append(tok)
605
+
606
+ ds = parts[0] if len(parts) == 1 else concatenate_datasets(parts)
607
+ return ds.shuffle(seed=args.seed)
608
+
609
+
610
  class CausalCollator:
611
  """Pads input_ids with pad_token and labels with IGNORE_INDEX."""
612
  def __init__(self, tokenizer):
 
917
  print(f"Base model: {args.model}")
918
  if preset_key:
919
  print(f"Preset: {preset_key}")
920
+ if args.mix_json:
921
+ print(f"Dataset mix: {len(json.loads(args.mix_json))} source(s)")
922
+ else:
923
+ print(f"Dataset: {args.dataset}")
924
  print(f"Output: {args.out}")
925
  print(f"Device: {args.device}")
926
 
 
938
  if _training_uses_cuda(args):
939
  print(f"GPU after model load: {_gpu_memory_summary()}")
940
 
941
+ ds = build_training_dataset(args, tokenizer)
 
 
 
 
 
 
 
 
 
 
942
 
943
  if args.val_split > 0:
944
  split = ds.train_test_split(test_size=args.val_split, seed=args.seed)
 
946
  else:
947
  train_ds, eval_ds = ds, None
948
 
949
+ # Default eval cadence to the run length so short (max_steps) runs still
950
+ # evaluate mid-training instead of only at the end.
951
+ eval_steps = args.eval_steps
952
+ if eval_steps is None:
953
+ eval_steps = max(1, args.max_steps // 5) if args.max_steps > 0 else 200
954
+
955
+ use_best = args.early_stopping_patience > 0 and eval_ds is not None
956
+ # load_best_model_at_end needs save_steps aligned to eval_steps.
957
+ save_steps = eval_steps if use_best else args.save_steps
958
+
959
  targs = TrainingArguments(
960
  output_dir=args.out,
961
  num_train_epochs=args.epochs,
 
964
  per_device_eval_batch_size=args.batch_size,
965
  gradient_accumulation_steps=args.grad_accum,
966
  learning_rate=lr,
967
+ lr_scheduler_type=args.lr_scheduler,
968
  warmup_ratio=args.warmup_ratio,
969
+ weight_decay=args.weight_decay,
970
+ max_grad_norm=args.max_grad_norm,
971
+ logging_steps=args.logging_steps,
972
  eval_strategy="steps" if eval_ds is not None else "no",
973
+ eval_steps=eval_steps,
974
  save_strategy="steps",
975
+ save_steps=save_steps,
976
+ save_total_limit=args.save_total_limit,
977
+ load_best_model_at_end=use_best,
978
+ metric_for_best_model="eval_loss" if use_best else None,
979
+ greater_is_better=False if use_best else None,
980
  bf16=bf16_ok,
981
  fp16=(not bf16_ok and _training_uses_cuda(args)),
982
  gradient_checkpointing=args.gradient_checkpointing,
983
+ neftune_noise_alpha=args.neftune_noise_alpha,
984
+ report_to=args.report_to,
985
  seed=args.seed,
986
  )
987
 
988
+ callbacks = []
989
+ if use_best:
990
+ from transformers import EarlyStoppingCallback
991
+ callbacks.append(
992
+ EarlyStoppingCallback(early_stopping_patience=args.early_stopping_patience)
993
+ )
994
+
995
  trainer = Trainer(
996
  model=model,
997
  args=targs,
998
  train_dataset=train_ds,
999
  eval_dataset=eval_ds,
1000
  data_collator=CausalCollator(tokenizer),
1001
+ callbacks=callbacks,
1002
  )
1003
 
1004
  train_result = trainer.train(resume_from_checkpoint=args.resume)
 
1032
  eval_metrics=eval_metrics,
1033
  )
1034
  m = json.loads(results_path.read_text())["metrics"]
1035
+ print("\n--- scores ---")
1036
  print(f"loss_score = {m['loss_score']} (lower is better)")
1037
  print(f"result_score = {m['result_score']} (0–100, higher is better)")
1038
  print(f"Saved to {results_path}")
research/modal/_common.py CHANGED
@@ -75,7 +75,12 @@ image = (
75
  ],
76
  )
77
  .run_commands(
78
- "cd /repo && uv sync --frozen --group finetune --group lm-eval --no-dev"
 
 
 
 
 
79
  )
80
  )
81
 
@@ -114,6 +119,35 @@ def apply_defaults(job: dict[str, Any], defaults: dict[str, Any]) -> dict[str, A
114
  return {**defaults, **job}
115
 
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  def build_finetune_cmd(job: dict[str, Any], out_dir: str) -> list[str]:
118
  cmd = [
119
  "uv",
@@ -124,23 +158,43 @@ def build_finetune_cmd(job: dict[str, Any], out_dir: str) -> list[str]:
124
  job.get("preset", "minicpm5-1b"),
125
  "--mode",
126
  job.get("mode", "lora"),
127
- "--dataset",
128
- job["dataset"],
129
- "--format",
130
- job["format"],
131
  "--out",
132
  out_dir,
133
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  if job.get("max_steps") is not None:
135
  cmd.extend(["--max_steps", str(int(job["max_steps"]))])
136
  if job.get("epochs") is not None:
137
  cmd.extend(["--epochs", str(job["epochs"])])
138
- if job.get("dataset_config"):
139
- cmd.extend(["--dataset-config", job["dataset_config"]])
140
- if job.get("dataset_split"):
141
- cmd.extend(["--dataset-split", str(job["dataset_split"])])
142
- if job.get("max_samples") is not None:
143
- cmd.extend(["--dataset-max-samples", str(int(job["max_samples"]))])
 
 
 
 
 
 
 
144
  return cmd
145
 
146
 
@@ -313,21 +367,28 @@ def evaluate_gate(
313
 
314
  def pull_artifacts(job_name: str, exp_name: str, dest: str = "models/finetuned") -> None:
315
  """Download an adapter and its lm-eval results from the `slm-finetune` Volume (run locally)."""
 
316
  import subprocess
317
 
318
- local_dir = f"{dest}/{job_name}"
319
- print(f"--- pulling {job_name} -> {local_dir} ---")
320
- subprocess.run(
321
- ["modal", "volume", "get", "slm-finetune", job_name, local_dir, "--force"],
322
- check=False,
323
- )
 
 
 
 
 
 
324
 
325
- results_dir = f"results/lm_eval/{exp_name}"
326
- print(f"--- pulling {results_dir} ---")
327
- subprocess.run(
328
- ["modal", "volume", "get", "slm-finetune", results_dir, results_dir, "--force"],
329
- check=False,
330
- )
331
 
332
 
333
  def check_gate_files(
 
75
  ],
76
  )
77
  .run_commands(
78
+ "cd /repo && uv sync --frozen --group finetune --group lm-eval --no-dev",
79
+ # lm-eval's ifeval task (instructions profile) needs these, declared via
80
+ # the lm-eval[ifeval] extra but not activated into the project venv by the
81
+ # frozen group sync. Install the lock-pinned versions into /repo/.venv so
82
+ # `uv run slm-lm-eval` can import them.
83
+ "cd /repo && uv pip install langdetect==1.0.9 immutabledict==4.3.1",
84
  )
85
  )
86
 
 
119
  return {**defaults, **job}
120
 
121
 
122
+ # Scalar hyperparameters an experiments.yaml job (or its nested `args:` block)
123
+ # may set; each maps 1:1 onto a research/finetune.py flag so any run is tunable
124
+ # from config without code changes.
125
+ _FINETUNE_FLAGS: dict[str, str] = {
126
+ "model": "--model",
127
+ "lr": "--lr",
128
+ "batch_size": "--batch_size",
129
+ "grad_accum": "--grad_accum",
130
+ "max_len": "--max_len",
131
+ "warmup_ratio": "--warmup_ratio",
132
+ "weight_decay": "--weight_decay",
133
+ "max_grad_norm": "--max_grad_norm",
134
+ "lr_scheduler": "--lr_scheduler",
135
+ "logging_steps": "--logging_steps",
136
+ "eval_steps": "--eval_steps",
137
+ "save_steps": "--save_steps",
138
+ "save_total_limit": "--save_total_limit",
139
+ "early_stopping_patience": "--early_stopping_patience",
140
+ "neftune_noise_alpha": "--neftune_noise_alpha",
141
+ "report_to": "--report_to",
142
+ "seed": "--seed",
143
+ "lora_r": "--lora_r",
144
+ "lora_alpha": "--lora_alpha",
145
+ "lora_dropout": "--lora_dropout",
146
+ "lora_targets": "--lora_targets",
147
+ "val_split": "--val_split",
148
+ }
149
+
150
+
151
  def build_finetune_cmd(job: dict[str, Any], out_dir: str) -> list[str]:
152
  cmd = [
153
  "uv",
 
158
  job.get("preset", "minicpm5-1b"),
159
  "--mode",
160
  job.get("mode", "lora"),
 
 
 
 
161
  "--out",
162
  out_dir,
163
  ]
164
+ # Dataset: a `mix:` list (skill data + general replay) takes precedence over
165
+ # a single --dataset/--format source.
166
+ if job.get("mix"):
167
+ cmd.extend(["--mix-json", json.dumps(job["mix"])])
168
+ else:
169
+ cmd.extend(["--dataset", job["dataset"], "--format", job["format"]])
170
+ if job.get("dataset_config"):
171
+ cmd.extend(["--dataset-config", job["dataset_config"]])
172
+ if job.get("dataset_split"):
173
+ cmd.extend(["--dataset-split", str(job["dataset_split"])])
174
+ if job.get("max_samples") is not None:
175
+ cmd.extend(["--dataset-max-samples", str(int(job["max_samples"]))])
176
+ # Optional column remap so a dataset's own columns fit the --format
177
+ # (e.g. MetaMathQA query/response -> prompt format).
178
+ for field, col in (job.get("columns") or {}).items():
179
+ cmd.extend([f"--{field}-key", str(col)])
180
+
181
  if job.get("max_steps") is not None:
182
  cmd.extend(["--max_steps", str(int(job["max_steps"]))])
183
  if job.get("epochs") is not None:
184
  cmd.extend(["--epochs", str(job["epochs"])])
185
+ if job.get("mask_prompt") is False:
186
+ cmd.append("--no_mask_prompt")
187
+
188
+ # Scalar hyperparameters: top-level keys plus an optional nested `args:` block.
189
+ overrides = {k: job[k] for k in _FINETUNE_FLAGS if k in job}
190
+ overrides.update(job.get("args") or {})
191
+ for key, value in overrides.items():
192
+ flag = _FINETUNE_FLAGS.get(key, f"--{key}")
193
+ if isinstance(value, bool):
194
+ if value:
195
+ cmd.append(flag)
196
+ else:
197
+ cmd.extend([flag, str(value)])
198
  return cmd
199
 
200
 
 
367
 
368
  def pull_artifacts(job_name: str, exp_name: str, dest: str = "models/finetuned") -> None:
369
  """Download an adapter and its lm-eval results from the `slm-finetune` Volume (run locally)."""
370
+ import shutil
371
  import subprocess
372
 
373
+ def _get(remote: str, parent: str) -> None:
374
+ # For a folder REMOTE_PATH, `modal volume get` expects the *parent*
375
+ # directory as the destination and recreates the folder inside it.
376
+ # Passing the full target path (parent/<name>) raises
377
+ # "[Errno 21] Is a directory". Clear the target first for a clean pull.
378
+ name = remote.rsplit("/", 1)[-1]
379
+ shutil.rmtree(Path(parent) / name, ignore_errors=True)
380
+ Path(parent).mkdir(parents=True, exist_ok=True)
381
+ subprocess.run(
382
+ ["modal", "volume", "get", "slm-finetune", remote, f"{parent}/", "--force"],
383
+ check=False,
384
+ )
385
 
386
+ print(f"--- pulling {job_name} -> {dest}/{job_name} ---")
387
+ _get(job_name, dest)
388
+
389
+ exp_dir = f"results/lm_eval/{exp_name}"
390
+ print(f"--- pulling {exp_dir} ---")
391
+ _get(exp_dir, "results/lm_eval")
392
 
393
 
394
  def check_gate_files(
research/modal/experiments.yaml CHANGED
@@ -62,14 +62,32 @@ finetune:
62
  hub_repo: MSGEncrypted/minicpm5-1b-science-lora
63
  private: false
64
 
65
- # --- math: grade-school word problems + instruction-style math solutions ---
 
 
 
 
 
66
  - name: math-lora
67
  category: math
68
- dataset: TIGER-Lab/MathInstruct
69
- format: alpaca
70
- dataset_split: "train[:1000]"
71
- max_samples: 1000
72
- description: Math instruction tuning (Hub, instruction/output columns)
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  eval_profile: math
74
  goals:
75
  task: gsm8k
@@ -78,6 +96,10 @@ finetune:
78
  guard_tasks:
79
  - task: arc_challenge
80
  max_regress: 0.03
 
 
 
 
81
  publish:
82
  hub_repo: MSGEncrypted/minicpm5-1b-math-lora
83
  private: false
@@ -95,6 +117,11 @@ finetune:
95
  task: mbpp
96
  min_score: 0.05
97
  min_improve: 0.01
 
 
 
 
 
98
  publish:
99
  hub_repo: MSGEncrypted/minicpm5-1b-coding-lora
100
  private: false
 
62
  hub_repo: MSGEncrypted/minicpm5-1b-science-lora
63
  private: false
64
 
65
+ # --- math: GSM8K/MATH natural-language CoT augmentation (MetaMathQA) ---
66
+ # MetaMathQA's NL chain-of-thought matches GSM8K's 5-shot format far better
67
+ # than MathInstruct's program-of-thought answers (which regressed gsm8k).
68
+ # The `mix:` adds a general-data replay slice (alpaca) so skill tuning does
69
+ # not regress the arc/hellaswag/piqa guards. `args:` flows any
70
+ # research/finetune.py hyperparameter straight through.
71
  - name: math-lora
72
  category: math
73
+ max_steps: 150
74
+ mix:
75
+ - dataset: meta-math/MetaMathQA
76
+ format: prompt
77
+ columns:
78
+ prompt: query
79
+ response: response
80
+ dataset_split: "train[:3000]"
81
+ max_samples: 3000
82
+ - dataset: tatsu-lab/alpaca # general replay: protect guard tasks
83
+ format: alpaca
84
+ dataset_split: "train[:600]"
85
+ max_samples: 600
86
+ args:
87
+ lora_r: 32
88
+ lora_alpha: 64
89
+ neftune_noise_alpha: 5
90
+ description: GSM8K/MATH NL-CoT (MetaMathQA) + alpaca replay, r=32 + NEFTune
91
  eval_profile: math
92
  goals:
93
  task: gsm8k
 
96
  guard_tasks:
97
  - task: arc_challenge
98
  max_regress: 0.03
99
+ - task: hellaswag
100
+ max_regress: 0.03
101
+ - task: piqa
102
+ max_regress: 0.03
103
  publish:
104
  hub_repo: MSGEncrypted/minicpm5-1b-math-lora
105
  private: false
 
117
  task: mbpp
118
  min_score: 0.05
119
  min_improve: 0.01
120
+ guard_tasks:
121
+ - task: hellaswag
122
+ max_regress: 0.03
123
+ - task: piqa
124
+ max_regress: 0.03
125
  publish:
126
  hub_repo: MSGEncrypted/minicpm5-1b-coding-lora
127
  private: false
research/modal/server_app.py CHANGED
@@ -85,6 +85,7 @@ app = modal.App(APP_NAME, image=image)
85
  timeout=DEFAULT_WORKER_TIMEOUT,
86
  scaledown_window=DEFAULT_SCALEDOWN_WINDOW,
87
  min_containers=1,
 
88
  )
89
  class GpuWorker:
90
  """Single warm GPU container for sequential finetune / lm-eval / shell commands."""
@@ -165,6 +166,10 @@ class GpuWorker:
165
  compare_to: str | None = None,
166
  ) -> dict[str, Any]:
167
  """Run slm-lm-eval on base model or finetuned checkpoint."""
 
 
 
 
168
  if adapter_path:
169
  adapter_dir = Path(adapter_path)
170
  adapter_cfg = adapter_dir / "adapter_config.json"
 
85
  timeout=DEFAULT_WORKER_TIMEOUT,
86
  scaledown_window=DEFAULT_SCALEDOWN_WINDOW,
87
  min_containers=1,
88
+ max_containers=1, # single warm container; serialize work, never sprawl
89
  )
90
  class GpuWorker:
91
  """Single warm GPU container for sequential finetune / lm-eval / shell commands."""
 
166
  compare_to: str | None = None,
167
  ) -> dict[str, Any]:
168
  """Run slm-lm-eval on base model or finetuned checkpoint."""
169
+ # Pick up adapters committed by another container (e.g. a separate
170
+ # eval-only invocation) — the warm container's mount may predate them.
171
+ reload_volumes()
172
+
173
  if adapter_path:
174
  adapter_dir = Path(adapter_path)
175
  adapter_cfg = adapter_dir / "adapter_config.json"