Skip to content

Commit 1636164

Browse files
committed
chore(release): v0.4.2 — embedding NaN fix + configurable max_tokens + MXFP8 infrastructure
Fixes the `Qwen3-Embedding-4B-4bit-DWQ` all-NaN embedding bug (P@1 0.200 → 1.000 on the 25-prompt labeled-corpus quality eval). MLX's DWQ rebuild ships scales/biases as BF16 instead of the original AFFINE F16 convention; the embedding loader was reading both as F16, which silently misinterpreted the 5-vs-8-bit exponent layout and propagated NaN through every projection. Loader now dispatches on the safetensors dtype and handles both formats. Other user-visible changes: - Configurable default `max_tokens` in the CONTEXT card (schema v6 → v7). Was hardcoded server-side at 2048 for any chat/completion request that omitted the field; now mirrors a UI input via `LUMEN_DEFAULT_MAX_TOKENS`. Existing configs migrate stamped at 2048 so behavior is preserved. - `scan_local` now strictly matches the HuggingFace repo-id grammar (`[A-Za-z0-9._-]+`). Filters Finder duplicates (`mlx-community--…-8bit 2`) and other scratch dirs so the periodic update check stops emitting 401 Unauthorized log lines for repo ids that aren't real. Under-the-hood (no catalog recommendation yet): - Full MXFP8 (OCP) Metal kernel — E4M3 elements + E8M0 byte scales, group_size=32, no biases. Naive + qmv_fast cooperative simdgroup variants; GPU↔CPU bit-identical parity on random inputs. Loader path + `QuantProj::M8` wired through so a future `mxfp8` catalog entry works end-to-end without further plumbing. Validated against `mlx-community/Qwen3-Embedding-4B-mxfp8` (P@1 = 1.000, mean cosine 0.948 vs the DWQ 4B reference) — implementation is correct but DWQ is faster (2.8×) and uses half the memory at equivalent quality, so the catalog stays DWQ-only for the 4B slot.
1 parent 515f571 commit 1636164

24 files changed

Lines changed: 2175 additions & 154 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/lumen-app/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "lumen-app"
3-
version = "0.4.1"
3+
version = "0.4.2"
44
edition.workspace = true
55
rust-version.workspace = true
66
license.workspace = true

