0c65387a5f
Route contract is now a JSON array of action objects (one per ask) so
compound utterances route all their intents, not just the first. Grammar
root emits `[{intent...},...]`; parseActions tolerates a bare object.
Cascade still returns one Decision — full N-action dispatch lands with the
engine turn-on (marked in-code).
Router prompt rewritten shorter + decision-ordered (prompt-guy feedback),
fact redefined as "implicit update" not "trackable state", kept in Russian
to match the CPT base + phraser. "интент" → "намерение".
CLAUDE.md: routing-architecture section + refreshed open items.
docs/plans: route-data generation plan.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017GMrVfuYN3nE4L1vEiFYC9
150 lines
8.8 KiB
Markdown
150 lines
8.8 KiB
Markdown
# Maven RU LoRA — training guide
|
||
|
||
Fine-tuning a small LLM so Maven speaks correct, in-character Russian (and English)
|
||
without breaking Cyrillic. Training scripts live in
|
||
`/home/kami/Programs/esp32-whisper-fine-tune/llm`; the model is consumed by this
|
||
daemon.
|
||
|
||
## Two machines — don't conflate them
|
||
|
||
| Role | Box | Specs | Constraint |
|
||
|---|---|---|---|
|
||
| **Train** | this workstation | RX 7900 GRE, gfx1100 (RDNA3), **16GB VRAM**, ROCm 7.2 / torch 2.10 HIP, `cuda.is_available()==True` | fits 1.5B bf16 LoRA with huge headroom |
|
||
| **Deploy** | homesrv `kami@192.168.1.104` | Ryzen 5 5600U (Zen3 6c/12t), Vega iGPU (Cezanne), **13GB RAM, already swapping** (~8.8GB avail), Ubuntu 24.04 | **CPU-only** inference; caps model at ~1.5B for voice latency |
|
||
|
||
**Path duality via SSHFS:** `/mnt/server/home/kami/` is an SSHFS mount from homesrv. So when you're in this repo:
|
||
- `/home/kami/Programs/` → **local** to workpc
|
||
- `/mnt/server/home/kami/` → **remote** homesrv filesystem
|
||
- `/mnt/server/home/kami/apps/Maven/` = this repo = remote filesystem accessed locally
|
||
|
||
**Inference router** (`localhost:6446`) isn't running on either machine. Check for a compose file (`docker compose up -d`) on whichever box has it before assuming it's available.
|
||
|
||
- The Vega iGPU is **not** worth ROCm inference (gfx90c). Inference is CPU llama.cpp;
|
||
Vulkan can offload a few layers (`-ngl 4`-ish) for a small speedup.
|
||
- homesrv swaps already → every ~200MB of model footprint matters. Keep `--mlock` on.
|
||
- A 4B model at any quant is ~5–8 tok/s here = too slow for voice. **Stay ≤1.5B.**
|
||
|
||
## Root cause of broken Russian
|
||
|
||
Cyrillic breakage is a **base-model + quantization** problem, **not** a missing LoRA:
|
||
- Shipped models (`LFM2.5-1.2B-Thinking`, `Nemotron-3-Nano-4B`) are Western, thin-RU,
|
||
run at **Q4_K** → mangled endings, Latin/Cyrillic homoglyph swaps.
|
||
- A LoRA **cannot** add spelling knowledge the base lacks. It only steers
|
||
format / persona / mood / tool-schema / language-mirroring.
|
||
|
||
**Levers, in order of impact:**
|
||
1. **RU-native base** — `Vikhrmodels/Vikhr-Qwen-2.5-1.5B-Instruct` (continued RU
|
||
pretraining on Qwen2.5). Same latency class as the current 1.2B, doesn't shatter
|
||
Cyrillic. *(Verify exact HF repo id before download — Vikhr naming shifts.)*
|
||
2. **Less aggressive quant** — deploy Q4_K_M first (~1.0GB, matches current
|
||
footprint); bump to Q5_K_M only if Cyrillic still breaks. RU-native base tolerates
|
||
Q4 far better than the Western models did.
|
||
3. **LoRA** — format/persona/mood/tools, on top of a base that already spells.
|
||
|
||
## Output contract: `{response,mood}` (Decision B, 2026-07-11)
|
||
|
||
All LLM output paths now produce `{"response":"...","mood":"..."}` — the Go side
|
||
(`replier_llm.go`, `llmphraser.go`) parses it in all 4 consumers. Fallback logic
|
||
preserves backward compat with plain text and the old `{"body","summary"}` format.
|
||
|
||
| Consumer (file) | Now parses |
|
||
|---|---|
|
||
| Replier (`cmd/mavend/replier_llm.go`) | `{"response","mood"}` via `parseResponseMood` |
|
||
| Nudges (`internal/phraser/llmphraser.go`) | `{"response","mood"}` via `parseResponseMood` → `Body`, `Mood` on struct |
|
||
| Reminders (`internal/phraser/llmphraser.go`) | same, summary derived from response |
|
||
| Chat / query (`internal/phraser/llmphraser.go`) | `{"response","mood"}` → returns plain response text |
|
||
|
||
Every training sample's assistant turn must produce `{"response":"...","mood":"..."}`.
|
||
|
||
## Routing architecture: LLM-as-router (REARCH.md, target)
|
||
|
||
Target arch = **LLM-as-router** (`REARCH.md`, supersedes the classifier-first
|
||
model). One resident model — the **CPT'd Qwen3-1.7B** (RU-CPT run; replaces LFM,
|
||
"too meh") — fills **both** router and phraser roles, two call-sites / two contracts:
|
||
|
||
| Prompt | Contract | Source of truth |
|
||
|---|---|---|
|
||
| route-prompt | `{"intent":<enum>, key?, value?, text?, verb?}` GBNF-constrained | `internal/router/llmrouter.go` (`routeSystem`+`routeGrammar`) |
|
||
| phrase-prompt | `{"response","mood"}` | above section |
|
||
|
||
7 intents: `fact, reminder, note, query, act, chat, system`. Key rule:
|
||
«запомни/запиши» = note, «напомни/не забудь» = reminder. Embedder is **demoted**
|
||
from router to a tool (RAG hint), not a threshold gate.
|
||
|
||
**Status: phase 1 NOT done.** Plumbing exists (`LLMRouter`, `Route` cascade calls
|
||
it at `router.go:87`) but `voice.go:209` wires it `nil` — engine OFF, classifier
|
||
stopgap still active. Flip `nil` → `NewLLMRouter(qwen)` after CPT finishes. Do NOT
|
||
read the current committed code as the intended design — it's the interim stopgap.
|
||
|
||
Route-training data: `esp32-whisper-fine-tune/llm/gen_route_data.py` relabels real
|
||
utterances through the verbatim `routeSystem` into `{intent,...}`. Keep its
|
||
`ROUTE_SYSTEM` in sync with the Go const.
|
||
|
||
## Data defects measured in current corpus
|
||
|
||
- **Mood collapse:** `neutral 1051, thinking 666, happy 280, confused 206, tired 7`
|
||
(of 2210). Graceful failure (`tired`) is essentially untrained — the most important
|
||
small-assistant behavior. Rebalance to ~10–15% combined `tired`+`confused`.
|
||
- **System-prompt drift:** 3 variants in `synthetic_dataset.jsonl` + a 4th in
|
||
`function_calling.jsonl`. Pick **one canonical system prompt = the exact string the
|
||
daemon sends at inference**, normalize all data to it. Train-prompt ≠ deploy-prompt
|
||
is a silent accuracy tax.
|
||
- **Dolphin trap:** `dolphin_to_messages` wraps ~3000 free-form paragraph outputs
|
||
under a strict-format system prompt → teaches the model the format is optional.
|
||
Drop it, or reshape via the distiller. Consistency > volume for format LoRAs.
|
||
- **`user-*.jsonl`** are bare message fragments (not wrapped in `{"messages":[...]}`);
|
||
the `"messages" in s` filter silently drops them. Wrap/normalize before use.
|
||
|
||
## Target data recipe (RU-native base — spelling is NOT the job)
|
||
|
||
Job = output contract + persona + mood mapping + tool schema + language mirroring.
|
||
|
||
| Bucket | ~Share | Source |
|
||
|---|---|---|
|
||
| Persona chit-chat, on-contract | 45% | distill (Qwen3-4B / API): topic → canonical prompt → generate → validate (parses, mood∈enum, Cyrillic-clean, length) |
|
||
| Graceful failure / clarify | 15% | author unanswerable prompts → `tired`/`confused` (current hole) |
|
||
| Tool calls | 20% | expand `function_calling.jsonl` (473) — paraphrase 3–5× RU+EN, vary params |
|
||
| Real utterances | 10% | mine Maven dialogue history / logs, relabel |
|
||
| English → English | 10% | same buckets, EN in/out, so language-mirroring is trained |
|
||
|
||
**Hygiene:** dedup near-dup user turns; hold out a *real* (non-distiller) eval set;
|
||
route all data through a validator rejecting mixed-script words + invalid JSON — the
|
||
same validator is the regression metric (Cyrillic eval).
|
||
|
||
## Training (ROCm) — `esp32-whisper-fine-tune/llm/train_rocm.py`
|
||
|
||
Adapted from `train_llama.py`; the ROCm fix is dropping the two CUDA-only pieces:
|
||
- **No bitsandbytes** — bf16 full weights (1.5B fits 16GB easily), no 4-bit BnB.
|
||
- `optim="adamw_torch_fused"` replaces `paged_adamw_8bit`.
|
||
- Base `Vikhr-Qwen-2.5-1.5B`; Qwen ChatML masking (`<|im_start|>assistant` / `<|im_end|>`).
|
||
- `attn_implementation="eager"` (flash-attn ROCm wheels flaky).
|
||
- `HSA_OVERRIDE_GFX_VERSION=11.0.0` belt-and-suspenders (gfx1100 is officially supported).
|
||
- batch 2 × grad-accum 4 (eff. 8), LoRA r16/α32, 3 epochs, grad checkpointing.
|
||
|
||
## Deploy path (after training)
|
||
|
||
```bash
|
||
# 1. merge adapter → bf16 weights
|
||
python -c "from peft import AutoPeftModelForCausalLM; import torch; \
|
||
m=AutoPeftModelForCausalLM.from_pretrained('./Vikhr-Qwen-1.5b-ru-lora',torch_dtype=torch.bfloat16); \
|
||
m.merge_and_unload().save_pretrained('./Vikhr-merged'); \
|
||
from transformers import AutoTokenizer; AutoTokenizer.from_pretrained('./Vikhr-Qwen-1.5b-ru-lora').save_pretrained('./Vikhr-merged')"
|
||
# 2. HF → GGUF (in llama.cpp)
|
||
python convert_hf_to_gguf.py ./Vikhr-merged --outfile vikhr-maven-f16.gguf --outtype f16
|
||
# 3. quantize (start Q4_K_M, test Cyrillic, bump to Q5_K_M if needed)
|
||
./llama-quantize vikhr-maven-f16.gguf Vikhr-Maven-1.5B-Q4_K_M.gguf Q4_K_M
|
||
# 4. scp to homesrv; point deploy/mavend.json phraser.model_path at it; --mlock, small -ngl on Vulkan
|
||
```
|
||
|
||
## Open items
|
||
|
||
- [x] **Decide output contract** — **B: `{response,mood}`**, Go side patched (2026-07-11).
|
||
- [x] **Base model** — Qwen3-1.7B, RU via **continued pretraining** (not Vikhr). CPT run in progress.
|
||
- [x] **Write `gen_data.py` distiller** — ran, produced `persona_train.jsonl` (2045) + eval (107), mood collapse fixed.
|
||
- [x] **Write `gen_route_data.py`** — route-schema relabeler (run when router up).
|
||
- [ ] **Turn router engine ON** — swap `voice.go:209` `nil` → `NewLLMRouter(qwen)` after CPT (REARCH phase 1).
|
||
- [ ] Run `gen_route_data.py`; train route-LoRA (or fold into persona SFT).
|
||
- [ ] Improve `routeSystem` prompt for sub-1B disambiguation (awaiting prompt-guy input).
|
||
- [ ] Write Cyrillic-validity + JSON eval (extend `llama-eval-test.py`).
|
||
- [ ] Normalize `user-*.jsonl` into `{"messages":[...]}`.
|