crates/lumen-app/frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "lumen-app-frontend",
33
"private": true,
4-
"version": "0.4.1",
4+
"version": "0.4.2",
55
"type": "module",
66
"scripts": {
77
"predev": "cargo build --manifest-path ../../../Cargo.toml -p lumen-server --release",

crates/lumen-app/frontend/src/App.svelte

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -938,6 +938,21 @@
938938
{t("context.hint.prefill")}{#if config.quant.turboquant_enabled}, ~{turboquantKvRatio.toFixed(1)}×{/if}).
939939
{t("context.hint.max.env")} <code class={inlineCode}>LUMEN_PREFILL_CHUNK</code>.
940940
</div>
941+
942+
<div class={kvRow}>
943+
<span class="dim">{t("context.defaultMaxTokens")}</span>
944+
<input
945+
type="number"
946+
min="1"
947+
step="256"
948+
bind:value={config.context.default_max_tokens}
949+
onchange={saveContext}
950+
/>
951+
</div>
952+
<div class={ctxHint}>
953+
{t("context.hint.defaultMaxTokens")}
954+
{t("context.hint.max.env")} <code class={inlineCode}>LUMEN_DEFAULT_MAX_TOKENS</code>.
955+
</div>
941956
{/if}
942957
</section>
943958

crates/lumen-app/frontend/src/lib/api.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ export interface ContextConfig {
4848
max: number;
4949
sliding: number;
5050
prefill: number;
51+
/** Default `max_tokens` (generation budget) for OpenAI-compatible chat /
52+
* completion requests that omit the field. Default 2048. Emitted as the
53+
* `LUMEN_DEFAULT_MAX_TOKENS` env var; explicit `max_tokens` in the request
54+
* body always wins. */
55+
default_max_tokens: number;
5156
}
5257

5358
export type BackendMode = "auto" | "candle" | "mlx-native" | "mlx-pyo3";

crates/lumen-app/frontend/src/messages/en.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export const en: Record<string, string> = {
9494
"context.max": "Max",
9595
"context.sliding": "Sliding",
9696
"context.prefill": "Prefill",
97+
"context.defaultMaxTokens": "Default max_tokens",
9798
"context.turboquant.on": "TurboQuant:",
9899
"context.turboquant.off": "· baseline KV memory (no compression)",
99100
"context.recommended": "Recommended max on this Mac",
@@ -183,6 +184,8 @@ export const en: Record<string, string> = {
183184
"Stacks with TurboQuant — sliding bounds which tokens are kept, TurboQuant compresses how they're stored.",
184185
"context.hint.prefill":
185186
"Prompt-processing chunk cap. Server rejects prompts longer than this with a \"prompt too large\" error. Larger = accepts long prompts but more peak memory during prefill (attention QK·T = chunk × KV",
187+
"context.hint.defaultMaxTokens":
188+
"Generation budget applied to OpenAI-compatible chat / completion requests that omit `max_tokens`. Request bodies that explicitly set `max_tokens` always win — this is only the server-side fallback.",
186189

187190
// ── SERVER memory explainer ─────────────────────────────────────
188191
"server.memory.explainer.intro":

crates/lumen-app/frontend/src/messages/ko.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export const ko: Record<string, string> = {
8989
"context.max": "최대",
9090
"context.sliding": "슬라이딩",
9191
"context.prefill": "프리필",
92+
"context.defaultMaxTokens": "기본 max_tokens",
9293
"context.turboquant.on": "TurboQuant:",
9394
"context.turboquant.off": "· 기본 KV 메모리 (압축 없음)",
9495
"context.recommended": "이 Mac 권장 최대",
@@ -177,6 +178,8 @@ export const ko: Record<string, string> = {
177178
"TurboQuant와 함께 적용됩니다 — 슬라이딩은 어떤 토큰을 유지할지 결정, TurboQuant는 어떻게 저장할지 결정.",
178179
"context.hint.prefill":
179180
"프롬프트 처리 청크 상한. 이 값보다 긴 프롬프트는 \"prompt too large\" 오류로 거부됩니다. 클수록 긴 프롬프트를 받지만 프리필 동안 피크 메모리도 증가 (어텐션 QK·T = 청크 × KV",
181+
"context.hint.defaultMaxTokens":
182+
"OpenAI 호환 chat/completion 요청에서 `max_tokens`가 빠졌을 때 서버가 적용하는 생성 토큰 예산. 요청 본문에 `max_tokens`가 명시되어 있으면 그 값이 항상 우선합니다 — 이 값은 서버 측 폴백일 뿐입니다.",
180183

181184
// ── SERVER 메모리 설명 ──────────────────────────────────────────
182185
"server.memory.explainer.intro":

crates/lumen-app/src/config.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
99
/// chain in `migrate_in_place` is keyed by this number — incrementing it
1010
/// without adding a corresponding migration step is a deserialization
1111
/// landmine for anyone with an older config.toml.
12-
pub const CURRENT_SCHEMA_VERSION: u32 = 6;
12+
pub const CURRENT_SCHEMA_VERSION: u32 = 7;
1313

1414
/// On-disk persistent config. Lives at
1515
/// `~/Library/Application Support/ai.lumen.app/config.toml` on macOS.
@@ -162,6 +162,16 @@ pub struct ContextConfig {
162162
pub max: usize,
163163
pub sliding: usize,
164164
pub prefill: usize,
165+
/// Default `max_tokens` (generation budget) applied to chat / completion
166+
/// requests that omit the field. Emitted as `LUMEN_DEFAULT_MAX_TOKENS`;
167+
/// `lumen-server` reads it in `types.rs::default_max_tokens()`. `0` means
168+
/// "unbounded — generate until EOS / stop / context budget".
169+
#[serde(default = "default_default_max_tokens")]
170+
pub default_max_tokens: u32,
171+
}
172+
173+
fn default_default_max_tokens() -> u32 {
174+
2048
165175
}
166176

167177
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -258,6 +268,7 @@ impl Default for PersistentConfig {
258268
max: 81920,
259269
sliding: 1024,
260270
prefill: 40960,
271+
default_max_tokens: 2048,
261272
},
262273
advanced: AdvancedConfig {
263274
backend_mode: BackendMode::Auto,
@@ -411,6 +422,19 @@ fn migrate_in_place(cfg: &mut PersistentConfig) {
411422
}
412423
cfg.schema_version = 6;
413424
}
425+
// v6 -> v7: introduced `context.default_max_tokens` so the OpenAI-compat
426+
// server-side default for chat/completion `max_tokens` (when the client
427+
// omits the field) is user-configurable instead of hardcoded to 2048.
428+
// Existing configs that serialized without the field deserialize as 0
429+
// (serde's u32 default) — that would silently mean "unbounded" and is a
430+
// behaviour change from v6. Stamp the legacy 2048 default explicitly so
431+
// upgraders see no surprise.
432+
if cfg.schema_version < 7 {
433+
if cfg.context.default_max_tokens == 0 {
434+
cfg.context.default_max_tokens = 2048;
435+
}
436+
cfg.schema_version = 7;
437+
}
414438
// Future migrations append here.
415439
}
416440

crates/lumen-app/src/models.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,31 @@ fn read_sha_marker(model_dir: &Path) -> Option<String> {
7777
/// actual files under `snapshots/<commit_sha>/` (symlinks to `blobs/`).
7878
/// `id` = `<org>/<repo>`.
7979
///
80+
/// HuggingFace Hub repo id grammar: `<org>/<repo>` where each segment is
81+
/// `[A-Za-z0-9._-]+`. Rejects empty parts, missing slash, multiple slashes,
82+
/// and any character HF wouldn't accept in a repo URL — notably whitespace
83+
/// (Finder's "Item 2" duplicate convention) which would 401 every update
84+
/// check and never resolve to a real Hub URL.
85+
fn is_valid_hf_repo_id(id: &str) -> bool {
86+
let mut parts = id.split('/');
87+
let (Some(org), Some(repo), None) = (parts.next(), parts.next(), parts.next()) else {
88+
return false;
89+
};
90+
!org.is_empty() && !repo.is_empty() && is_hf_segment(org) && is_hf_segment(repo)
91+
}
92+
93+
/// Plain flat (no-org) local-only model directory name. Same character class
94+
/// as an HF segment — no whitespace, no slash. Skips Finder duplicates and
95+
/// scratch dirs that would otherwise surface as bogus model entries.
96+
fn is_valid_flat_id(name: &str) -> bool {
97+
!name.is_empty() && is_hf_segment(name)
98+
}
99+
100+
fn is_hf_segment(s: &str) -> bool {
101+
s.chars()
102+
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
103+
}
104+
80105
/// `catalog` is consulted to set the `supported` / `label` flags.
81106
pub fn scan_local(models_dir: &Path, catalog: &Catalog) -> Result<Vec<ModelEntry>> {
82107
if !models_dir.exists() {
@@ -100,6 +125,9 @@ pub fn scan_local(models_dir: &Path, catalog: &Catalog) -> Result<Vec<ModelEntry
100125
let (id, scan_dir) = if let Some(rest) = name.strip_prefix("models--") {
101126
// hf-hub cache layout: `models--<org>--<repo>/snapshots/<sha>/<files>`
102127
let id = rest.replacen("--", "/", 1);
128+
if !is_valid_hf_repo_id(&id) {
129+
continue;
130+
}
103131
let snapshot = resolve_hf_snapshot(&path);
104132
match snapshot {
105133
Some(snap) => (id, snap),
@@ -111,9 +139,22 @@ pub fn scan_local(models_dir: &Path, catalog: &Catalog) -> Result<Vec<ModelEntry
111139
// canonical `<org>/<repo>` id so catalog matching works the
112140
// same as for hf-hub-style caches.
113141
let id = name.replacen("--", "/", 1);
142+
// Strict HF naming check filters Finder duplicates (`… 2`),
143+
// .DS_Store siblings, and anything else that wouldn't round-trip
144+
// to a valid Hub URL. Avoids spamming HF API with 401s on the
145+
// periodic update check.
146+
if !is_valid_hf_repo_id(&id) {
147+
continue;
148+
}
114149
(id, path.clone())
115150
} else {
116151
// Plain flat (README convention, no org prefix in dir name).
152+
// Skip names with whitespace or other non-HF-id characters —
153+
// these are almost always Finder duplicates or scratch dirs,
154+
// never real local-only models.
155+
if !is_valid_flat_id(&name) {
156+
continue;
157+
}
117158
(name, path.clone())
118159
};
119160

crates/lumen-app/src/server.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,13 @@ fn apply_env(
739739
if cfg.context.sliding > 0 {
740740
cmd.env("LUMEN_SLIDING_WINDOW", cfg.context.sliding.to_string());
741741
}
742+
// Default `max_tokens` budget when the API client omits the field on
743+
// /v1/chat/completions or /v1/completions. `0` is forwarded as "unbounded
744+
// — generate until EOS / stop / context budget".
745+
cmd.env(
746+
"LUMEN_DEFAULT_MAX_TOKENS",
747+
cfg.context.default_max_tokens.to_string(),
748+
);
742749

743750
// ── Advanced ───────────────────────────────────────────────────
744751
match cfg.advanced.backend_mode {
@@ -833,6 +840,7 @@ pub const TYPED_ENV_KEYS: &[&str] = &[
833840
"LUMEN_MAX_CTX",
834841
"LUMEN_SLIDING_WINDOW",
835842
"LUMEN_PREFILL_CHUNK",
843+
"LUMEN_DEFAULT_MAX_TOKENS",
836844
"LUMEN_WIRED_LIMIT_BYTES",
837845
"LUMEN_MLX_BACKEND",
838846
"LUMEN_SPEC",

0 commit comments

Comments
 (0)