Compare commits
44
Commits
0ad332bb4a
...
7b5fd91d3c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b5fd91d3c | ||
|
|
872c2c562f | ||
|
|
07743c6aff | ||
|
|
d6dfd61c91 | ||
|
|
33433e0d1e | ||
|
|
47ec3d1a97 | ||
|
|
c9943b1507 | ||
|
|
9d70100867 | ||
|
|
c507db9ac0 | ||
|
|
9d0e628643 | ||
|
|
668e590e7d | ||
|
|
5415fd4b30 | ||
|
|
019ccff7e8 | ||
|
|
14ff4a3f57 | ||
|
|
8d6a9390de | ||
|
|
3446367d5e | ||
|
|
1bd90eaacc | ||
|
|
24e8826219 | ||
|
|
f509668e45 | ||
|
|
27155c0f3b | ||
|
|
850e0c3351 | ||
|
|
35adc4a043 | ||
|
|
39da1d4a97 | ||
|
|
f6f2f69649 | ||
|
|
32349b7653 | ||
|
|
d419b11d43 | ||
|
|
bd209951ac | ||
|
|
d127e29fac | ||
|
|
4e83395ddf | ||
|
|
ffb7fba346 | ||
|
|
41091eef8f | ||
|
|
6217d3993e | ||
|
|
efddb4e511 | ||
|
|
22ae9cd480 | ||
|
|
f39b66d2e1 | ||
|
|
7bc9754e40 | ||
|
|
6edebe4864 | ||
|
|
062215e81a | ||
|
|
8ecffa1fab | ||
|
|
d42e9d8712 | ||
|
|
cf0cb2cbb3 | ||
|
|
e41d19f1cb | ||
|
|
5af362e9d0 | ||
|
|
b6340519bc |
@@ -62,6 +62,23 @@ repeats the playbook, you are re-litigating — record the delta in the playbook
|
||||
instead. When a playbook claim turns out wrong, don't just fix it: add a dated
|
||||
row to its superseded-claims table so old docs stop misleading people.
|
||||
|
||||
## Training throughput
|
||||
|
||||
Same contract as quantization, different subject: **`docs/pfi/training-throughput-playbook.md`
|
||||
is the durable home** for why a training run is slow — the 10-minute scaling
|
||||
triage that names the regime before you profile, the padding/masking landmines,
|
||||
the profiler traps, and its own superseded-claims table. Read it before
|
||||
hypothesising about kernels.
|
||||
|
||||
The instruments are committed at [`scripts/training-probes/`](scripts/training-probes/)
|
||||
with raw output kept alongside, so the claims can be re-derived rather than
|
||||
taken on faith.
|
||||
|
||||
⚠ **Measure before you argue.** The playbook exists because a four-model
|
||||
frontier panel produced four self-retractions in ninety minutes on this
|
||||
question, and every one of them was a derivation while every survivor was a
|
||||
measurement.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Inventory of servers and their state
|
||||
@@ -98,15 +115,32 @@ Observed and standardized across servers:
|
||||
- **Named volumes** for service state (pattern: `<stack>_<name>`)
|
||||
- **Bind mounts** only for: model files (`/tank/aimodels/...`), config files (`/opt/docker/conf/...`), docker socket where required
|
||||
- **Restart policy:** `restart: unless-stopped` for daemons
|
||||
- **Homepage labels** on user-facing services:
|
||||
- **Homepage labels** on user-facing services. The dashboard runs on
|
||||
`esh-docker-vm` and reads the Docker API of **every** host in
|
||||
`stacks/homepage/conf/docker.yaml` (ana-docker, ana-ml2, nh3-docker,
|
||||
irv-ml1, esh-docker-vm), so a labelled container is discovered from
|
||||
wherever it runs — you do not add it to `services.yaml` as well. Doing both
|
||||
renders it twice.
|
||||
```yaml
|
||||
labels:
|
||||
- homepage.group=AI Systems
|
||||
- homepage.group=<ExistingGroup>
|
||||
- homepage.name=<ServiceName>
|
||||
- homepage.icon=mdi-<icon>
|
||||
- homepage.description=<short>
|
||||
- homepage.href=http://<host-ip>:<port>
|
||||
```
|
||||
⚠ **`homepage.group` must name a group that already exists in
|
||||
`stacks/homepage/conf/settings.yaml`'s `layout:` block.** A group the layout
|
||||
has never heard of gets no `tab:`, and Homepage renders an untabbed group on
|
||||
**all four tabs**. Inventing a group name here is how Scriberr's
|
||||
`AI Systems` ended up repeated at the bottom of every tab from 2026-08-23
|
||||
(fixed 2026-08-24). If the service genuinely needs a new group, add the group
|
||||
to `layout:` **with a `tab:`** in the same change.
|
||||
Check with `curl -s http://10.0.50.45:5100/api/services | jq -r '.[].name'` —
|
||||
anything in that list that is not a key in `layout:` is leaking onto all
|
||||
tabs right now.
|
||||
Labels only apply at container **creation**, so a label edit needs
|
||||
`docker compose up -d <service>`, not `restart`.
|
||||
- **Healthchecks** on services that expose HTTP
|
||||
|
||||
## Servers
|
||||
|
||||
@@ -736,6 +736,21 @@ _Entries moved out of persistent-memory.md to keep the active file scannable. Re
|
||||
impl deferred. `services/lora-training-worker/`, commits 888ba6a→b617a8b. `reference_lora_training_worker`.
|
||||
_Archived 2026-07-13._
|
||||
|
||||
- `[2026-08-08]` **worldtree-dev #400 CLOSED → fiction-decomp snapshot cleared from nh3-dev.** worldtree-dev signaled #400 done (shipped v1.0.0b185; exact-lexical efficacy 79%→12% on ratatoskr's gate, brokkr no-harm bracket green both ends; the snapshot served 4 probe rounds — rank decomposition, promoted-vs-gold annotation, tie-set falsification, A0/A1/A2 mechanism probe). Cleared `~/snapshots/worldtree-400-fiction-decomp` (208M: chroma + manifest/provenance/stamp) — a read-only rsync copy of PERSONAL Worldtree's Chroma (source on corviduo-dev, so safe to remove). **LEFT INTACT:** `rex393-fiction-index`/`rex393-fiction-snapshot` (separate operator KEEP word, unchanged) + `r42-gate-*`. No config deltas rode this train. Only remaining non-blocking await = ratatoskr-dev's chatterbox-fast knob revert. Replied confirming (`01KZJ9GMCC…`).
|
||||
_Archived 2026-08-24._
|
||||
|
||||
- `[2026-08-07]` **chatterbox-fast "broken audio" root-caused (T3 AR tail over-run) + FIXED (max_chunk_chars=250 cap, :v2 deployed).** Long saga, operator-driven clean diagnosis. **Symptom:** ratatoskr's migrated RP-surface TTS "swaps to German" / "dead air" / "garbage" on long turns. **NOT** German-leak (Turbo `generate()` has NO language param — plain AutoTokenizer, no `language_id`; the multilingual `language_id="en"` lever lives only in the separate `ChatterboxMultilingualTTS`), **NOT** OOM alone. **Real cause:** the Chatterbox **Turbo T3 model OVER-RUNS its generation tail** — a long single `generate()` degrades into garble/dead-air in its final ~2-3s (lib filters OOV tokens `<6561` + pads silence = messy AR tail). The scheduler's buffer-ratchet builds 300-600 char mega-chunks that land in that zone; streaming concatenates each bad tail (worst case). **ratatoskr's anti-"German" knobs (top_k=80/temp=0.5) made it WORSE** — tight sampling pulls the degradation onset SHORTER (~200 chars vs ~300 at default knobs). **Diagnosis method** (deterministic, no ears-only): single-shot length sweep + **amplitude-gated voiced-ZCR** (garble spikes ZCR; must gate on |x|>500 else trailing silence confounds it) — degraded voiced-tail = 1.58× mid, clean = ~0.64-1.1×. **FIX:** server-side `max_chunk_chars=250` cap on the scheduler (`:v2` image, `CBF_MAX_CHUNK_CHARS=250` env) — bounds each generation to just under the ~300-char onset → clean **3-4 sentence** chunks (max prosodic arc while clean). Operator ear-confirmed clean audio + clean joins; **chatterbox's low emotiveness keeps chunk joins smooth** (the harsh joins that got Zonos rejected are absent — operator's key call). **ratatoskr TODO (relayed msg `01KZER9X7S`):** revert knobs to default (top_k→1000, temp→0.8), send full text (server chunks internally), keep the 503-on-empty guard. **Cap value tunable** per-request (`max_chunk_chars`) + env. **Deeper prosody** (if ever wanted) = scheduler Phase-2 context-priming at joins (feed prior sentence as discarded-audio context; +latency). **⚠ FOOT-GUNS:** (1) acoustic tail-trim is UNRELIABLE — sibilants ('s'/'sh'/'f') spike ZCR like garble, can't cleanly detect the speech→garble boundary. (2) **build-context vs image drift** — the `:v2` image was built from cap source, but after a `:v1` rollback the build context held `:v1` source → a `docker compose build` would've silently produced a cap-less `:v2`; re-synced the flat cap source to `/opt/docker/compose/chatterbox-fast/` (rebuild-verified). **⚠ DIVERGENCE (follow-up):** deployed build context is FLAT (`app.py`/`scheduler.py`, `from scheduler import`, thin-overlay `FROM local/chatterbox:v1`, cap-only) vs the `vh/chatterbox-fast` REPO which is PACKAGE-layout (`chatterbox_fast/`, `from chatterbox_fast.scheduler`, self-contained Dockerfile) + has `norm_loudness` (repo commit `6bc7bf0` = cap; deployed omits norm_loudness deliberately to keep the ear-test unconfounded). Reconcile the two layouts so a repo-based rebuild matches deploy. Rollback: `.bak-cap-20260807-104850` backups on irv-ml1 + `:v1` image both retained. [[reference_chatterbox_fast_repo]] [[reference_zonos_tts_stack]]
|
||||
_Archived 2026-08-24._
|
||||
|
||||
- `[2026-08-07]` **Zonos2 TAKEN DOWN on the 3090 (irv-ml1) — operator-directed "for memory", TEMPORARY.** Freed ~17.4 GB (3090: 728 MiB → 18.2 GB free) so chatterbox-fast (co-resident, was OOMing on long generations) has headroom. **⚠ Restore is manual — Zonos2 :1920 was a DETACHED native process (NOT systemd/docker), reparented to init.** GPU memory was held by the `--multiprocessing-fork` CHILDREN (1966165=16.4G, 1966166=1G), which ORPHAN to init when you kill the parent — had to SIGTERM the children explicitly (killing the parent 1965942 + uv-run 1965935 alone left the 16.4G held). **RESTORE CMD** (from irv-ml1, user lkraven): `cd /home/lkraven/tts-audition/models/zonos2 && nohup uv run python -m zonos2 --model-path Zyphra/ZONOS2 --host 0.0.0.0 --port 1920 --tts-default-voices-dir ./default_voices/ --cuda-graph-max-bs 1 --num-pages 16384 --max-running-requests 2 --memory-ratio 0.3 > /tmp/zonos2.log 2>&1 &` then `docker start zonos-gateway`. **Consumers that lost Zonos:** asset-engine + gateway-chat (via LiteLLM `ext-tts` alias → zonos-gateway :8890, now stopped); ratatoskr already migrated OFF to chatterbox-fast (unaffected). Also unblocks proper drift/cap testing (OOM was blocking it). [[reference_zonos_tts_stack]]
|
||||
_Archived 2026-08-24._
|
||||
|
||||
- `[2026-08-07]` **chatterbox-fast: donut voice added + full contract delivered to ratatoskr-dev (their TTS migration off Zonos).** Operator-directed. Copied `zonos-gateway/voices/Donut.wav` → chatterbox `/refs` (`/worktank/chatterbox/reference_audio/donut.wav` — the reference_audio SUBDIR is lkraven-owned so no sudo despite `/worktank` root; container globs `/refs` live → **NO restart**), exposed as `voice:"donut"` (lowercase); verified clean 7.5s synth (24kHz, RTF ~0.31). A/B booth (chatterbox vs zonos donut, same line) at `http://10.100.10.50:8090/b/donut-chatterbox/`. Answered ratatoskr's 8-question contract ask from the live gateway (`local/chatterbox-fast:v1`) + source: **NOT OpenAI-shaped** (`POST /tts`; body `text`/`voice`/`format`/`stream`, not `input`/`model`/`response_format`); **NO affect dials** (Turbo ignores cfg_weight/min_p/exaggeration — the architecture-changing answer they flagged; **Zonos stays the only fleet TTS with real emotion steering**); streaming WAV placeholder-header shape IDENTICAL to Zonos (their per-chunk Web Audio path survives); SR 24000 (Zonos 44100); server chunks arbitrary-length text internally (no client-side chunking, unlike Zonos's 71.2s cap); English-only, no language pin. **FYI-worthy (operator):** ratatoskr is moving its RP-surface TTS OFF Zonos back to chatterbox-fast → loses the live-PAD affect coupling (heavy Zonos emotion investment) — their call, trade-off flagged to them. auto-memory `reference_chatterbox_fast_repo` enriched w/ the live contract. [[reference_zonos_tts_stack]]
|
||||
_Archived 2026-08-24._
|
||||
|
||||
- `[2026-08-07]` **Fleet reranker cut over: Qwen3-Reranker-0.6B → BAAI/bge-reranker-v2-m3 (Brokkr R43).** The incumbent was measured HARMING 80/90 fleet queries (no-reranker beat it 89/90 vs 56/90). R43 bake-off: the A2 control (same Qwen weights, seq-cls head) scored identical to the incumbent → proved the fault is a training-prior not the serving head → cancelled the expensive Qwen3-4B arm; A3 (bge-v2-m3) won on multilingual safety + bare-name recovery. LiteLLM `reranker` repointed incumbent→A3 :8013 (boundary 2026-08-06T17:37:48Z, config-edit + ~52s gateway restart); **R42 v13 gate PASSED first-ever** (56/90→90/90). Incumbent kept warm :8002 (rollback via `qwen3-reranker` alias), A4 fallback :8014. Full arc + rollback runbook `docs/pfi/reranker-selection-ledger.md`; commits ad2df89/2c11748/377f8a4 (unpushed). auto-memories: the earlier reranker-serving notes.
|
||||
_Archived 2026-08-24._
|
||||
|
||||
## Tried and abandoned (archived)
|
||||
|
||||
- `[2026-04-30]` task-board workflow with
|
||||
|
||||
@@ -108,3 +108,4 @@ aliases:
|
||||
- {name: gateway, site: ana, target: ana-docker, note: LiteLLM gateway :4000}
|
||||
- {name: booth, site: nh3, target: nh3-dev, note: The Booth :8090}
|
||||
- {name: homepage, site: esh, target: esh-docker-vm, note: fleet dashboard :5100}
|
||||
- {name: scriberr, site: ana, target: ana-ml2, note: transcription + diarization :8080 (GPU1)}
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
# Gemma-4 26B-A4B ERP/RP tune — GPU sizing adjudication
|
||||
|
||||
_Measured 2026-08-24 on `ana-ml2` against
|
||||
`/tank/aimodels/gemma4-26b-a4b-it-heretic-bf16` (llmfan46 abliterated trainee)._
|
||||
|
||||
Division of labour for this run: **Eitri writes the harness, brokkr-smithy-dev
|
||||
audits, infra-ops owns the GPU window and executes.** This document is the
|
||||
sizing infra-ops owes; it is arithmetic against the real checkpoint and the
|
||||
real card, not an estimate.
|
||||
|
||||
---
|
||||
|
||||
## 1. ⚠ QLoRA IS NOT AVAILABLE ON THIS ARCHITECTURE
|
||||
|
||||
**The proposed shape was QLoRA r64. It cannot be run as specified**, and the
|
||||
reason is structural rather than a tuning preference.
|
||||
|
||||
The checkpoint stores each layer's 128 experts as **two fused 3-D
|
||||
`nn.Parameter` tensors**, not as 128 `nn.Linear` modules:
|
||||
|
||||
model.language_model.layers.N.experts.gate_up_proj BF16 [128, 1408, 2816]
|
||||
model.language_model.layers.N.experts.down_proj BF16 [128, 2816, 704]
|
||||
|
||||
Note the absence of a `.weight` suffix — compare `mlp.down_proj.weight`
|
||||
(an `nn.Linear`) against `experts.down_proj` (a bare parameter). That is the
|
||||
tell, and it is decisive: **`bitsandbytes` 4-bit replacement walks `nn.Linear`
|
||||
modules.** A fused 3-D parameter is not one, so it is skipped and stays BF16.
|
||||
|
||||
What `load_in_4bit=True` would actually buy on this model:
|
||||
|
||||
| block | params | BF16 | after bnb NF4 | saved |
|
||||
|---|---:|---:|---:|---:|
|
||||
| **MoE experts** (fused 3-D — **NOT quantized**) | 22.84 B | 42.54 GiB | **42.54 GiB** | **0** |
|
||||
| lm attention (`nn.Linear`) | 1.11 B | 2.07 GiB | 0.52 GiB | 1.55 |
|
||||
| dense shared MLP (`nn.Linear`) | 0.54 B | 1.00 GiB | 0.25 GiB | 0.75 |
|
||||
| vision tower (`nn.Linear`) | 0.57 B | 1.06 GiB | 0.27 GiB | 0.79 |
|
||||
| embed (tied, normally kept BF16) | 0.74 B | 1.38 GiB | 1.38 GiB | 0 |
|
||||
| router + norms | 0.01 B | 0.02 GiB | 0.02 GiB | 0 |
|
||||
| **total** | **25.81 B** | **48.07 GiB** | **~44.98 GiB** | **~3.1 GiB** |
|
||||
|
||||
**88.5% of the model is in tensors bitsandbytes cannot touch.** "QLoRA" here
|
||||
means paying the NF4 dequant tax on 6% of the weights to save 6% of the
|
||||
footprint. The premise does not survive contact with the checkpoint.
|
||||
|
||||
> **Eitri: do not hard-code a `BitsAndBytesConfig` / `load_in_4bit` path.**
|
||||
> It will not error loudly — it will load, report a 4-bit model, and quietly
|
||||
> leave 42.5 GiB in BF16. Same silent-failure shape as the stale chat template.
|
||||
|
||||
**The one thing that could overturn this** is a third-party fork shipping
|
||||
custom grouped-GEMM 4-bit MoE kernels for this specific architecture (Unsloth
|
||||
is the candidate). **Not chased, deliberately** — see §4, where the run fits in
|
||||
BF16 without displacing anything the fleet depends on, which collapses QLoRA's
|
||||
value to zero. If it is ever revisited, it must be *before* the harness
|
||||
hard-codes a quantization path, not after.
|
||||
|
||||
**Verdict: plain LoRA on BF16 weights.**
|
||||
|
||||
---
|
||||
|
||||
## 2. What the run actually costs
|
||||
|
||||
Adapter targeting `q_proj,k_proj,v_proj,o_proj` at r64, computed from the real
|
||||
tensor shapes:
|
||||
|
||||
| | layers | per layer | total |
|
||||
|---|---:|---:|---:|
|
||||
| sliding-attention (q 4096, kv 2048, o 4096) | 25 | 1,507,328 | 37,683,200 |
|
||||
| full-attention (q 8192, kv 1024, o 8192) | 5 | 1,654,784 | 8,273,920 |
|
||||
| **trainable** | | | **45,957,120** (0.178% of base) |
|
||||
|
||||
⚠ **`v_proj` DOES NOT EXIST ON LAYERS 5, 11, 17, 23, 29.** Those are the
|
||||
`full_attention` layers, and `attention_k_eq_v: true` means one projection
|
||||
serves both K and V. Consequences the harness must respect:
|
||||
|
||||
- PEFT matches by name suffix, so a `v_proj` target **silently produces no
|
||||
adapter** on those five layers. Do not assert a fixed adapter count.
|
||||
- Adapting `k_proj` on a global layer **adapts K and V simultaneously** — a
|
||||
different intervention than on the sliding layers. If that asymmetry matters
|
||||
to the recipe, say so explicitly rather than discovering it in the loss curve.
|
||||
|
||||
### Memory budget, batch 1, `max_seq_len` 8192
|
||||
|
||||
| item | GiB | note |
|
||||
|---|---:|---|
|
||||
| base weights BF16 | 48.07 | measured: 25,805,936,206 params × 2 B |
|
||||
| adapters + grads + AdamW fp32 m/v | 0.75 | 45.96 M trainable — rounding error |
|
||||
| checkpointed layer inputs | 1.29 | 30 × 8192 × 2816 × 2 B |
|
||||
| recompute peak, one layer | ~2.5 | 8192 tok × top-8 of 128, `moe_intermediate 704` |
|
||||
| loss head, **fused/chunked CE** | ~2.0 | see the warning below |
|
||||
| CUDA context + cuBLAS + fragmentation | ~3.0 | the item `--gpu-memory-utilization` never covered |
|
||||
| **total** | **~57.6** | |
|
||||
|
||||
Marginal cost per extra sequence in the micro-batch: **~2.5 GiB.**
|
||||
|
||||
| micro-batch | GiB |
|
||||
|---:|---:|
|
||||
| 1 | 54.3 |
|
||||
| 2 | 56.8 |
|
||||
| **4** | **61.8** |
|
||||
| 6 | 66.8 |
|
||||
| 8 | 71.8 |
|
||||
|
||||
### ⚠ The loss head is the whole ballgame, and it is not in the brief
|
||||
|
||||
`vocab_size` is **262,144** and `final_logit_softcapping` is **30.0**. One
|
||||
8192-token sequence produces **2.147 billion logits**. Through a naive HF
|
||||
`ForCausalLM` loss that is:
|
||||
|
||||
BF16 logits 4.0 GiB
|
||||
fp32 upcast 8.0 GiB
|
||||
softcap tanh saved 8.0 GiB (autograd keeps the pre-cap tensor)
|
||||
softmax + grad 8.0 GiB
|
||||
------------------------------
|
||||
~28-30 GiB transient, at BATCH 1
|
||||
|
||||
Naive CE at batch 1 lands the run at **~85.6 GiB on a 95.6 GiB card** — it will
|
||||
appear to work and then OOM on the first long sample. At micro-batch 4 it is
|
||||
~120 GiB and never starts. **Fused/chunked linear cross-entropy is mandatory,
|
||||
not an optimization.**
|
||||
|
||||
⚠ Honest uncertainty: Liger ships per-architecture patches and Gemma-4 MoE with
|
||||
softcapping may not have one. Three ways out, in order of preference —
|
||||
(a) generic `LigerFusedLinearCrossEntropyLoss` wired against the lm_head with
|
||||
softcapping applied inside the chunk; (b) `cut-cross-entropy`; (c) hand-rolled
|
||||
sequence-chunked CE. **This must be proven on a 10-step smoke run before the
|
||||
window is booked**, because everything else in this document assumes it works.
|
||||
|
||||
### Step count
|
||||
|
||||
58.2 M tokens / 20,576 samples = **2,829 tokens/sample average** — well under
|
||||
8192, so packing matters.
|
||||
|
||||
- Packed to 8192: **7,104 sequences.** At micro-batch 4 × grad-accum 4
|
||||
(effective 16) → **444 optimizer steps for the whole epoch.**
|
||||
- ⚠ That is a *small* step count. A "checkpoint every 100 steps" default gives
|
||||
four checkpoints across a multi-hour run. This is exactly why the amendment
|
||||
asked for **wall-clock-interval checkpointing, not step-count** — the case is
|
||||
now concrete, not hypothetical.
|
||||
- ⚠ **Packing must use `position_ids` + varlen/block-diagonal attention.** Naive
|
||||
concatenation bleeds samples into each other. `sliding_window` is 1024 on 25
|
||||
of 30 layers so the damage is bounded there — but the 5 `full_attention`
|
||||
layers see the entire packed sequence.
|
||||
|
||||
**Open question for brokkr/Eitri:** what fraction of the 20,576 samples exceed
|
||||
8192 tokens? Below ~2%, 8192 is right. A long tail means truncation is cutting
|
||||
the ends off RP scenes, which is where the signal lives.
|
||||
|
||||
### Runtime
|
||||
|
||||
Active parameters per token ≈ **3.67 B** (2.93 B routed + attention, plus the
|
||||
0.74 B tied lm_head matmul). Forward + backward + gradient-checkpoint recompute
|
||||
≈ 6 × active × tokens = **1.28e18 FLOPs** for the epoch.
|
||||
|
||||
At 10–25% MFU on a 300 W-capped Max-Q card — HF MoE paths with 704-wide experts
|
||||
are not efficient — **4 to 10 hours, most likely ~6.** Treat as a band, not a
|
||||
number; it will be measured on the smoke run.
|
||||
|
||||
---
|
||||
|
||||
## 3. Where it fits (measured 2026-08-24, 18:20 PDT)
|
||||
|
||||
Card total: 97,887 MiB = **95.60 GiB** each.
|
||||
|
||||
| | GPU0 | GPU1 |
|
||||
|---|---|---|
|
||||
| resident before the window | `vllm-gen` 42,508 MiB (up 3 h) | `vllm-mog-sec` 56,624 MiB + `embed` 3,304 + `reward` 9,512 + `coder` 6,158 + `rerank-a3` 2,170; Scriberr pinned here, loads on demand |
|
||||
| free | 54,741 MiB = **53.46 GiB** | 19,446 MiB = **18.99 GiB** |
|
||||
|
||||
Three placements were on the table:
|
||||
|
||||
- **GPU0 beside `gen`: does not fit.** 53.46 GiB free against ~57.6 GiB needed —
|
||||
short by ~4 GiB. And `gen` is only three hours old: measured footprint runs
|
||||
38.5 GiB fresh → 42.5 GiB at 3 h → 45.6 GiB at 3 days. Budgeting against the
|
||||
current number is budgeting against a moving one.
|
||||
- **GPU1 with `mog-sec` stopped: 76,070 MiB free.** Fits, but shares a card with
|
||||
four small seats and Scriberr.
|
||||
- **GPU0 with `gen` MOVED OFF: the whole card.** ← what was chosen.
|
||||
|
||||
---
|
||||
|
||||
## 4. The window, as executed
|
||||
|
||||
**Operator call, 2026-08-24: move `gen` to GPU1 and stand `sec` down, so GPU0 is
|
||||
emptied completely rather than shared.** This is strictly better than training
|
||||
beside `gen`: the tune gets 95.60 GiB with no co-tenant, and the fleet's general
|
||||
seat never goes dark beyond its own ~5-minute restart.
|
||||
|
||||
before: GPU0 [ gen 42.5 ] GPU1 [ sec 55.3 | small seats 20.7 ]
|
||||
after: GPU0 [ ---- empty, 95.60 GiB ---- ] GPU1 [ gen ~41 | small seats 20.7 | ~33 free ]
|
||||
|
||||
`sec` is genuinely in use and this is not free — but it is the smaller blast
|
||||
radius by a wide margin:
|
||||
|
||||
| | `gen` | `sec` |
|
||||
|---|---|---|
|
||||
| aliases | 7 (`gen`, `gen-reasoning`, `chat-judge`, `image-judge`, summarizer/classifier family) | 2 (`sec`, `sec-reasoning`) |
|
||||
| standing role | the fleet's general seat; a documented always-available dependency in global `CLAUDE.md` | M.O.G.-SEC, niche |
|
||||
| measured traffic | 765 busy-engine log lines in 24 h — continuously in use | bursty; peak 8 concurrent, **last request ~5 h ago** |
|
||||
|
||||
Traffic to `sec` arrives from `10.250.50.70` (the LiteLLM gateway), so the
|
||||
aliases will fail at the gateway for the duration. Per the standing rule, let
|
||||
them fail — **do not route `sec` to another model as a stand-in.**
|
||||
|
||||
Both directions are playbooks, and **the order in each is load-bearing**:
|
||||
|
||||
scripts/elway infra-ops@10.250.50.54 --playbook playbooks/ana-ml2-training-window-open.yaml
|
||||
scripts/elway infra-ops@10.250.50.54 --playbook playbooks/ana-ml2-training-window-close.yaml
|
||||
|
||||
⚠ `gen` runs at `--gpu-memory-utilization 0.43`, which vLLM reads as a fraction
|
||||
of **total** card memory: 42,091 MiB must be *free at startup* or the engine
|
||||
refuses to boot. GPU1 has 19,446 MiB free while `mog-sec` is up. **Recreating
|
||||
`gen` onto GPU1 before stopping `mog-sec` takes the fleet's main seat down and
|
||||
leaves it down.** The open playbook stops `mog-sec` first and hard-gates on the
|
||||
freed memory; the close playbook mirrors it, because `mog-sec` needs 50,901 MiB
|
||||
of its own and cannot start until `gen` has vacated GPU1.
|
||||
|
||||
⚠ Invoke elway as `infra-ops@10.250.50.54`, not the `ana-ml2` ssh-target — that
|
||||
resolves to `lkraven`, which has no NOPASSWD sudo, and elway aborts at its sudo
|
||||
probe.
|
||||
|
||||
### ⚠ MEASURED 2026-08-24 — the estimates below this line were ~3× optimistic
|
||||
|
||||
Everything above was arithmetic. This was run on the real checkpoint on GPU0
|
||||
with synthetic tokens (`/tank/erp-tune/smoke_ce.py`), and it moves the answer:
|
||||
|
||||
| config | peak | verdict |
|
||||
|---|---:|---|
|
||||
| naive CE, bsz1 seq 8192 | **81.93 GiB** | fits, ~14 GiB spare |
|
||||
| naive CE, bsz1 seq 16384 | **OOM** | tried to allocate 16.00 GiB |
|
||||
| chunked CE, bsz1 seq 16384 | **65.66 GiB** | ✅ |
|
||||
| **chunked CE, bsz2 seq 16384** | **79.71 GiB** | ✅ **the run config** |
|
||||
| chunked CE, bsz4 seq 16384 | **OOM** | — |
|
||||
|
||||
**The marginal cost of an extra 16,384-token sequence is ~14 GiB, not the ~5 GiB
|
||||
estimated.** The estimate modelled gradient checkpointing as storing only layer
|
||||
inputs plus a modest recompute peak; the real MoE recompute peak (8,192+ tokens ×
|
||||
top-8 of 128 experts, plus scatter/gather buffers) is far heavier. **Do not size
|
||||
an MoE run from dense-model intuition — measure it.**
|
||||
|
||||
Two predictions did land exactly, which is why the rest of the model of the thing
|
||||
is trustworthy: **205 target modules** (q30/k30/v25/o30/gate30/up30/down30) and
|
||||
**74,342,400 trainable params** at r64.
|
||||
|
||||
The headline: **chunked CE at seq 16384 costs 16 GiB LESS than naive CE at seq
|
||||
8192.** Chunking is not an optimisation, it is what makes brokkr's 16384
|
||||
recommendation reachable at all.
|
||||
|
||||
Base load peak: **49,221 MiB**, confirming the 48.07 GiB weight figure.
|
||||
|
||||
### Revised run parameters, now that it is a whole card
|
||||
|
||||
**FINAL, measured: `max_seq_len` 16384, `per_device_batch_size` 2,
|
||||
`gradient_accumulation_steps` 8** → effective batch 16, **~1,280 optimizer
|
||||
steps**, 79.71 GiB of 95.60 with ~15.9 GiB clear.
|
||||
|
||||
`max_seq_len` went 8192 → 16384 on brokkr's truncation finding: at 8192 the cap
|
||||
drops **6.2% of samples but 22.4% of TOKENS** (61.2M → 47.5M), concentrated
|
||||
*entirely* in dialogue — 46% of c2-logs, 47.5% of pippa, 95.6% of bluemoon —
|
||||
which is 60% of the mix and the axis the seat exists for. Prose and fireball
|
||||
truncate at zero. p50 is 2,084 and p90 4,751, so the cost is the long tail only.
|
||||
|
||||
⚠ The 79.71 GiB figure is **worst case** — every sample in the micro-batch at the
|
||||
full cap. Samples are one-per-sequence padded to the batch max, so with p90 4,751
|
||||
the typical step sits far below it.
|
||||
|
||||
⚠ **Keep gradient checkpointing ON**, and keep `enable_input_require_grads()`
|
||||
with it. Dropping checkpointing looks like ~17% off wall-clock and instead
|
||||
forces micro-batch 1. Worse, the second call is the silent one: **without
|
||||
`enable_input_require_grads()` the frozen base produces no gradient through the
|
||||
checkpointed blocks, every adapter stays at its initialisation, and the run
|
||||
completes successfully with an inert adapter.** `prepare_model_for_kbit_training`
|
||||
used to do it as a side effect of the 4-bit path — so removing 4-bit removes it
|
||||
too, and nothing warns you.
|
||||
|
||||
### Harness changes this required (eitri-smithy `62b556b`)
|
||||
|
||||
`9d64257` as audited would not have run here. Four fixes:
|
||||
|
||||
1. `runtime.py` hardcoded `BitsAndBytesConfig(load_in_4bit=True)` — now a config
|
||||
key, defaulting off, per §1.
|
||||
2. Sequence-chunked CE replacing the model's own loss (the measured table above).
|
||||
3. `chat_template_path` — `apply_chat_template` resolved the checkpoint's own
|
||||
stale 365-line template and there was **no override parameter anywhere**, so
|
||||
the upstream-template requirement was not expressible in the code.
|
||||
4. Gradient checkpointing + `enable_input_require_grads()`.
|
||||
|
||||
Plus `training_eligibility_override` / `overridden_blockers` /
|
||||
`substitute_controls` in the provenance manifest, and `device_map` pinned to
|
||||
device 0 so the run cannot stray onto the card holding the inference seats.
|
||||
|
||||
Also fold in:
|
||||
|
||||
- **Scriberr STAYS on GPU1.** (An earlier draft of this document suggested moving
|
||||
it to GPU0; that was written when training was going to live on GPU1, and it is
|
||||
now exactly backwards. GPU0 is the training card and wants no co-tenant.)
|
||||
- **Package as a `uv` venv on `/tank`, not a Docker image.** Root is at **91%
|
||||
(36 GB free)** and `/var/lib/docker` lives on it; a PyTorch training image
|
||||
would come close to filling it. `/tank` has 4.0 TB.
|
||||
- **The run is still resumable-by-design** (INV-T7 + wall-clock checkpointing).
|
||||
Nothing about a dedicated card removes that requirement — a 4–10 hour window
|
||||
is long enough that an unresumable run is a bad bet regardless of who owns the
|
||||
GPU.
|
||||
|
||||
---
|
||||
|
||||
## 5. Standing warnings that apply to this run
|
||||
|
||||
- **Never render training examples through the base's own
|
||||
`chat_template.jinja`.** Every third-party Gemma-4 derivative ships a stale
|
||||
one; the trainee's is 365 lines against upstream's 390. Use
|
||||
`/tank/aimodels/gemma4-26b-a4b-it-bf16/chat_template.jinja`. Training through
|
||||
the wrong template is train/serve skew with no error — it presents as a
|
||||
tuning failure.
|
||||
- **Base path and chat-template path are config keys, not constants.** The
|
||||
trainee base already moved once (stock BF16 → `-heretic-bf16`).
|
||||
- **`--gpu-memory-utilization` sizes the KV cache only.** It does not cover CUDA
|
||||
context, graphs, or non-torch overhead — the same misreading that OOM'd the
|
||||
char-rp seat.
|
||||
- **Serving the result is not settled.** LoRA-on-NVFP4 hot-swap was a silent
|
||||
no-op on vLLM 0.24.0 (#47639, proven quant-agnostic). Retest on the tagged
|
||||
`vllm/vllm-openai:v0.27.1` already on disk. **If it still no-ops, the harness
|
||||
must emit merged weights** — and Eitri needs that requirement while he is
|
||||
early, not after the run.
|
||||
|
||||
---
|
||||
|
||||
## 6. Round-1 aborted; throughput root-caused (measured 2026-08-24 22:00 PDT)
|
||||
|
||||
Run-01 launched, reached step 19 of 1,312 at ~35–46 s/it, and was **killed by
|
||||
operator instruction** — not a crash, not an OOM. ETA was ~13.9 h at 8.6% MFU
|
||||
and the operator elected to root-cause before spending the window.
|
||||
|
||||
Nothing was destroyed: the 609 MB encode cache, `order-manifest.jsonl`,
|
||||
`truncation-report.json` and `resume-run-01.sh` are all preserved at
|
||||
`/tank/erp-tune/run-01/`. **There are no checkpoints** — the first was due at
|
||||
step 100, so brokkr's `lora_B` inert-adapter gate never ran. That question is
|
||||
open and moves to the restart.
|
||||
|
||||
Model-agnostic lessons from this investigation are in
|
||||
[`training-throughput-playbook.md`](training-throughput-playbook.md); the
|
||||
probes are at [`scripts/training-probes/`](../../scripts/training-probes/).
|
||||
What follows is Gemma-4-specific.
|
||||
|
||||
### 6.1 Where the step time goes
|
||||
|
||||
Real checkpoint, GPU0, `attn_implementation="sdpa"`, PEFT + gradient
|
||||
checkpointing + the chunked CE, fwd+bwd, best-of-2 after warmup:
|
||||
|
||||
| shape | time | peak |
|
||||
|---|---:|---:|
|
||||
| 2 × 2,048 | 1.776 s | 53.2 GiB |
|
||||
| 2 × 8,192 | 11.570 s | 62.3 GiB |
|
||||
| 2 × 16,384 | **35.017 s** | 76.6 GiB |
|
||||
|
||||
Fitting `t(w) = A·w + B·w²` over all three (per-sequence `w`, batch 2):
|
||||
|
||||
A = 6.8715e-04 s/token B = 8.8509e-08 s/token²
|
||||
|
||||
| w | predicted | measured | linear | quadratic | quad share |
|
||||
|---:|---:|---:|---:|---:|---:|
|
||||
| 2,048 | 1.779 | 1.776 | 1.407 | 0.371 | 20.9% |
|
||||
| 8,192 | 11.569 | 11.570 | 5.629 | 5.940 | 51.3% |
|
||||
| 16,384 | 35.017 | 35.017 | 11.258 | 23.759 | **67.8%** |
|
||||
|
||||
**Two terms, three points, residuals under 3 ms across an 8× range.** No fixed
|
||||
per-batch term was needed, which refutes the launch-bound hypothesis outright —
|
||||
~3,840 expert-GEMM launches per forward are not the cost.
|
||||
|
||||
Independently, the profiler kernel table (device rows only — see playbook §3.4):
|
||||
|
||||
| device kernel | ms | of step |
|
||||
|---|---:|---:|
|
||||
| `fmha_cutlassB_bf16_aligned_128x64_k65536_sm80` (attn BWD) | 16,144.6 | 46.1% |
|
||||
| `fmha_cutlassF_bf16_aligned_32x128_gmem_sm80` (attn FWD) | 6,691.2 | 19.1% |
|
||||
| `cutlass_80_tensorop_bf16_s16816gemm` ×3 (dense GEMM) | 2,774.0 | 7.9% |
|
||||
| elementwise / vectorized / unrolled ×9 | 4,787.4 | 13.7% |
|
||||
| gather / Memcpy DtoD / dropout | 951.6 | 2.7% |
|
||||
| **attention total** | **22,835.8** | **65.2%** |
|
||||
|
||||
**Scaling fit says 67.8% quadratic; kernel table says 65.2% attention. Two
|
||||
independent methods, 2.6 points apart.**
|
||||
|
||||
### 6.2 ⚠ The attention kernels are Ampere, on a Blackwell card
|
||||
|
||||
`fmha_cutlass*_sm80` on sm_120. There is no Blackwell-tuned attention kernel in
|
||||
this path at all, and the forward is additionally on `gmem` — the
|
||||
global-memory fallback tier of the memory-efficient backend, selected when the
|
||||
working set will not fit in shared memory.
|
||||
|
||||
This is the mechanism behind the 100%-SM / 27-TFLOPS / 304-TFLOPS-capable
|
||||
reading: the chip is saturated running a kernel generation behind on the
|
||||
dominant cost centre.
|
||||
|
||||
The candidate fix is a purpose-built kernel for this architecture's mixed
|
||||
256/512 head-dim split — `zzhhjjj/gemma-triton-flash-attn`
|
||||
(`register_triton_attention()`, then `_attn_implementation = "triton_gqa"`),
|
||||
reported 9.23× over SDPA at N=16K D=256 SWA and 2.94× fwd+bwd at D=512.
|
||||
`flex_attention` + `BlockMask` is the no-new-dependency alternative.
|
||||
|
||||
⚠ **Prefer a UNIFORM backend over a per-layer split.** vLLM special-cased this
|
||||
exact mixed-head-dim architecture and measured mixed backends **8% slower** than
|
||||
uniform. And `attn_implementation` is all-or-nothing at `from_pretrained` /
|
||||
`set_attn_implementation` — per-layer routing requires a custom function
|
||||
registered on `ALL_ATTENTION_FUNCTIONS` branching on `module.head_dim` /
|
||||
`sliding_window`.
|
||||
|
||||
⚠ **FA2 is not available for this model**: it caps head_dim at 256 and the 5
|
||||
global layers are at 512. FA3 is Hopper-only. Do not bet on FA4 on sm_120.
|
||||
|
||||
### 6.3 Masking is CORRECT — and padding is what costs
|
||||
|
||||
Band structure asserted directly against the real config at n=16,384:
|
||||
|
||||
sliding_attention max 1,024 allowed/row, saturates at row 1,023 PASS
|
||||
|
||||
Constraints were **not** silently dropped; the 25 sliding layers were genuinely
|
||||
windowed. Run-01 was training the model we intended.
|
||||
|
||||
The same probe found the mechanism nobody had measured:
|
||||
|
||||
| 2D mask supplied | `full_attention` mask returned |
|
||||
|---|---|
|
||||
| `None` | **`None`** → `is_causal` fast path AVAILABLE |
|
||||
| all-ones (no padding) | **`None`** → `is_causal` fast path AVAILABLE |
|
||||
| right-padded (what `collate_mixed` emits) | 4D `16384²` → **fast path LOST** |
|
||||
|
||||
**Padding is what pins the 5 global layers to an explicit mask.** The 25
|
||||
sliding layers get a 4D tensor either way — `sdpa_attention_forward` sets
|
||||
`is_causal=True` only when `attention_mask is None`, and a 1024 window cannot
|
||||
be expressed as `is_causal`.
|
||||
|
||||
Isolated, same width, only the mask differing:
|
||||
|
||||
2 × 16,384, no padding 35.244 s 26,048 loss targets
|
||||
2 × 16,384, 50% pad on row 1 38.567 s 19,640 loss targets
|
||||
|
||||
**9.4% slower for 24% less work.**
|
||||
|
||||
### 6.4 The corpus is 29.9% padding — and bucketing is the biggest win available
|
||||
|
||||
Measured off the preserved encode cache in true `SequentialSampler` order:
|
||||
|
||||
records 20,982 (3,583 rp-dialogue / 12,003 prose-chunk / 5,396 actual-play)
|
||||
seq len min/mean/max 142 / 2,752 / 16,384
|
||||
micro-batches (mb=2) 10,491
|
||||
real tokens 57,733,156
|
||||
padded tokens 82,337,318
|
||||
PADDING WASTE 29.9%
|
||||
mb width p50/p90/p99 2,092 / 10,634 / 16,341
|
||||
micro-batches at 16,384 3 of 10,491 (0.0%)
|
||||
|
||||
⚠ Note the last line against §6.1: **the 2 × 16,384 benchmark shape occurs in
|
||||
three micro-batches out of 10,491.** Weighted over the real distribution the
|
||||
quadratic share is ~51%, not 67.8%.
|
||||
|
||||
**Bucket-to-pair, shuffle-to-mix** (brokkr's design, validated on measured
|
||||
lengths — form micro-batches within length buckets, then shuffle the resulting
|
||||
*micro-batches* globally):
|
||||
|
||||
| bucket | waste | predicted step | zero-pad mb | roots/accum window |
|
||||
|---:|---:|---:|---:|---:|
|
||||
| current | 29.9% | 44.3 s → 16.13 h | 0.1% | 3.68 |
|
||||
| **2** | **0.0%** | **28.6 s → 10.40 h** | **78.3%** | 3.56 |
|
||||
| 8 | 0.0% | 28.6 s → 10.41 h | 65.3% | 3.54 |
|
||||
| 32 | 0.1% | 28.6 s → 10.43 h | 41.9% | 3.55 |
|
||||
| 128 | 0.6% | 28.8 s → 10.51 h | 14.7% | 3.55 |
|
||||
| 512 | 2.4% | 29.7 s → 10.82 h | 4.1% | 3.61 |
|
||||
|
||||
**≥35.5% wall clock, no kernel work, no new dependency, peak memory unchanged.**
|
||||
|
||||
Two findings that changed the design:
|
||||
|
||||
- **Bucket size is not a diversity knob.** Roots per accumulation window are
|
||||
flat at 3.54–3.61 across a 256× range. The global micro-batch shuffle does
|
||||
all the mixing. Use the tightest bucket.
|
||||
- **35.5% is a floor.** Zero-pad micro-batches go 0.1% → 78.3%, which puts the
|
||||
5 global layers back on `is_causal` for most of the run (§6.3). The cost
|
||||
model does not capture that. Direction certain, magnitude not yet measured at
|
||||
representative shapes.
|
||||
|
||||
⚠ **Source-homogeneity is a real hazard here** — length correlates hard with
|
||||
root (kvasir short, chunked RP windows long), so length-homogeneous batches are
|
||||
root-homogeneous batches. The global micro-batch shuffle is what prevents an
|
||||
accumulation window drawing its whole gradient from one source. It is
|
||||
load-bearing, not decoration.
|
||||
|
||||
### 6.5 The chunked CE is fine — do not swap it
|
||||
|
||||
2 × 16,384 CE forward 374 ms of 35.329 s = 1.1%
|
||||
2 × 4,096 CE forward 93 ms of 4.387 s = 2.1%
|
||||
|
||||
⚠ **Forward only** — the `torch.utils.checkpoint` recompute runs inside
|
||||
`.backward()`, outside the timing window. Even at 3× it is ~3%.
|
||||
|
||||
`liger-kernel` fused linear CE is a ~1–3% lever on this shape. §2's finding
|
||||
stands unchanged: chunking is what makes seq 16384 *reachable*, and it is not
|
||||
what makes it slow.
|
||||
|
||||
### 6.6 MoE is ~8% — stop optimising it
|
||||
|
||||
Dense GEMM is 7.9% of the step, confirming the earlier decomposition bound of
|
||||
~10% from the kernel side.
|
||||
|
||||
On `grouped_mm`: **the trace does not adjudicate it.** Run-01 was relaunched on
|
||||
`eager`, so the profile shows the *default* path — 25,463 `aten::mm` dispatches
|
||||
in one fwd+bwd, far more than the ~90 a grouped path would produce, so the
|
||||
default is per-expert sequential. Whether the flag changes that when set is a
|
||||
different measurement and was not run. At 7.9% it is not worth running.
|
||||
|
||||
### 6.7 Restart parameters for round 2
|
||||
|
||||
**Do not relaunch without the sampler change.** It is the only lever that wins
|
||||
under every branch of the diagnosis.
|
||||
|
||||
1. **Implement bucket-to-pair + shuffle-to-mix** in the harness, tightest
|
||||
bucket, global micro-batch shuffle. Expected ~16.1 h → ~10.4 h or better.
|
||||
2. **Re-assert the mask band structure** after the sampler change —
|
||||
`scripts/training-probes/step0_mask.py`, 30 s, no GPU. The sampler touches
|
||||
batch composition, which is what drives mask construction.
|
||||
3. **Resume with `/tank/erp-tune/resume-run-01.sh`, NEVER the original launch
|
||||
command** — it begins `rm -rf /tank/erp-tune/run-01` and would destroy the
|
||||
609 MB encode cache (2.5 min to reuse, ~4.3 h to rebuild). ⚠ A sampler change
|
||||
alters record *order*, not encoding, so the cache stays valid — but bump
|
||||
`encode_version` if anything upstream of `input_ids` changes.
|
||||
4. **Run the `lora_B` inert-adapter gate at step 100.** It never ran in round 1.
|
||||
Norm every `lora_B` tensor in the checkpoint: all-non-zero = real, all-zero =
|
||||
INERT (kill the run), partial = module-selection problem. This is the one
|
||||
failure that stays invisible until brokkr's acceptance gate reports
|
||||
base-identical numbers.
|
||||
5. **The corpus override is ONE RUN ONLY** (`operator-2026-08-25-rnd-run`). A
|
||||
second run needs a second operator grant.
|
||||
6. **Attention backend is round 2's second lever**, gated on an A/B on the
|
||||
replica — not on argument. It can run while the tuned job trains.
|
||||
|
||||
⚠ GPU0 is currently **reserved and idle** by operator instruction; `sec` /
|
||||
mog-sec remains down. The window is still open, so
|
||||
`playbooks/ana-ml2-training-window-close.yaml` has NOT been run.
|
||||
@@ -0,0 +1,174 @@
|
||||
# ESH IPv6 naming scheme
|
||||
|
||||
Every ESH LAN carries an **eight-hex-digit phrase** as the first half of the
|
||||
interface identifier. Picked 2026-08-18/19. This file is the canonical record.
|
||||
|
||||
> **Why this file exists.** The scheme originally lived as a single line in
|
||||
> `persistent-memory.md` and was silently deleted by a `memory: snapshot`
|
||||
> commit (`837fa36`). Recovering it took a hunt through session transcripts to
|
||||
> find the commit that had held it. A naming convention is not temporal state —
|
||||
> it belongs in a document, so it now is one.
|
||||
|
||||
## The names
|
||||
|
||||
| network | hex | reads as |
|
||||
|---|---|---|
|
||||
| `Default` | **`4BA5:3417`** | A BASE FOR IT |
|
||||
| `esh-mgmt` | **`15DA:B055`** | IS DA BOSS |
|
||||
| `esh-server` | **`4411:B105`** | FOR ALL BIOS |
|
||||
| `esh-userland` | **`CAFE:4411`** | CAFE FOR ALL |
|
||||
| `esh-iot` | **`4DBA:D107`** | FOR DA BAD IOT |
|
||||
| `esh-cameras` | **`1533:FACE5`** | I SEE FACES |
|
||||
| *(reserved)* DMZ | **`4411:DBAD`** | FOR ALL DA BAD |
|
||||
|
||||
The DMZ name is **claimed against a network that does not exist yet** — there is
|
||||
no DMZ on the ESH UDM. Do not reuse it.
|
||||
|
||||
Substitutions are the standard hexspeak set: `0`→O, `1`→I/L, `5`→S, plus letters
|
||||
that are already native hex (`A`–`F`). Anything outside `0-9a-f` is not
|
||||
expressible — `b0ss` and `c00l` do **not** work, which is why the set above uses
|
||||
`B055` and avoids `c00l` entirely.
|
||||
|
||||
House style, arrived at rather than designed: **eight digits, and a complete
|
||||
phrase rather than a single word.** Words are allowed to straddle the group
|
||||
boundary (`4DBA:D107` is `4·D·BAD·107`); the phrase reads through the colon.
|
||||
|
||||
## Address structure
|
||||
|
||||
```
|
||||
2607:73c0:402:1d02 : 4411:b105 : 50 : 45
|
||||
└──── ISP /64 ────┘ └ segment ─┘ └ 10.0.50.45 ┘
|
||||
```
|
||||
|
||||
- **Prefix** — Cityside's, not ours to name. ESH holds a `/56`
|
||||
(`2607:73c0:402:1d00::/56`, 256 × /64); the subnet id is assigned by UniFi's
|
||||
`ipv6_pd_prefixid`. `esh-cameras` is `1d00`, `esh-server` is `1d02`.
|
||||
- **Segment word pair** — 32 bits, from the table above.
|
||||
- **Host** — the last two IPv4 octets, written as literal digits so they read
|
||||
straight off the address. `10.0.50.45` → `:50:45`.
|
||||
|
||||
The scheme lives entirely in the **interface identifier**, so it is
|
||||
**delegation-size independent**. It works identically on a `/56`, a `/48`, or
|
||||
NH3's single `/64`. It never competes with the subnet id, which is far too small
|
||||
to hold a word (8 bits at ESH — two hex digits).
|
||||
|
||||
Note: `4411:b105:50:45` fills all four host groups, so there is **no `::`** in
|
||||
these addresses. Writing `...::4411:b105:50:45` is malformed and will be
|
||||
rejected.
|
||||
|
||||
## What can and cannot carry a name
|
||||
|
||||
| slot | nameable? |
|
||||
|---|---|
|
||||
| `/64` subnet id (`ipv6_pd_prefixid`) | **No** — 8 bits at ESH, two hex digits, no room for a word |
|
||||
| the gateway's own address | **No** — fixed at `::1` by UniFi, no field for it |
|
||||
| a UniFi *client* reservation | **No** — UniFi has no IPv6 equivalent of `use_fixedip` |
|
||||
| **a host taking its own address** | **Yes** — this is the one that works |
|
||||
|
||||
⚠ **The original note concluded these names could never appear in a `dig` or
|
||||
`ip -6` output. That is wrong.** The first three rows are correct, but they only
|
||||
establish that *UniFi* cannot assign the address. A Linux host can simply take
|
||||
one within its own advertised prefix, and the router gets no vote. Appliances
|
||||
with no shell — cameras, most IoT — genuinely cannot, so `1533:FACE5` and
|
||||
`4DBA:D107` are likely to stay documentation-only.
|
||||
|
||||
## Applying it to a host
|
||||
|
||||
Do **not** use an `iface … inet6 static` stanza: on Debian that sets
|
||||
`accept_ra=0`, killing SLAAC and the IPv6 default route — a good way to strand a
|
||||
headless box. Use an `if-up.d` hook that derives the live prefix instead.
|
||||
|
||||
Live example, `/etc/network/if-up.d/ipv6-scheme-addr` on `esh-docker-vm`:
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
[ "$IFACE" = ens18 ] || exit 0
|
||||
(
|
||||
i=0
|
||||
while [ $i -lt 30 ]; do
|
||||
PFX=$(ip -6 -o addr show dev "$IFACE" scope global 2>/dev/null \
|
||||
| awk '{print $4}' | cut -d/ -f1 | head -1 | cut -d: -f1-4)
|
||||
if [ -n "$PFX" ]; then
|
||||
ip -6 addr replace "${PFX}:4411:b105:50:45/64" dev "$IFACE" && exit 0
|
||||
fi
|
||||
sleep 2
|
||||
i=$((i + 1))
|
||||
done
|
||||
) >/dev/null 2>&1 &
|
||||
exit 0
|
||||
```
|
||||
|
||||
Three deliberate properties:
|
||||
|
||||
- **The prefix is derived, never hardcoded** — self-heals if Cityside
|
||||
re-delegates.
|
||||
- **Backgrounded with a retry** — SLAAC may not have landed when `if-up.d` runs,
|
||||
and a hook that blocks or fails would stall interface bring-up.
|
||||
- **Additive** — `/etc/network/interfaces` already sources `interfaces.d/`;
|
||||
nothing existing is edited, and removal is one `rm`.
|
||||
|
||||
Remaining gap: a *mid-life* prefix change is only picked up at the next
|
||||
interface-up. A timer would close it; not worth building until the prefix is
|
||||
observed to actually move.
|
||||
|
||||
## Deployed
|
||||
|
||||
All three Linux hosts on `esh-server` now carry the segment name, with the last
|
||||
two groups reading straight off their IPv4 address:
|
||||
|
||||
| host | address | v4 | applied via |
|
||||
|---|---|---|---|
|
||||
| `esh-docker-vm` (AdGuard) | `2607:73c0:402:1d02:4411:b105:50:45` | 10.0.50.45 | `if-up.d` on `ens18` |
|
||||
| `esh-pve-nas` | `2607:73c0:402:1d02:4411:b105:50:55` | 10.0.50.55 | `if-up.d` on `vmbr0` |
|
||||
| `esh-vm-db` | `2607:73c0:402:1d02:4411:b105:50:60` | 10.0.50.60 | `if-up.d` on `ens18` |
|
||||
|
||||
`esh-docker-vm`'s is load-bearing, not decorative: the ESH UDM advertises an
|
||||
IPv6 resolver to clients via RDNSS, macOS prefers it over the DHCPv4-supplied
|
||||
one, so whatever sits there is what resolves `*.internal` for every Mac on the
|
||||
network. It previously pointed at AdGuard's **MAC-derived SLAAC address**, which
|
||||
would have broken if that VM's NIC ever changed. Both `esh-userland` and
|
||||
`esh-server` now advertise the scheme address instead
|
||||
(`dhcpdv6_dns_auto=false` + `dhcpdv6_dns_1=<address>`), verified on the wire by
|
||||
soliciting an RA and parsing option type 25.
|
||||
|
||||
### ⚠ Proxmox bridges need `accept_ra=2` or SLAAC never runs
|
||||
|
||||
`esh-pve-nas` had **link-local only** despite `accept_ra=1`, `autoconf=1` and
|
||||
IPv6 enabled — every sysctl looked correct. The cause: **`vmbr0.forwarding = 1`**
|
||||
(Proxmox sets per-interface forwarding on bridges), and the kernel ignores RAs on
|
||||
a forwarding interface unless `accept_ra` is explicitly **`2`**. `accept_ra=1`
|
||||
means "accept only if not forwarding", so it silently did nothing.
|
||||
|
||||
Fixed in `/etc/sysctl.d/60-ipv6-accept-ra.conf` on that host:
|
||||
|
||||
```
|
||||
net.ipv6.conf.vmbr0.accept_ra = 2
|
||||
net.ipv6.conf.vmbr0.accept_ra_defrtr = 0
|
||||
```
|
||||
|
||||
`accept_ra_defrtr=0` is deliberate — it takes the advertised **prefix** (so
|
||||
SLAAC configures an address) while **declining the default route**, so a
|
||||
hypervisor gains an IPv6 identity with no change to its routing behaviour.
|
||||
Verified after: SLAAC address present, v6 default routes still **0**, v4 intact.
|
||||
|
||||
Expect the same on any other Proxmox node when its LAN gets IPv6.
|
||||
|
||||
### Getting into a host with no direct root
|
||||
|
||||
`esh-vm-db` refuses key auth for `root` and `infra-ops`, and `lkraven`'s sudo
|
||||
wants a password. It is VMID 101 on `esh-pve`, and the **QEMU guest agent** runs
|
||||
as uid 0 inside it, so the hook was installed with:
|
||||
|
||||
```
|
||||
qm guest exec 101 -- /bin/sh -c 'echo <base64> | base64 -d > /etc/network/if-up.d/... '
|
||||
```
|
||||
|
||||
base64 because quoting a multi-line script through two SSH layers mangles it.
|
||||
Worth remembering as the general path for guests whose credentials are not
|
||||
vaulted.
|
||||
|
||||
Every Linux host on `esh-server` now carries its name. The remaining ESH
|
||||
segments have no eligible hosts: `esh-cameras` and `esh-iot` are appliances
|
||||
with no shell, and `esh-mgmt`, `esh-userland` and `Default` are still
|
||||
`ipv6_interface_type: none` pending the firewall-policy pass — enabling SLAAC
|
||||
there gives every client a globally reachable address.
|
||||
@@ -0,0 +1,359 @@
|
||||
# Training throughput playbook — how to find where the step time went
|
||||
|
||||
_Sibling to [`model-quantization-playbook.md`](model-quantization-playbook.md).
|
||||
That one is for making a model small; this one is for making a training run
|
||||
fast. Same contract: **model-agnostic lessons live here, model-specific ones
|
||||
stay in the per-model artifact and link up.**_
|
||||
|
||||
First written 2026-08-24 out of the Gemma-4 26B-A4B ERP/RP tune, which ran at
|
||||
8.6% MFU and cost a four-model frontier panel and most of a night to explain.
|
||||
The worked example in §7 is that run. The lessons above it are not about
|
||||
Gemma-4.
|
||||
|
||||
> **Read this before hypothesising about kernels.** The single most expensive
|
||||
> failure in that investigation was not a wrong hypothesis. It was *four
|
||||
> people, including four frontier models, reasoning confidently from
|
||||
> arithmetic instead of spending ten minutes on a measurement that settled
|
||||
> it.* Two of the panel's conclusions were retracted by their own authors
|
||||
> within the hour. Every retraction was a derivation; every survivor was a
|
||||
> measurement.
|
||||
|
||||
---
|
||||
|
||||
## 1. The 10-minute triage — do this FIRST, always
|
||||
|
||||
Before you profile, before you read a modelling file, before you ask anyone:
|
||||
**measure the step's scaling curve.** Three sequence lengths, fixed batch,
|
||||
fwd+bwd, best-of-2 after a warmup.
|
||||
|
||||
t(w) = A·w + B·w² w = per-sequence length
|
||||
|
||||
Fit two parameters to three points. The residuals tell you which regime you
|
||||
are in, and the regime tells you which lever exists:
|
||||
|
||||
| observed `t(4w)/t(w)` | regime | the lever |
|
||||
|---|---|---|
|
||||
| ~4× | **linear** — per-token work dominates | fewer tokens; fused elementwise |
|
||||
| ~16× | **quadratic** — attention dominates | attention backend / kernel |
|
||||
| ~1× | **launch-bound** — fixed per-batch cost | CUDA graphs, `torch.compile`, bigger batch |
|
||||
|
||||
**If the two-term fit closes with residuals under ~1%, launch-bound is
|
||||
refuted.** You did not need a constant term, so there is not a meaningful one.
|
||||
This is the cheapest possible refutation of the most seductive wrong answer,
|
||||
and it costs one extra data point.
|
||||
|
||||
### ⚠ 1.1 ⭐⭐ Three points minimum. A two-point fit with three plausible terms is UNDETERMINED
|
||||
|
||||
This is the lesson that cost the most. A two-point fit over {quadratic,
|
||||
linear, fixed} has infinitely many solutions, and which one you land on is
|
||||
decided by whichever per-step number you happened to quote. In the worked
|
||||
example a peer produced **two confident, opposite conclusions from the same
|
||||
method inside an hour** — "attention is ~5 s of 35" and then "attention is
|
||||
21–33 s of 35" — because the inputs drifted between attempts.
|
||||
|
||||
Three points, two parameters, and check the residuals. If they do not close,
|
||||
you have a third term and you need a fourth point.
|
||||
|
||||
### ⚠ 1.2 ⭐⭐ Benchmark the shape you RUN, not the worst case you can construct
|
||||
|
||||
The quadratic share is **strongly shape-dependent** — in the worked example it
|
||||
ran 20.9% at w=2,048, 51.3% at w=8,192, 67.8% at w=16,384. A synthetic
|
||||
`max_seq_len` benchmark therefore measures the shape where attention looks
|
||||
worst, and generalising from it overstates the attention prize by ~1.3×.
|
||||
|
||||
Get the real distribution off the encode cache and weight by it:
|
||||
|
||||
E[t] = A·E[w] + B·E[w²]
|
||||
|
||||
**`E[w²]` is not `E[w]²`.** For a bimodal length distribution they can differ
|
||||
by 2× or more, and a quadratic term is dominated by the rare long batches that
|
||||
an `E[w]²` shortcut averages away. In the worked example `E[n²]/E[n]²` was
|
||||
**2.08**.
|
||||
|
||||
Sanity check the weighted prediction against the observed `s/it` before you
|
||||
trust any of it.
|
||||
|
||||
---
|
||||
|
||||
## 2. The reference probe set
|
||||
|
||||
Committed at [`scripts/training-probes/`](../../scripts/training-probes/).
|
||||
Run them in this order; each is minutes and none needs the real checkpoint
|
||||
except the profiler.
|
||||
|
||||
| probe | what it settles | needs GPU? |
|
||||
|---|---|---|
|
||||
| `step0_mask.py` | mask band structure + which layers keep the fast path | no |
|
||||
| `step2_padding.py` | padding waste, length distribution, CE chunk sizing | no |
|
||||
| `step_bucket.py` | bucketing gain, bucket-size sweep, root diversity | no |
|
||||
| `step1_profile.py` | scaling fit, padding penalty, CE wall clock, kernel table | yes |
|
||||
|
||||
`step1_profile.py` loads the real model but reuses the harness's own
|
||||
`discover_target_modules` and `compute_loss`, so it measures the thing that
|
||||
actually runs rather than a re-implementation. **Keep that property when you
|
||||
adapt it** — a probe that reimplements the training step measures the probe.
|
||||
|
||||
---
|
||||
|
||||
## 3. The recurring landmines
|
||||
|
||||
### 3.1 ⭐⭐ Right-padding is a compute tax AND a backend tax
|
||||
|
||||
Everyone knows padding wastes tokens. The second effect is the one that gets
|
||||
missed: **an explicit padding mask can knock fast-path-eligible layers off
|
||||
`is_causal`.**
|
||||
|
||||
`scaled_dot_product_attention` takes `is_causal=True` **or** an `attn_mask`,
|
||||
never both usefully. HF sets `is_causal=True` only when `attention_mask is
|
||||
None`. Right-pad a batch and you hand it a 2D mask, it materialises a 4D
|
||||
tensor, and every layer that could have taken the clean causal route now takes
|
||||
a masked dense one.
|
||||
|
||||
Measured, same width, same `n`, only the mask differing:
|
||||
|
||||
no padding 35.244 s 26,048 loss targets
|
||||
50% pad on one row 38.567 s 19,640 loss targets
|
||||
|
||||
**9.4% slower for 24% less work.** Verify this on your own stack with
|
||||
`step0_mask.py` — it prints whether `create_causal_mask` returns `None` or a
|
||||
tensor for each mask case.
|
||||
|
||||
### 3.2 ⭐⭐ Length-bucket to PAIR, shuffle micro-batches to MIX — and the bucket should be TIGHT
|
||||
|
||||
Naive length-bucketing has a real hazard: length correlates with data source,
|
||||
so length-homogeneous batches are **source-homogeneous batches**, and an
|
||||
accumulation window can end up drawing its entire gradient from one root.
|
||||
|
||||
The fix costs nothing: **form micro-batches within length buckets, then
|
||||
shuffle the resulting micro-batches globally.** Padding efficiency is a
|
||||
property of the pairing alone, so all of the saving survives the shuffle.
|
||||
|
||||
**The non-obvious part — bucket size is not a diversity knob.** Measured
|
||||
across a 256× range of bucket sizes, roots per accumulation window stayed flat
|
||||
at 3.54–3.61 (against 3.68 for a pure shuffle). The *global micro-batch
|
||||
shuffle* does all of the mixing; the bucket contributes nothing to diversity
|
||||
and only costs padding. So use the tightest bucket you can — which in the
|
||||
limit is a full length sort.
|
||||
|
||||
| bucket | padding waste | zero-pad micro-batches | roots/window |
|
||||
|---|---|---|---|
|
||||
| current (shuffle) | 29.9% | 0.1% | 3.68 |
|
||||
| 2 | 0.0% | **78.3%** | 3.56 |
|
||||
| 32 | 0.1% | 41.9% | 3.55 |
|
||||
| 512 | 2.4% | 4.1% | 3.61 |
|
||||
|
||||
Note the `zero-pad micro-batches` column — that is §3.1 compounding. A tight
|
||||
bucket does not merely cut tokens, it puts most batches back on the causal
|
||||
fast path.
|
||||
|
||||
**Peak memory does not rise.** `padded = batch × max(len)`, so one long record
|
||||
forces a full-width batch regardless of its partner. Bucketing pairs long
|
||||
records *with each other*, which roughly halves the number of worst-case
|
||||
batches.
|
||||
|
||||
### 3.3 ⭐⭐ Check the kernel GENERATION, not just the backend name
|
||||
|
||||
The backend name (`EFFICIENT_ATTENTION`, `FLASH_ATTENTION`, …) is not the whole
|
||||
story. Read the actual kernel symbols out of the profiler:
|
||||
|
||||
fmha_cutlassF_bf16_aligned_32x128_gmem_sm80
|
||||
fmha_cutlassB_bf16_aligned_128x64_k65536_sm80
|
||||
^^^^
|
||||
|
||||
`sm80` is **Ampere**. Those were running on an sm_120 Blackwell card, on the
|
||||
dominant cost centre of the step. A backend can be "selected correctly" and
|
||||
still be a generation behind, and nothing in the config surface tells you.
|
||||
|
||||
Also read the variant suffix: `gmem` on the forward kernel is the
|
||||
**global-memory fallback tier** of the memory-efficient path, chosen when the
|
||||
working set will not fit in shared memory. Wrong backend *and* that backend's
|
||||
slow path.
|
||||
|
||||
### 3.4 ⭐ `key_averages()` double-counts — use device-kernel rows only
|
||||
|
||||
`torch.profiler`'s `key_averages()` table lists both the ATen op and the CUDA
|
||||
kernel it launched, each carrying the same `self_device_time_total`. Summing
|
||||
the whole table gives you roughly **2× the real step time**.
|
||||
|
||||
The tell is exact equality between an `aten::` row and a kernel row:
|
||||
|
||||
aten::_efficient_attention_backward 30 16144.6
|
||||
fmha_cutlassB_bf16_aligned_128x64_k65536 30 16144.6
|
||||
|
||||
Filter to device kernels (`void …`, `fmha_…`, `cutlass::…`, `Memcpy…`) and
|
||||
sanity-check the total against the measured wall clock. In the worked example
|
||||
the filtered total came to 89.5% of the step, which is the right shape; the
|
||||
unfiltered total came to 202%.
|
||||
|
||||
### 3.5 ⭐ Time the loss forward AND account for its backward recompute separately
|
||||
|
||||
If the loss head is gradient-checkpointed, a CUDA-event window around the
|
||||
forward loop measures **half the story at best** — the recompute happens inside
|
||||
`.backward()`, outside your window.
|
||||
|
||||
State the caveat explicitly when you report the number. In the worked example
|
||||
the CE forward measured 374 ms of a 35.3 s step (1.1%); even at 3× for
|
||||
recompute-plus-backward it is ~3%, which was enough to kill a proposed
|
||||
dependency swap — but "1.1%" alone would have been an unearned claim.
|
||||
|
||||
### 3.6 ⭐⭐ Assert mask band structure directly; never infer it from performance
|
||||
|
||||
`transformers` can **silently skip mask creation** and pass
|
||||
`attention_mask=None` when a mask function is not registered. If that fires on
|
||||
a sliding-window model, the windowed layers do full causal attention — not a
|
||||
speed bug, **a different model from the one you will serve**.
|
||||
|
||||
There is a tempting alibi: "if constraints were dropped we would be on the
|
||||
fast path and fast; we are slow, therefore correct." It is decent evidence and
|
||||
it is not an assertion. Materialise the mask once and count allowed positions
|
||||
per row:
|
||||
|
||||
sliding_attention max 1,024 allowed/row, saturates at row 1,023 PASS
|
||||
|
||||
Thirty seconds, on CPU, no weights. Do it before every run that changes the
|
||||
masking path, and before believing any optimisation result.
|
||||
|
||||
### 3.7 ⭐ "Bit-identical output from a different backend" — ask *could this have disagreed?*
|
||||
|
||||
A backend flag that produces `max_abs_diff == 0.0` against the reference is
|
||||
either (a) legitimately the same GEMMs behind a different launcher, or (b) a
|
||||
flag that never took. **Argument cannot separate these** — in the worked
|
||||
example three frontier models split 2–1 on it and the majority was not
|
||||
obviously right.
|
||||
|
||||
Do not resolve it by vote. **Count kernel launches.** A per-expert loop leaves
|
||||
`n_experts` dispatches per layer visible; a grouped path leaves one. That is
|
||||
unambiguous and falls out of a trace you are running anyway.
|
||||
|
||||
Related trap: **a trace of the default path does not test the flag.** If the
|
||||
run was relaunched without the flag set, the profile tells you what the default
|
||||
does and nothing about the flag. Say so rather than over-claiming.
|
||||
|
||||
### 3.8 ⭐ MFU is a denominator argument waiting to happen — report the decomposition instead
|
||||
|
||||
MFU invites an unwinnable fight about what counts as a FLOP (active vs dense
|
||||
params for MoE, whether checkpoint recompute counts, whether frozen-base
|
||||
skipped GEMMs count). That fight consumed an hour of the worked example and
|
||||
produced nothing.
|
||||
|
||||
Report these **beside** MFU, not instead of it:
|
||||
|
||||
- tokens/s, and **real (unpadded) tokens/s** separately
|
||||
- achieved hardware FLOPs straight from the profiler
|
||||
- the time decomposition (attention / GEMM / elementwise / other)
|
||||
|
||||
Then the denominator stops mattering.
|
||||
|
||||
**The reading that actually diagnosed it** was not an MFU number at all:
|
||||
|
||||
> 100% SM utilisation at 279–292 W, running 27 TFLOPS, on a card that does
|
||||
> 304 TFLOPS on a dense GEMM at the same power.
|
||||
|
||||
**SM-busy, tensor-core-idle.** The chip is fully occupied doing work that is
|
||||
not matrix multiplication. No FLOP-counting convention changes that, and it
|
||||
points straight at the kernel table.
|
||||
|
||||
### 3.9 Frozen-base LoRA is ~4ND, not ~6ND — and the arithmetic intensity does NOT drop
|
||||
|
||||
A claim that circulated and was wrong: "frozen-base LoRA has structurally lower
|
||||
arithmetic intensity, so a dense-GEMM ceiling is unreachable in principle."
|
||||
|
||||
The correct accounting: forward is 2ND, input-gradient backward through the
|
||||
frozen weights is 2ND, and only the weight-gradient (~2ND) is skipped. So
|
||||
**~4ND against ~6ND — two-thirds of the work, at the same arithmetic intensity
|
||||
per remaining GEMM.** You do fewer GEMMs; the ones you do are exactly as dense.
|
||||
|
||||
Gradient checkpointing is a separate, real ~⅓ recompute tax. Account for it
|
||||
separately rather than folding it into an intensity story.
|
||||
|
||||
---
|
||||
|
||||
## 4. Panel / consult discipline for perf work
|
||||
|
||||
Perf investigations are unusually good at generating confident wrong answers,
|
||||
because the arithmetic is easy and the ground truth is expensive. Specific
|
||||
guards, learned the hard way:
|
||||
|
||||
- **Every arm's claim gets a measurement or an expiry date.** In the worked
|
||||
example the panel produced four self-retractions in ninety minutes. The
|
||||
measurements produced zero.
|
||||
- **Treat cross-arm agreement as weak evidence.** Ask arms to attack a
|
||||
hypothesis rather than extend it; agreement among similarly-primed readers of
|
||||
the same artifact is not independent confirmation.
|
||||
- **A dispute about what a specific dispatcher does is a question of fact.**
|
||||
Do not put it to a panel. Instrument it.
|
||||
- **When an arm says "you missed X," check what they read.** If your settled
|
||||
artifact was not on their reading list, the "miss" is usually
|
||||
restatement-of-a-settled-prior, not a genuine gap.
|
||||
|
||||
---
|
||||
|
||||
## 5. Superseded claims — do not follow these
|
||||
|
||||
| claim | status | replaced by |
|
||||
|---|---|---|
|
||||
| "Explicit mask → `EFFICIENT_ATTENTION`" is over-specific; Blackwell defaults to `CUDNN_ATTENTION` | **WRONG** (2026-08-24) | Measured: sm_120 selects `fmha_cutlass*_sm80`, i.e. `EFFICIENT_ATTENTION`. The original claim was right. |
|
||||
| Attention's quadratic share is ~5 s of a 35 s step | **WRONG** (2026-08-24) | Measured 22.8 s / 65.2% at w=16,384; 67.8% by independent scaling fit |
|
||||
| Frozen-base LoRA has structurally lower arithmetic intensity | **WRONG** (2026-08-24) | ~4ND vs 6ND at unchanged intensity — see §3.9 |
|
||||
| The chunked CE is a 2–5× under-estimated cost centre | **WRONG** (2026-08-24) | Measured 1.1% of step forward, ≲3% with recompute |
|
||||
| `attn_implementation="flash_attention_2"` is the per-layer lever | **NOT A FLAG** (2026-08-24) | All-or-nothing at `from_pretrained`; per-layer needs a custom fn on `ALL_ATTENTION_FUNCTIONS`. FA2 also caps head_dim at 256. |
|
||||
| Bucket size ~256 is needed to preserve source diversity | **UNNECESSARY** (2026-08-24) | Diversity is flat in bucket size; the global micro-batch shuffle does that work — see §3.2 |
|
||||
|
||||
## 6. Measured negatives — don't re-chase
|
||||
|
||||
- **Fused MoE kernel (`grouped_mm`) as the throughput fix.** Measured 0.9%
|
||||
*slower* than the Python loop and bit-identical. Independently, dense GEMM is
|
||||
only 7.9% of the step, so the whole category is capped near 10%.
|
||||
- **CUDA graphs / `torch.compile` over the expert loop.** The two-term scaling
|
||||
fit closed without a constant term, so there is no meaningful fixed per-batch
|
||||
cost to amortise. ~3,840 expert-GEMM launches per forward are not what you
|
||||
are paying for.
|
||||
- **`liger-kernel` fused linear CE.** Real and correct, but a ~1–3% lever on
|
||||
this shape. Not a project.
|
||||
- **FlashAttention-4 on sm_120.** Public reports are sour — one measurement of
|
||||
1.07× over FA2, and an sm_120 patch people could not get working that fell
|
||||
back to torch SDPA. Do not bet a round on it.
|
||||
|
||||
---
|
||||
|
||||
## 7. Worked example — Gemma-4 26B-A4B ERP/RP tune, 2026-08-24
|
||||
|
||||
Model-specific detail lives in
|
||||
[`gemma4-erp-tune-sizing.md`](gemma4-erp-tune-sizing.md) §6. The short version,
|
||||
because the *shape* of the investigation is the transferable part:
|
||||
|
||||
**Symptom.** 8.6% MFU, ~35–46 s/it, 1,312 steps, ~13.9 h ETA.
|
||||
|
||||
**What the panel produced.** Four frontier arms plus an orchestrator, over
|
||||
ninety minutes: a sliding-window hypothesis, a retraction of it, a retraction
|
||||
of the retraction, a correctness scare that resolved itself, two mutually
|
||||
contradictory readings of one dispatcher, and four self-corrections.
|
||||
|
||||
**What settled it, in about twenty minutes of GPU time:**
|
||||
|
||||
scaling fit (3 points, 2 params, residuals <3 ms over an 8× range)
|
||||
A = 6.87e-4 s/token B = 8.85e-8 s/token²
|
||||
quadratic share: 20.9% @ w=2,048 → 67.8% @ w=16,384
|
||||
|
||||
kernel table (device rows only)
|
||||
attention 22,835.8 ms 65.2% fmha_cutlass*_sm80
|
||||
dense GEMM 2,774.0 ms 7.9%
|
||||
other 5,739.0 ms 16.4%
|
||||
|
||||
Two independent methods, 2.6 points apart. **Attention was the answer, on
|
||||
Ampere-generation kernels, with the forward on a global-memory fallback tier.**
|
||||
|
||||
**The largest actionable win was not the attention kernel.** It was a sampler
|
||||
change — bucket-to-pair, shuffle-to-mix — worth 29.9% of tokens and ~35.5% of
|
||||
wall clock, with no new dependency, no kernel work, and unchanged peak memory.
|
||||
It also wins under *every* branch of the diagnosis, which is why it was
|
||||
recommended while the rest was still unresolved.
|
||||
|
||||
**The transferable ordering:**
|
||||
|
||||
1. Assert correctness (mask band structure). Everything downstream assumes it.
|
||||
2. Scaling curve. Names the regime in ten minutes.
|
||||
3. Kernel table. Names the cost centre.
|
||||
4. Data-side levers first (padding, bucketing) — they need no dependency and
|
||||
they multiply into every other cost.
|
||||
5. Kernel/backend levers last, gated on 2 and 3.
|
||||
@@ -127,3 +127,59 @@ Cloudflare DNS-edit token, so this is self-serve.
|
||||
WireGuard holds keys in kernel memory, so no restart was needed. The parent
|
||||
`/etc/wireguard` was already 700, which capped the real exposure to root-capable
|
||||
contexts inside the LXC — but the modes were still wrong.
|
||||
|
||||
---
|
||||
|
||||
## CORRECTION (recorded 2026-08-24): "AT&T delegates exactly ONE /64" is the
|
||||
## per-REQUEST truth, not the total — eight /64s exist and are unclaimed
|
||||
|
||||
The section above concludes AT&T hands out a single `/64` and that the
|
||||
`c110`/`c11f` pattern reading as a `/60` was a misread. **That conclusion was
|
||||
itself superseded later in the same session, and the correction never made it
|
||||
into memory** — it survived only in the session transcript, and was recovered
|
||||
2026-08-24 while assessing a proposal to grab more prefixes.
|
||||
|
||||
Reading the **BGW's own LAN statistics page** gave the whole picture:
|
||||
|
||||
```
|
||||
BGW WAN v6 2001:506:70b2:8958::1 <- AT&T's transit prefix
|
||||
BGW LAN v6 2600:1700:b25:c110::/64 <- the BGW keeps this for itself
|
||||
Delegated 2600:1700:b25:c11f::/64 <- what the UDM got
|
||||
```
|
||||
|
||||
**The BGW holds the `/60` and rations it**, keeping `c110`–`c117` for itself and
|
||||
delegating from the top down — the UDM got `c11f`, the last one. So
|
||||
`c118`–`c11f` are **eight delegatable /64s that genuinely exist and are yours**,
|
||||
sitting unclaimed.
|
||||
|
||||
Both observations are compatible, which is why the first one looked conclusive:
|
||||
the prefix-ID test only carves *within* a delegation already held, so a UDM
|
||||
holding one `/64` cannot move it no matter what prefix-ID you set. The BGW
|
||||
issues **one `/64` per IA_PD request**, and **UniFi solicits exactly once**.
|
||||
|
||||
**Consequence — the ceiling is the requester, not the carrier.** More prefixes
|
||||
need more IA_PD requests (multiple IAIDs, or multiple client DUIDs), which the
|
||||
UDM will not do. That is what makes a separate DHCPv6-PD client viable, and it
|
||||
is why "ask AT&T for a bigger delegation" may be aimed at the wrong party: this
|
||||
looks like BGW rationing rather than a provisioning-profile limit.
|
||||
|
||||
Live state at correction time: `wan_dhcpv6_pd_size: 64`, `wan1 v6
|
||||
2600:1700:b25:c110::48`, all 5 NH3 LANs still `ipv6_interface_type: none`.
|
||||
|
||||
### ⛔ CLOSED 2026-08-24 — operator ruling, do not re-raise
|
||||
|
||||
The seven unclaimed `/64`s stay unclaimed. Two facts close it:
|
||||
|
||||
- **The BGW has no IP-passthrough mode.** Operator confirmed, and we hold admin
|
||||
on it — so the cheap path (let the UDM take the `/60` directly and carve it
|
||||
natively, as it already does at ESH) does not exist here.
|
||||
- **The only remaining route is a multi-DUID DHCPv6 client on a VM**, which
|
||||
requires re-cabling to reach the BGW's DHCPv6 server, split-stack routing
|
||||
(UDM for v4, VM for v6), and — the actual cost — **rebuilding the whole IPv6
|
||||
firewall policy in nftables on that VM**, because routing v6 around the UDM
|
||||
bypasses its zone firewall entirely and would leave every LAN host globally
|
||||
reachable.
|
||||
|
||||
Operator's call: not worth it. **NH3 LANs stay `ipv6_interface_type: none`.**
|
||||
Do not re-propose on the strength of "there are seven free prefixes" — the
|
||||
prefixes are real, the firewall rebuild is why nobody wants them.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# [2026-08-23] Every secret-bearing `.env` on ana-docker tightened to 0600
|
||||
|
||||
Found while taking uptime ownership of hrafn: its `.env` was mode 0644 with a live
|
||||
bearer token. Not a hrafn lapse — **0644 was the de facto pattern on the host**.
|
||||
Eight stacks carried secret-shaped vars in world-readable `.env` files on a box with
|
||||
four interactive accounts, verified as real exposure by reading one as `nobody`.
|
||||
|
||||
Swept: **vaultwarden, traefik**, beszel, gitea-runner, miniflux, news-digest,
|
||||
searxng, vor. (hrafn and nevermore were fixed separately the same day.) Six other
|
||||
stacks already used 0600, so this converged on the existing house pattern rather
|
||||
than inventing one. Post-sweep the host has **zero** secret-bearing `.env` readable
|
||||
by `nobody`.
|
||||
|
||||
Playbook: `playbooks/tighten-env-perms.yaml`, one run per stack, re-runnable.
|
||||
|
||||
## The check that matters
|
||||
|
||||
Every run asserts `docker compose config` still renders **as the deploy user**
|
||||
(`lkraven`), not as root. Checking the mode proves the bits changed; only rendering
|
||||
as the deploy user proves the next deploy can still resolve its variables.
|
||||
|
||||
## Two gotchas recorded in the playbook
|
||||
|
||||
- **vaultwarden looked like it bind-mounted its `.env`** — which would mean the
|
||||
*container's* UID reads it and 0600 could break the password vault. It does not:
|
||||
that `- .env` is under `env_file:`, not `volumes:`. My grep matched the YAML list
|
||||
item without checking its parent key. The playbook now **refuses** any stack that
|
||||
genuinely bind-mounts its `.env`, since that case is read by the container UID.
|
||||
- **elway prompted for a sudo password.** The `ana-docker` ssh alias resolves to
|
||||
`lkraven`, who needs one; **`infra-ops@10.250.50.70` has NOPASSWD**. `corviduo-dev`
|
||||
was repointed to infra-ops at some point and `ana-docker` was not. Run elway against
|
||||
the infra-ops target on this host.
|
||||
|
||||
Commit `a896c0a`.
|
||||
@@ -0,0 +1,663 @@
|
||||
# [2026-08-23] Anaheim's IPsec tunnel delivers ~25% of a verified 2 Gbps circuit
|
||||
|
||||
> **⛔ SUPERSEDED 2026-08-23 (same day, later session) — read the CORRECTION at
|
||||
> the bottom before acting on anything here.** The headline is wrong (the
|
||||
> relevant ceiling is NH3's **1 Gbps** uplink, not Anaheim's 2 Gbps), the
|
||||
> aggregate number is wrong (**692 Mbit/s** at 8 streams, not ~550 — the
|
||||
> original stopped measuring at 4), and the proposed remedy is **impossible**:
|
||||
> UniFi's manual site-to-site IPsec does not implement AES-GCM at all. The
|
||||
> per-stream observation and the parallelise-your-transfers mitigation are the
|
||||
> parts that survive.
|
||||
|
||||
The operator noticed site-to-site transfers were slow for a datacenter fiber
|
||||
handoff and asked whether WireGuard was the limit. It is not WireGuard, and the
|
||||
circuit is fine.
|
||||
|
||||
## Measured
|
||||
|
||||
```
|
||||
ana-docker -> internet, 8 parallel 2,153 Mbit/s <- the 2 Gbps handoff, delivering
|
||||
ANA <-> NH3 through the tunnel, 4 par. 460 Mbit/s
|
||||
FortiGate's own recorded peak 554 Mbit/s
|
||||
ANA <-> NH3, single stream 227 Mbit/s
|
||||
ANA <-> ESH, single stream 249-265 Mbit/s
|
||||
ESH <-> NH3 (never touches ana-gw) 545-557 Mbit/s on a SINGLE stream
|
||||
```
|
||||
|
||||
Method: stdlib TCP probe (no ssh, no crypto, no compression) between site
|
||||
endpoints; raw circuit measured with 8 parallel HTTPS fetches from Hetzner
|
||||
Ashburn. Host NICs are virtio with no reported cap, so no host-side ceiling.
|
||||
|
||||
## What it is not
|
||||
|
||||
- **Not WireGuard.** Both Anaheim tunnels are IPsec on ana-gw
|
||||
(`pfi-ana-nh3` -> 70.230.226.88, `ana-eshudm-dyn` -> the ESH UDM). WireGuard
|
||||
on ana-wg is remote-access only and is not in this path. Traceroute confirms:
|
||||
both slow paths have hop 1 = `10.250.50.1` (the FortiGate); the fast
|
||||
ESH<->NH3 path rides a `192.168.x` Site Magic overlay and never touches it.
|
||||
- **Not CPU or crypto exhaustion.** FortiGate CPU was **100% idle across all
|
||||
8 cores** during the tests, and both live tunnels report `npu_flag=03` with
|
||||
`dec_npuid=1 enc_npuid=1` — encrypt *and* decrypt are hardware-offloaded.
|
||||
- **Not a 250 Mbit/s cap.** That was the first number and it is misleading —
|
||||
single-stream TCP. Four parallel streams doubled it. Quote the aggregate.
|
||||
- **Not the interface.** wan1: `rxe=0 txe=0 rxd=0 txd=0`, no collisions.
|
||||
|
||||
## Most likely cause
|
||||
|
||||
Both tunnels negotiate **`aes256-sha1`** in phase 1 *and* phase 2 (dhgrp 14,
|
||||
IKEv2). AES-CBC + SHA1 is a two-pass operation; FortiGate NPUs are markedly
|
||||
faster on **AES-GCM**, which combines encryption and authentication in one
|
||||
pass. The datasheet IPsec headline for an 80F assumes GCM with large packets,
|
||||
not CBC+SHA1 at the 1438-byte tunnel MTU this link negotiates. The ~4x
|
||||
shortfall is consistent with that.
|
||||
|
||||
## Not executed
|
||||
|
||||
Changing the proposal is a **production-edge change requiring a matching
|
||||
change at the far end** (NH3 UDM and the ESH UDM), and each tunnel drops while
|
||||
it renegotiates. Left for the operator. See the index entry for authorization
|
||||
state.
|
||||
|
||||
## Immediate mitigation, no config change
|
||||
|
||||
Per-flow is the weak axis: a single stream over Site Magic gets 557 Mbit/s, a
|
||||
single stream through IPsec gets 227. **Anything moving bulk data across the
|
||||
Anaheim link should parallelise** — that alone roughly doubles throughput
|
||||
today.
|
||||
|
||||
## Practical consequence already observed
|
||||
|
||||
`/mnt/smithy` mounted on ana-ml2 reads at 24.7 MB/s sequential vs 98.3 MB/s
|
||||
from nh3-dev (same file, same mount) — that gap *is* this tunnel, not NFS and
|
||||
not the NAS. See [[2026-08-23-smithy-mount-ana-ml2]].
|
||||
|
||||
## Access note
|
||||
|
||||
ana-gw is a FortiGate-80F, FortiOS 7.2.10, at 10.250.0.1. `sshpass` is absent
|
||||
on nh3-dev; connect with paramiko via `uv run --with paramiko`. Password is
|
||||
vaulted at `fortigate/ana-gw-infra-ops-password`. **`diagnose vpn tunnel list`
|
||||
prints live ESP session keys** — never paste its output into althing, a
|
||||
booth, or a commit.
|
||||
|
||||
---
|
||||
|
||||
## CORRECTION (2026-08-23, later session): the cutover was attempted and the remedy does not exist
|
||||
|
||||
The operator authorised the AES-GCM cutover, NH3 side first. It cannot be done,
|
||||
and the measurements taken while trying show there is very little left to win.
|
||||
|
||||
### AES-GCM is unavailable on the far end — not a naming problem
|
||||
|
||||
The NH3 edge is a **UDM Pro SE** terminating `pfi-nh3-ana` (networkconf
|
||||
`_id 697d64414c85dd2b6669b00a`, `ifname vti64`). Its UniFi API **validates** the
|
||||
crypto enum and rejected every GCM spelling tried — `aes256gcm`, `aes256gcm128`,
|
||||
`aes256gcm16`, `aes-256-gcm`, `aes256-gcm`, `aes256gcm12`, `gcm`, `aes128gcm128`
|
||||
— all `HTTP 400 api.err.InvalidPayload`, nothing applied.
|
||||
|
||||
**The control that makes this conclusive:** the *identical* request body with
|
||||
`ipsec_esp_encryption: "aes256"` returns `HTTP 200 rc:ok`. So the 400s are the
|
||||
enum rejecting the value, not a malformed body. Corroborating: **zero
|
||||
case-insensitive `gcm` matches across 7.3 MB of UniFi OS UI bundles.**
|
||||
|
||||
Accepted enum (probed): `aes128`, `aes192`, `aes256`, `3des` → 200; `des`,
|
||||
`chacha20poly1305` → 400. There is no AEAD option. Both Anaheim tunnels land on
|
||||
UniFi far ends, so this blocks the ESH tunnel too.
|
||||
|
||||
The FortiGate side **was** widened and is GCM-capable: phase2 `pfi-ana-nh3` now
|
||||
reads `set proposal aes256-sha1 aes256gcm`. Left in place deliberately — it is
|
||||
functionally identical while the peer only offers CBC, and reverting it would
|
||||
cost another SA renegotiation for a cosmetic gain. Phase 1 was never touched;
|
||||
IKE protects the control channel only and has no bearing on data throughput.
|
||||
|
||||
### The numbers that retire this as a problem
|
||||
|
||||
Measured NH3→ANA through the tunnel, and NH3→ESH over Site Magic (WireGuard) on
|
||||
the same UDM and the same uplink, with the same stdlib TCP probe:
|
||||
|
||||
| streams | IPsec NH3→ANA | WireGuard NH3→ESH |
|
||||
|---|---|---|
|
||||
| 1 | 245 Mbit/s | 557 Mbit/s |
|
||||
| 4 | 471 Mbit/s | 767 Mbit/s |
|
||||
| 8 | **692 Mbit/s** | **795 Mbit/s** |
|
||||
|
||||
**NH3's WAN is a 1 Gbps link** (`uplink.speed = 1000`, port capable of 10G) —
|
||||
that, not Anaheim's 2 Gbps, is the ceiling for anything crossing this tunnel.
|
||||
So the tunnel does **~69% of the achievable uplink** at 8 streams, and the
|
||||
IPsec-vs-WireGuard gap collapses from 2.3× at one stream to **15% at eight**.
|
||||
|
||||
Re-architecting the transport (site-to-site WireGuard via `ana-wg`, since
|
||||
FortiOS has no WireGuard) would chase that last 15%. Not worth it.
|
||||
|
||||
### What the constraint actually is
|
||||
|
||||
A **per-stream** limit (~245 Mbit/s), not an aggregate crypto ceiling. Both
|
||||
endpoints are idle at load — FortiGate CPU 100% idle with `npu_flag=03`
|
||||
(offloaded both directions), UDM CPU ~7% with load1 moving 0.70 → 1.55. The
|
||||
shape is per-SA/per-flow serialisation, and WireGuard shows the same shape from
|
||||
a higher floor (557 → 795 is only 1.43× scaling).
|
||||
|
||||
### Actionable consequence
|
||||
|
||||
Anything moving bulk data across this link should **parallelise** — 245 → 692
|
||||
Mbit/s, a 2.8× win with no config change. For single-stream workloads that
|
||||
cannot be parallelised at the application layer, **NFS `nconnect=N` is the
|
||||
lever**: it opens N TCP connections per mount, converting a single-stream
|
||||
workload into a parallel one. The `/mnt/smithy` mount on ana-ml2 reading at
|
||||
24.7 MB/s (~200 Mbit/s, i.e. exactly the single-stream ceiling) is the live
|
||||
example — remounting with `nconnect=8` is the obvious test.
|
||||
|
||||
### Foot-gun recorded
|
||||
|
||||
Probing the enum by PUTting candidate values **applies the accepted ones**. A
|
||||
probe loop here timed out with `3des` briefly live on the NH3 side, which the
|
||||
FortiGate would not accept — a short tunnel outage until `aes256` was restored
|
||||
(~1 minute, confirmed by the SA counters resetting). If you enumerate a UniFi
|
||||
config enum this way, restore the known-good value after **every** 200, not at
|
||||
the end of the loop. Post-change verification: the UDM object was diffed
|
||||
field-by-field against its pre-change snapshot and is **byte-identical**.
|
||||
|
||||
---
|
||||
|
||||
## FOLLOW-UP (2026-08-23): what the per-stream limit actually is
|
||||
|
||||
The correction above called the constraint "per-SA/per-flow serialisation".
|
||||
That was a hand-wave. Measured properly, it is a **hard per-flow rate cap of
|
||||
~230–245 Mbit/s with a very deep buffer in front of it** — not a tuning
|
||||
problem, not loss, not window size.
|
||||
|
||||
### The evidence: pin the send buffer and sweep it
|
||||
|
||||
Single stream NH3 → ana-docker, `SO_SNDBUF` pinned, `ss -ti` sampled in flight:
|
||||
|
||||
| in-flight cap | throughput | RTT in flight | minRTT | retrans |
|
||||
|---|---|---|---|---|
|
||||
| 256 KB | 224 Mbit/s | 7.8 ms | 5.3 ms | 0 |
|
||||
| 416 KB | 225 Mbit/s | 11.8 ms | 6.6 ms | 0 |
|
||||
| 416 KB | 245–247 Mbit/s | 12.0 ms | 5.6 ms | 0 |
|
||||
| ~3.3 MB (autotuned) | 245 Mbit/s | **107 ms** | 5.5 ms | 0 |
|
||||
|
||||
**Throughput is flat across a 13× range of in-flight data while RTT scales with
|
||||
it.** That is the signature of a fixed service rate with a standing queue: the
|
||||
window controls only how much queue you build, never how fast you go. Had this
|
||||
been window-limited, throughput would have risen with the buffer. Had it been
|
||||
congestion, there would be retransmits — there are essentially none
|
||||
(`retrans:0`, 0% ping loss).
|
||||
|
||||
So `net.ipv4.tcp_*` tuning, window scaling and congestion-control choice are all
|
||||
**red herrings here**. Do not go there.
|
||||
|
||||
### Bufferbloat: one bulk stream wrecks latency for everything else
|
||||
|
||||
Measured on the same tunnel, ping to ana-docker:
|
||||
|
||||
- idle: **6.9 ms** avg
|
||||
- during a **single** bulk TCP stream: **102 ms** avg, 136 ms max, 0% loss
|
||||
|
||||
**15× latency inflation from one transfer.** This is the operationally
|
||||
important finding — any interactive traffic sharing the Anaheim link (ssh,
|
||||
RDP, althing, VoIP) degrades badly whenever anything moves bulk data, and it
|
||||
takes only one stream to do it. Parallelising transfers makes throughput
|
||||
better and this *worse*. If it starts biting, the fix is an AQM/shaper on the
|
||||
tunnel (or rate-limiting bulk jobs), not more buffer.
|
||||
|
||||
### Where the cap lives — strong inference, not proof
|
||||
|
||||
Three paths, and the FortiGate is the only variable:
|
||||
|
||||
| path | single-stream |
|
||||
|---|---|
|
||||
| FortiGate ↔ NH3 UDM (IPsec) | 245 Mbit/s |
|
||||
| FortiGate ↔ ESH UDM (IPsec) | 249–265 Mbit/s |
|
||||
| NH3 UDM ↔ ESH UDM (WireGuard, **no FortiGate**) | 557 Mbit/s |
|
||||
|
||||
Present in both slow paths, absent from the fast one. Aggregate over the same
|
||||
SA reaches 692 Mbit/s, so it cannot be the SA or the crypto engine as a whole —
|
||||
many flows spread out fine, one flow does not.
|
||||
|
||||
The mechanism that fits is **FortiGate NPU IPsec offload being per-session**:
|
||||
each firewall session is bound to one crypto engine, so a single TCP flow is
|
||||
capped at one engine's rate while many sessions spread across engines. **This
|
||||
is inference from the throughput shape, not something confirmed on the box** —
|
||||
`diagnose sys session list` was not captured for a TCP flow (the filter caught
|
||||
only traceroute UDP probes). A single-stream control through ana-gw *without*
|
||||
IPsec returned 290 Mbit/s to Hetzner Ashburn, but at ~60 ms RTT that is
|
||||
window-limited and does not discriminate. **If this matters, the clean test is
|
||||
a non-IPsec single stream between two Anaheim VLANs at low RTT.**
|
||||
|
||||
**Relevant to the FortiGate cutover decision:** if the per-flow cap is the
|
||||
FortiGate's IPsec path, replacing the box plausibly lifts single-stream
|
||||
throughput toward the WireGuard figure. That is a point in favour of the
|
||||
cutover, and it is cheap to verify afterwards by re-running the sweep.
|
||||
|
||||
---
|
||||
|
||||
## FOLLOW-UP 2 (2026-08-23): it is NOT a capacity problem, and it IS specific to IPsec
|
||||
|
||||
Operator asked directly whether the 80F "can't handle the traffic". It can.
|
||||
Two new measurements settle the shape of this, and correct an overstatement in
|
||||
FOLLOW-UP 1 (which pointed at the FortiGate on evidence that was confounded —
|
||||
every slow path was *both* IPsec *and* FortiGate, so protocol and box could not
|
||||
be separated by that argument).
|
||||
|
||||
### The 80F routes a single flow at line rate when IPsec is not involved
|
||||
|
||||
`ana-ml2 → pfi-pve`, inter-VLAN **through** ana-gw (traceroute hop 1 =
|
||||
`10.250.50.1`), 0.36 ms RTT, no tunnel:
|
||||
|
||||
| streams | throughput |
|
||||
|---|---|
|
||||
| 1 | **940.2 Mbit/s** |
|
||||
| 8 | 939.3 Mbit/s |
|
||||
|
||||
Single stream saturates 1 GbE. So the box does **not** cap single sessions in
|
||||
general, and there is no per-session ceiling in its plain forwarding path. The
|
||||
~250 Mbit/s per-flow cap is **specific to the IPsec datapath**.
|
||||
|
||||
### Both IPsec tunnels converge on the same numbers despite different far ends
|
||||
|
||||
Measured today with the same probe:
|
||||
|
||||
| tunnel | far-end gateway | RTT | 1 stream | 8 streams |
|
||||
|---|---|---|---|---|
|
||||
| NH3 ↔ ANA | UDM Pro **SE** | 6.7 ms | 245 Mbit/s | 692 Mbit/s |
|
||||
| ESH ↔ ANA | UDM Pro **Max** | 3.9 ms | **268 Mbit/s** | **715 Mbit/s** |
|
||||
|
||||
Different gateway hardware, different sites, different uplinks, and RTT
|
||||
differing by 1.7× — yet single-stream differs by only 9%. **If this were
|
||||
window-limited the 3.9 ms path would be ~1.7× faster.** It is not, which is
|
||||
independent confirmation of a rate cap rather than a BDP effect.
|
||||
|
||||
### Capacity summary — the box has headroom it will not give one flow
|
||||
|
||||
- plain routing, 1 stream: **940 Mbit/s** (line rate)
|
||||
- plain routing to internet, 8 streams: **2,153 Mbit/s**
|
||||
- IPsec, 8 streams: **692–715 Mbit/s**
|
||||
- IPsec, 1 stream: **245–268 Mbit/s**
|
||||
- CPU **100% idle** throughout; IPsec NPU-offloaded (`npu_flag=03`)
|
||||
|
||||
Within a single SA, 8 sessions get ~2.9× what 1 session gets, so the datapath
|
||||
distributes work **by inner session** — consistent with IPsec offload binding a
|
||||
session to one crypto engine.
|
||||
|
||||
### What is still NOT separated
|
||||
|
||||
Whether the cap belongs to **the 80F's IPsec offload** or to **UniFi's IPsec
|
||||
implementation**. Both tunnels have a UDM at the far end, and both UDMs run the
|
||||
same UniFi firmware, so identical caps are explainable either way. The Pro Max
|
||||
being only 9% faster than the Pro SE argues against the UniFi side (a beefier
|
||||
CPU should show more), but that is suggestive, not conclusive.
|
||||
|
||||
**The test that closes it:** an IPsec tunnel whose endpoints do not include the
|
||||
80F — e.g. a temporary UDM↔UDM IPsec tunnel between NH3 and ESH, measured
|
||||
single-stream. If it also caps ~250, the FortiGate is exonerated and replacing
|
||||
it buys nothing on this axis. If it runs near the 557 Mbit/s that UDM↔UDM
|
||||
WireGuard achieves, the 80F is the limiter. **Bears directly on the pending
|
||||
FortiGate cutover** — worth running before that decision, not after.
|
||||
|
||||
---
|
||||
|
||||
## FOLLOW-UP 3 (2026-08-23): WireGuard over the same internet path does 767 Mbit/s on ONE stream
|
||||
|
||||
Operator asked for a WireGuard test from `ana-wg` to NH3 over the public
|
||||
internet. It is the test that separates the *path* from the *crypto*, and the
|
||||
answer is unambiguous. **It also overturns FOLLOW-UP 1's "re-architecting the
|
||||
transport is not worth it" — that conclusion compared 8-stream numbers and was
|
||||
wrong for single-stream workloads.**
|
||||
|
||||
### Setup (fully torn down afterwards)
|
||||
|
||||
`ana-wg` (10.250.50.252, Debian 12 LXC, 4 cores) already has an
|
||||
internet-reachable WireGuard endpoint: wg0 on **UDP 31337**, published by
|
||||
FortiGate VIP `wg-to-ana-wg` (extip **38.120.12.42** → 10.250.50.252:31337,
|
||||
policy 46, service `WireGuard-LEET`). **No FortiGate change was needed.** A
|
||||
temporary `wgt0` was created on nh3-dev (10.30.10.200/32) as a fourth peer on
|
||||
wg0, measured, then removed — ana-wg is back to its original 3 peers and the
|
||||
keys were shredded. `wireguard-tools` was installed on nh3-dev and **left in
|
||||
place** (benign, and wanted if this becomes permanent).
|
||||
|
||||
In this topology **neither gateway does crypto**: the FortiGate and the NH3 UDM
|
||||
only NAT/forward UDP, and Linux does WireGuard at both ends.
|
||||
|
||||
### The full comparison
|
||||
|
||||
| path | crypto performed by | 1 stream | 8 streams |
|
||||
|---|---|---|---|
|
||||
| IPsec NH3↔ANA | FortiGate + UDM | 245 Mbit/s | 692 Mbit/s |
|
||||
| IPsec ESH↔ANA | FortiGate + UDM | 268 Mbit/s | 715 Mbit/s |
|
||||
| **WireGuard NH3→ana-wg** (same internet path) | **Linux + Linux** | **767 Mbit/s** | 763 Mbit/s |
|
||||
| WireGuard NH3↔ESH (Site Magic) | UDM + UDM | 557 Mbit/s | 795 Mbit/s |
|
||||
| plain routing through the 80F (inter-VLAN) | none | 940 Mbit/s | 939 Mbit/s |
|
||||
|
||||
**One stream equals eight streams over Linux WireGuard (767 ≈ 763).** There is
|
||||
no per-flow penalty at all, and a single flow already saturates the path. So
|
||||
the ~245 Mbit/s per-flow cap is **not** the ISP, not the circuit, not the NH3
|
||||
uplink and not the physical path — all of which sustain 767 on one flow.
|
||||
|
||||
Per-flow penalty ranks by implementation:
|
||||
|
||||
- **Linux WireGuard — none** (767 → 763, flat)
|
||||
- **UDM WireGuard — mild**, ~1.4× (557 → 795)
|
||||
- **IPsec on this pair — severe**, ~2.8× (245 → 692)
|
||||
|
||||
### Latency under load — the same story
|
||||
|
||||
| path | idle | during ONE bulk stream |
|
||||
|---|---|---|
|
||||
| IPsec NH3↔ANA | 6.9 ms | **102 ms** avg, 136 ms max |
|
||||
| WireGuard NH3→ana-wg | 6.2 ms | **12.7 ms** avg, 23 ms max |
|
||||
|
||||
WireGuard carries **3.1× the single-stream throughput with 8× less latency
|
||||
inflation** on the same wire.
|
||||
|
||||
### Attribution — still not fully separated, and it no longer matters much
|
||||
|
||||
Both IPsec measurements have a FortiGate *and* a UDM doing IPsec, so this still
|
||||
does not isolate which one imposes the 2.8× penalty. Closing that would need
|
||||
Linux↔Linux IPsec or UDM↔UDM IPsec on the same path. **But the practical
|
||||
decision no longer depends on the answer**, because the fix is the same either
|
||||
way and it is already demonstrated.
|
||||
|
||||
### Recommendation (supersedes FOLLOW-UP 1)
|
||||
|
||||
A **WireGuard site-to-site between NH3 and Anaheim, terminated on `ana-wg`**, is
|
||||
worth real consideration: 3.1× single-stream, flat scaling, far better latency
|
||||
under load, and it reuses infrastructure that already exists and is already
|
||||
internet-reachable. It is also the architecture already proven for NH3↔ESH.
|
||||
Open questions before committing: routing/failover if ana-wg (an LXC) is down,
|
||||
whether it replaces or parallels the IPsec tunnel, and firewall policy for the
|
||||
new transit. ana-wg CPU was only ~40% busy across 4 cores at 767 Mbit/s, so it
|
||||
has headroom.
|
||||
|
||||
**AND: `nconnect=8` on /mnt/smithy remains worth doing regardless** — it is the
|
||||
same lever (turn one flow into many) and brokkr-smithy-dev has given standing
|
||||
approval to apply it once the FortiGate work settles, with no need to ask again.
|
||||
|
||||
---
|
||||
|
||||
## RESOLVED (2026-08-23): it is the UDM's software AES-CBC. The FortiGate is exonerated.
|
||||
|
||||
Operator's theory — the UDM does IPsec in software with no crypto offload, so
|
||||
the cost of the cipher itself is the limit — is **correct**, and it is now
|
||||
demonstrated rather than inferred. He also correctly pointed out that
|
||||
UDM↔UDM Site Magic is **WireGuard, not IPsec**, so that row never said anything
|
||||
about UniFi's IPsec performance. It didn't, and I had leaned on it.
|
||||
|
||||
### The controlled experiment: vary cipher cost, hold everything else
|
||||
|
||||
AES-128 is 10 rounds, AES-256 is 14. If software crypto is the binding
|
||||
constraint, throughput must rise when the cipher gets cheaper. If the limit
|
||||
were the FortiGate's NPU, it would not move at all — hardware crypto is not
|
||||
cipher-cost-sensitive in that range. Run A/B/A, single stream, 25–60 s each:
|
||||
|
||||
| condition | ESP cipher | single-stream | UDM CPU |
|
||||
|---|---|---|---|
|
||||
| A | aes256-cbc + sha1 | 232.3 Mbit/s | 35.4% |
|
||||
| B | **aes128**-cbc + sha1 | **281.8**, 274.9 Mbit/s | 35.5% |
|
||||
| A again | aes256-cbc + sha1 | 244.9, 242.5 Mbit/s | — |
|
||||
|
||||
**~1.16–1.20× faster on the cheaper cipher at identical CPU.** Same bytes of
|
||||
CPU work, more payload through it. That is the signature of CPU-bound software
|
||||
crypto, and it rules out the FortiGate's NPU as the limiter.
|
||||
|
||||
### Correcting two of my own earlier claims
|
||||
|
||||
1. **"UDM CPU is only ~7%, so it isn't CPU-bound" was WRONG — a sampling
|
||||
artifact.** UniFi's `system-stats.cpu` refreshes on the device report
|
||||
interval; 4-second sample windows were reading stale values. Under a
|
||||
sustained 60 s single-stream load it reads **35.4%**, with load1 rising
|
||||
0.60 → 1.17. On a 4-core UDM Pro SE that is ≈1.4 cores — one core saturated
|
||||
on crypto plus overhead. **Always drive load for ≥60 s before trusting a
|
||||
UniFi CPU figure.**
|
||||
2. **The "FortiGate per-session NPU offload" hypothesis is REFUTED**, not merely
|
||||
unproven. It predicts no change from a cipher swap; a 20% change was measured.
|
||||
|
||||
### Why the numbers all line up now
|
||||
|
||||
- **1 stream = 1 core of UDM crypto** → ~240 Mbit/s on AES-256-CBC.
|
||||
- **8 streams = ~3 usable cores** → ~692 Mbit/s, ≈2.9× the single-stream figure
|
||||
on a 4-core box. Aggregate is noisy (492–692 across repeats on a live link)
|
||||
and is *not* cipher-sensitive, consistent with it being bounded by the path/
|
||||
uplink rather than crypto once several cores are engaged.
|
||||
- **AES-CBC is the specific villain: it is serial.** Each block depends on the
|
||||
previous one, so the ARM AES instructions cannot pipeline across blocks. GCM
|
||||
(CTR-based) and ChaCha20-Poly1305 both parallelise freely. That is why the
|
||||
same UDM does 557 Mbit/s single-stream on WireGuard and only 240 on IPsec.
|
||||
- **This retroactively vindicates the GCM cutover as the right idea aimed at the
|
||||
right box** — GCM would have removed the serial dependency on the constrained
|
||||
end. UniFi simply does not offer it, which is what made it impossible.
|
||||
|
||||
### Options this opens
|
||||
|
||||
- **AES-128 instead of AES-256: ~16–20% for free**, no topology change, one API
|
||||
call per end. 128-bit is not the weak link here (SHA1 integrity is more
|
||||
dated, and unchanged either way). Operator's call — **not adopted**, restored
|
||||
to aes256.
|
||||
- **WireGuard site-to-site via ana-wg: 767 Mbit/s single-stream** (3.1×), and it
|
||||
sidesteps the UDM's IPsec datapath entirely. Still the biggest win available.
|
||||
- Replacing the FortiGate **will not help this** — it was never the constraint.
|
||||
Worth knowing before the cutover.
|
||||
|
||||
### State left behind
|
||||
|
||||
UDM network object verified **byte-identical** to its pre-test snapshot
|
||||
(aes256/sha1). Tunnel up, selectors 1/1. FortiGate phase2 `pfi-ana-nh3` is
|
||||
left as `aes256-sha1 aes256gcm aes128-sha1` — a permissive superset; the peer
|
||||
offers only aes256 so the extra entries are inert, but **narrowing it back to
|
||||
`aes256-sha1` is one line** if the looser list is unwanted.
|
||||
|
||||
---
|
||||
|
||||
## FOLLOW-UP 4 (2026-08-23): a downstream WireGuard terminator costs nothing to forward through
|
||||
|
||||
Operator's point: FortiOS has no WireGuard, so a WireGuard site-to-site must
|
||||
terminate on a box *behind* the edge. Correct — and `ana-wg` (LXC, CT 113 on
|
||||
pfi-pve, 10.250.50.252) already is that box.
|
||||
|
||||
**This closes a gap in FOLLOW-UP 3.** That 767 Mbit/s figure was measured with
|
||||
traffic terminating *on* ana-wg. Real traffic must be forwarded onward to other
|
||||
Anaheim hosts, which was never measured. Now it is:
|
||||
|
||||
| topology | 1 stream | 8 streams |
|
||||
|---|---|---|
|
||||
| IPsec, FortiGate ↔ UDM (today) | 245 Mbit/s | 692 Mbit/s |
|
||||
| WG terminating **on** ana-wg | 767 Mbit/s | 763 Mbit/s |
|
||||
| **WG transit: nh3 → wg → ana-wg → forward → ana-docker** | **763.8 Mbit/s** | **790.4 Mbit/s** |
|
||||
|
||||
**Forwarding through the LXC is free** (763.8 vs 767). The downstream-VM
|
||||
architecture delivers the full 3.1× single-stream for real transit traffic, not
|
||||
just for traffic landing on the tunnel box.
|
||||
|
||||
ana-wg while forwarding 764 Mbit/s: **~22% busy across 4 cores** (77.8% idle),
|
||||
so roughly 0.9 cores. Note `/proc/loadavg` inside this LXC reports the *host's*
|
||||
load, not the container's — do not read it as ana-wg's own. For contrast the
|
||||
UDM burns 35.4% of its 4 cores to move 240 Mbit/s, so ana-wg has ample headroom.
|
||||
|
||||
### Design consequences of terminating downstream — the parts that need decisions
|
||||
|
||||
1. **Anaheim hosts must route to ana-wg, not to the FortiGate.** The 763.8
|
||||
figure was obtained with an explicit `10.30.10.200/32 via 10.250.50.252`
|
||||
route on ana-docker. Without that, a host sends 10.100.0.0/16 to its default
|
||||
gateway (ana-gw), which routes it back out the *same* interface to ana-wg — a
|
||||
LAN hairpin crossing the FortiGate twice. **The hairpin variant was NOT
|
||||
measured.** Options: DHCP option 121 pushing the route fleet-wide, a dedicated
|
||||
transit VLAN for ana-wg, or accept the hairpin.
|
||||
2. **New single point of failure.** Today site-to-site dies only when the edge
|
||||
dies, which is total anyway. A downstream terminator fails independently.
|
||||
Mitigation: keep the IPsec tunnel configured as a higher-metric fallback
|
||||
route so it takes over when ana-wg is down.
|
||||
3. **ana-wg is an LXC on pfi-pve**, so its ~0.9 cores and NIC traffic land on the
|
||||
hypervisor shared with the rest of the Anaheim VMs.
|
||||
4. **The NH3 end needs a terminator too**, and there are two shapes:
|
||||
- **Linux VM at NH3** (nh3-dev or a dedicated VM on nh3-pve) — this is what
|
||||
was measured: **764 Mbit/s**.
|
||||
- **NH3 UDM's existing WireGuard server** (`PFI-NH3-WG`, wireguard-server on
|
||||
UDP 31337) accepting ana-wg as a peer — plausible but **untested**, and
|
||||
UniFi's WireGuard shows a per-flow penalty (557 Mbit/s single-stream on
|
||||
Site Magic), so expect ~557 rather than 764. Still 2.3× today.
|
||||
|
||||
### Standing recommendation
|
||||
|
||||
Worth doing, but it is **a project, not a config tweak** — routing, failover and
|
||||
policy all need deciding. The cheap wins remain available meanwhile and are
|
||||
independent: `nconnect=8` on NFS mounts (approved by brokkr-smithy-dev, pending
|
||||
the FortiGate work settling) and AES-128 for ~20%.
|
||||
|
||||
---
|
||||
|
||||
## LANDED (2026-08-23): AES-128 on both tunnels; FortiGate public admin closed
|
||||
|
||||
Operator directed: adopt AES-128 on **both** Anaheim tunnels, make-before-break,
|
||||
then close the FortiGate's WAN and SSH admin surfaces. All done and verified.
|
||||
|
||||
**Context that retires the WireGuard-in-a-VM design work:** the FortiGate is
|
||||
being **replaced by OPNsense on a Dell R420**, which gives **WireGuard on the
|
||||
edge device itself**. The downstream-terminator architecture (FOLLOW-UP 4) is
|
||||
therefore moot — do not scope it. This also **un-parks the OPNsense migration**,
|
||||
which auto-memory recorded as PARKED pending "hardware acquisition"; the R420
|
||||
is that trigger.
|
||||
|
||||
### What changed
|
||||
|
||||
Make-before-break on the FortiGate first, so neither tunnel dropped waiting on
|
||||
a far end:
|
||||
|
||||
| phase2 | proposal now |
|
||||
|---|---|
|
||||
| `pfi-ana-nh3` | `aes256-sha1 aes256gcm aes128-sha1` |
|
||||
| `ana-eshudm-dyn` | `aes256-sha1 aes128-sha1` |
|
||||
|
||||
Then each UDM flipped to `ipsec_esp_encryption: aes128`:
|
||||
|
||||
| tunnel | UDM object | before | after |
|
||||
|---|---|---|---|
|
||||
| NH3 ↔ ANA | `pfi-nh3-ana` `697d64414c85dd2b6669b00a` @ 10.100.0.1 | 245 Mbit/s | **269.7** |
|
||||
| ESH ↔ ANA | `esh-ana` `697723b9b9d4266dddf2bcc7` @ 10.0.0.1 | 268 Mbit/s | **304.3** |
|
||||
|
||||
Single-stream gain ~10–13% here, against 16–20% in the earlier controlled A/B —
|
||||
the difference is live-link variance, not a different result. Both UDM objects
|
||||
were diffed field-by-field against pre-change snapshots: **the only field that
|
||||
moved on either is `ipsec_esp_encryption`.**
|
||||
|
||||
The FortiGate proposal lists were deliberately **left permissive** (still
|
||||
accepting aes256). The peers offer only aes128 so the extra entries are inert,
|
||||
and keeping them means a UDM reverting does not strand the tunnel. Narrowing to
|
||||
`aes128-sha1` alone is a one-liner if the looser list is unwanted.
|
||||
|
||||
### Admin surfaces closed
|
||||
|
||||
`wan1 allowaccess` → **`ping`** (https + ssh removed) and `infra-ops` trusthost
|
||||
→ **10.0.0.0/8 only** (the 8 wide-open ranges unset). Verified 443 and 22 closed
|
||||
from both NH3 and ESH; management over the tunnel at 10.250.0.1 still works.
|
||||
**Sequencing that matters: the close was executed over the TUNNEL path, not over
|
||||
WAN** — removing `ssh` from allowaccess while connected over WAN kills the
|
||||
session mid-command.
|
||||
|
||||
**Consequence to hold in mind: ana-gw now has no out-of-band management path.**
|
||||
If both tunnels drop it is console-only until someone is on site.
|
||||
|
||||
### Gotcha: the two UDM vault items have DIFFERENT shapes
|
||||
|
||||
- `unifi/pfi-udmse-api-key` → a **bare 32-char key**. `secret get` output is the key.
|
||||
- `unifi/esh-udmpm-api-key` → a **19-line documentation note** with the key on a
|
||||
`key:` line. `secret get` piped straight into a header yields a 1396-byte
|
||||
value and the UDM answers **`400 Bad Request` from nginx**. Extract with
|
||||
`grep '^key:' | awk '{print $2}'`.
|
||||
|
||||
**The ESH key's first-ever confirmed WRITE happened here** (auto-memory recorded
|
||||
it as read-verified only): a control PUT of the unchanged object returned
|
||||
`rc:ok`, then the real change did too. That key has a full read+write admin role.
|
||||
|
||||
---
|
||||
|
||||
## CORRECTION (2026-08-23): port 80 on the WAN IP is the FortiOS ACME listener
|
||||
|
||||
The claim in the previous section that `.42:80` was an **ISP transparent proxy**
|
||||
was **WRONG**, and so was the earlier warning that ACME renewal would fail with
|
||||
port 80 absent from `allowaccess`. Operator pushed back asking where the port-80
|
||||
map terminated. It terminates **on the FortiGate itself**.
|
||||
|
||||
**What it is:** the FortiOS **ACME HTTP-01 challenge listener**. `config system
|
||||
acme` has `set interface "wan1"`, and FortiOS opens port 80 on that interface to
|
||||
answer Let's Encrypt challenges **independently of `allowaccess`** — `wan1
|
||||
allowaccess` reads `ping` only and the port is still open. Every non-challenge
|
||||
request returns a fixed 403 whose body is literally:
|
||||
|
||||
```
|
||||
<!DOCTYPE html><html><head><title>ACME Access Only</title></head><body>ACME Access Only</body></html>
|
||||
```
|
||||
|
||||
**Not a DNAT.** The full VIP table has 14 entries; only two land on `.42` —
|
||||
`Kokoro-In` (:8880 → 10.250.50.51) and `wg-to-ana-wg` (:31337 → 10.250.50.252).
|
||||
~~Worth noting separately: four VIPs are all-port static NAT~~ — **that claim was
|
||||
WRONG, see the correction below.** All fourteen VIPs are scoped.
|
||||
|
||||
### The methodology error that produced the wrong answer — worth not repeating
|
||||
|
||||
The sniffer filter used was `dst host 38.120.12.42 and tcp port 80`. **`dst host`
|
||||
matches only inbound packets**, so outbound SYN-ACKs were excluded *by
|
||||
construction*; concluding "the box sends no SYN-ACK" from that capture was
|
||||
unsound. Re-run with the bidirectional `host 38.120.12.42 and tcp port 80` it
|
||||
immediately shows `wan1 out 38.120.12.42.80 -> <scanner>: syn ack`.
|
||||
|
||||
**Rule: when testing whether a box *answers*, the sniffer filter must be
|
||||
bidirectional. `dst host` silently answers a different question.**
|
||||
|
||||
### Consequences
|
||||
|
||||
- **ACME renewal will work** with `allowaccess ping`. The earlier "add `http`
|
||||
back or the cert expires" warning is retracted — FortiOS opens the challenge
|
||||
port itself. Cert valid to 2026-10-27, renewal attempt ~2026-09-27.
|
||||
- **It is not an admin surface** — static 403, no auth, no GUI.
|
||||
- Its practical value is now low: WAN admin is closed, so the cert only serves
|
||||
the internal GUI at 10.250.0.1, where the name would not match anyway. Killing
|
||||
it (`config system acme` → unset interface) would close the last WAN listener
|
||||
at the cost of cert renewal. Operator's call; **not done**.
|
||||
|
||||
---
|
||||
|
||||
## CLOSED OUT (2026-08-23): ACME disabled; and the "all-port VIP" alarm was FALSE
|
||||
|
||||
### ACME disabled — the WAN IP now exposes nothing
|
||||
|
||||
`config system acme / unset interface` (the account object is left in place;
|
||||
with no interface bound there is no listener). Verified:
|
||||
|
||||
- **External scan of 38.120.12.42 across 55 ports: no open TCP ports at all.**
|
||||
- Internal GUI at 10.250.0.1 still answers **200**, SSH still works.
|
||||
- `admin-server-cert` is still `ana-fw.pfi` — the existing cert is untouched and
|
||||
serves the internal GUI until **2026-10-27**; it simply will not auto-renew.
|
||||
|
||||
Reverse with `config system acme / set interface "wan1"`.
|
||||
|
||||
### RETRACTION: the four VIPs are NOT all-port
|
||||
|
||||
A previous section claimed `Rustdesk`, `https-to-tacticalrmm`, `web-to-webhost`
|
||||
and `web-to-sfcontainer` were unrestricted all-port static NATs. **They are not.**
|
||||
A FortiOS VIP can be scoped **two different ways** and the parser used only
|
||||
checked one:
|
||||
|
||||
1. `set portforward enable` + `set extport <n>` — a single mapped port, **or**
|
||||
2. `set service "<svc>"` on the VIP object — constrains the VIP to that service.
|
||||
|
||||
All four use form 2. The custom services are narrow: `Rustdesk` = TCP
|
||||
21115–21119 + UDP 21116 (the standard RustDesk range), `ssh-mapped-2223` = TCP
|
||||
2223 only. **Every one of the 14 VIPs is scoped; none is unrestricted.**
|
||||
|
||||
**Lesson: absence of `portforward` does NOT mean all-port on a FortiOS VIP —
|
||||
check `service` too.** Better still, do what settled it here: scan from outside
|
||||
rather than reading config.
|
||||
|
||||
### Ground-truth public exposure (external TCP scan, post-change)
|
||||
|
||||
| IP | open | maps to |
|
||||
|---|---|---|
|
||||
| 38.120.12.41 | *nothing* | — |
|
||||
| **38.120.12.42** | ***nothing*** | the FortiGate itself — fully closed |
|
||||
| 38.120.12.43 | 80, 443 | sf-ana-container 10.250.150.100 (SureFire tenant) |
|
||||
| 38.120.12.44 | 22, 80, 443, 8025, 21115–21119 | gitea (→222), traefik, mailrise, RustDesk |
|
||||
| 38.120.12.45 | 80, 443, 2223 | pfi-ana-webhost 10.250.50.52 (2223→22) |
|
||||
| 38.120.12.46 | 443 | pfi-tacticalrmm 10.250.50.57 |
|
||||
|
||||
Configured-but-closed: 8443 (mattermost-calls), 8444 (webdav-nas), 8880
|
||||
(Kokoro-In) — VIPs exist, nothing listening behind them. Worth a tidy-up during
|
||||
the OPNsense translation but not exposure.
|
||||
@@ -0,0 +1,63 @@
|
||||
# [2026-08-23] hrafn adopted; its CI deploy reported green while deploying nothing
|
||||
|
||||
`hrafn` — genuine-Chromium browser-fetch behind a REST API, for bot-gated sites
|
||||
(Reddit first). Built by nevermore-claude on ana-docker, handed to infra-ops for
|
||||
uptime ownership. Internal-only on `traefik-net`, no host port; consumers reach
|
||||
`http://hrafn:8080`. Canonical at `stacks/hrafn/`.
|
||||
|
||||
## Intake found a live credential exposure
|
||||
|
||||
`/opt/docker/compose/hrafn/.env` was mode **0644 with a live 57-char bearer token**
|
||||
— verified as real exposure by reading it as `nobody` on a box with four
|
||||
interactive accounts. Tightened to 0600. That triggered the wider sweep (see
|
||||
[[2026-08-23-ana-docker-env-perms-sweep]]).
|
||||
|
||||
## The CI defect — the one worth remembering
|
||||
|
||||
I authored the deploy (elway playbook + gitea workflow) to replace a hand-rsync,
|
||||
tagging the image with the commit SHA for provenance. nevermore-claude later found
|
||||
v1.0.0 deploying "green" while the host still served 0.1.0.
|
||||
|
||||
**Root cause was mine and nastier than either hypothesis.** The staging dir was
|
||||
`$compose_dir/.stage` — **inside** the rsync target. So
|
||||
`rsync -a --delete $compose_dir/.stage/ $compose_dir/` deleted `.stage` from the
|
||||
destination (absent from the source listing) **during** the transfer, destroying
|
||||
its own source mid-copy. Reproduced exactly:
|
||||
|
||||
```
|
||||
before: app.py="OLD" leftover.txt .stage/app.py="NEW"
|
||||
after: app.py="OLD" leftover.txt GONE, .stage GONE
|
||||
```
|
||||
|
||||
Deletion succeeded, the copy silently did not, rsync exited 0. So the directory
|
||||
*looked* converged while host source stayed frozen at the first manual rsync —
|
||||
and because the build's `COPY` inputs never changed, Docker full-cache-hit and
|
||||
every SHA tag aliased one image. **The provenance the tagging existed to provide
|
||||
was false for the pipeline's entire life.**
|
||||
|
||||
**The real failure is the verification.** The verify steps asserted the marker,
|
||||
container health, and a 200 from `/readyz` — all of which pass against a
|
||||
completely frozen host. None measured *content*. A deploy that reports success
|
||||
without asserting the bytes changed is verifying an **uptime**, not a deploy.
|
||||
|
||||
## Fixes
|
||||
|
||||
- stage at `/tmp/hrafn-deploy-stage`, outside the target
|
||||
- CI computes `context_sha256` over the shipped file list; the playbook recomputes
|
||||
it **on the host after the converge** and fails on mismatch
|
||||
- compare the running container's `src/**/*.py` against the host's, so a SHA tag
|
||||
cannot name layers the image lacks
|
||||
- **compare `*.py` only** — `pip install .` generates `src/*.egg-info/*` inside the
|
||||
image and `__pycache__` appears at runtime, so a naive `find src -type f` compare
|
||||
false-fails on every healthy deploy. Verified against a known-good container
|
||||
before shipping (12 host files, 18 in container, 0 content differences).
|
||||
- declined `--no-cache`: a cache hit is *correct* when the context is genuinely
|
||||
unchanged; assert the property rather than brute-force it.
|
||||
|
||||
## Access
|
||||
|
||||
Operator granted claude-bot **write** on `vh/hrafn`, so infra-ops maintains the
|
||||
pipeline it owns instead of routing patches through the repo holder. `vh/hrafn` is
|
||||
canonical; `stacks/hrafn/ci/` is a verified mirror.
|
||||
|
||||
Commits `b6924de`, `b001d0c`, `11b9d18`, `b38c369`, `9642952`.
|
||||
@@ -0,0 +1,81 @@
|
||||
# [2026-08-23] selene seat retired after losing a head-to-head; 7 aliases share one seat
|
||||
|
||||
## Why selene went
|
||||
|
||||
Benchmarked against `gen` on selene's own job — 24 designed judge items with
|
||||
checkable ground truth, pairwise + absolute modes, 3 repeats, run on **both** a
|
||||
neutral JSON prompt and Selene's **native Atla template** (288 calls, free local).
|
||||
|
||||
```
|
||||
neutral JSON selene 20/24 (83%) gen 23/24 (96%)
|
||||
native Atla selene 21/24 (88%) gen 22/24 (92%)
|
||||
```
|
||||
|
||||
gen won on both templates and **selene's best sat below gen's worst**. Selene was
|
||||
given its own fine-tuned template as a fairness check before any recommendation;
|
||||
it gained one point, not three.
|
||||
|
||||
**Decisive defect: selene cannot emit "tie"** — 0/2 on both templates, forcing a
|
||||
winner on every equivalent pair. For eval work that is the case that matters.
|
||||
|
||||
brokkr-smithy-dev independently corroborated from the other end with a **null
|
||||
control** (an excerpt compared against ITSELF, where tie is definitional):
|
||||
`chat-judge`(selene) TIE **27/60 = 45%**, gen **60/60 = 100%**; ground-truth
|
||||
recovery on real-corpus ranking selene **47% — chance** vs gen 94%. My 83-vs-96
|
||||
understated it: on a *ranking* task selene was a coin flip. Absolute scoring on
|
||||
designed items is an easier task than ranking real text — the harness is a
|
||||
**screen, not a verdict**, and its README says so.
|
||||
|
||||
Reclaimed **17.2 GiB** on ana-ml2 GPU1 (free 1,818 -> 19,450 MiB).
|
||||
|
||||
## The naming rule, restated the hard way
|
||||
|
||||
I proposed repointing `selene-1-mini-8b` at gen and was **correctly overruled**:
|
||||
|
||||
> never repoint a named model at a different model's endpoint — that is
|
||||
> intentionally misleading
|
||||
|
||||
`chat-judge` is a **role** alias (ADR-0012: consumers bind the capability) and
|
||||
moved to gen with a deterministic judge profile copied from `image-judge`.
|
||||
`selene-1-mini-8b` is a **model** name and was removed outright — it now returns
|
||||
`HTTP 400 Invalid model name`, verified. The discriminator: *does the string
|
||||
promise a capability, or an identity?*
|
||||
|
||||
## The 7-way alias collision — the finding with the longest reach
|
||||
|
||||
```
|
||||
chat-judge classifier gen image-judge
|
||||
qwen-image-bench summarizer summarizer-large -> qwen3.8-27b-uncensored :8015
|
||||
```
|
||||
|
||||
Also colliding: `gen-frontier`/`gen-frontier-reasoning`/`glm-5.2`/`glm-5.2-reasoning`;
|
||||
`ext-tts`/`gpt-4o-mini-tts`/`tts-1`/`tts-1-hd`; `reranker`/`reranker-a3-bge-v2-m3`.
|
||||
|
||||
**Cross-checking a result against another alias measures nothing when they are the
|
||||
same weights — agreement is an echo, not corroboration.** Documented at the head of
|
||||
`model_list` in the live gateway config, because it belongs where people read it.
|
||||
|
||||
This caught a real defect within hours: brokkr's R47 premium-corpus gate was about
|
||||
to run ~46,000 record-exposures against `gen` with `summarizer` shortlisted as an
|
||||
independent second opinion. They pinned the backing model in the preregistration
|
||||
and dropped the second-alias idea instead.
|
||||
|
||||
## Provenance seam (brokkr's pushback, adopted)
|
||||
|
||||
The gateway returns the **alias** in the response `model` field, not the backing
|
||||
model — so a per-call guard catches a swap *during* a run and is blind to one
|
||||
*between* runs. **Role alias for routing, concrete model for provenance.**
|
||||
`GET :4000/model/info` with the shared key already exposes backing model +
|
||||
api_base; resolve at run start AND end and void on mismatch.
|
||||
|
||||
## Artifacts
|
||||
|
||||
- Harness kept at `tools/judge-bench/` (`--models` REQUIRED — a stale default
|
||||
would silently benchmark a retired seat).
|
||||
- `stacks/selene/` keeps compose + a README explaining the retirement.
|
||||
- Technique worth stealing, from brokkr: **a control constructed so the correct
|
||||
answer is DEFINITIONAL rather than judged cannot inherit the designer's error.**
|
||||
Item vs itself; response vs its own truncation; text vs its own clauses
|
||||
permuted. Add those before adding more judged items.
|
||||
|
||||
Commits `ca3c984`, `b8a5355`.
|
||||
@@ -0,0 +1,50 @@
|
||||
# [2026-08-23] `/mnt/smithy` mounted on ana-ml2 — read-only and SOFT, deliberately not matching nh3-dev
|
||||
|
||||
brokkr-smithy-dev asked for `10.100.50.50:/volume1/smithy` on ana-ml2 to run R47's
|
||||
CPU-bound corpus pipeline on 96 idle EPYC cores instead of one nh3-dev vCPU. Granted,
|
||||
with two deliberate deviations from what was requested.
|
||||
|
||||
```
|
||||
sudo mount -t nfs4 -o ro,soft,timeo=30,retrans=3,proto=tcp,vers=4.1 \
|
||||
10.100.50.50:/volume1/smithy /mnt/smithy
|
||||
```
|
||||
|
||||
The export already permitted ana-ml2 — no DSM change needed. Write is genuinely
|
||||
refused.
|
||||
|
||||
## Why soft, not hard
|
||||
|
||||
They asked to match nh3-dev's mount, which is `hard`. **nh3-dev is same-site as the
|
||||
NAS; ana-ml2 is not** — this is cross-site NFS on the box running the fleet's
|
||||
inference seats. A hard mount turns a link blip into unkillable D-state, and this
|
||||
fleet has already lost a host that way (esh-docker-vm; only fix was a reboot). Soft
|
||||
returns EIO, the batch job fails, you rerun it. The soft-mount corruption caveat is a
|
||||
**write** hazard and this is read-only. Mirrors the existing ESH books mount.
|
||||
|
||||
## Why not in fstab
|
||||
|
||||
Manual only, matching irv-ml1's `/mnt/smithy` precedent. A cross-site NFS entry in
|
||||
fstab can hang boot on a GPU host with 71 days uptime. **Needs remounting after a
|
||||
reboot.**
|
||||
|
||||
## The performance reality, measured on the same file through the same mount
|
||||
|
||||
```
|
||||
ana-ml2 (cross-site) nh3-dev (same-site)
|
||||
sequential read 24.7 MB/s 98.3 MB/s
|
||||
small-file rate 45.3 files/s 34.6 files/s
|
||||
```
|
||||
|
||||
Two different stories, and file layout decides which you get:
|
||||
|
||||
- **Many small records -> ana-ml2 wins on BOTH axes.** That path is bound by per-file
|
||||
round-trips and NAS overhead, not bandwidth, and ana-ml2 is an idle 96-core box
|
||||
while nh3-dev is a loaded 16-vCPU VM.
|
||||
- **Bulk sequential streaming -> the link eats the win.** 4x read penalty against a
|
||||
6x CPU gain. `datasets/raw` is 126 GB, `datasets/derived` is 1.7 GB — which one the
|
||||
pipeline traverses changes the answer by two orders of magnitude. Staging a subset
|
||||
to ana-ml2 local disk (195 GB free) beats pulling it over the wire repeatedly.
|
||||
|
||||
The 24.7 MB/s is the Anaheim tunnel, not NFS and not the NAS — see
|
||||
[[2026-08-23-anaheim-ipsec-tunnel-ceiling]]. No mount tuning will move it;
|
||||
parallelism will.
|
||||
@@ -0,0 +1,70 @@
|
||||
# [2026-08-23] Worldtree b187 shipped; all three instances de-armed from a 69-day-stale `:latest`; Matrix homeserver re-plumbed
|
||||
|
||||
## b187 pre-stage (#405 phases 1+2)
|
||||
|
||||
The matrix bridge stopped embedding the engine and became an HTTP client of the
|
||||
Conversation API, so `WORLDTREE_API_URL` became **boot-blocking** — absent from the
|
||||
container env, the bridge exits by design. Demo's compose never passed it; the next
|
||||
recreate would have crash-looped. Pre-staged on demo and personal (additive, backed
|
||||
up, verified with `docker compose config`, nothing restarted).
|
||||
|
||||
**Key decision, and I got its scope wrong first.** I argued demo should stay keyless
|
||||
(no homeserver -> no rooms -> no turns -> no 401s). Right about turns, **wrong about
|
||||
scope**: the engine preflight authenticates at boot regardless of homeserver, so demo
|
||||
booted permanently degraded. Corrected — key `341c1488` minted under worldtree-dev's
|
||||
recorded authorization, vaulted, wired, three-hop hash-verified.
|
||||
|
||||
## The 69-day-stale `:latest` landmine
|
||||
|
||||
All three instances pinned `WORLDTREE_IMAGE=.../worldtree:latest` in `.env` while
|
||||
running SHA-tagged images built that day. Local `:latest` = `b19afd71d7cc`, built
|
||||
**2026-06-14**. So ANY `docker compose up` — anyone's, for any reason — silently
|
||||
downgraded that service by 69 days. Same footgun as the 2026-06-15 outage.
|
||||
|
||||
Re-pinned all three to their running SHAs (Worldtree #410), verified by rendering
|
||||
compose config rather than reading `.env`, containers untouched. Playbook at
|
||||
`playbooks/repin-worldtree-image.yaml`.
|
||||
|
||||
**`worldtree-pinned` was the worst case:** the instance whose entire purpose is being
|
||||
frozen was running a **dangling image with no repo tags**, kept alive only by the
|
||||
running container. One `docker rm` from garbage collection. Tagged
|
||||
`:446e5807bf43` first, then pinned.
|
||||
|
||||
The guard I wrote had two bugs the pinned case exposed: it compared the container's
|
||||
`.Config.Image` **string** (only the tag it was CREATED from — pinned was created
|
||||
from `:latest` back when that meant 446e5807), and it reported CHANGED
|
||||
unconditionally. Now compares **image IDs** and skips when already correct.
|
||||
|
||||
## Matrix homeserver ownership
|
||||
|
||||
Operator ruled: **personal owns the Matrix bridge.** The appservice tokens were never
|
||||
missing — both sat at length 64 in the vaulted dev `env.sh` while both deployed
|
||||
instances had them at length **zero**. Someone wired four of six Matrix vars and
|
||||
stopped. Wired them into personal, three-hop verified.
|
||||
|
||||
**The trap worth remembering:** Synapse's registration pointed at
|
||||
`http://10.100.10.50:8009` — nh3-dev, a dead epoch, with transaction 2801 queued at
|
||||
512s backoff. The natural fix (swap the IP) gives `10.250.50.152:8009` which is
|
||||
**DEMO's** bridge, and Synapse can reach both — it would have connected, delivered,
|
||||
and looked correct while routing the operator's live rooms to the demo instance.
|
||||
**Personal's bridge is :8010.** `docker port` is ground truth.
|
||||
|
||||
Corrected the URL, restarted Synapse (healthy in 32s after 3.5 months up), verified
|
||||
`GET /_matrix/app/v1/ping -> 200` from inside the Synapse container. worldtree-dev's
|
||||
smoke passed first try: room created, mimir accepted the invite, a real engine turn
|
||||
ran, mimir replied in persona voice. #408 closed.
|
||||
|
||||
## Open on worldtree-dev's side
|
||||
|
||||
- **#411** — personal's bridge logs `Debug sink init failed: Permission denied:
|
||||
/app/sessions/debug_rooms.json`. It creates two debug rooms but cannot persist
|
||||
their IDs, so **every restart mints a fresh pair on the live homeserver**. Room
|
||||
litter that compounds silently. Needs a which-container-writes-what check on the
|
||||
sessions volume before anyone chowns it.
|
||||
- Bridge/engine agent-roster drift: 6 of the bridge's 9 configured agents are not
|
||||
listed by the engine on either instance.
|
||||
- Historical Domari pairwise verdicts from the selene era are coin-flip-grade
|
||||
(see [[2026-08-23-selene-retired-alias-collision]]); worldtree-dev banked that so
|
||||
no future arc leans on them without re-judging.
|
||||
|
||||
Commits `064181a`, `bb19a96`.
|
||||
@@ -0,0 +1,50 @@
|
||||
# [2026-08-24] ana-gw public admin surface closed to zero, ACME listener included
|
||||
|
||||
WAN admin was opened at the start of the session as a cutover contingency
|
||||
("so I don't have to drive down there"), then closed again on operator
|
||||
instruction once the AES-128 work landed. Net result: **the FortiGate's WAN
|
||||
address now exposes no TCP port at all.**
|
||||
|
||||
## Final state
|
||||
|
||||
External scan of `38.120.12.42`, 55 ports: **nothing open**. Verified from two
|
||||
sites. `wan1 allowaccess` = `ping`; `infra-ops` trusthost back to `10.0.0.0/8`.
|
||||
|
||||
**Consequence to hold: there is no out-of-band path to ana-gw.** If both tunnels
|
||||
drop it is console-only. Re-open is two one-liners (allowaccess + trusthost) —
|
||||
both are recorded in auto-memory `reference_fortigate_ana_gw_access`.
|
||||
|
||||
## Port 80 was the FortiOS ACME listener, and I got it wrong first
|
||||
|
||||
`38.120.12.42:80` answered a bare 403 (`ACME Access Only`, 101 bytes) with
|
||||
`allowaccess` set to ping only. First diagnosis — "an ISP transparent proxy" —
|
||||
was **wrong**, and the reason is worth keeping:
|
||||
|
||||
> The sniffer filter was `dst host 38.120.12.42 and tcp port 80`. **`dst host`
|
||||
> matches inbound only**, so outbound SYN-ACKs were excluded *by construction*,
|
||||
> and concluding "the box sends no SYN-ACK" from that capture was unsound.
|
||||
|
||||
Re-run bidirectionally (`host … and tcp port 80`) it immediately showed
|
||||
`wan1 out 38.120.12.42.80 -> <scanner>: syn ack`. **Rule: to test whether a box
|
||||
*answers*, the filter must be bidirectional.**
|
||||
|
||||
The listener is opened by `config system acme / set interface "wan1"` and
|
||||
**bypasses `allowaccess` by design** — FortiOS needs port 80 for HTTP-01. It
|
||||
was disabled (`config system acme / unset interface`); the LE cert (`ana-fw.pfi`,
|
||||
valid to 2026-10-27) is untouched and simply stops renewing, which is fine
|
||||
because WAN admin is closed and the box is being replaced.
|
||||
|
||||
## Retracted in the same pass: the "four all-port VIPs" alarm
|
||||
|
||||
Claimed four VIPs were unrestricted all-port static NAT. **False.** A FortiOS
|
||||
VIP is scoped **two** ways — `portforward`+`extport`, *or* a `service` binding
|
||||
on the VIP object — and only the first was checked. All 14 VIPs are scoped;
|
||||
`Rustdesk` is TCP 21115–21119, `ssh-mapped-2223` is TCP 2223 only.
|
||||
|
||||
Ground-truth external scan of all six public IPs is recorded in
|
||||
`reference_fortigate_ana_gw_access`. Configured-but-dead: `:8443`
|
||||
(mattermost-calls), `:8444` (webdav-nas), `:8880` (Kokoro-In) — tidy-up
|
||||
candidates for the OPNsense translation, not exposure.
|
||||
|
||||
**Lesson, twice in one session: measure from outside instead of parsing config.**
|
||||
Both wrong answers came from a filter that answered a different question.
|
||||
@@ -0,0 +1,167 @@
|
||||
# `[2026-08-24]` char-rp seat: OOM root-cause, Gemma-4 MoE swap, and the abliterated trainee base
|
||||
|
||||
One evening, one thread with brokkr-smithy-dev, five commits: `850e0c3`,
|
||||
`27155c0`, `f509668`+`24e8826`+`1bd90ea`+`3446367`+`8d6a939`, `14ff4a3`,
|
||||
`019ccff`, `5415fd4`.
|
||||
|
||||
## 1. The seat was crash-looping, and the cause was NOT its config
|
||||
|
||||
`vllm-meromero-rp` reported up-but-unreachable, RestartCount climbing (13 by the
|
||||
time it was examined, not the 4 first reported). Startup logs looked clean all
|
||||
the way through weights, `torch.compile` and CUDA-graph capture, then:
|
||||
|
||||
torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 336.00 MiB.
|
||||
GPU 0 has a total capacity of 94.97 GiB of which 195.19 MiB is free.
|
||||
|
||||
**⚠ `--gpu-memory-utilization` SIZES THE KV CACHE AND DOES NOT COVER CUDA
|
||||
CONTEXT, GRAPHS OR NON-TORCH OVERHEAD.** gen is configured at 0.43 and actually
|
||||
held 45.6 GiB. char-rp was at 0.51. The pair was committed to 0.94 of the card
|
||||
with ~0.6 GiB of real headroom — it fit on the 21st and stopped fitting on the
|
||||
24th.
|
||||
|
||||
Dropped char-rp to 0.47: ~4.8 GiB margin, KV 27.36 → 23.56 GiB, 430,825 →
|
||||
371,023 tokens against a max-model-len of 262,144. **Cost nothing usable** — the
|
||||
pool still holds 1.4x a full-length sequence; what is lost is concurrent long
|
||||
requests, not context.
|
||||
|
||||
**⚠⚠ THE MISSING HALF, found later that evening: gen's footprint GROWS WITH
|
||||
UPTIME.** Same container, same 0.43: **45.6 GiB after ~3 days up, 38.5 GiB
|
||||
freshly restarted** — ~7 GiB apart. Nothing about char-rp changed between the
|
||||
21st and the 24th; *gen crept up underneath it*. **Headroom arithmetic done
|
||||
against a long-running gen is measuring a moving number.** Measure against a
|
||||
freshly-restarted one.
|
||||
|
||||
## 2. `char-rp` and `char-rp-reasoning` are ONE seat, not two
|
||||
|
||||
Both LiteLLM routes point at `10.250.50.54:8016/v1` — `hosted_vllm/char-rp` and
|
||||
`hosted_vllm/char-rp-thinking`. brokkr had reported 30/80 and 80/80 failure
|
||||
rates as two failing services; it was one outage sampled twice. This also
|
||||
*improved* a result of theirs: their CoT on/off battery had assumed both aliases
|
||||
were the same weights under two chat templates, and the routing detail turned an
|
||||
assumption into a verified fact.
|
||||
|
||||
(`vllm-charrp-reasoning-nvfp4`, the Heretic2 NVFP4+MTP container, has been
|
||||
stopped for 12+ days and is unrelated — it is not what that alias resolves to.)
|
||||
|
||||
## 3. The seat swapped to the Gemma-4 26B-A4B MoE
|
||||
|
||||
Operator-directed straight-across replacement: same port, same
|
||||
served-model-names, so no gateway route or consumer config moved. Rationale is
|
||||
throughput under CoT — the user waits through the whole reasoning block before
|
||||
the first visible token, and the MoE measures ~114 tok/s @32K against the dense
|
||||
31B's ~40.7.
|
||||
|
||||
Serving copy is `RedHatAI/gemma-4-26B-A4B-it-NVFP4` (16 GB), chosen over the
|
||||
other `-it` quants because it is compressed-tensors (`nvfp4-pack-quantized`) —
|
||||
the same loader path the outgoing seat used. Smaller weights at the same 0.47
|
||||
budget bought **1,724,110 KV tokens against the predecessor's 371,023**.
|
||||
|
||||
`meromero-charrp` is retained stopped in `created` state, labelled
|
||||
`AI - Dormant`. Both stacks bind `:8016`, so rollback is **stop-then-start**.
|
||||
|
||||
## 4. ⚠ THE STALE-CHAT-TEMPLATE TRAP IS ENDEMIC, NOT A ONE-OFF
|
||||
|
||||
Verified by hash across every third-party Gemma-4 derivative pulled:
|
||||
|
||||
| build | lines | sha256 (normalised) |
|
||||
|---|---|---|
|
||||
| upstream `google/gemma-4-26B-A4B-it` | 390 | `6a1015c47ccfcfa6` |
|
||||
| RedHatAI NVFP4 (served) | 389 | `6a1015c47ccfcfa6` — the only match |
|
||||
| llmfan46 heretic | 365 | `0a52be69cda5ab8a` |
|
||||
| TrevorJS abliterated | 266 | `58c66fdee4afa297` |
|
||||
| jenerallee78 abliterated | 266 | `58c66fdee4afa297` |
|
||||
| prithivMLmods NVFP4A16 | 266 | `58c66fdee4afa297` |
|
||||
|
||||
Three independent repos carrying the *identical* stale file means it propagated
|
||||
through the ecosystem. Consequences differ by use and **both are silent**:
|
||||
serving a mismatched template renders a different prompt; **training through
|
||||
`base/chat_template.jinja` means training on a different prompt format than
|
||||
production serves** — train/serve skew, no error, presents as a tuning failure.
|
||||
|
||||
The production compose now pins the template explicitly. It is a **no-op for the
|
||||
served weights** (the A4 build ships that exact file) and permanently closes the
|
||||
class. ⚠ If `GEMMA4_MODEL` ever points at a different checkpoint, the template
|
||||
default must move with it.
|
||||
|
||||
## 5. A benchmark result was RETRACTED — below chance indicts the instrument
|
||||
|
||||
A battery appeared to show Gemma at **12% contradiction detection with CoT off
|
||||
against gen's 81%**. An A16 activation-precision control was staged to test
|
||||
whether the quant scheme owned it. Then the operator asked to see the individual
|
||||
items, and the item was **ill-posed**: it presented two mutually contradicting
|
||||
statements and asked for "*the* contradicting statement", but **contradiction is
|
||||
symmetric**. The model consistently named the absolute claim — a defensible
|
||||
reading the labelling scored wrong every time.
|
||||
|
||||
**⚠ THE TELL WAS IN PLAIN SIGHT: 12% ON A FIVE-OPTION TASK IS BELOW THE 20%
|
||||
CHANCE FLOOR.** A below-chance score indicts the instrument before it indicts
|
||||
the model, and neither side reacted to it. I spent the afternoon verifying repo
|
||||
names, config fields, template hashes and tokenizer vocabs — every layer of
|
||||
plumbing — and never asked whether the number itself was *possible*. **A
|
||||
preflight can be thorough and still be aimed in the wrong direction.**
|
||||
|
||||
Retracted: "the model owns the contradiction deficit"; "domain tuning costs 43
|
||||
points of contradiction detection" (on a sound instrument it **reverses**); all
|
||||
pre-fix T2 numbers. Recorded as a dated superseded-claims table in
|
||||
`stacks/gemma4-charrp/README.md` rather than a silent edit.
|
||||
|
||||
**What survived:** the A16 control result — activation precision is close to free
|
||||
on this battery, every other task identical across W4A4 and W4A16 builds.
|
||||
|
||||
## 6. The abliterated trainee base — measured, not assumed
|
||||
|
||||
Operator directed a low-damage abliterated instruct build. "Low damage" was
|
||||
treated as a measurable claim; the field spreads from KL 0.09 to 0.4118:
|
||||
|
||||
| build | method | KL | refusals |
|
||||
|---|---|---|---|
|
||||
| **llmfan46** (operator's pick) | Heretic v1.2.0 ARA | 0.1237 | 3/100 |
|
||||
| TrevorJS | ARA-family | 0.09 | 1/100 effective, 5/686 cross-dataset |
|
||||
| jenerallee78 | ARA 2-pass | 0.1299 | 7.7% StrongREJECT |
|
||||
| huihui-ai | remove-refusals | none published | none published |
|
||||
|
||||
Fleet anchor: our own work found **Heretic at KL 0.12 preserved the MTP head at
|
||||
83.7% acceptance**, so both staged builds sit inside an already-measured band.
|
||||
huihui-ai rejected — no metrics, its card calls the method "a crude,
|
||||
proof-of-concept implementation", it abliterates both thinking and non-thinking
|
||||
modes, and its parameter count runs ~738M over upstream. Operator's independent
|
||||
read matched ("huihui produces garbage").
|
||||
|
||||
**Abliteration isolated properly** (stock BF16 vs llmfan46 BF16, same precision,
|
||||
same pinned template, same 192 items):
|
||||
|
||||
T2 contradiction 75% → 59% (−5 items)
|
||||
T6 spatial 75% → 88% (+4 items)
|
||||
core 90.0% → 89.4% (−0.6 pts)
|
||||
|
||||
**It MOVED capability rather than removing it** — five lost on contradiction,
|
||||
four gained on spatial, nearly cancelling. Nobody predicted a gain. **llmfan46
|
||||
stands**; no case for re-staging on TrevorJS over 0.6 points.
|
||||
|
||||
⚠ Read as ~5 and ~4 items at n=32, not as −15.6/+12.5 percent. ⚠ Says nothing
|
||||
about quantization — the stock-NVFP4 T2 figure came from n=16 against n=32,
|
||||
different item sets, n-confounded.
|
||||
|
||||
## 7. ⚠ The production compose hardcodes `--quantization compressed-tensors`
|
||||
|
||||
Pointing the char-rp stack at unquantized BF16 weights crash-loops immediately:
|
||||
|
||||
TypeError: CompressedTensorsConfig.__init__() missing 3 required
|
||||
positional arguments: 'target_scheme_map', 'ignore', 'quant_format'
|
||||
|
||||
vLLM trying to read a quantization config out of a checkpoint that has none. 35
|
||||
restarts before it was caught. Hence `stacks/gemma4-trainee-bench/` — a separate
|
||||
ephemeral stack with no quantization flag, `restart: "no"` so a bench seat cannot
|
||||
resurrect itself and block gen's restore, and no homepage labels so it leaves no
|
||||
permanently-offline card. That detour is why a base swap is now ~5 minutes
|
||||
instead of ~15.
|
||||
|
||||
## 8. BF16 cannot coexist with gen
|
||||
|
||||
48.07 GiB of BF16 weights plus gen's footprint exceeds the 94.97 GiB card before
|
||||
a byte of KV cache. Every BF16 bench window means **gen is stopped**. Two such
|
||||
windows were run and gen restored both times; the restore was triggered by
|
||||
observing the seat's own throughput logs (a large prefill burst then zero
|
||||
running/zero waiting) rather than waiting on a courtesy message.
|
||||
|
||||
Cross-links: [[2026-08-24-homepage-uniform-grid]]
|
||||
@@ -0,0 +1,77 @@
|
||||
# [2026-08-24] ESH DNS fixed at the IPv6 layer, and the naming scheme went live
|
||||
|
||||
Reported as "`scriberr.ana.internal` doesn't resolve on my Mac, and nslookup
|
||||
shows an IPv6 DNS server." Operator's diagnosis was right; the fix took three
|
||||
wrong turns worth recording.
|
||||
|
||||
## Root cause
|
||||
|
||||
`esh-userland` has IPv6 PD with RA at `pref high`, and the UDM advertises
|
||||
**itself** as the resolver via RDNSS. macOS honours RDNSS and prefers it over
|
||||
the DHCPv4-supplied resolver, so queries went to the UDM — which does not know
|
||||
`.internal` — and returned NXDOMAIN. AdGuard was never consulted.
|
||||
|
||||
Two adjacent gaps found while there: `esh-userland`'s **secondary** v4 resolver
|
||||
was `10.0.10.1` (the UDM itself), and `esh-server` had **DNS handout disabled
|
||||
entirely**, so every host there got the UDM and could never resolve `.internal`
|
||||
— esh-docker-vm was living proof.
|
||||
|
||||
## The three wrong turns
|
||||
|
||||
1. **`dhcpdv6_dns_auto=false` alone does nothing.** It is only honoured **when an
|
||||
explicit server is supplied**. Setting it bare looks like a no-op and invites
|
||||
the conclusion that the field is inert — which I drew, wrongly. Despite the
|
||||
`dhcpdv6_` prefix it *does* drive the RA's RDNSS option on a SLAAC network.
|
||||
2. **`wan_dns1` is NOT used by the UDM's LAN-facing forwarder.** Setting it to
|
||||
AdGuard persists, reads back, and changes nothing. Proven with **fresh
|
||||
uncached ad domains** — AdGuard blocklists answer `0.0.0.0`, the UDM returned
|
||||
real IPs. Reverted.
|
||||
3. **`force-provision` returns `rc:ok` and proves nothing** — consistent with the
|
||||
known `cmd/devmgr` behaviour.
|
||||
|
||||
Every failed attempt returned `rc: ok`. **Verify by observed effect.** RAs were
|
||||
probed with a stdlib raw-socket Router Solicitation parsing option type 25
|
||||
(`rdisc6`/`tcpdump` were both absent; nothing was installed).
|
||||
|
||||
## What landed
|
||||
|
||||
RDNSS **redirected** rather than disabled — better than switching it off:
|
||||
|
||||
| VLAN | v4 | v6 RDNSS |
|
||||
|---|---|---|
|
||||
| `esh-userland` | 10.0.50.45 + 10.100.50.40 | `…:4411:b105:50:45` |
|
||||
| `esh-server` | 10.0.50.45 + 10.100.50.40 | `…:4411:b105:50:45` |
|
||||
|
||||
The v4 secondary moved from the UDM to the **NH3 AdGuard** — reachable over
|
||||
Site Magic and authoritative for the zone. ⚠ **A secondary only fails over on
|
||||
SILENCE, not on wrong answers**: NXDOMAIN is a *successful* answer, the client
|
||||
accepts it and never retries. A secondary that doesn't know your private zone is
|
||||
a coin flip, not a spare tyre. `esh-cameras` deliberately untouched — routing
|
||||
camera DNS through AdGuard's filtering risks their cloud features.
|
||||
|
||||
## The naming scheme became real
|
||||
|
||||
The resolver address is the scheme's first live use, replacing a MAC-derived
|
||||
SLAAC address that would break on a NIC change. All three `esh-server` Linux
|
||||
hosts now carry `4411:B105` ("FOR ALL BIOS"):
|
||||
|
||||
```
|
||||
esh-docker-vm …:4411:b105:50:45 esh-pve-nas …:4411:b105:50:55
|
||||
esh-vm-db …:4411:b105:50:60
|
||||
```
|
||||
|
||||
Applied by an `if-up.d` hook that **derives the prefix at runtime** (self-heals
|
||||
on re-delegation), backgrounds itself with a retry (SLAAC may not have landed;
|
||||
a blocking hook would stall bring-up on a headless box), and adds nothing to
|
||||
existing config. **Not** an `iface … inet6 static` stanza — on Debian that sets
|
||||
`accept_ra=0` and would strand the host.
|
||||
|
||||
⚠ **Proxmox bridges need `accept_ra=2`.** `esh-pve-nas` had link-local only
|
||||
despite every sysctl looking right: `vmbr0.forwarding=1`, and the kernel ignores
|
||||
RAs on a forwarding interface unless `accept_ra` is explicitly `2`. Fixed with
|
||||
`accept_ra_defrtr=0` alongside, so it takes the prefix but **declines the default
|
||||
route** — an IPv6 identity with no change to a hypervisor's routing. Expect this
|
||||
on every Proxmox node when its LAN gets v6.
|
||||
|
||||
Canonical: `docs/pfi/ipv6-naming-scheme.md`. UniFi limits:
|
||||
auto-memory `reference_unifi_dns_rdnss_limits`.
|
||||
@@ -0,0 +1,308 @@
|
||||
# `[2026-08-24]` Homepage: remote-label consumption re-verified, then the board relaid out on a uniform grid
|
||||
|
||||
Prompted by the operator: *"Homepage on esh-vm-docker lists remote dockers and
|
||||
can absolutely consume their labels, please verify again. I am still
|
||||
unsatisfied with the layout and aesthetics."*
|
||||
|
||||
## The verification — the operator was right, and the record now says so
|
||||
|
||||
**Homepage on `esh-docker-vm` discovers services by container label from all
|
||||
five Docker engines in `conf/docker.yaml`, not just its own.** This is not an
|
||||
inference; `GET /api/services` returns every card's `server` field, and the
|
||||
2026-08-24 snapshot resolves to:
|
||||
|
||||
| `server` | host | label-discovered services |
|
||||
|---|---|---|
|
||||
| `ana-pfi-docker` | 10.250.50.70 | 30 |
|
||||
| `irv-ml1-docker` | 10.100.79.3 (over WireGuard) | 15 |
|
||||
| `ana-ml2-docker` | 10.250.50.54 | 14 |
|
||||
| `esh-vm-docker` | 10.0.50.45 (the dashboard's own host) | 13 |
|
||||
| `nh3-pfi-docker` | 10.100.50.40 | 2 |
|
||||
|
||||
**74 of 107 cards are label-discovered, and only 13 of those come from the
|
||||
dashboard's own engine** — the other 61 are read off four remote hosts,
|
||||
including irv-ml1 across the WireGuard tunnel. The remaining 33 carry
|
||||
`server: null`: those are the manual `services.yaml` entries — hardware, BMCs,
|
||||
hypervisors, printers, and user-level systemd services that have no container
|
||||
to label in the first place. **That null is the only thing "not label-driven"
|
||||
about this dashboard**, and it is a property of the entry, not of the host it
|
||||
points at.
|
||||
|
||||
⚠ If a future session doubts this again, the check is one command and takes two
|
||||
seconds — do not reason about it from the docs:
|
||||
|
||||
```bash
|
||||
curl -s http://10.0.50.45:5100/api/services \
|
||||
| jq -r '.[].services[] | .server' | sort | uniq -c
|
||||
```
|
||||
|
||||
## What was actually wrong with the layout
|
||||
|
||||
Measured with Playwright against the live board (per-group `card=` width, card
|
||||
height spread, and a geometric title-vs-status overlap test), not judged by
|
||||
eye:
|
||||
|
||||
- **Card width changed at every group boundary.** `columns:` is not a density
|
||||
dial — it sets `lg:grid-cols-N` for one group, so it fixes that group's card
|
||||
width. Notes rendered a single **1464px** card; News and Media **728px**;
|
||||
Eval & Retrieval **286px**; everything else 360px. Scrolling the page, the
|
||||
grid resized five times.
|
||||
- **Long names printed underneath their own status pill.** Measured by
|
||||
re-injecting the old rule and testing the title text node's box against the
|
||||
status cluster's box: **6 cards, all on the AI tab** — 3 in Inference, 2 in
|
||||
Dormant, 1 in Eval & Retrieval; zero on the other three tabs, which is why
|
||||
it survived earlier passes. Root cause is a genuinely counter-intuitive one:
|
||||
the rule reserved a
|
||||
78px gutter with `padding-right` and relied on `overflow: hidden` to hold it,
|
||||
but **overflow clips at the padding box, not the content box** — so the
|
||||
reserved gutter was spill room the title rendered straight through. The
|
||||
intended `text-overflow: ellipsis` never fired either, because the ellipsis
|
||||
is painted by whichever block's own line overflows, and here that is the
|
||||
anonymous box around the bare title text node, which does not carry
|
||||
`overflow`.
|
||||
- **`AI Systems` / Scriberr was on all four tabs** — the 2026-08-18 UltraSeedbox
|
||||
bug recurring, this time arriving from a container label rather than from
|
||||
`settings.yaml`.
|
||||
- **Icons were grey smudges.** Homepage masks every glyph over
|
||||
`--color-logo-start/stop`; stock slate-400 → slate-700 sinks the bottom half
|
||||
of each icon into the card fill.
|
||||
- Bookmark groups and Jellyfin's trailing stream rows were the two components
|
||||
the theme had never reached.
|
||||
|
||||
## The fixes
|
||||
|
||||
`stacks/homepage/conf/settings.yaml` — **all 20 groups to `columns: 4`.**
|
||||
`stacks/homepage/theme/australis.css.in` → rebuilt → `conf/custom.css`:
|
||||
gutter held by wrapping, description clamped to 3 lines (floor still 2), icon
|
||||
ramp overridden, bookmark + trailing-widget components themed, group gap
|
||||
10px → 22px. `stacks/scriberr/compose.yaml` — `homepage.group` → `AI - Audio
|
||||
Tools`, container recreated on ana-ml2.
|
||||
|
||||
After: **every group renders at card=360**, and the collision count is zero.
|
||||
|
||||
Before/after, all four tabs: `http://10.100.10.50:8090/b/homepage-relayout/`
|
||||
(24h TTL; also on the standing link board).
|
||||
|
||||
## ⚠ Three traps worth carrying forward
|
||||
|
||||
1. **"Columns = member count" is RETIRED** (it was the 2026-08-18 rule). It was
|
||||
avoiding dead cells in a short last row and bought a worse defect. A short
|
||||
last row is what a grid looks like; a card wider than its neighbours is what
|
||||
a mistake looks like.
|
||||
2. **A `:root` override of a Homepage theme variable is silently ignored.**
|
||||
Homepage sets `--color-logo-*` on `.theme-slate`, and that class is on the
|
||||
`<html>` element — the same element `:root` matches. `.theme-slate` (0,1,0)
|
||||
beats `:root` (0,0,1), so the override does nothing and looks like the
|
||||
variable is not the one in play. `html[class]` (0,1,1) wins, and does not
|
||||
hard-code which `theme-*` class is active. Specificity alone is not enough
|
||||
either: a custom property resolves from the *nearest* ancestor that sets it,
|
||||
so the override has to land on `<html>`, not on `<body>`.
|
||||
3. **The post-recreate tab-bar loss is INTERMITTENT, not guaranteed.** The
|
||||
2026-08-19 note reads as though every recreate costs up to an hour of broken
|
||||
render. This recreate came up correct within 10 seconds — fresh payload on
|
||||
the first poll, all four tabs clickable a minute later. Recreate, *check*,
|
||||
and only then walk away if it is actually in the broken state.
|
||||
|
||||
Also re-confirmed, since the change depended on it: **a `settings.yaml` edit
|
||||
needs a container recreate, not a restart.** `docker restart homepage` left the
|
||||
old `"columns":1` payload embedded in the served HTML with the correct file
|
||||
mounted and readable inside the container; `compose up -d --force-recreate`
|
||||
cleared it immediately.
|
||||
|
||||
## Deliberately not done — operator's call
|
||||
|
||||
The Main tab still opens on three sparse bands: **Notes** (1 member) and
|
||||
**Games** (1) each burn a full 4-wide row, and **News** has 2. Merging Notes +
|
||||
News, or folding Games into Apps, would tighten the top of the page — but that
|
||||
is information architecture, not layout, and the group names are the operator's.
|
||||
Surfaced rather than done.
|
||||
|
||||
→ **Resolved in pass 2 below**, where the operator delegated the naming
|
||||
("re-categorize however you want"). Notes + News became `Daily`, Games folded
|
||||
into `Apps`, and the `AI - Audio Tools` placement in this pass was superseded
|
||||
(Scriberr moved on to `AI - Studios`).
|
||||
|
||||
---
|
||||
|
||||
# `[2026-08-24, pass 2]` Recategorised on "do I open this?", API groups collapsed
|
||||
|
||||
Operator, after seeing pass 1: *"You can re-categorize however you want.
|
||||
service networking tab is uneven, you can split out the adguard cards, etc.
|
||||
most of the issues are that tools I use and have a UI are interspersed with API
|
||||
endpoints which are largely informational only. They might even go in their own
|
||||
cards or start collapsed."*
|
||||
|
||||
## The axis
|
||||
|
||||
Every group is now either **tools** (expanded, top of tab) or **endpoints** (an
|
||||
API, a broker, an agent — `initiallyCollapsed: true`, bottom of tab). A
|
||||
collapsed group still renders its eyebrow and rule, so presence costs one line
|
||||
instead of two rows.
|
||||
|
||||
Second, quieter rule that fell out of the same pass: **a group's members should
|
||||
all carry a widget or none should.** A stat strip adds ~50px, so one widget card
|
||||
in a row of plain ones opens a void under the plain ones — which is most of what
|
||||
made the 13-member `Service Networking` band look broken.
|
||||
|
||||
## Shape
|
||||
|
||||
- **Main** — `Daily` (Memos, Miniflux, Nevermore, SearXNG — replaces the
|
||||
1-card Notes and 2-card News bands), `Monitoring`, `Apps` (12; absorbed the
|
||||
1-card `Games` band), `Media`, `UltraSeedbox`.
|
||||
- **AI** — `AI - Gateways & Chat` (8) and `AI - Studios` (6) expanded; then
|
||||
`AI - Inference` (7), `AI - Eval & Retrieval` (4), `AI - Speech (TTS)` (4),
|
||||
`AI - Audio Tools` (2), `AI - Dormant` (6) all collapsed.
|
||||
- **Toolchain** — `DNS & Filtering` (3), `Reverse Proxies` (2),
|
||||
`Compose Consoles` (5), `Toolchain` (3), `Agents (no UI)` (6, collapsed).
|
||||
- **Infrastructure** — unchanged; every card there is already a console.
|
||||
|
||||
Measured after: every group `card=360`, and `DNS & Filtering` and
|
||||
`Reverse Proxies` both `h=134..134` — dead flush.
|
||||
|
||||
## ⚠ The move that made it affordable
|
||||
|
||||
**The sixteen GPU-backed model seats were NOT relabelled.** `homepage.group` is
|
||||
read at container **creation**, so renaming `AI - Inference` to something
|
||||
clearer would have meant recreating six vLLM seats plus four eval seats plus
|
||||
four TTS engines — multi-minute model reloads on endpoints peers reach through
|
||||
the gateway. Order plus `initiallyCollapsed` buys the same separation for free,
|
||||
so the names stay ugly on purpose. **Do not spend that recreate on a label.**
|
||||
|
||||
28 containers *were* relabelled — all cheap web services — via five rerunnable
|
||||
elway playbooks, `playbooks/homepage-regroup-<host>.yaml`. The canonical
|
||||
`stacks/` tree was synced to match afterwards, so intent and reality agree.
|
||||
|
||||
`initiallyCollapsed: true` is a per-group key in `layout:`; confirmed present in
|
||||
this build (`defaultOpen: !(group?.initiallyCollapsed ?? global)` in
|
||||
`/app/.next/server/pages/index.js`).
|
||||
|
||||
## AdGuard (ANA) gained its widget, and the credential is fleet-wide
|
||||
|
||||
It was the only AdGuard without a query/blocked/latency strip, so it sat short
|
||||
beside two tall siblings. **One `infra-ops` AdGuard login authenticates against
|
||||
all three instances** (ANA `:8053`, NH3 `:8080`, ESH `:8080` — all returned 200
|
||||
on `POST /control/login`, verified 2026-08-24). Vaulted at
|
||||
`secret get nh3-dev/adguard-infra-ops-password`; written to
|
||||
`/opt/docker/compose/adguard-ana/.env` (0600, root) and never into git. Its icon
|
||||
was also the odd one out (`mdi-dns` against two `si-adguard`).
|
||||
|
||||
## ⚠⚠ `initialSettings":{}` — the tab-bar mystery is a SWALLOWED EXCEPTION
|
||||
|
||||
The biggest durable finding of the day, and it cost ~25 minutes. Full write-up
|
||||
in `stacks/homepage/README.md`; the short version:
|
||||
|
||||
`initialSettings":{}` in the served HTML is **the catch branch** of the page's
|
||||
data loader, not a warm-up and not a cache. And the error can vanish without
|
||||
trace: the logger is assigned as the first statement *inside* the same `try`,
|
||||
and the `catch` only logs `if (logger)`. If the logger is what threw, nothing is
|
||||
written anywhere — which is exactly what was observed.
|
||||
|
||||
Ruled out by measurement, do not re-run: `/api/services`, `/api/bookmarks`,
|
||||
`/api/widgets` and `/api/hash` all return **200 with correct content** while the
|
||||
page serves `{}`; restoring the previous known-good `settings.yaml` reproduces
|
||||
it identically; `/api/validate` returns `[]`; disk and permissions are fine.
|
||||
|
||||
**One-command test:**
|
||||
`curl -s http://10.0.50.45:5100/ | grep -o 'initialSettings":[^,]\{0,20\}'`
|
||||
|
||||
**What broke the streak:** three consecutive recreates came up empty, then
|
||||
rolling the 8.6 MB `conf/homepage/logs/homepage.log` aside and recreating healed
|
||||
it within 15 seconds. That is one observation, not proof — but it is a coherent
|
||||
mechanism (oversized log → logger init throws → silent catch) and it is the
|
||||
cheapest thing to try first next time.
|
||||
|
||||
---
|
||||
|
||||
# `[2026-08-24, pass 3]` Rebuilt on Australis Skyfall — dual theme, light shipped
|
||||
|
||||
Operator supplied the Skyfall design-system README and said "Go full with
|
||||
skyfall."
|
||||
|
||||
## The bundle was already in this repo's git history
|
||||
|
||||
**The Skyfall tokens did not need to be hunted down.** A predecessor vendored
|
||||
them on 2026-08-19 and a later commit deleted them; git kept everything:
|
||||
|
||||
```bash
|
||||
git show 45c1995:stacks/homepage/theme/colors.css # 208 lines, BOTH themes
|
||||
git show 45c1995:stacks/homepage/theme/layout.css # calm-depth tokens
|
||||
git show 45c1995:stacks/homepage/theme/typography.css
|
||||
git show 45c1995:stacks/homepage/theme/fonts/Supreme-{400,500,700}.woff2
|
||||
```
|
||||
|
||||
`colors.css` carries `:root` (dark) **and** `[data-theme="light"]` (Skyfall
|
||||
Day) — so the light ramp is canonical, not derived. That killed the entire
|
||||
objection from the previous answer, which was correct only about the
|
||||
`australis-design` skill ("Always dark first. No light mode in this system").
|
||||
**Skyfall is the dual-theme derivative; australis-design is the terminal
|
||||
theme. They are different systems and only one of them has a light mode.**
|
||||
|
||||
## ⚠⚠ REMOVING `theme:` FROM settings.yaml BREAKS THE DASHBOARD
|
||||
|
||||
The documented way to get Homepage's own light/dark toggle is to leave `theme:`
|
||||
unpinned. **Do not.** With the key absent, the page's data loader throws and its
|
||||
catch branch serves `initialSettings: {}` — no tab bar, no layout, no i18n.
|
||||
|
||||
Measured, not inferred: six force-recreates over seven minutes all came up
|
||||
empty with the key removed; restoring `theme: dark` rendered correctly on the
|
||||
next recreate in **12 seconds**. `/api/services` stays 200 and fully correct
|
||||
throughout, which is exactly why this reads as a caching or warm-up problem and
|
||||
is not one.
|
||||
|
||||
This is the first *confirmed* trigger for the long-running "tab bar goes
|
||||
missing" mystery. It does not explain every occurrence (the symptom has
|
||||
appeared with `theme:` present), but it means **the first diagnostic step is
|
||||
now `git log -p -- stacks/homepage/conf/settings.yaml`**, not container
|
||||
archaeology. Also retires an earlier lead from this same session: rolling the
|
||||
8.6 MB `homepage.log` aside once coincided with a recovery, but did nothing
|
||||
during the `theme:`-key episode — coincidence, not cause.
|
||||
|
||||
## So the toggle is ours
|
||||
|
||||
`conf/custom.js` renders it (was an empty placeholder). Precedence:
|
||||
|
||||
1. explicit choice — `localStorage['skyfall-theme']`, written by the toggle;
|
||||
2. OS preference — `@media (prefers-color-scheme: light)`;
|
||||
3. dark — Skyfall's default.
|
||||
|
||||
`theme/build.py` re-emits each vendored `[data-theme="light"]` block twice: as
|
||||
`[data-theme="light"], html.light`, and inside the media query scoped to
|
||||
`html:not([data-theme="dark"]):not([data-theme="light"])`. **That `:not()` pair
|
||||
is what lets a stored *dark* choice survive a light-mode OS.** Verified across
|
||||
both OS preferences: load, click, click again, reload — all four correct.
|
||||
|
||||
⚠ Homepage keeps its own `class="dark scheme-dark theme-slate"` on `<html>`
|
||||
regardless, because `theme:` is pinned. That is fine and was checked
|
||||
explicitly: with the dark class present AND `data-theme="light"`, every themed
|
||||
surface resolves to Skyfall Day, because our rules carry `!important` on the
|
||||
surfaces Tailwind's `dark:` variants would otherwise claim. **`data-theme` is
|
||||
the control surface; the class is not.**
|
||||
|
||||
## The anti-fork guard is now mechanical
|
||||
|
||||
`build.py` records the SHA-256 of each vendored file and **fails the build** on
|
||||
a mismatch, rather than warning. A vendored file is either byte-identical to
|
||||
the bundle or it is a fork wearing the bundle's name. Overrides go in
|
||||
`skyfall.css.in`, which is written entirely against the semantic layer
|
||||
(`--surface-*`, `--text-*`, `--border-*`, `--success/--danger/--warning`) — no
|
||||
raw family tokens, no colour literals.
|
||||
|
||||
The one place a literal is unavoidable: Homepage consumes
|
||||
`--color-logo-start/stop` as `rgb(var(--x))`, which cannot take an `oklch()`.
|
||||
Those four values are exact sRGB conversions of real tokens (`--sea-80`,
|
||||
`--blue-base` for dark; `--sea-40`, `--blue-deep` for light), computed rather
|
||||
than eyeballed, with the conversion recorded in the file.
|
||||
|
||||
## Deviations, all deliberate and all written down
|
||||
|
||||
- **The aurora ribbon under the tab bar is gone.** Skyfall sanctions exactly two
|
||||
accent expressions — the active rail and hero-only glows — and a decorative
|
||||
gradient across the chrome is neither. The colour moved to a 2px accent bar
|
||||
plus `--accent-soft` fill on the active tab, which *is* the rail.
|
||||
- **Widget stat values moved from the display face to mono**, per Skyfall's
|
||||
"numbers and telemetry are always `--font-mono`".
|
||||
- **Two font substitutions**: Space Grotesk for Bespoke Sans, JetBrains Mono
|
||||
for Victor Mono. Only Supreme was ever vendored, and Skyfall's own notes call
|
||||
Victor Mono "user-supplied". Two-line swap when the real faces arrive.
|
||||
|
||||
Dark + light, all four tabs: `http://10.100.10.50:8090/b/homepage-skyfall/`
|
||||
@@ -0,0 +1,46 @@
|
||||
# [2026-08-24] Scriberr transcription deployed on ana-ml2, GPU1
|
||||
|
||||
Self-hosted audio/video transcription + diarization. Operator chose GPU
|
||||
placement over ana-docker (8 cores shared with 50 containers, 37 GB disk)
|
||||
against ana-ml2's 96 cores, `/tank`'s terabytes and GPU1's headroom.
|
||||
|
||||
**Live:** `http://scriberr.ana.internal:8080` (DNS alias added), health `healthy`,
|
||||
all seven backends up, zero failures: `whisperx pyannote sortformer parakeet
|
||||
canary voxtral openai`. ~30 GB of weights on `/tank`.
|
||||
|
||||
Stack: `stacks/scriberr/`. Full gotcha list in auto-memory
|
||||
`reference_scriberr_ana_ml2`.
|
||||
|
||||
## Three upstream bugs, none of them ours
|
||||
|
||||
**1. The Blackwell image does not exist.** Upstream's README documents
|
||||
`scriberr-cuda-blackwell`; GHCR has **no tags for it**. Published
|
||||
`scriberr-cuda` covers sm_61–sm_89 only — on these sm_120 cards it fails or
|
||||
silently drops to CPU. The real sm_120 path is `Dockerfile.cuda.12.9`
|
||||
(CUDA 12.9.1, cu128 torch), **built from source**. Do not "simplify" the compose
|
||||
back to the published image.
|
||||
|
||||
**2. It must run as uid 10001, not 1000** — and the error lies:
|
||||
`unable to open database file: out of memory (14)`. Error 14 is
|
||||
`SQLITE_CANTOPEN`, not an OOM, on a box with 566 GB RAM. That Dockerfile creates
|
||||
`appuser` at 10001 (Ubuntu 24.04 owns uid 1000 as `ubuntu`) and chowns `/app` to
|
||||
it, while the entrypoint's PUID remap covers only the data dirs.
|
||||
**Isolated by elimination**: SQLite writes fine to `/tank` as 1000 → not the
|
||||
mount; fails on a plain named volume too → not the storage; the **published CPU
|
||||
image works at PUID=1000** because there `appuser` *is* 1000.
|
||||
Generalisable: *when a container "permission" bug appears, compare the uid the
|
||||
image was BUILT for against the uid you are RUNNING as.*
|
||||
|
||||
**3. `UV_LINK_MODE=copy` is required.** Scriberr builds each backend's Python env
|
||||
with `uv` at start; uv's reflink mode fails on overlayfs+ZFS with
|
||||
`Failed to clone … Resource temporarily unavailable (os error 11)`. **Partial
|
||||
failure** — WhisperX and PyAnnote came up and the app looked fine while Parakeet
|
||||
and Sortformer were silently absent. Occurrences 2 → 0 after the fix.
|
||||
|
||||
## Related
|
||||
|
||||
`speaches` on irv-ml1 **stopped** the same day (stack retained, one command to
|
||||
restart): Eyra was abandoned pre-implementation because Scriberr covers the need,
|
||||
leaving it with no consumer. Scriberr runs its **own** WhisperX in-container and
|
||||
is **not** a speaches consumer. Idle footprint at stop was 274 MiB, not the
|
||||
~5.9 GB quoted — that figure is the loaded-model working set.
|
||||
+51
-31
@@ -1,6 +1,6 @@
|
||||
# Persistent memory — eshpfi-management
|
||||
|
||||
_Last updated: 2026-08-22_
|
||||
_Last updated: 2026-08-25_
|
||||
|
||||
> **Always check for `/tmp/infra-ops-handoff.md`** — if it exists and its
|
||||
> `Written:` stamp is under an hour old, read it (it carries the in-flight
|
||||
@@ -108,24 +108,53 @@ no longer deployed sidecars here. See Recent decisions.)
|
||||
(no NOPASSWD)** — stage model pulls to `/home`, not root-owned `/worktank`.
|
||||
## Current state / in-flight
|
||||
|
||||
_As of 2026-08-22 — three AI seats live on ana-ml2; `sec` is the one that moved this session._
|
||||
_As of 2026-08-25 ~04:20Z — the ERP/RP tune is TRAINING on ana-ml2 GPU0, ~17h, unattended. The homepage and char-rp arcs closed earlier. **The live thread is the run itself plus a parallel question: whether a fused MoE kernel lands fast enough to justify restarting it.**_
|
||||
|
||||
- **SEAT MAP.** **`gen`** = `orcarouter/Qwen3.8-27B-Uncensored` NVFP4-mixed, GPU0 :8015, 7 aliases, **still on the OLD nightly `311b3513` with MTP k=3**. **`char-rp`** = MeroMero-v2 dual-mode (prose + streaming CoT, one weight set, two aliases), GPU0 :8016, pinned `v0.26.0`. **`sec`/`sec-reasoning`** = M.O.G.-SEC, GPU1 :8019 — **rebuilt this session, see below**.
|
||||
- **🟢 THE ERP TUNE IS RUNNING (launched 2026-08-24 ~20:40 PDT, ETA ~13h → ~09:40 PDT 08-25).** GPU0 on ana-ml2, dedicated. `gen` relocated to GPU1 and healthy; **`sec`/mog-sec STOPPED for the whole run, operator-ruled ("let it run, keep sec down")**. Restore = `playbooks/ana-ml2-training-window-close.yaml` (gates on GPU0 idle; `--var allow_busy_gpu0=true` to override). Harness **eitri-smithy `997c4a4`** at `/tank/erp-tune/eitri-smithy`, venv `/tank/erp-tune/venv` (torch 2.13.0+cu130, transformers 5.15.1, peft 0.20.0, sm_120 verified), config `/tank/erp-tune/run-01.json`, log `/tank/erp-tune/run-01.log`, output `/tank/erp-tune/run-01/`. **Config: BF16 (NOT QLoRA), max_seq_len 16384, mb2×accum8 → 1,312 steps, r64/α128, 205 modules, 74,342,400 trainable.** Step-10 loss **3.664**, grad_norm 5.178 — ⚠ above brokkr's 1.8–3.0 band but the doubled-divisor signature was ~0.25, so `num_items_in_batch` is NOT double-applied; hypothesis = the mix is 52.9% literary prose where every token is a loss target. GPU0 runs **84,222 MiB of 97,887** (above my measured 79.71 GiB worst case — adjacent `#w0`/`#w1` windows share micro-batches systematically, exactly as brokkr predicted). **Encode is CACHED** (`run-01/encode-cache/`, keyed on encode_version+max_seq_len+template sha) so a restart costs ~2.5 min, not the 4.3h it would single-threaded. ⚠ **encode_version must be BUMPED on ANY encoder change** — that has mattered five times. **RESUME: use `/tank/erp-tune/resume-run-01.sh`, NEVER the original launch command** — that one starts `rm -rf /tank/erp-tune/run-01`, which destroys the 609 MB encode cache AND every checkpoint. First checkpoint at step 100; `save_steps=100` at ~46.5 s/it = **~73 min of crash exposure** per interval. → `docs/pfi/gemma4-erp-tune-sizing.md`
|
||||
- **⚠ MFU IS 8.6% AND I HAVE DISPROVEN MY OWN HYPOTHESIS TWICE — CONSULT OUT TO THE FRONTIER DWARVES.** 27.1 TFLOPS against a **benchmarked 313.8 TFLOPS** peak; one fwd+bwd at the real shape is **34.85s** (4 passes within 1%). **RULED OUT, with numbers, not argument:** (1) **hardware** — a plain dense GEMM hits **97.1% of peak** (304.6 TFLOPS), card draws 279-292W of 300W; (2) **the Python expert loop** — swapping to transformers' `grouped_mm` experts backend gave **35.149s vs eager's 34.847s, bit-identical output (max_abs_diff EXACTLY 0.0), same 75.8 GiB**, and torch 2.13 HAS both `F.grouped_mm` and `torch._grouped_mm`, so it is not a missing kernel; `batched_mm` both OOMs and MISMATCHES (rel 0.79 — it computes all 128 experts per token); (3) **MoE being the bottleneck at all** — isolated at real shapes the MoE block is **37.54 ms at 26.5% of peak**, of which **13.39 ms is pure gather/scatter dispatch** and a dispatch-free `bmm` version would be **12.28 ms at 80.9% of peak** — but **30 layers × 37.54 ms × 3 (fwd+recompute+bwd) ≈ 3.4s of a 34.85s step, only ~10%.** Making MoE free buys ~7%. **~90% of the time is somewhere I have not looked.** ⚠ **LEADING UNTESTED HYPOTHESIS: the 5 `full_attention` layers use `global_head_dim: 512`, and FlashAttention-2 caps head_dim at 256** — if that pushes torch SDPA onto the mem-efficient or math backend, 5 layers are doing O(n²) attention at seq 16384 on a slow path. Other un-excluded candidates: the chunked CE (vocab 262,144 + softcap, 1024-tok chunks re-materialised under `checkpoint`), the `attention_k_eq_v` K=V path, grad-ckpt × MoE dispatch interaction, PEFT's wrapper on 205 modules. ⚠ **My earlier "5% MFU" was ALSO wrong** (divided by UNPADDED tokens, compared against a GUESSED peak) — operator caught it. Padding is a real but secondary **29.9%** tax (82,337,318 padded vs 57,733,156 real). Artifacts: `/tank/erp-tune/{micro_moe,bench_moe,bench_bf16}.py`. → park id 47, althing thread `01M0VKBPZD71Q302NH84BXHTWS`
|
||||
|
||||
- **🟢 `sec` NOW RUNS DFLASH2 ON A NEWER vLLM — promoted to its compose stack after real-use testing.** `nightly-e9d1398d` (+259 commits over production, `behind_by=0`), `dflash` k=7 with the 3.85 GB drafter, **util 0.52 / max-model-len 420,000 / KV ~453k**, 2048² vision. `restart: unless-stopped`, survives reboot. Canonical in `stacks/mog-sec/` with a fully-commented `.env.example`. **ROLLBACK:** `.env.bak-pre-dflash2-20260822` on the host, or swap `MOG_SPEC_CONFIG` + `MOG_IMAGE`. ⚠ `PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True` was **deliberately dropped** — the validated container never had it.
|
||||
|
||||
- **⚠️ THE `sec` DEGENERATION QUESTION IS OPEN AND CONFOUNDED.** It no longer degenerates, but **engine and drafter changed together**. **The isolating experiment is MTP k=3 on `e9d1398d`** — not yet run. Operator ruling: the degeneration lives in the **un-fixed vLLM**, not the weights; my MTP-head hypothesis is **retracted**. ⚠⚠ **Both the "degenerates at 2k" and "coherent to 10k" sightings are n=1 from uncontrolled sessions and are NOT evidence.** Production is **172 commits behind GDN spec-decode fix #53077**; `#51113` is present in both builds and is **necessary but insufficient**.
|
||||
|
||||
- **⏳ `gen` IS UNTOUCHED and still on the old build.** If DFlash2 + the newer engine are the answer, gen is the obvious next beneficiary — but that decision is gated on the isolating experiment above, not on sec's n=1 result.
|
||||
|
||||
- **🟢 ESH IS DUAL-STACK; the v4 static is a Cityside ticket.** IPv6 live on `esh-userland` (SSID `PVC`) and `esh-server` from a delegated `2607:73c0:402:1d00::/56`; hosts egress over v6 as themselves, un-NATted. **v4 remains CGNAT (`100.104.3.250`) and a full gateway reboot proved the purchased static is NOT provisioned** — carrier ticket, nothing left to try locally. v6 firewall audited: default-deny inbound both versions, correct. NH3 stays v6-off deliberately (single /64 reserved for meshing). Flat-zone lateral-movement finding **parked, id 44**.
|
||||
|
||||
- **🟢 OTHER SERVICES.** speaches ASR live irv-ml1:8204 (Eyra; loop closed). Open WebUI on esh-docker-vm:3211 (admin creds + admin-scoped API key vaulted; **Lobe retirement still the operator's call**). Waterland, Homepage/Skyfall, fleet `.internal` DNS all landed earlier and are stable.
|
||||
|
||||
- **⏳ OPEN:** the MTP-k3-on-new-build isolating experiment; file the drafted upstream vLLM issue (operator's GitHub identity); Cold-Fusion NVFP4 quants (44 GB) delete/keep; OWUI image-tag drift (`:main` vs pinned v0.11.0); `/tank` DEGRADED **70+ days**; Brokkr duplicate `reranker-a3-bge-v2-m3` alias; **MANY commits unpushed** — push is the operator's call.
|
||||
- **🛑 THE CORPUS GATE — OVERRIDDEN FOR THIS ONE RUN ONLY (operator, 2026-08-25).** Grant staged at `/mnt/smithy/datasets/derived/_recipes/erp-seat-sft-r1/TRAINING-ELIGIBILITY-OVERRIDE.md`. ⚠ It does NOT flip any root's `training_eligible` flag — they still read `false` and name both blockers, deliberately, so the signal survives. **A second run needs a second grant.** Provenance records `training_eligibility_override: operator-2026-08-25-rnd-run` + both blockers + both substitute controls; those keys are in `REQUIRED_PROVENANCE` as present-with-explicit-null so a future run cannot silently omit them. Background: Every `clean-v1/CLEANROOT.json` carries `training_eligible: false` with `training_blocked_by: [contamination-scan-not-implemented, stage-2-csam-detector-inert]`, and the recipe itself says *"nothing here is Charter §3 training-eligible"*. ⚠ **`scoped_grant: operator-2026-08-22` is NOT training clearance** — it governs INV-4 one-way tier inheritance (the adapter is permanently `internal-erp-rnd`, never distributable). I initially misread the grant as authorization and told brokkr I was proceeding; **brokkr-smithy-dev — who WROTE those fields — corrected it**: *"I wrote them so that exactly this would happen… do not take my word as clearance; I do not have the authority to give it."* **The detector is measured-inert, not suspected:** `auditcore` v3.7.2 returned its hard-drop rc-2 **zero times across 42,662 raw RP records**, its printed verdict ignores its own printed threshold, and it passed a blind-audit-identified record of sexual content involving a participant the text marks as a child (`pippa-5083`, composite 4.34 vs threshold 6.5). → `research/R47-premium-corpus-gate/FINDING-auditcore-inert.md`, Contract Amendment 11. **I verified the one decisive thing:** `pippa-5083` IS in `kept-manifest.jsonl` (4,551 rows) but **ABSENT from `recipe-dedup-kept.jsonl` (20,473 rows)** — the survivor list the harness gates on — so brokkr's substitute *stage-A lexical* screen caught it. That is one known instance caught by a stopgap; it says nothing about what the screen misses. **Both brokkr and I recommend STOPPING; only an explicit operator override opens it.** Neither blocker is hours of work (the 13-gram scanner is spec-only, DRAFT since 2026-06-01; the detector needs replacing). ⚠ **Do NOT stage or copy corpus content while gated.**
|
||||
- **🟢 SIZING + SEAT CALL — DONE AND EXECUTED, full detail in the doc.** QLoRA structurally unavailable (fused 3-D experts vs bitsandbytes' nn.Linear walk); plain BF16 LoRA; chunked CE mandatory (naive CE OOMs at seq16384, 81.93 GiB at seq8192); `v_proj` exists on only 25 of 30 layers (`attention_k_eq_v`, K=V sharing — real, not a miss). `gen` moved to GPU1, `sec` down, GPU0 dedicated. → `docs/pfi/gemma4-erp-tune-sizing.md`, `playbooks/ana-ml2-training-window-{open,close}.yaml`
|
||||
- **⚠ TELL EITRI BEFORE HE HARD-CODES: the trainee base changed.** Contract still names the stock BF16. It is now `/tank/aimodels/gemma4-26b-a4b-it-heretic-bf16` (llmfan46). **Base path AND chat-template path must be config keys, not constants** — and the template must point at upstream's (`gemma4-26b-a4b-it-bf16/chat_template.jinja`), never the base's own, or training renders a different prompt than production serves.
|
||||
- **🟢 char-rp seat = Gemma-4 26B-A4B MoE NVFP4** on `:8016`, both aliases on ONE backend. **Currently DOWN by operator instruction** to hold GPU0 headroom for the tune. `gen` is UP and verified. MeroMero-v2 retained stopped in `created` state for rollback (stop-then-start; both bind :8016). → `persistent-memory.d/2026-08-24-charrp-gemma4-moe-swap-and-trainee.md`
|
||||
- **🟢 THREE trainee-relevant model dirs on `/tank/aimodels/`, NOT interchangeable:** `gemma4-26b-a4b-it-bf16` (stock, 49 GB — its chat_template is the canonical upstream one), `gemma4-26b-a4b-it-heretic-bf16` (llmfan46 abliterated, the trainee), `gemma4-26b-a4b-it-abliterated-bf16` (TrevorJS, KL 0.09, alternate). Plus `-nvfp4` (served) and `-nvfp4a16` (activation control). ⚠ **BF16 cannot coexist with `gen`** — 48.07 GiB of weights on a 94.97 GiB card. Every BF16 window means gen stops.
|
||||
- **🟢 `stacks/gemma4-trainee-bench/`** is the ephemeral BF16 bench stack — no `--quantization` flag (the production compose hardcodes `compressed-tensors` and crash-loops on BF16), `restart: "no"`, no homepage labels. Base swap is ~5 minutes because it exists.
|
||||
- **🎨 Homepage runs AUSTRALIS SKYFALL with a working light/dark toggle**, recategorised on "do I open this?" (TOOLS expanded / ENDPOINTS collapsed). ⚠ **`theme:` MUST stay pinned in settings.yaml** — removing it makes the page loader throw and serve `initialSettings: {}`, the first *confirmed* trigger for the "tab bar goes missing" mystery. → `persistent-memory.d/2026-08-24-homepage-uniform-grid.md`
|
||||
- **🔒 ana-gw's public admin surface is ZERO open TCP ports**; box scheduled for replacement by **OPNsense on a Dell R420** (brings WireGuard onto the edge — the downstream-WireGuard-VM design is moot, do not scope it). **No out-of-band path remains** — if both tunnels drop it is console-only. → `persistent-memory.d/2026-08-24-ana-gw-admin-closed-acme-disabled.md`
|
||||
- **🟢 Both Anaheim IPsec tunnels run AES-128.** NH3 245→**270 Mbit/s**, ESH 268→**304**. Ceiling is **the UDM's software AES-CBC, not the FortiGate**. → `persistent-memory.d/2026-08-23-anaheim-ipsec-tunnel-ceiling.md`
|
||||
- **🟢 Scriberr LIVE** — ana-ml2 **GPU1** :8080, built locally, uid **10001**, needs `UV_LINK_MODE=copy`. → `persistent-memory.d/2026-08-24-scriberr-ana-ml2.md`
|
||||
- **🟢 ESH DNS fixed at the IPv6 layer**; RDNSS **redirected** to AdGuard. ⚠ Proxmox bridges need `accept_ra=2`. Naming scheme lives in `docs/pfi/ipv6-naming-scheme.md` — **a convention, not memory state; never let a memory line be the only copy again.** → `persistent-memory.d/2026-08-24-esh-dns-rdnss-and-scheme-live.md`
|
||||
- **🟢 SEAT MAP.** ⚠ **ana-ml2 runs a vLLM VERSION SPREAD, not one version** — do not say "ana-ml2 runs X". Measured 2026-08-24: `gen` **0.27.2rc1.dev150** (`nightly-311b3513`), `mog-sec` **0.26.1rc1.dev1102** (`nightly-e9d1398d`), `rerank-a3`/`coder`/`reward`/`embed` **0.24.0**, char-rp + trainee-bench pinned **v0.26.0**. `v0.27.1` (tagged) and three nightlies sit on disk unused. **`gen`** = Qwen3.8-27B-Uncensored NVFP4-mixed, GPU0 :8015, 7 aliases, UP. **`char-rp`** = Gemma-4 MoE NVFP4, GPU0 :8016, DOWN deliberately. **`sec`/`sec-reasoning`** = M.O.G.-SEC, GPU1 :8019, sharing GPU1 with Scriberr.
|
||||
- **⚠️ THE `sec` DEGENERATION QUESTION IS STILL OPEN AND CONFOUNDED.** Isolating experiment is **MTP k=3 on `e9d1398d`** — still not run. Operator ruling: degeneration lives in the **un-fixed vLLM**, not the weights; MTP-head hypothesis **retracted**. Both sightings n=1.
|
||||
- **🟢 ana-ml2 mounts `/mnt/smithy`** ro + soft, **NOT in fstab** — manual remount after reboot. `nconnect=8` approved but deliberately not applied. → `persistent-memory.d/2026-08-23-smithy-mount-ana-ml2.md`
|
||||
- **🟢 ESH IS DUAL-STACK**; v4 static is an unprovisioned Cityside ticket. **NH3 stays v6-off by explicit ruling.**
|
||||
- **⏳ OPEN ELSEWHERE:** MTP-k3 isolating experiment; upstream vLLM issue to file; Cold-Fusion NVFP4 quants (44 GB) delete/keep; OWUI image-tag drift; `/tank` DEGRADED **70+ days**; Worldtree **#411** debug-room litter; Lobe retirement is the operator's call; brokkr's `gen` vs trained-reward-model bake-off. **Commits are local and unpushed** — push is the operator's call.
|
||||
- **⚠️ STANDING: NO FLEET NOTIFICATIONS unless the operator asks** (2026-08-24). Direct task correspondence with a counterparty is fine; unsolicited broadcasts are not.
|
||||
|
||||
## Recent decisions
|
||||
|
||||
- `[2026-08-25]` **Fused MoE kernel path — DEFERRED, tracked at park `fused-moe-kernel-path-for-gemma-4-moe-training` (id 47).** Operator: "note the fused MoE kernel for round two… if we nail it soon, the math has us wanting to restart the run anyway." Training MFU is **8.6%** (27.1 of a benchmarked 313.8 TFLOPS) because `transformers` runs the Gemma-4 experts in a Python loop — 128 experts × 30 layers, ~11,500 iterations per step under gradient checkpointing. ⚠ **The same fused 3-D expert layout that made bitsandbytes skip 88.5% of the model is exactly what a grouped GEMM wants** — the format is good for storage and for fused kernels, and hostile only to naive iteration. Two fixes: `group_by_length` (−29.9% compute, free, but breaks the seeded order manifest and re-opens a batch-composition call brokkr already made) and a grouped-GEMM/compiled MoE forward (the remaining ~10×). **Not applied to the live run** — restarting mid-flight to change batch ordering was judged a bad trade at step ~50 of 1,312.
|
||||
- `[2026-08-25]` **The ERP/RP tune LAUNCHED after 12 harness defects and an operator override of the corpus gate.** Four of the twelve would have crashed the run; two were INERT GATES that passed because they could not fail. Run is `/tank/erp-tune/run-01`, harness eitri-smithy `997c4a4`. Full arc — override, defects, sizing, the measured MFU — in the in-flight section and `docs/pfi/gemma4-erp-tune-sizing.md`.
|
||||
- `[2026-08-24]` **char-rp seat swapped to the Gemma-4 26B-A4B MoE; abliterated trainee base staged and measured.** OOM root-caused to `--gpu-memory-utilization` not covering CUDA context (and to gen's footprint GROWING WITH UPTIME); a benchmark finding retracted because it scored below chance; abliteration isolated at −0.6 core points but it MOVES capability rather than removing it. → `persistent-memory.d/2026-08-24-charrp-gemma4-moe-swap-and-trainee.md`
|
||||
- `[2026-08-24]` **Serving the tuned ERP model: LoRA-on-NVFP4 PREFERRED, merged weights the expected fallback — and the recorded objection may be STALE.** Operator: "if you CAN load it as a lora, all the better, the issue is that we will want to run nvfp4 weights, which we had some serious trouble with loading loras on top of nvfp4." ⚠ **The archived root-cause says it was NOT NVFP4-specific**: `[2026-07-07]` vLLM 0.24.0 qwen3_5 LoRA application was a silent no-op (#47639, regression from #37912) — adapter loads HTTP 200, zero deltas at inference, proven **quant-agnostic (NVFP4 AND FP8 both inert)** and adapter-format-agnostic by a 3-peer dwarf panel. Fix PR #47640 was OPEN then. **ana-ml2 is FAR past 0.24.0 and the box runs a SPREAD, not one version** (measured 2026-08-24): `gen` on `nightly-311b3513` = **0.27.2rc1.dev150**, `mog-sec` on `nightly-e9d1398d` = 0.26.1rc1.dev1102, the small seats still on 0.24.0, and char-rp/trainee-bench pinned to v0.26.0. ⚠ **`vllm/vllm-openai:v0.27.1` is already ON DISK, unused** — a TAGGED release, which is the right retest target: no nightly variance, no pull, ~4 months past the diagnosis. So: RETEST hot-swap LoRA on **v0.27.1** before designing around merge — it is cheap, and if it works the post-tune gate can be two aliases on one engine. If it still no-ops, merged weights it is, which means the harness must EMIT merged weights and Eitri needs that in the contract while he is early. Tracked at this snapshot commit; settle it in the QLoRA sizing conversation.
|
||||
- `[2026-08-24]` **Homepage rebuilt on Australis Skyfall; light mode shipped.** Two findings worth more than the theme: **(a)** the Skyfall bundle including its canonical light ramp was sitting in this repo's git history at `45c1995` — check `git show` before concluding a vendored design asset is lost; **(b)** removing `theme:` from `settings.yaml` deterministically breaks the dashboard render (six recreates empty, restoring the key fixed it in 12s), which is the first confirmed cause of the "tab bar goes missing" symptom. Retires the `homepage.log` size lead from earlier the same day — it did nothing on this episode. → `persistent-memory.d/2026-08-24-homepage-uniform-grid.md`
|
||||
- `[2026-08-24]` **Homepage reorganised on the axis "do I open this?" — UI groups expanded on top, API/agent groups collapsed at the bottom** (operator-delegated: "re-categorize however you want"). Load-bearing constraint: `homepage.group` is read at container CREATION, so the 16 GPU-backed model seats keep their unlovely names rather than eat a recreate — `initiallyCollapsed` + order is free. Second rule discovered here: **group members should all have widgets or none should**, because a stat strip adds ~50px and opens a void beside plain cards. → `persistent-memory.d/2026-08-24-homepage-uniform-grid.md`
|
||||
- `[2026-08-24]` **Homepage columns unified at 4 for every group; the 2026-08-18 "columns = member count" rule is retired.** It was avoiding dead cells in a short last row and bought a worse defect — card width changing at every group boundary. Also carries two CSS traps: `overflow: hidden` clips at the PADDING box (so a `padding-right` gutter is spill room, not a guard), and a `:root` override of a Homepage theme variable is silently outranked by `.theme-slate` on the same `<html>` element. → `persistent-memory.d/2026-08-24-homepage-uniform-grid.md`
|
||||
- `[2026-08-24]` **AES-128 adopted on both Anaheim tunnels; the per-flow ceiling root-caused to the UDM's software AES-CBC, exonerating the FortiGate.** Proven by an A/B/A cipher swap at identical CPU — hardware offload is not cipher-cost-sensitive. → `persistent-memory.d/2026-08-23-anaheim-ipsec-tunnel-ceiling.md`
|
||||
- `[2026-08-24]` **ana-gw's public admin surface closed to zero open ports, ACME listener included.** Two of my diagnoses were wrong first (an "ISP proxy" that was the FortiGate, and an "all-port VIP" alarm that was a parser gap) — both from reading config instead of measuring from outside. → `persistent-memory.d/2026-08-24-ana-gw-admin-closed-acme-disabled.md`
|
||||
- `[2026-08-24]` **Scriberr deployed on ana-ml2 GPU1, image built from source.** Three upstream bugs: the Blackwell image was never published, it must run as uid 10001, and `UV_LINK_MODE=copy` is required or two backends fail silently. → `persistent-memory.d/2026-08-24-scriberr-ana-ml2.md`
|
||||
- `[2026-08-24]` **ESH DNS fixed at the IPv6 layer and the naming scheme went live on three hosts.** UniFi's RDNSS cannot be disabled but CAN be redirected — the field is only honoured when an explicit server is given. → `persistent-memory.d/2026-08-24-esh-dns-rdnss-and-scheme-live.md`
|
||||
- `[2026-08-24]` **`speaches` on irv-ml1 stopped, stack retained** — Eyra was abandoned pre-implementation (Scriberr covers the need), leaving it no consumer. Disposition confirmed to eyra-dev; one command to restart. Tracked at althing thread `01M0RRJX8GPZEBDHF1E3W18RZF`.
|
||||
- `[2026-08-24]` **esh-vm-db brought onto the fleet infra-ops identity and given its first vaulted credential.** It previously had none: root and infra-ops refused key auth and `lkraven`'s sudo wanted a password nobody held, leaving `qm guest exec` from the hypervisor as the only privileged path. Break-glass root password at `secret get esh-vm-db/root-breakglass-password` (console-only; plaintext never crossed the wire — only its SHA-512 hash did).
|
||||
- `[2026-08-24]` **`nconnect=8` on `/mnt/smithy` — approved but DEFERRED at operator instruction.** brokkr-smithy-dev pre-approved it for "once the FortiGate work settles" and does not need re-asking; the operator declined it in this session's scope. Tracked at althing thread `01M0R46SFYF83099N16WD67KGD`.
|
||||
- `[2026-08-23]` **Anaheim's IPsec tunnel ceiling — investigated, then CLOSED 2026-08-24.** The 25%-of-2-Gbps framing was wrong (NH3's uplink is 1 Gbps); AES-GCM proved impossible; AES-128 landed instead. → `persistent-memory.d/2026-08-23-anaheim-ipsec-tunnel-ceiling.md`
|
||||
- `[2026-08-23]` **selene retired after losing a head-to-head on its own job; `chat-judge` moved to gen, the model name 404s by design.** Also surfaced that **7 aliases share one seat** — cross-checking between them is an echo, which caught a real defect in brokkr's 46k-exposure R47 gate. → `persistent-memory.d/2026-08-23-selene-retired-alias-collision.md`
|
||||
- `[2026-08-23]` **hrafn adopted; its CI reported green for its whole life while deploying nothing.** A staging dir inside the rsync target destroyed its own source mid-copy; the deeper fault was verify steps that asserted uptime, never content. → `persistent-memory.d/2026-08-23-hrafn-adopted-ci-frozen-source.md`
|
||||
- `[2026-08-23]` **Worldtree b187 shipped; all three instances de-armed from a 69-day-stale `:latest`; Matrix homeserver re-plumbed to personal.** Includes the `:8009`-is-demo port trap that an IP-only fix would have walked into. → `persistent-memory.d/2026-08-23-worldtree-b187-pins-matrix.md`
|
||||
- `[2026-08-23]` **Every secret-bearing `.env` on ana-docker tightened to 0600** — eight stacks including vaultwarden and traefik, verified exposed by reading one as `nobody`. → `persistent-memory.d/2026-08-23-ana-docker-env-perms-sweep.md`
|
||||
- `[2026-08-23]` **`pfi` gitea org created; claude-bot is an Owner and creates repos self-serve.** Closes the repo-creation half of the credential-migration directive — `vh` is a USER namespace so no service account could ever create there. Repo creation needs `write:user` + `write:repository` + `write:organization`; `POST /users/{u}/tokens` is basic-auth only, so minting needs the account password. Default new repos to `pfi/`. (`vh/eitri-smithy` was its first tenant, then moved.)
|
||||
- `[2026-08-23]` **Booth: kept boards are deletable and link rows are prunable.** `release` on a kept card drops the sentinel so the existing × applies; `booth links` / `booth unlink <id|index>` prune one row. Rows are addressed by **content id, never position** — the board is append-only and multi-writer. **Releasing a board RESETS its TTL clock** (unlink bumps the dir mtime), so unkeep-and-wait is a 24h delay, not a delete. (`4be880f`, `0ad332b`)
|
||||
|
||||
- `[2026-08-22]` **DFlash2 spec-decode measured on our own stack; `sec` promoted to it.** +18–21% accepted length and +15–18% throughput over MTP k=3, drafter proved model-agnostic across two finetunes to 0.06%, and the k=7 MTP *control* showed deeper MTP is a throughput trap. → `persistent-memory.d/2026-08-22-dflash2-spec-decode.md`
|
||||
- `[2026-08-22]` **Quant pipeline shipped a crippled tokenizer for months — fixed at source.** `quant_mixed_nvfp4.py` baked its calibration truncation (`max_length 2048`) into every mixed-NVFP4 build; latent on old transformers, fatal on new. Both live quants corrected, pipeline now saves a source-pristine tokenizer and asserts it. Playbook §3.14. (`0755ba7`)
|
||||
- `[2026-08-22]` **`sec` retuned to util 0.52 / 420K after a runtime OOM at 0.55/480K** — `gpu-memory-utilization` is not a hard reservation; activation grows past the dummy-data profile and six vLLM containers share GPU1. Also measured: the KV pool varies ~6.6% between boots, so max-model-len must be sized against the *lower* observation. (`6e82899`)
|
||||
@@ -157,7 +186,7 @@ _As of 2026-08-22 — three AI seats live on ana-ml2; `sec` is the one that move
|
||||
|
||||
- `[2026-08-17]` **Gen seat swapped to `absolute-heresy` — and the three bugs the swap exposed are worth more than the swap.** Candidate `MuXodious/Qwen3.8-27B-absolute-heresy` (Heretic v1.4.0 + SOMPOA, T377) beat the incumbent on refusals AND KL simultaneously, which is the unusual part — those normally trade off. Validated on the probe port per operator ruling, promoted, all 7 aliases green. **Durable lessons banked:** (1) **A CPU-only MTP head hash can replace the ~56 GB bf16 acceptance gate.** The `Qwen3_5ForConditionalGeneration` wrapper never loads the MTP head, so PEFT merges / Heretic runs / llm-compressor passes all leave `mtp.*` pristine — hashing it against a head we have already measured (the incumbent's, 47.7%) answers the question for free. Predicted 47.7%, measured 47.2%. Saved downing meromero. Tool: `services/gen-seat-mixed-quant/compare_mtp_head.py` (hash bf16 via **uint8 reinterpret** — numpy has no bfloat16). (2) **`post_quant.py` assumed a standalone `model-mtp.safetensors`**; a full checkpoint keeps `mtp.*` in a NUMBERED shard, so the copy silently no-op'd while the index was still rewritten to point at a file that never existed — 15 unresolvable tensors behind a correct-looking tensor count. Its own FAILED-CHECKS assertion caught it; **that is why the check exists rather than an assumption**. Fixed to extract. (3) **A probe that does not mirror the live seat manufactures failures.** `serve_probe.sh` hardcoded `:latest` (seat is a pinned nightly for #51113), had no tool-call/reasoning parsers, and its `--speculative-config` JSON died twice on quoting — **bash BRACE-EXPANDS `{"a":1,"b":2}` on the comma** unless single-quoted at the REMOTE shell. Adding the seat's flags took the surface test from 5/6 to **6/6**; the "tool calling broken" result was pure probe config. Commits `7997f11`,`254c588`,`2c36028`,`b0c2d3d`,`993421b`.
|
||||
|
||||
- `[2026-08-17]` **Fleet IPv6 mapped + the real VPN topology verified; the driver is CGNAT at ESH, not the WireGuard mesh.** New ESH fiber (installing 2026-08-18) lands the house behind **CGNAT**, which breaks **Site Magic** (NH3↔ESH `sdwan-mesh-tunnel`) on IPv4 — so IPv6 becomes load-bearing as the escape hatch, and that is its most likely first consumer. Topology as VERIFIED (a prior turn assumed wrong and was corrected): UniFi↔UniFi = **Site Magic**; colo↔UniFi = **IPsec IKEv2** (`pfi-ana-nh3` 158M/165M pkt = the workhorse, `ana-to-eshudm`); **WireGuard is an RA convention only, host-based on `ana-wg`** UDP 31337 behind a FortiGate VIP — the FortiGate never terminates WG (FortiOS 7.2 has none; 7.4 added it) so "upgrade the edge for WireGuard" is a **non-problem, do not re-derive**. IPv6 today: **NH3 WAN live** `2600:1700:b25:c110::48`, **colo none**, **ESH none**. **AT&T delegates exactly ONE /64** (`2600:1700:b25:c11f::/64`) — proven by forcing prefix-ID auto→`0` and watching the subnet NOT move, because the `c110`/`c11f` pattern otherwise reads convincingly as a /60. A mesh needs a routable **WAN** address, **not** PD. `ana-wg`'s WG socket is **already dual-stack** (`[::]:31337`) → v6 RA needs an address + a v6 port-forward, no WG reconfig. ⚠ UDM legacy `rest/firewallrule` returns **0 rules** (zone-based firewall) — use `v2/…/firewall-policies`; inbound v6 is default-deny and held. All three endpoints will be **dynamic** → extend the existing hostname pattern (`ana-fw`/`nh3.phasefinal.com`) to **AAAA**. Enabled PD on `nh3-iot` to measure, **reverted on operator instruction** (all 5 LANs back to `none`, verified). Also fixed: **`ana-wg` WireGuard key material was world-readable** (`wg0.conf` + `keys/*_priv` + `*_psk` + client `configs/*.conf` at 644) → now 600, dirs 700, service untouched. Detail → `persistent-memory.d/2026-08-17-fleet-ipv6-mesh.md`.
|
||||
- `[2026-08-17]` **Fleet IPv6 mapped + the real VPN topology verified; the driver is CGNAT at ESH, not the WireGuard mesh.** New ESH fiber (installing 2026-08-18) lands the house behind **CGNAT**, which breaks **Site Magic** (NH3↔ESH `sdwan-mesh-tunnel`) on IPv4 — so IPv6 becomes load-bearing as the escape hatch, and that is its most likely first consumer. Topology as VERIFIED (a prior turn assumed wrong and was corrected): UniFi↔UniFi = **Site Magic**; colo↔UniFi = **IPsec IKEv2** (`pfi-ana-nh3` 158M/165M pkt = the workhorse, `ana-to-eshudm`); **WireGuard is an RA convention only, host-based on `ana-wg`** UDP 31337 behind a FortiGate VIP — the FortiGate never terminates WG (FortiOS 7.2 has none; 7.4 added it) so "upgrade the edge for WireGuard" is a **non-problem, do not re-derive**. IPv6 today: **NH3 WAN live** `2600:1700:b25:c110::48`, **colo none**, **ESH none**. **AT&T delegates one /64 PER REQUEST** (`2600:1700:b25:c11f::/64`) — and the BGW holds the whole `/60`, rationing `c118`–`c11f` one at a time while keeping `c110`–`c117`. So eight /64s exist; UniFi just solicits once. ⛔ **CLOSED 2026-08-24 — operator ruling, do not re-raise:** the BGW has **no IP-passthrough** (operator confirmed, and we have admin on it), so the only route to the other seven is a multi-DUID DHCPv6 client on a VM — which means split-stack routing and rebuilding the entire v6 firewall policy off the UDM. Juice not worth the squeeze. NH3 LANs stay v6-off. A mesh needs a routable **WAN** address, **not** PD. `ana-wg`'s WG socket is **already dual-stack** (`[::]:31337`) → v6 RA needs an address + a v6 port-forward, no WG reconfig. ⚠ UDM legacy `rest/firewallrule` returns **0 rules** (zone-based firewall) — use `v2/…/firewall-policies`; inbound v6 is default-deny and held. All three endpoints will be **dynamic** → extend the existing hostname pattern (`ana-fw`/`nh3.phasefinal.com`) to **AAAA**. Enabled PD on `nh3-iot` to measure, **reverted on operator instruction** (all 5 LANs back to `none`, verified). Also fixed: **`ana-wg` WireGuard key material was world-readable** (`wg0.conf` + `keys/*_priv` + `*_psk` + client `configs/*.conf` at 644) → now 600, dirs 700, service untouched. Detail → `persistent-memory.d/2026-08-17-fleet-ipv6-mesh.md`.
|
||||
|
||||
- `[2026-08-17]` **Gen-seat multi-day degeneration RESOLVED — two compounding real causes, not one; the meta-lesson is "a mitigation that HELPS but doesn't FIX means a second cause, not a wrong one."** vLLM `qwen3_5_mtp`×GDN bug (#51113, real, fixed by nightly) + AEON full-W4A4 being lowest-fidelity (W4A4<W4+FP8<W4+bf16) → ~15-20% stochastic degeneration. Fixed by mixed FP8-attn build on pinned nightly. AEON purged. Also banked: **stochastic (~15-20%) degeneration is invisible to a small synthetic probe — n=1 "clean" validated THREE non-fixes (MTP-off, APC-off, nightly-alone) that all failed in real use; get the operator's real transcript, do not trust your own probe.** Full → `docs/pfi/model-quantization-playbook.md` §3.8 (+ §3.7 MTP-multi-turn). Commits `d28a371`,`2f2bbce`,`2185964`.
|
||||
|
||||
@@ -225,21 +254,6 @@ _As of 2026-08-22 — three AI seats live on ana-ml2; `sec` is the one that move
|
||||
- `[2026-08-09→10]` **dots.tts (rednote-hilab) TTS burn-in on irv-ml1 + canonical voice corpus built (`voices/`).** Operator-directed eval to potentially replace chatterbox-fast. **dots.tts VERIFIED real** (canonical HF ns `dots-studio/`, `rednote-hilab/dots.tts-*` redirects there; Apache-2.0; PyPI `dots.tts` 0.2.1; 2B continuous-AR = semantic enc + Qwen2.5-1.5B LLM + flow-matching acoustic head over 48kHz AudioVAE; zero-shot clone from wav+transcript). **Runs on Ampere 3090** (sm_86, bf16, no fp8 dep); **optimized RTF 0.22** at num_steps=10 (`from_pretrained(..., optimize=True)` CUDA graphs — raw unoptimized was 1.21), **~6GB VRAM**, 48kHz, streams (`generate_stream`). Venv+cache at `irv-ml1:/home/lkraven/dots-tts` (~10GB). **Operator design calls:** SGLang Omni serving (OpenAI `/v1/audio/speech`), transcribe-refs-first, `soar` variant. ⚠ Omni serves soar but its continuous-batching + streaming opts are **mf-only** (soar = single-request) — non-issue for ratatoskr's single-consumer RP surface. **KEY FINDING — dots is highly sensitive to an accurate AND sentence-bounded reference transcript:** mismatched transcript → 0.16s collapse; over-long/messy transcript → reference-audio BLEEDS as an output prefix; mid-clause trim → dangling-word leak (glados "we'll", emmie "And,"). RECIPE (baked into `voices/derive.py`): trim ref to a clean ~6–10s clip ending on a sentence boundary + accurate transcript of exactly that clip. **CANONICAL VOICE CORPUS** stood up in eshpfi `voices/` (operator idea): engine-agnostic `canonical/<v>.wav` + `transcripts/<v>.txt` → per-engine ref sets DERIVED by `derive.py` reading `engines.yaml` profiles (dots/chatterbox/zonos); canonical wavs git-tracked (small/curated), `derived/` gitignored. **4 voices optimized + verified CLEAN for dots: donut, glados, emmie, miranda** (glados canonical is low-SR 16kHz — flagged upgrade candidate). ⚠ GPU GOTCHA: irv-ml1 native CUDA orders **A6000=device0** (ComfyUI-full) — pin the 3090 with `CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES=0`; and `PYTORCH_CUDA_ALLOC_CONF=expandable_segments` CONFLICTS with `optimize=True` CUDA graphs (curr_block error). Booths: `dots-vs-chatterbox`, `dots-voices-optimized`. **SHIPPED 2026-08-10:** operator A/B verdict "dots is very good" → containerized as a **thin FastAPI wrapper over DotsTtsRuntime** (chosen over SGLang Omni — Omni's batching is mf-only, unneeded for ratatoskr's single consumer; wrapper is SERIALIZED one-gen-at-a-time via a threading.Lock, Omni+mf = parked API-compatible escalation if multi-consumer ever lands). **LIVE on irv-ml1:8198** (`local/dots-tts:v1`, OpenAI `/v1/audio/speech` + `/health` + `/v1/voices`, container healthy, both stream + non-stream verified CLEAN, 4 voices donut/glados/emmie/miranda) alongside chatterbox :8197 (nothing repointed). Stack = `stacks/dots-tts/` (Dockerfile/app.py/compose/.env.example/README). ⚠ CONTAINER GOTCHA: `optimize=True` (torch.compile/inductor/triton) needs a **C compiler at RUNTIME** — slim image must `apt install build-essential` or model-load dies "Failed to find C compiler" (host venv had gcc ambient, masking it); persist `TORCHINDUCTOR_CACHE_DIR` to a mounted dir or every restart re-JITs ~5min. Corpus home = eshpfi `voices/` (operator ruled keep-here). **REMAINING: ratatoskr client cutover** to :8198 `/v1/audio/speech` (Phase-2 tail, peer-coupled — draft the ask). [[reference_chatterbox_fast_repo]] [[reference_zonos_tts_stack]] [[reference_verify_hf_repo_ids_before_pull]]
|
||||
|
||||
|
||||
- `[2026-08-08]` **worldtree-dev #400 CLOSED → fiction-decomp snapshot cleared from nh3-dev.** worldtree-dev signaled #400 done (shipped v1.0.0b185; exact-lexical efficacy 79%→12% on ratatoskr's gate, brokkr no-harm bracket green both ends; the snapshot served 4 probe rounds — rank decomposition, promoted-vs-gold annotation, tie-set falsification, A0/A1/A2 mechanism probe). Cleared `~/snapshots/worldtree-400-fiction-decomp` (208M: chroma + manifest/provenance/stamp) — a read-only rsync copy of PERSONAL Worldtree's Chroma (source on corviduo-dev, so safe to remove). **LEFT INTACT:** `rex393-fiction-index`/`rex393-fiction-snapshot` (separate operator KEEP word, unchanged) + `r42-gate-*`. No config deltas rode this train. Only remaining non-blocking await = ratatoskr-dev's chatterbox-fast knob revert. Replied confirming (`01KZJ9GMCC…`).
|
||||
|
||||
|
||||
- `[2026-08-07]` **chatterbox-fast "broken audio" root-caused (T3 AR tail over-run) + FIXED (max_chunk_chars=250 cap, :v2 deployed).** Long saga, operator-driven clean diagnosis. **Symptom:** ratatoskr's migrated RP-surface TTS "swaps to German" / "dead air" / "garbage" on long turns. **NOT** German-leak (Turbo `generate()` has NO language param — plain AutoTokenizer, no `language_id`; the multilingual `language_id="en"` lever lives only in the separate `ChatterboxMultilingualTTS`), **NOT** OOM alone. **Real cause:** the Chatterbox **Turbo T3 model OVER-RUNS its generation tail** — a long single `generate()` degrades into garble/dead-air in its final ~2-3s (lib filters OOV tokens `<6561` + pads silence = messy AR tail). The scheduler's buffer-ratchet builds 300-600 char mega-chunks that land in that zone; streaming concatenates each bad tail (worst case). **ratatoskr's anti-"German" knobs (top_k=80/temp=0.5) made it WORSE** — tight sampling pulls the degradation onset SHORTER (~200 chars vs ~300 at default knobs). **Diagnosis method** (deterministic, no ears-only): single-shot length sweep + **amplitude-gated voiced-ZCR** (garble spikes ZCR; must gate on |x|>500 else trailing silence confounds it) — degraded voiced-tail = 1.58× mid, clean = ~0.64-1.1×. **FIX:** server-side `max_chunk_chars=250` cap on the scheduler (`:v2` image, `CBF_MAX_CHUNK_CHARS=250` env) — bounds each generation to just under the ~300-char onset → clean **3-4 sentence** chunks (max prosodic arc while clean). Operator ear-confirmed clean audio + clean joins; **chatterbox's low emotiveness keeps chunk joins smooth** (the harsh joins that got Zonos rejected are absent — operator's key call). **ratatoskr TODO (relayed msg `01KZER9X7S`):** revert knobs to default (top_k→1000, temp→0.8), send full text (server chunks internally), keep the 503-on-empty guard. **Cap value tunable** per-request (`max_chunk_chars`) + env. **Deeper prosody** (if ever wanted) = scheduler Phase-2 context-priming at joins (feed prior sentence as discarded-audio context; +latency). **⚠ FOOT-GUNS:** (1) acoustic tail-trim is UNRELIABLE — sibilants ('s'/'sh'/'f') spike ZCR like garble, can't cleanly detect the speech→garble boundary. (2) **build-context vs image drift** — the `:v2` image was built from cap source, but after a `:v1` rollback the build context held `:v1` source → a `docker compose build` would've silently produced a cap-less `:v2`; re-synced the flat cap source to `/opt/docker/compose/chatterbox-fast/` (rebuild-verified). **⚠ DIVERGENCE (follow-up):** deployed build context is FLAT (`app.py`/`scheduler.py`, `from scheduler import`, thin-overlay `FROM local/chatterbox:v1`, cap-only) vs the `vh/chatterbox-fast` REPO which is PACKAGE-layout (`chatterbox_fast/`, `from chatterbox_fast.scheduler`, self-contained Dockerfile) + has `norm_loudness` (repo commit `6bc7bf0` = cap; deployed omits norm_loudness deliberately to keep the ear-test unconfounded). Reconcile the two layouts so a repo-based rebuild matches deploy. Rollback: `.bak-cap-20260807-104850` backups on irv-ml1 + `:v1` image both retained. [[reference_chatterbox_fast_repo]] [[reference_zonos_tts_stack]]
|
||||
|
||||
|
||||
- `[2026-08-07]` **Zonos2 TAKEN DOWN on the 3090 (irv-ml1) — operator-directed "for memory", TEMPORARY.** Freed ~17.4 GB (3090: 728 MiB → 18.2 GB free) so chatterbox-fast (co-resident, was OOMing on long generations) has headroom. **⚠ Restore is manual — Zonos2 :1920 was a DETACHED native process (NOT systemd/docker), reparented to init.** GPU memory was held by the `--multiprocessing-fork` CHILDREN (1966165=16.4G, 1966166=1G), which ORPHAN to init when you kill the parent — had to SIGTERM the children explicitly (killing the parent 1965942 + uv-run 1965935 alone left the 16.4G held). **RESTORE CMD** (from irv-ml1, user lkraven): `cd /home/lkraven/tts-audition/models/zonos2 && nohup uv run python -m zonos2 --model-path Zyphra/ZONOS2 --host 0.0.0.0 --port 1920 --tts-default-voices-dir ./default_voices/ --cuda-graph-max-bs 1 --num-pages 16384 --max-running-requests 2 --memory-ratio 0.3 > /tmp/zonos2.log 2>&1 &` then `docker start zonos-gateway`. **Consumers that lost Zonos:** asset-engine + gateway-chat (via LiteLLM `ext-tts` alias → zonos-gateway :8890, now stopped); ratatoskr already migrated OFF to chatterbox-fast (unaffected). Also unblocks proper drift/cap testing (OOM was blocking it). [[reference_zonos_tts_stack]]
|
||||
|
||||
|
||||
- `[2026-08-07]` **chatterbox-fast: donut voice added + full contract delivered to ratatoskr-dev (their TTS migration off Zonos).** Operator-directed. Copied `zonos-gateway/voices/Donut.wav` → chatterbox `/refs` (`/worktank/chatterbox/reference_audio/donut.wav` — the reference_audio SUBDIR is lkraven-owned so no sudo despite `/worktank` root; container globs `/refs` live → **NO restart**), exposed as `voice:"donut"` (lowercase); verified clean 7.5s synth (24kHz, RTF ~0.31). A/B booth (chatterbox vs zonos donut, same line) at `http://10.100.10.50:8090/b/donut-chatterbox/`. Answered ratatoskr's 8-question contract ask from the live gateway (`local/chatterbox-fast:v1`) + source: **NOT OpenAI-shaped** (`POST /tts`; body `text`/`voice`/`format`/`stream`, not `input`/`model`/`response_format`); **NO affect dials** (Turbo ignores cfg_weight/min_p/exaggeration — the architecture-changing answer they flagged; **Zonos stays the only fleet TTS with real emotion steering**); streaming WAV placeholder-header shape IDENTICAL to Zonos (their per-chunk Web Audio path survives); SR 24000 (Zonos 44100); server chunks arbitrary-length text internally (no client-side chunking, unlike Zonos's 71.2s cap); English-only, no language pin. **FYI-worthy (operator):** ratatoskr is moving its RP-surface TTS OFF Zonos back to chatterbox-fast → loses the live-PAD affect coupling (heavy Zonos emotion investment) — their call, trade-off flagged to them. auto-memory `reference_chatterbox_fast_repo` enriched w/ the live contract. [[reference_zonos_tts_stack]]
|
||||
|
||||
|
||||
- `[2026-08-07]` **Fleet reranker cut over: Qwen3-Reranker-0.6B → BAAI/bge-reranker-v2-m3 (Brokkr R43).** The incumbent was measured HARMING 80/90 fleet queries (no-reranker beat it 89/90 vs 56/90). R43 bake-off: the A2 control (same Qwen weights, seq-cls head) scored identical to the incumbent → proved the fault is a training-prior not the serving head → cancelled the expensive Qwen3-4B arm; A3 (bge-v2-m3) won on multilingual safety + bare-name recovery. LiteLLM `reranker` repointed incumbent→A3 :8013 (boundary 2026-08-06T17:37:48Z, config-edit + ~52s gateway restart); **R42 v13 gate PASSED first-ever** (56/90→90/90). Incumbent kept warm :8002 (rollback via `qwen3-reranker` alias), A4 fallback :8014. Full arc + rollback runbook `docs/pfi/reranker-selection-ledger.md`; commits ad2df89/2c11748/377f8a4 (unpushed). auto-memories: the earlier reranker-serving notes.
|
||||
|
||||
|
||||
- `[2026-08-05]` **Fleet CI resilience flip (`DEFAULT_ACTIONS_URL=self`) — attempted end-to-end, PARKED on a runner action-fetch auth blocker; infra-ops to research it (operator-directed, deferred, NOT now).** 7 gitea action mirrors staged public+populated (orgs `actions`+`astral-sh`); the flip resolves `uses:` correctly but act_runner v0.6.0 can't authenticate its fetch to gitea 1.26 ("Invalid username or token. Password authentication is not supported"). Reverted (CI back on github default); `REQUIRE_SIGNIN_VIEW=false` KEPT as a standing change (operator, internal WG net). Full endeavor, the reliable nh3-dev-egress + git-SSH mirror method, exact config state, smoke method, and next step → `persistent-memory.d/2026-08-05-ci-flip-parked.md`
|
||||
|
||||
|
||||
@@ -253,9 +267,15 @@ _As of 2026-08-22 — three AI seats live on ana-ml2; `sec` is the one that move
|
||||
|
||||
|
||||
|
||||
_209 older entries archived to archival-memory.md._
|
||||
_214 older entries archived to archival-memory.md._
|
||||
## Tried and abandoned
|
||||
|
||||
- `[2026-08-24]` **AES-GCM on the Anaheim tunnels — impossible, not merely hard.** UniFi's manual site-to-site IPsec implements no AEAD cipher at all: eight GCM spellings rejected `api.err.InvalidPayload` against a passing `aes256` control. Blocks both tunnels since both far ends are UDMs. Accepted enum is `aes128/aes192/aes256/3des` — and 3DES is *slower* (no ARM instructions, 64-bit blocks), so AES-128 is the floor.
|
||||
- `[2026-08-24]` **Pointing the UDM's `wan_dns1` at AdGuard — silently ignored.** It persists and reads back correctly but the LAN-facing forwarder never uses it; proven with fresh uncached ad domains (AdGuard answers `0.0.0.0`, the UDM returned real IPs). Reverted rather than left in place.
|
||||
- `[2026-08-24]` **A multi-DUID DHCPv6 VM to claim NH3's seven unclaimed /64s — declined by the operator.** The BGW has no IP-passthrough (confirmed, we hold admin), so the only route needs re-cabling, split-stack routing and **rebuilding the entire v6 firewall policy off the UDM**. The prefixes are easy; the firewall rebuild is why nobody wants them. Do not re-raise on "there are seven free prefixes".
|
||||
- `[2026-08-23]` **A `HEAD == GITHUB_SHA` assertion in the hrafn CI — added, broke the checkout twice, removed.** It needed the `git` binary (run 9920, exit 127); installing `git` then flipped `actions/checkout@v4` off its **node** implementation onto the git binary, which died on a missing CA bundle (run 9921). A nice-to-have assertion changed the checkout's code path and broke a working pipeline. Removed rather than patched with `ca-certificates` — it guarded a hypothesis that proved wrong. **Do not add `git` to that prereq step.**
|
||||
- `[2026-08-23]` **Repointing `selene-1-mini-8b` at gen's endpoint — proposed by me, correctly overruled.** *"never repoint a named model at a different model's endpoint — that is intentionally misleading."* The trap is that it does not feel like deception; it feels like sparing consumers a migration. That framing is the tell. Role aliases move; model names die with the model and 4xx.
|
||||
|
||||
- `[2026-08-15]` **Grafted bf16 MTP loads UNINITIALIZED (0% accept) unless `re:^mtp.*` is in the quant-config `ignore`; and W4A16=Marlin (not native FP4) costs ~20% even on decode.** Cost a premature 79 GB delete of a good model (declared desync-dead off the 0%). Lessons: test MTP on bf16 FIRST, isolate before deleting; modelopt 0.43 is dependency-hell for qwen3_5 (list-vs-dict quant_cfg + transformers conflict) — use llm-compressor. Full → `persistent-memory.d/2026-08-15-uncensored-gen-seat.md`
|
||||
|
||||
- `[2026-08-03]` **ComfyUI `--enable-triton-backend` on the irv-ml1 A6000 crashes EVERY render — Ampere has no hardware e4m3.** adhoc-agent's operator-approved probe: comfy_kitchen's triton backend has a FUSED int8 matmul that would beat the eager backend's ~1.9x-slower unfused int8 path (21.3s vs 11.2s fp8 on the Moody Krea2 int8 checkpoints). Flipped it (added to `COMFY_CMDLINE_EXTRA`, recreated) → `triton.compiler.errors.CompilationError: ValueError("type fp8e4nv not supported in this architecture. supported: fp8e4b15, fp8e5")` in `comfy_kitchen/backends/triton/quantization.py:145 dequantize_per_tensor_fp8`, failing at **node 5 CLIPTextEncode**. Triton's fp8 dequant kernel targets `fp8e4nv` (Hopper/Ada e4m3); **sm_86 Ampere (A6000) lacks hardware e4m3** → the JIT compile dies. With triton on it grabs the **global** `--fp8_e4m3fn-text-enc` dequant, so every render (fp8 AND int8) dies upstream at the text-encode step — the int8 UNet path never ran, so the convrot-coverage caveat wasn't even the limiter. Reverted cleanly (~15s to healthy, image unchanged `sha256:94afb8ca`, sage intact, prod restored). **The parked cu130 rebuild won't fix it** (e4m3 = hardware format, not CUDA version). **DEFERRED to the Ada refresh** (operator: "ada is coming, we'll optimize then" — Ada sm_89 has native e4m3, so triton's fp8 path should compile there). **Mechanics:** `--enable-triton-backend` is a compose `environment:` var, so toggling it needs `docker compose up -d` (**recreate**), NOT `docker restart` (reuses the baked env, no-ops silently). Full: auto-memory `parked_triton_backend_ampere_fp8`.
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# ana-ml2 — CLOSE the ERP/RP tune window: put the fleet back the way it was.
|
||||
#
|
||||
# gen GPU1 -> GPU0 -> start mog-sec back onto GPU1
|
||||
#
|
||||
# The exact inverse of playbooks/ana-ml2-training-window-open.yaml.
|
||||
#
|
||||
# ⚠⚠ ORDER IS LOAD-BEARING, AND IT IS THE MIRROR OF THE OPEN ORDER.
|
||||
# `gen` must vacate GPU1 BEFORE mog-sec is started. mog-sec runs at
|
||||
# --gpu-memory-utilization 0.52 = 50,901 MiB that must be free at startup. With
|
||||
# gen still resident on GPU1 only ~30,000 MiB is free, so mog-sec would fail to
|
||||
# boot. gen moves back to the (empty) GPU0 first; step 4 waits for GPU1 to
|
||||
# actually release before mog-sec is started at all.
|
||||
#
|
||||
# ⚠ FIRST STEP IS A GATE, NOT A COURTESY. If a training process is still
|
||||
# resident on GPU0 this playbook REFUSES to run — moving gen back would either
|
||||
# OOM the run or OOM gen. Override only when you have confirmed the run is
|
||||
# finished or deliberately abandoned:
|
||||
#
|
||||
# scripts/elway ana-ml2 --playbook playbooks/ana-ml2-training-window-close.yaml \
|
||||
# --var allow_busy_gpu0=true
|
||||
#
|
||||
# scripts/elway ana-ml2 --playbook playbooks/ana-ml2-training-window-close.yaml
|
||||
|
||||
vars:
|
||||
gen_dir: /opt/docker/compose/gen-seat
|
||||
mog_dir: /opt/docker/compose/mog-sec
|
||||
gen_port: "8015"
|
||||
mog_port: "8019"
|
||||
# mog-sec's startup requirement: 0.52 x 97,887 MiB, rounded up.
|
||||
mog_required_free_mib: "50950"
|
||||
# Set to "true" to close the window even with a process still on GPU0.
|
||||
allow_busy_gpu0: "false"
|
||||
|
||||
steps:
|
||||
- name: "GATE — GPU0 is idle (refuses to evict a training run mid-flight)"
|
||||
sudo: true
|
||||
shell: |
|
||||
set -e
|
||||
gpu0_uuid=$(nvidia-smi --query-gpu=uuid --format=csv,noheader -i 0)
|
||||
n=$(nvidia-smi --query-compute-apps=gpu_uuid,pid --format=csv,noheader | grep -c "$gpu0_uuid" || true)
|
||||
echo "GPU0 compute procs: $n"
|
||||
if [ "$n" -eq 0 ]; then exit 0; fi
|
||||
if [ "{{ allow_busy_gpu0 }}" = "true" ]; then
|
||||
echo "GPU0 still busy but allow_busy_gpu0=true — proceeding under override"
|
||||
nvidia-smi --query-compute-apps=gpu_uuid,pid,used_memory,process_name --format=csv | grep "$gpu0_uuid" || true
|
||||
exit 0
|
||||
fi
|
||||
echo "REFUSING: a process is still resident on GPU0. Confirm the tune has"
|
||||
echo "finished, then rerun with --var allow_busy_gpu0=true"
|
||||
nvidia-smi --query-compute-apps=gpu_uuid,pid,used_memory,process_name --format=csv | grep "$gpu0_uuid" || true
|
||||
exit 1
|
||||
changed_when: "false"
|
||||
|
||||
- name: "PREFLIGHT — record gen's current container id (proves the recreate later)"
|
||||
sudo: true
|
||||
shell: docker inspect vllm-gen --format '{{.Id}}' | tee /tmp/gen-container-id-before.txt
|
||||
changed_when: "false"
|
||||
|
||||
- name: "Point gen back at GPU0 in its .env (GEN_GPU_ID 1 -> 0)"
|
||||
sudo: true
|
||||
# ⚠ `sudo` INSIDE the when: expression — a step's `sudo: true` does NOT
|
||||
# cover its guards, and the root-only .env makes an unsudo'd grep exit 2,
|
||||
# which silently skips the step. See the open playbook for the incident.
|
||||
shell: sed -i 's/^GEN_GPU_ID=1$/GEN_GPU_ID=0/' {{ gen_dir }}/.env
|
||||
when: "sudo grep -qx 'GEN_GPU_ID=1' {{ gen_dir }}/.env"
|
||||
|
||||
- name: "Assert the EFFECTIVE device id, not the .env line"
|
||||
sudo: true
|
||||
# ⚠ Parse the JSON, do not regex the YAML — compose emits `- "0"` with
|
||||
# DOUBLE quotes. See the open playbook for the incident.
|
||||
shell: |
|
||||
docker compose --project-directory {{ gen_dir }} config --format json \
|
||||
| jq -e '.services["vllm-gen"].deploy.resources.reservations.devices[0].device_ids == ["0"]'
|
||||
changed_when: "false"
|
||||
|
||||
- name: "Recreate gen onto GPU0"
|
||||
sudo: true
|
||||
shell: docker compose --project-directory {{ gen_dir }} up -d vllm-gen
|
||||
|
||||
- name: "Wait for gen to serve /health on GPU0"
|
||||
sudo: true
|
||||
shell: |
|
||||
for i in $(seq 1 180); do
|
||||
if curl -sf -o /dev/null http://127.0.0.1:{{ gen_port }}/health; then
|
||||
echo "gen healthy after $((i*5))s"; exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
echo "TIMEOUT: gen did not become healthy in 900s"; exit 1
|
||||
changed_when: "false"
|
||||
|
||||
- name: "Wait for GPU1 to release gen's memory before mog-sec is started"
|
||||
sudo: true
|
||||
shell: |
|
||||
for i in $(seq 1 60); do
|
||||
free=$(nvidia-smi --query-gpu=memory.free --format=csv,noheader,nounits -i 1)
|
||||
if [ "$free" -ge {{ mog_required_free_mib }} ]; then
|
||||
echo "GPU1 free: ${free} MiB"; exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "TIMEOUT: GPU1 free is ${free} MiB, need >= {{ mog_required_free_mib }}"; exit 1
|
||||
changed_when: "false"
|
||||
|
||||
- name: "Start mog-sec back up on GPU1 (`sec` / `sec-reasoning`)"
|
||||
sudo: true
|
||||
shell: docker compose --project-directory {{ mog_dir }} start vllm-mog-sec
|
||||
when: "! docker inspect -f '{{.State.Running}}' vllm-mog-sec 2>/dev/null | grep -q true"
|
||||
|
||||
- name: "Wait for mog-sec to serve /health"
|
||||
sudo: true
|
||||
shell: |
|
||||
for i in $(seq 1 180); do
|
||||
if curl -sf -o /dev/null http://127.0.0.1:{{ mog_port }}/health; then
|
||||
echo "mog-sec healthy after $((i*5))s"; exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
echo "TIMEOUT: mog-sec did not become healthy in 900s"; exit 1
|
||||
changed_when: "false"
|
||||
|
||||
verify:
|
||||
- name: "gen was genuinely RECREATED (container id changed)"
|
||||
sudo: true
|
||||
shell: |
|
||||
before=$(cat /tmp/gen-container-id-before.txt)
|
||||
after=$(docker inspect vllm-gen --format '{{.Id}}')
|
||||
echo "before=${before:0:12} after=${after:0:12}"
|
||||
test "$before" != "$after"
|
||||
changed_when: "false"
|
||||
|
||||
- name: "gen's process is resident on GPU0 again"
|
||||
sudo: true
|
||||
# ⚠ Match by CGROUP, not by `.State.Pid` — vLLM V1's EngineCore is a CHILD
|
||||
# of the container's pid 1, and it is the child nvidia-smi reports.
|
||||
shell: |
|
||||
cid=$(docker inspect vllm-gen --format '{{.Id}}')
|
||||
gpu0_uuid=$(nvidia-smi --query-gpu=uuid --format=csv,noheader -i 0)
|
||||
found=0
|
||||
for p in $(nvidia-smi --query-compute-apps=gpu_uuid,pid,used_memory --format=csv,noheader \
|
||||
| grep "$gpu0_uuid" | cut -d, -f2 | tr -d ' '); do
|
||||
if grep -q "$cid" /proc/$p/cgroup 2>/dev/null; then
|
||||
echo "gen pid $p resident on GPU0: $(nvidia-smi --query-compute-apps=pid,used_memory --format=csv,noheader | grep "^$p,")"
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
test "$found" -eq 1
|
||||
changed_when: "false"
|
||||
|
||||
- name: "gen answers a real completion"
|
||||
sudo: true
|
||||
shell: |
|
||||
. {{ gen_dir }}/.env
|
||||
curl -sf -m 120 http://127.0.0.1:{{ gen_port }}/v1/chat/completions \
|
||||
-H "Authorization: Bearer ${API_KEY}" -H 'Content-Type: application/json' \
|
||||
-d '{"model":"'"${GEN_SERVED_NAME}"'","messages":[{"role":"user","content":"reply with the single word: ok"}],"max_tokens":16}' \
|
||||
| grep -q '"content"'
|
||||
changed_when: "false"
|
||||
|
||||
- name: "sec answers a real completion"
|
||||
sudo: true
|
||||
shell: |
|
||||
. {{ mog_dir }}/.env
|
||||
curl -sf -m 120 http://127.0.0.1:{{ mog_port }}/v1/chat/completions \
|
||||
-H "Authorization: Bearer ${API_KEY}" -H 'Content-Type: application/json' \
|
||||
-d '{"model":"'"${MOG_SERVED_NAME}"'","messages":[{"role":"user","content":"reply with the single word: ok"}],"max_tokens":16}' \
|
||||
| grep -q '"content"'
|
||||
changed_when: "false"
|
||||
|
||||
- name: "Both cards are back to their normal tenancy"
|
||||
sudo: true
|
||||
shell: |
|
||||
nvidia-smi --query-gpu=index,memory.used,memory.free --format=csv
|
||||
nvidia-smi --query-compute-apps=gpu_uuid,pid,used_memory,process_name --format=csv
|
||||
changed_when: "false"
|
||||
@@ -0,0 +1,182 @@
|
||||
# ana-ml2 — OPEN the ERP/RP tune window: clear GPU0 completely.
|
||||
#
|
||||
# stop mog-sec (GPU1) -> move gen GPU0 -> GPU1 -> GPU0 empty for training
|
||||
#
|
||||
# Operator call 2026-08-24: rather than train beside `gen`, move `gen` off GPU0
|
||||
# entirely and stand `sec` down for the night. Training then gets a whole card
|
||||
# (95.60 GiB) instead of a shared one, and the fleet's general seat never goes
|
||||
# dark beyond its own restart.
|
||||
#
|
||||
# ⚠⚠ ORDER IS LOAD-BEARING — DO NOT REORDER THE STEPS.
|
||||
# `gen` runs at --gpu-memory-utilization 0.43, which vLLM reads as a fraction of
|
||||
# TOTAL card memory: 0.43 x 97,887 MiB = 42,091 MiB that must be FREE at startup
|
||||
# or the engine refuses to boot. GPU1 has only 19,446 MiB free while mog-sec is
|
||||
# up. Recreating `gen` onto GPU1 first would take the fleet's main seat down and
|
||||
# leave it down. mog-sec stops FIRST, and step 3 hard-gates on the freed memory
|
||||
# before `gen` is touched at all.
|
||||
#
|
||||
# ⚠ `stop`, never `down`. `down` removes the container; `stop` leaves it in
|
||||
# place so the close playbook can `start` it. Both seats are `restart:
|
||||
# unless-stopped`, which does NOT resurrect a deliberately-stopped container.
|
||||
#
|
||||
# ⚠ device_ids vs nvidia-smi ordering was VERIFIED on this host, not assumed:
|
||||
# gen (GEN_GPU_ID=0) reports under the GPU nvidia-smi indexes 0, mog-sec
|
||||
# (MOG_GPU_ID=1) under index 1. They agree here. (They do NOT on irv-ml1 —
|
||||
# never carry that assumption between boxes.)
|
||||
#
|
||||
# Restore with: playbooks/ana-ml2-training-window-close.yaml
|
||||
#
|
||||
# scripts/elway ana-ml2 --playbook playbooks/ana-ml2-training-window-open.yaml
|
||||
|
||||
vars:
|
||||
gen_dir: /opt/docker/compose/gen-seat
|
||||
mog_dir: /opt/docker/compose/mog-sec
|
||||
gen_port: "8015"
|
||||
# gen's startup requirement: 0.43 x 97,887 MiB, rounded up. If GPU1 has less
|
||||
# than this free, gen will not boot and the window must not proceed.
|
||||
gen_required_free_mib: "42100"
|
||||
|
||||
steps:
|
||||
- name: "PREFLIGHT — GPU0 holds vllm-gen and nothing else unexpected"
|
||||
sudo: true
|
||||
shell: |
|
||||
set -e
|
||||
procs=$(nvidia-smi --query-compute-apps=gpu_uuid,pid --format=csv,noheader | wc -l)
|
||||
gpu0_uuid=$(nvidia-smi --query-gpu=uuid --format=csv,noheader -i 0)
|
||||
gpu0_procs=$(nvidia-smi --query-compute-apps=gpu_uuid,pid --format=csv,noheader | grep -c "$gpu0_uuid" || true)
|
||||
echo "GPU0 compute procs: $gpu0_procs (total on box: $procs)"
|
||||
test "$gpu0_procs" -le 1
|
||||
changed_when: "false"
|
||||
|
||||
- name: "PREFLIGHT — record gen's current container id (proves the recreate later)"
|
||||
sudo: true
|
||||
shell: docker inspect vllm-gen --format '{{.Id}}' | tee /tmp/gen-container-id-before.txt
|
||||
changed_when: "false"
|
||||
|
||||
- name: "Stop mog-sec (the `sec` / `sec-reasoning` seat) — frees ~55.3 GiB on GPU1"
|
||||
sudo: true
|
||||
shell: docker compose --project-directory {{ mog_dir }} stop vllm-mog-sec
|
||||
# Skip if already stopped, so the playbook is rerunnable.
|
||||
when: "docker inspect -f '{{.State.Running}}' vllm-mog-sec 2>/dev/null | grep -q true"
|
||||
|
||||
- name: "Wait for GPU1 memory to actually release (teardown is not instant)"
|
||||
sudo: true
|
||||
shell: |
|
||||
for i in $(seq 1 60); do
|
||||
free=$(nvidia-smi --query-gpu=memory.free --format=csv,noheader,nounits -i 1)
|
||||
if [ "$free" -ge {{ gen_required_free_mib }} ]; then
|
||||
echo "GPU1 free: ${free} MiB"; exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "TIMEOUT: GPU1 free is ${free} MiB, need >= {{ gen_required_free_mib }}"; exit 1
|
||||
changed_when: "false"
|
||||
|
||||
- name: "HARD GATE — GPU1 has room for gen's 0.43 budget before we touch gen"
|
||||
sudo: true
|
||||
shell: |
|
||||
free=$(nvidia-smi --query-gpu=memory.free --format=csv,noheader,nounits -i 1)
|
||||
echo "GPU1 free ${free} MiB vs required {{ gen_required_free_mib }} MiB"
|
||||
test "$free" -ge {{ gen_required_free_mib }}
|
||||
changed_when: "false"
|
||||
|
||||
- name: "Point gen at GPU1 in its .env (GEN_GPU_ID 0 -> 1)"
|
||||
sudo: true
|
||||
# ⚠ `sudo` INSIDE the when: expression. A step's `sudo: true` covers the
|
||||
# shell, NOT its when/creates/changed_when guards — those run as the login
|
||||
# user. The .env is root-only 0600, so an unsudo'd grep exits 2
|
||||
# (permission denied), which is not 0, so the step SILENTLY SKIPS and the
|
||||
# flip never happens. Caught 2026-08-24 by the effective-value assert below.
|
||||
shell: sed -i 's/^GEN_GPU_ID=0$/GEN_GPU_ID=1/' {{ gen_dir }}/.env
|
||||
when: "sudo grep -qx 'GEN_GPU_ID=0' {{ gen_dir }}/.env"
|
||||
|
||||
- name: "Assert the EFFECTIVE device id, not the .env line"
|
||||
sudo: true
|
||||
# grep on the .env proves a substring is present; only `compose config`
|
||||
# proves what the container will actually be created with.
|
||||
# ⚠ Parse the JSON, do not regex the YAML. The first version of this grepped
|
||||
# for -\s*'?1'? and failed against compose's DOUBLE-quoted `- "1"` — an
|
||||
# assert that fails for the wrong reason is worse than no assert.
|
||||
shell: |
|
||||
docker compose --project-directory {{ gen_dir }} config --format json \
|
||||
| jq -e '.services["vllm-gen"].deploy.resources.reservations.devices[0].device_ids == ["1"]'
|
||||
changed_when: "false"
|
||||
|
||||
- name: "Recreate gen onto GPU1 (a device change needs up -d, not restart)"
|
||||
sudo: true
|
||||
shell: docker compose --project-directory {{ gen_dir }} up -d vllm-gen
|
||||
|
||||
- name: "Wait for gen to serve /health (cold start: weights + CUDA graphs + MTP)"
|
||||
sudo: true
|
||||
shell: |
|
||||
for i in $(seq 1 180); do
|
||||
if curl -sf -o /dev/null http://127.0.0.1:{{ gen_port }}/health; then
|
||||
echo "gen healthy after $((i*5))s"; exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
echo "TIMEOUT: gen did not become healthy in 900s"; exit 1
|
||||
changed_when: "false"
|
||||
|
||||
verify:
|
||||
- name: "gen was genuinely RECREATED (container id changed)"
|
||||
sudo: true
|
||||
shell: |
|
||||
before=$(cat /tmp/gen-container-id-before.txt)
|
||||
after=$(docker inspect vllm-gen --format '{{.Id}}')
|
||||
echo "before=${before:0:12} after=${after:0:12}"
|
||||
test "$before" != "$after"
|
||||
changed_when: "false"
|
||||
|
||||
- name: "gen's process is resident on GPU1"
|
||||
sudo: true
|
||||
# ⚠ Match by CGROUP, not by `.State.Pid`. vLLM V1 runs EngineCore as a CHILD
|
||||
# of the container's pid 1, and it is the child that holds the GPU memory —
|
||||
# nvidia-smi never reports `.State.Pid`, so comparing against it always fails.
|
||||
shell: |
|
||||
cid=$(docker inspect vllm-gen --format '{{.Id}}')
|
||||
gpu1_uuid=$(nvidia-smi --query-gpu=uuid --format=csv,noheader -i 1)
|
||||
found=0
|
||||
for p in $(nvidia-smi --query-compute-apps=gpu_uuid,pid,used_memory --format=csv,noheader \
|
||||
| grep "$gpu1_uuid" | cut -d, -f2 | tr -d ' '); do
|
||||
if grep -q "$cid" /proc/$p/cgroup 2>/dev/null; then
|
||||
echo "gen pid $p resident on GPU1: $(nvidia-smi --query-compute-apps=pid,used_memory --format=csv,noheader | grep "^$p,")"
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
test "$found" -eq 1
|
||||
changed_when: "false"
|
||||
|
||||
- name: "gen answers a real completion, not just /health"
|
||||
sudo: true
|
||||
shell: |
|
||||
. {{ gen_dir }}/.env
|
||||
curl -sf -m 120 http://127.0.0.1:{{ gen_port }}/v1/chat/completions \
|
||||
-H "Authorization: Bearer ${API_KEY}" -H 'Content-Type: application/json' \
|
||||
-d '{"model":"'"${GEN_SERVED_NAME}"'","messages":[{"role":"user","content":"reply with the single word: ok"}],"max_tokens":16}' \
|
||||
| grep -q '"content"'
|
||||
changed_when: "false"
|
||||
|
||||
- name: "GPU0 IS EMPTY — zero compute processes"
|
||||
sudo: true
|
||||
shell: |
|
||||
gpu0_uuid=$(nvidia-smi --query-gpu=uuid --format=csv,noheader -i 0)
|
||||
n=$(nvidia-smi --query-compute-apps=gpu_uuid,pid --format=csv,noheader | grep -c "$gpu0_uuid" || true)
|
||||
free=$(nvidia-smi --query-gpu=memory.free --format=csv,noheader,nounits -i 0)
|
||||
echo "GPU0 compute procs=${n} free=${free} MiB"
|
||||
test "$n" -eq 0 && test "$free" -ge 95000
|
||||
changed_when: "false"
|
||||
|
||||
- name: "mog-sec is stopped (not removed — close depends on `start` working)"
|
||||
sudo: true
|
||||
shell: |
|
||||
docker inspect -f '{{.State.Status}}' vllm-mog-sec | tee /dev/stderr | grep -qx exited
|
||||
changed_when: "false"
|
||||
|
||||
- name: "GPU1 still has headroom for Scriberr's on-demand load"
|
||||
sudo: true
|
||||
shell: |
|
||||
free=$(nvidia-smi --query-gpu=memory.free --format=csv,noheader,nounits -i 1)
|
||||
echo "GPU1 free after gen landed: ${free} MiB"
|
||||
test "$free" -ge 12000
|
||||
changed_when: "false"
|
||||
@@ -0,0 +1,226 @@
|
||||
# Homepage recategorisation — ana-docker (10.250.50.70), 13 containers.
|
||||
#
|
||||
# Splits the dashboard on ONE axis: do you open this thing, or is it an
|
||||
# endpoint you only want to know is alive? See the layout: block in
|
||||
# stacks/homepage/conf/settings.yaml for the target shape.
|
||||
#
|
||||
# memos, miniflux, nevermore, searxng -> Daily (was Notes / News / Apps)
|
||||
# zed-fim-proxy -> AI - Inference (no UI; href is /ping)
|
||||
# adguardhome -> DNS & Filtering
|
||||
# traefik -> Reverse Proxies
|
||||
# dockge -> Compose Consoles
|
||||
# crowdsec, mailrise, rest-server,
|
||||
# gitea-runner, hbbr (RustDesk relay) -> Agents (no UI)
|
||||
#
|
||||
# `homepage.group` is read at container CREATION, so each edit is followed by
|
||||
# `compose up -d <service>` — a restart would leave the old label in place.
|
||||
# Both halves are idempotent: the sed is gated on the old value still being
|
||||
# present, and `up -d` is a no-op when the container already matches its spec.
|
||||
#
|
||||
# Run: scripts/elway infra-ops@10.250.50.70 --playbook playbooks/homepage-regroup-ana-docker.yaml
|
||||
|
||||
steps:
|
||||
# ---- label edits -------------------------------------------------------
|
||||
- name: memos -> Daily
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=Notes$|homepage.group=Daily|'
|
||||
/opt/docker/compose/memos/compose.yaml
|
||||
when: grep -q 'homepage.group=Notes$' /opt/docker/compose/memos/compose.yaml
|
||||
|
||||
- name: miniflux -> Daily
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=News$|homepage.group=Daily|'
|
||||
/opt/docker/compose/miniflux/compose.yaml
|
||||
when: grep -q 'homepage.group=News$' /opt/docker/compose/miniflux/compose.yaml
|
||||
|
||||
- name: nevermore -> Daily
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=News$|homepage.group=Daily|'
|
||||
/opt/docker/compose/nevermore/compose.yaml
|
||||
when: grep -q 'homepage.group=News$' /opt/docker/compose/nevermore/compose.yaml
|
||||
|
||||
- name: searxng -> Daily
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=Apps$|homepage.group=Daily|'
|
||||
/opt/docker/compose/searxng/compose.yaml
|
||||
when: grep -q 'homepage.group=Apps$' /opt/docker/compose/searxng/compose.yaml
|
||||
|
||||
- name: zed-fim-proxy -> AI - Inference
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=AI - Gateways . Chat$|homepage.group=AI - Inference|'
|
||||
/opt/docker/compose/zed-fim-proxy/compose.yaml
|
||||
when: grep -q 'homepage.group=AI - Gateways . Chat$' /opt/docker/compose/zed-fim-proxy/compose.yaml
|
||||
|
||||
- name: adguardhome -> DNS & Filtering
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=Service Networking$|homepage.group=DNS \& Filtering|'
|
||||
/opt/docker/compose/adguard-ana/compose.yaml
|
||||
when: grep -q 'homepage.group=Service Networking$' /opt/docker/compose/adguard-ana/compose.yaml
|
||||
|
||||
- name: traefik -> Reverse Proxies
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=Service Networking$|homepage.group=Reverse Proxies|'
|
||||
/opt/docker/compose/traefik/compose.yaml
|
||||
when: grep -q 'homepage.group=Service Networking$' /opt/docker/compose/traefik/compose.yaml
|
||||
|
||||
- name: dockge -> Compose Consoles
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=Service Networking$|homepage.group=Compose Consoles|'
|
||||
/opt/docker/compose/dockge/compose.yaml
|
||||
when: grep -q 'homepage.group=Service Networking$' /opt/docker/compose/dockge/compose.yaml
|
||||
|
||||
- name: crowdsec -> Agents (no UI)
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=Service Networking$|homepage.group=Agents (no UI)|'
|
||||
/opt/docker/compose/crowdsec/compose.yaml
|
||||
when: grep -q 'homepage.group=Service Networking$' /opt/docker/compose/crowdsec/compose.yaml
|
||||
|
||||
- name: mailrise -> Agents (no UI)
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=Service Networking$|homepage.group=Agents (no UI)|'
|
||||
/opt/docker/compose/mailrise/compose.yaml
|
||||
when: grep -q 'homepage.group=Service Networking$' /opt/docker/compose/mailrise/compose.yaml
|
||||
|
||||
- name: rest-server -> Agents (no UI)
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=Service Networking$|homepage.group=Agents (no UI)|'
|
||||
/opt/docker/compose/rest-server-ana/compose.yaml
|
||||
when: grep -q 'homepage.group=Service Networking$' /opt/docker/compose/rest-server-ana/compose.yaml
|
||||
|
||||
- name: gitea-runner -> Agents (no UI)
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=Toolchain$|homepage.group=Agents (no UI)|'
|
||||
/opt/docker/compose/gitea-runner/compose.yaml
|
||||
when: grep -q 'homepage.group=Toolchain$' /opt/docker/compose/gitea-runner/compose.yaml
|
||||
|
||||
- name: rustdesk (hbbr) -> Agents (no UI)
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=Apps$|homepage.group=Agents (no UI)|'
|
||||
/opt/docker/compose/rustdesk/compose.yaml
|
||||
when: grep -q 'homepage.group=Apps$' /opt/docker/compose/rustdesk/compose.yaml
|
||||
|
||||
# Two containers were both named plain "Open WebUI" and, once the ESH one
|
||||
# joined this group, they landed side by side — same name, same icon family,
|
||||
# only the description telling them apart. Site suffix, like Traefik/Dockge/
|
||||
# AdGuard already carry.
|
||||
- name: openwebui (ana) -> "Open WebUI (ana)"
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.name=Open WebUI$|homepage.name=Open WebUI (ana)|'
|
||||
/opt/docker/compose/openwebui/compose.yaml
|
||||
when: grep -q 'homepage.name=Open WebUI$' /opt/docker/compose/openwebui/compose.yaml
|
||||
|
||||
# ---- recreates ---------------------------------------------------------
|
||||
# traefik goes LAST: crowdsec is its bouncer, so bounce the bouncer first
|
||||
# and let traefik come up against a settled agent.
|
||||
- name: recreate memos
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/memos && docker compose up -d memos
|
||||
|
||||
- name: recreate miniflux
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/miniflux && docker compose up -d miniflux
|
||||
|
||||
- name: recreate nevermore-web
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/nevermore && docker compose up -d nevermore-web
|
||||
|
||||
- name: recreate searxng
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/searxng && docker compose up -d searxng
|
||||
|
||||
- name: recreate zed-fim-proxy
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/zed-fim-proxy && docker compose up -d zed-fim-proxy
|
||||
|
||||
- name: recreate mailrise
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/mailrise && docker compose up -d mailrise
|
||||
|
||||
- name: recreate rest-server
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/rest-server-ana && docker compose up -d rest-server
|
||||
|
||||
- name: recreate gitea-runner
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/gitea-runner && docker compose up -d runner
|
||||
|
||||
- name: recreate rustdesk relay
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/rustdesk && docker compose up -d hbbr
|
||||
|
||||
- name: recreate openwebui (ana)
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/openwebui && docker compose up -d open-webui
|
||||
|
||||
- name: recreate dockge
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/dockge && docker compose up -d dockge
|
||||
|
||||
- name: recreate adguardhome
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/adguard-ana && docker compose up -d adguardhome
|
||||
|
||||
- name: recreate crowdsec
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/crowdsec && docker compose up -d crowdsec
|
||||
|
||||
- name: recreate traefik
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/traefik && docker compose up -d traefik
|
||||
|
||||
verify:
|
||||
- name: every relabelled container now carries its new group
|
||||
sudo: true
|
||||
changed_when: "false"
|
||||
shell: >-
|
||||
docker inspect -f '{{.Name}} {{index .Config.Labels "homepage.group"}}'
|
||||
memos miniflux nevermore-web searxng zed-fim-proxy adguardhome traefik
|
||||
dockge crowdsec mailrise rest-server gitea-runner hbbr
|
||||
|
||||
- name: no container is left in the retired Service Networking group
|
||||
sudo: true
|
||||
changed_when: "false"
|
||||
shell: >-
|
||||
test -z "$(docker ps -q --filter 'label=homepage.group=Service Networking')"
|
||||
|
||||
# dig is not installed everywhere in the fleet, so fall back to the AdGuard
|
||||
# UI — a resolver that serves its own dashboard on :8053 has come back up.
|
||||
- name: adguard is back (DNS answer, or its UI if dig is absent)
|
||||
changed_when: "false"
|
||||
shell: >-
|
||||
if command -v dig >/dev/null 2>&1;
|
||||
then dig +short +time=3 +tries=2 @10.250.50.70 gitea.phasefinal.com | grep -q .;
|
||||
else curl -sf -o /dev/null -m 8 http://10.250.50.70:8053/; fi
|
||||
|
||||
# Retried, not one-shot: the first run of this playbook checked 0.12s after
|
||||
# `Started` and got rc=7 while traefik was still binding. The container was
|
||||
# fine — `:8380/` 301s to /dashboard/ and both public hostnames answered 200
|
||||
# seconds later. A recreate needs a moment; assert the settled state.
|
||||
- name: traefik still routes
|
||||
changed_when: "false"
|
||||
shell: >-
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
curl -sfL -o /dev/null -m 5 http://127.0.0.1:8380/dashboard/ && exit 0;
|
||||
sleep 3; done; exit 1
|
||||
|
||||
- name: everything is running
|
||||
sudo: true
|
||||
changed_when: "false"
|
||||
shell: >-
|
||||
test "$(docker inspect -f '{{.State.Running}}' memos miniflux nevermore-web
|
||||
searxng zed-fim-proxy adguardhome traefik dockge crowdsec mailrise
|
||||
rest-server gitea-runner hbbr | sort -u)" = "true"
|
||||
@@ -0,0 +1,74 @@
|
||||
# Homepage recategorisation — ana-ml2 (10.250.50.54), 2 containers.
|
||||
# Sibling of playbooks/homepage-regroup-ana-docker.yaml; rationale lives there.
|
||||
#
|
||||
# scriberr -> AI - Studios (a transcription UI you open, not an API seat)
|
||||
# dockge -> Compose Consoles
|
||||
#
|
||||
# ⚠ THE vLLM SEATS ON THIS HOST ARE DELIBERATELY NOT TOUCHED. Every one of them
|
||||
# would need a recreate to change its `homepage.group`, and a recreate means a
|
||||
# multi-minute model reload on a seat that peers reach through the gateway. The
|
||||
# separation the operator asked for — UI up top, API endpoints out of the way —
|
||||
# is achieved for those groups by ORDER and `initiallyCollapsed` in
|
||||
# stacks/homepage/conf/settings.yaml, which costs nothing. Keep it that way: if
|
||||
# a future pass wants to rename `AI - Inference`, weigh it against bouncing six
|
||||
# model seats.
|
||||
#
|
||||
# Run: scripts/elway infra-ops@10.250.50.54 --playbook playbooks/homepage-regroup-ana-ml2.yaml
|
||||
|
||||
steps:
|
||||
- name: scriberr -> AI - Studios
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=AI - Audio Tools$|homepage.group=AI - Studios|'
|
||||
/opt/docker/compose/scriberr/compose.yaml
|
||||
when: grep -q 'homepage.group=AI - Audio Tools$' /opt/docker/compose/scriberr/compose.yaml
|
||||
|
||||
- name: dockge -> Compose Consoles
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=Service Networking$|homepage.group=Compose Consoles|'
|
||||
/opt/docker/compose/dockge/compose.yaml
|
||||
when: grep -q 'homepage.group=Service Networking$' /opt/docker/compose/dockge/compose.yaml
|
||||
|
||||
- name: recreate dockge
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/dockge && docker compose up -d dockge
|
||||
|
||||
- name: recreate scriberr
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/scriberr && docker compose up -d scriberr
|
||||
|
||||
verify:
|
||||
- name: every relabelled container now carries its new group
|
||||
sudo: true
|
||||
changed_when: "false"
|
||||
shell: >-
|
||||
docker inspect -f '{{.Name}} {{index .Config.Labels "homepage.group"}}'
|
||||
scriberr dockge
|
||||
|
||||
- name: no container is left in the retired Service Networking group
|
||||
sudo: true
|
||||
changed_when: "false"
|
||||
shell: >-
|
||||
test -z "$(docker ps -q --filter 'label=homepage.group=Service Networking')"
|
||||
|
||||
# Container age, not liveness — a seat may be legitimately stopped, so
|
||||
# "is it running" cannot answer "did I bounce it". See the same step in
|
||||
# playbooks/homepage-regroup-irv-ml1.yaml for how that distinction was found.
|
||||
- name: the vLLM seats were NOT recreated by this run
|
||||
sudo: true
|
||||
changed_when: "false"
|
||||
shell: >-
|
||||
for c in $(docker ps -a --filter 'name=vllm-' --filter 'name=llama-'
|
||||
--format '{{.Names}}'); do
|
||||
created=$(docker inspect -f '{{.Created}}' "$c" 2>/dev/null) || continue;
|
||||
age=$(( $(date +%s) - $(date -d "$created" +%s) ));
|
||||
if [ "$age" -lt 600 ]; then echo "$c was recreated ${age}s ago"; exit 1; fi;
|
||||
done
|
||||
|
||||
- name: scriberr answers
|
||||
changed_when: "false"
|
||||
shell: >-
|
||||
for i in 1 2 3 4 5 6 7 8 9 10 11 12; do
|
||||
curl -sfL -o /dev/null -m 5 http://127.0.0.1:8080/ && exit 0;
|
||||
sleep 5; done; exit 1
|
||||
@@ -0,0 +1,135 @@
|
||||
# Homepage recategorisation — esh-docker-vm (10.0.50.45), 6 containers.
|
||||
# Sibling of playbooks/homepage-regroup-ana-docker.yaml; the rationale, the
|
||||
# label-at-creation constraint and the idempotency scheme are documented there.
|
||||
#
|
||||
# lobe-chat, open-webui -> AI - Gateways & Chat (chat frontends belong
|
||||
# with the other chat frontends, not in Apps)
|
||||
# adguardhome -> DNS & Filtering
|
||||
# traefik -> Reverse Proxies
|
||||
# dockge -> Compose Consoles
|
||||
# mosquitto -> Agents (no UI) (an MQTT broker has no page)
|
||||
#
|
||||
# ⚠ adguard and traefik here use `docker-compose.yml`, not `compose.yaml`.
|
||||
#
|
||||
# Run: scripts/elway infra-ops@10.0.50.45 --playbook playbooks/homepage-regroup-esh-docker-vm.yaml
|
||||
|
||||
steps:
|
||||
- name: lobe-chat -> AI - Gateways & Chat
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=Apps$|homepage.group=AI - Gateways \& Chat|'
|
||||
/opt/docker/compose/lobe-chat/compose.yaml
|
||||
when: grep -q 'homepage.group=Apps$' /opt/docker/compose/lobe-chat/compose.yaml
|
||||
|
||||
- name: open-webui -> AI - Gateways & Chat
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=Apps$|homepage.group=AI - Gateways \& Chat|'
|
||||
/opt/docker/compose/open-webui/compose.yaml
|
||||
when: grep -q 'homepage.group=Apps$' /opt/docker/compose/open-webui/compose.yaml
|
||||
|
||||
# Site suffix — the ana instance is also called "Open WebUI" and the two now
|
||||
# sit side by side in the same group. See the sibling step in
|
||||
# playbooks/homepage-regroup-ana-docker.yaml.
|
||||
- name: open-webui -> "Open WebUI (esh)"
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.name=Open WebUI$|homepage.name=Open WebUI (esh)|'
|
||||
/opt/docker/compose/open-webui/compose.yaml
|
||||
when: grep -q 'homepage.name=Open WebUI$' /opt/docker/compose/open-webui/compose.yaml
|
||||
|
||||
- name: mosquitto -> Agents (no UI)
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=Apps$|homepage.group=Agents (no UI)|'
|
||||
/opt/docker/compose/mosquitto/compose.yaml
|
||||
when: grep -q 'homepage.group=Apps$' /opt/docker/compose/mosquitto/compose.yaml
|
||||
|
||||
- name: adguardhome -> DNS & Filtering
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=Service Networking$|homepage.group=DNS \& Filtering|'
|
||||
/opt/docker/compose/adguard/docker-compose.yml
|
||||
when: grep -q 'homepage.group=Service Networking$' /opt/docker/compose/adguard/docker-compose.yml
|
||||
|
||||
- name: traefik -> Reverse Proxies
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=Service Networking$|homepage.group=Reverse Proxies|'
|
||||
/opt/docker/compose/traefik/docker-compose.yml
|
||||
when: grep -q 'homepage.group=Service Networking$' /opt/docker/compose/traefik/docker-compose.yml
|
||||
|
||||
- name: dockge -> Compose Consoles
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=Service Networking$|homepage.group=Compose Consoles|'
|
||||
/opt/docker/compose/dockge/compose.yaml
|
||||
when: grep -q 'homepage.group=Service Networking$' /opt/docker/compose/dockge/compose.yaml
|
||||
|
||||
# ---- recreates ---------------------------------------------------------
|
||||
- name: recreate lobe-chat
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/lobe-chat && docker compose up -d lobe-chat
|
||||
|
||||
- name: recreate open-webui
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/open-webui && docker compose up -d open-webui
|
||||
|
||||
- name: recreate mosquitto
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/mosquitto && docker compose up -d mosquitto
|
||||
|
||||
- name: recreate dockge
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/dockge && docker compose up -d dockge
|
||||
|
||||
- name: recreate adguardhome
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/adguard && docker compose up -d adguardhome
|
||||
|
||||
- name: recreate traefik
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/traefik && docker compose up -d traefik
|
||||
|
||||
verify:
|
||||
- name: every relabelled container now carries its new group
|
||||
sudo: true
|
||||
changed_when: "false"
|
||||
shell: >-
|
||||
docker inspect -f '{{.Name}} {{index .Config.Labels "homepage.group"}}'
|
||||
lobe-chat open-webui mosquitto adguardhome traefik dockge
|
||||
|
||||
- name: no container is left in the retired Service Networking group
|
||||
sudo: true
|
||||
changed_when: "false"
|
||||
shell: >-
|
||||
test -z "$(docker ps -q --filter 'label=homepage.group=Service Networking')"
|
||||
|
||||
- name: adguard is back
|
||||
changed_when: "false"
|
||||
shell: >-
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
curl -sfL -o /dev/null -m 5 http://127.0.0.1:8080/ && exit 0;
|
||||
sleep 3; done; exit 1
|
||||
|
||||
- name: traefik still routes
|
||||
changed_when: "false"
|
||||
shell: >-
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
curl -sfL -o /dev/null -m 5 http://127.0.0.1:8380/dashboard/ && exit 0;
|
||||
sleep 3; done; exit 1
|
||||
|
||||
# ⚠ Must be 10.0.50.45, NOT 127.0.0.1. `HOMEPAGE_ALLOWED_HOSTS` matches
|
||||
# host AND port, and `127.0.0.1:5100` is not in the list — it answers 400
|
||||
# while the dashboard is perfectly healthy. The first run of this playbook
|
||||
# failed here on rc=22 for exactly that reason.
|
||||
- name: the dashboard itself is still served
|
||||
changed_when: "false"
|
||||
shell: curl -sf -o /dev/null -m 10 http://10.0.50.45:5100/api/services
|
||||
|
||||
- name: everything is running
|
||||
sudo: true
|
||||
changed_when: "false"
|
||||
shell: >-
|
||||
test "$(docker inspect -f '{{.State.Running}}' lobe-chat open-webui
|
||||
mosquitto adguardhome traefik dockge | sort -u)" = "true"
|
||||
@@ -0,0 +1,115 @@
|
||||
# Homepage recategorisation — irv-ml1 (10.100.79.3), 5 containers.
|
||||
# Sibling of playbooks/homepage-regroup-ana-docker.yaml; rationale lives there.
|
||||
#
|
||||
# arbo, comfyui, waterland-studio -> AI - Studios (was AI - Image & Media)
|
||||
# yt-voice-clipper -> AI - Studios (was AI - Audio Tools)
|
||||
# dockge -> Compose Consoles
|
||||
#
|
||||
# `AI - Studios` is the "you open this and do work in it" group; the ASR and
|
||||
# TTS API seats stay where they are and get collapsed by settings.yaml instead,
|
||||
# which is what keeps the GPU seats out of this playbook entirely.
|
||||
#
|
||||
# ⚠ yt-voice-clipper carries its homepage labels in `docker-compose.override.yml`,
|
||||
# not in `docker-compose.yml`, and its compose dir is a git checkout of the
|
||||
# project — the override is the deploy-local layer, which is the right place
|
||||
# for it.
|
||||
#
|
||||
# ⚠ This host is reached over the WireGuard tunnel. If the run cannot connect,
|
||||
# check the tunnel before assuming the host is down.
|
||||
#
|
||||
# Run: scripts/elway infra-ops@10.100.79.3 --playbook playbooks/homepage-regroup-irv-ml1.yaml
|
||||
|
||||
steps:
|
||||
- name: arbo -> AI - Studios
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=AI - Image . Media$|homepage.group=AI - Studios|'
|
||||
/opt/docker/compose/arbo/compose.yaml
|
||||
when: grep -q 'homepage.group=AI - Image . Media$' /opt/docker/compose/arbo/compose.yaml
|
||||
|
||||
- name: comfyui -> AI - Studios
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=AI - Image . Media$|homepage.group=AI - Studios|'
|
||||
/opt/docker/compose/comfyui/compose.yaml
|
||||
when: grep -q 'homepage.group=AI - Image . Media$' /opt/docker/compose/comfyui/compose.yaml
|
||||
|
||||
- name: waterland-studio -> AI - Studios
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=AI - Image . Media$|homepage.group=AI - Studios|'
|
||||
/opt/docker/compose/waterland-studio/compose.yaml
|
||||
when: grep -q 'homepage.group=AI - Image . Media$' /opt/docker/compose/waterland-studio/compose.yaml
|
||||
|
||||
- name: yt-voice-clipper -> AI - Studios
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=AI - Audio Tools$|homepage.group=AI - Studios|'
|
||||
/opt/docker/compose/yt-voice-clipper/docker-compose.override.yml
|
||||
when: grep -q 'homepage.group=AI - Audio Tools$' /opt/docker/compose/yt-voice-clipper/docker-compose.override.yml
|
||||
|
||||
- name: dockge -> Compose Consoles
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=Service Networking$|homepage.group=Compose Consoles|'
|
||||
/opt/docker/compose/dockge/compose.yaml
|
||||
when: grep -q 'homepage.group=Service Networking$' /opt/docker/compose/dockge/compose.yaml
|
||||
|
||||
# ---- recreates ---------------------------------------------------------
|
||||
- name: recreate arbo
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/arbo && docker compose up -d engine
|
||||
|
||||
- name: recreate comfyui
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/comfyui && docker compose up -d comfyui
|
||||
|
||||
- name: recreate waterland-studio
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/waterland-studio && docker compose up -d waterland-studio
|
||||
|
||||
- name: recreate yt-voice-clipper
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/yt-voice-clipper && docker compose up -d api
|
||||
|
||||
- name: recreate dockge
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/dockge && docker compose up -d dockge
|
||||
|
||||
verify:
|
||||
- name: every relabelled container now carries its new group
|
||||
sudo: true
|
||||
changed_when: "false"
|
||||
shell: >-
|
||||
docker inspect -f '{{.Name}} {{index .Config.Labels "homepage.group"}}'
|
||||
arbo comfyui waterland-studio yt-voice-clipper-api-1 dockge
|
||||
|
||||
- name: no container is left in the retired Service Networking group
|
||||
sudo: true
|
||||
changed_when: "false"
|
||||
shell: >-
|
||||
test -z "$(docker ps -q --filter 'label=homepage.group=Service Networking')"
|
||||
|
||||
# Assert NOT-RECREATED, not RUNNING. The first version of this checked that
|
||||
# all five seats were up and failed — because chatterbox-fast has been down
|
||||
# since 2026-08-10 and speaches since earlier the same morning, both long
|
||||
# before this playbook existed. "Is it running" is the wrong question: a seat
|
||||
# can be legitimately stopped. The question this step is actually asking is
|
||||
# "did I bounce a model seat to relabel a dashboard", and container age
|
||||
# answers it directly.
|
||||
- name: the TTS and ASR seats were NOT recreated by this run
|
||||
sudo: true
|
||||
changed_when: "false"
|
||||
shell: >-
|
||||
for c in kokoro dots-tts chatterbox-fast tts-gateway parakeet speaches; do
|
||||
created=$(docker inspect -f '{{.Created}}' "$c" 2>/dev/null) || continue;
|
||||
age=$(( $(date +%s) - $(date -d "$created" +%s) ));
|
||||
if [ "$age" -lt 600 ]; then echo "$c was recreated ${age}s ago"; exit 1; fi;
|
||||
done
|
||||
|
||||
- name: everything relabelled is running
|
||||
sudo: true
|
||||
changed_when: "false"
|
||||
shell: >-
|
||||
test "$(docker inspect -f '{{.State.Running}}' arbo comfyui
|
||||
waterland-studio yt-voice-clipper-api-1 dockge | sort -u)" = "true"
|
||||
@@ -0,0 +1,58 @@
|
||||
# Homepage recategorisation — nh3-docker (10.100.50.40), 2 containers.
|
||||
# Sibling of playbooks/homepage-regroup-ana-docker.yaml; rationale lives there.
|
||||
#
|
||||
# adguardhome -> DNS & Filtering (⚠ uses docker-compose.yml)
|
||||
# dockge -> Compose Consoles
|
||||
#
|
||||
# Run: scripts/elway infra-ops@10.100.50.40 --playbook playbooks/homepage-regroup-nh3-docker.yaml
|
||||
|
||||
steps:
|
||||
- name: adguardhome -> DNS & Filtering
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=Service Networking$|homepage.group=DNS \& Filtering|'
|
||||
/opt/docker/compose/adguard/docker-compose.yml
|
||||
when: grep -q 'homepage.group=Service Networking$' /opt/docker/compose/adguard/docker-compose.yml
|
||||
|
||||
- name: dockge -> Compose Consoles
|
||||
sudo: true
|
||||
shell: >-
|
||||
sed -i 's|homepage.group=Service Networking$|homepage.group=Compose Consoles|'
|
||||
/opt/docker/compose/dockge/compose.yaml
|
||||
when: grep -q 'homepage.group=Service Networking$' /opt/docker/compose/dockge/compose.yaml
|
||||
|
||||
- name: recreate dockge
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/dockge && docker compose up -d dockge
|
||||
|
||||
- name: recreate adguardhome
|
||||
sudo: true
|
||||
shell: cd /opt/docker/compose/adguard && docker compose up -d adguardhome
|
||||
|
||||
verify:
|
||||
- name: every relabelled container now carries its new group
|
||||
sudo: true
|
||||
changed_when: "false"
|
||||
shell: >-
|
||||
docker inspect -f '{{.Name}} {{index .Config.Labels "homepage.group"}}'
|
||||
adguardhome dockge
|
||||
|
||||
- name: no container is left in the retired Service Networking group
|
||||
sudo: true
|
||||
changed_when: "false"
|
||||
shell: >-
|
||||
test -z "$(docker ps -q --filter 'label=homepage.group=Service Networking')"
|
||||
|
||||
- name: adguard is back
|
||||
changed_when: "false"
|
||||
shell: >-
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
curl -sfL -o /dev/null -m 5 http://127.0.0.1:8080/ && exit 0;
|
||||
sleep 3; done; exit 1
|
||||
|
||||
- name: everything is running
|
||||
sudo: true
|
||||
changed_when: "false"
|
||||
shell: >-
|
||||
test "$(docker inspect -f '{{.State.Running}}' adguardhome dockge
|
||||
| sort -u)" = "true"
|
||||
@@ -0,0 +1,53 @@
|
||||
# Training throughput probes
|
||||
|
||||
Instruments for finding where a training step's time actually went. Written
|
||||
2026-08-24 during the Gemma-4 26B-A4B ERP/RP tune investigation; the lessons
|
||||
they produced live in
|
||||
[`docs/pfi/training-throughput-playbook.md`](../../docs/pfi/training-throughput-playbook.md).
|
||||
|
||||
**These are diagnostic instruments, not production code.** They hard-code paths
|
||||
for that run. Adapt the constants at the top; keep the measurement design.
|
||||
|
||||
## The probes
|
||||
|
||||
| script | settles | GPU | runtime |
|
||||
|---|---|---|---|
|
||||
| `step0_mask.py` | mask band structure; which layers keep the `is_causal` fast path | no | ~30 s |
|
||||
| `step2_padding.py` | padding waste, length distribution, CE chunk sizing | no | ~2 min |
|
||||
| `step_bucket.py` | bucketing gain, bucket-size sweep, source diversity | no | ~3 min |
|
||||
| `step1_profile.py` | scaling fit, padding penalty, CE wall clock, kernel table | **yes** | ~15 min |
|
||||
|
||||
Run in that order. Only the last needs the real checkpoint, and it wants an
|
||||
idle card — it loads ~48 GiB and peaks near 77 GiB at `2 × 16,384`.
|
||||
|
||||
## Design rules worth preserving when you adapt these
|
||||
|
||||
**`step1_profile.py` reuses the harness's own `discover_target_modules` and
|
||||
replicates its `compute_loss` byte-for-byte** rather than re-implementing the
|
||||
step. A probe that reimplements the training step measures the probe. If you
|
||||
port this, keep the import from the real harness.
|
||||
|
||||
**`step0_mask.py` needs no weights and no GPU** — SDPA backend selection and
|
||||
mask construction depend on shapes, dtype and mask presence, not on weight
|
||||
values. That is what makes the correctness assertion cheap enough to run before
|
||||
every job.
|
||||
|
||||
**The scaling test takes three points, not two.** Two points over three
|
||||
plausible terms (quadratic, linear, fixed-per-batch) is underdetermined; see
|
||||
playbook §1.1 for the hour that cost.
|
||||
|
||||
**`step_bucket.py` sweeps bucket size deliberately.** The first version
|
||||
re-sorted within each bucket, which silently collapsed every bucket size to a
|
||||
full global sort and made the sweep a no-op. If you change the pairing logic,
|
||||
check that the sweep still varies something.
|
||||
|
||||
## Raw evidence
|
||||
|
||||
`step1-profile-output-2026-08-24.txt` is the unedited output of the run the
|
||||
playbook's numbers come from — scaling points, padding penalty, CE timing, and
|
||||
the full `key_averages()` kernel table. Kept so the claims can be re-derived
|
||||
rather than taken on faith.
|
||||
|
||||
⚠ That table **double-counts**: `key_averages()` lists both the ATen op and the
|
||||
CUDA kernel it launched, each carrying the same self device time. Sum device
|
||||
kernel rows only. See playbook §3.4.
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Step 0 - assert the sliding mask band structure, and record which path
|
||||
mask creation actually takes under the run config (attn_implementation=sdpa).
|
||||
|
||||
Correctness gate: transformers can SILENTLY skip mask creation and pass
|
||||
attention_mask=None, which would make the 25 sliding layers do full causal
|
||||
attention - a different model from the one vLLM serves. This converts
|
||||
"probably fine because we are slow" into a measurement.
|
||||
|
||||
CPU only. No weights. No GPU.
|
||||
"""
|
||||
import torch
|
||||
from transformers import AutoConfig
|
||||
from transformers.masking_utils import (
|
||||
create_causal_mask, create_sliding_window_causal_mask,
|
||||
)
|
||||
|
||||
MODEL = "/tank/aimodels/gemma4-26b-a4b-it-heretic-bf16"
|
||||
N = 16384
|
||||
W = 1024
|
||||
PAD = " " + " " * 20
|
||||
|
||||
cfg = AutoConfig.from_pretrained(MODEL)
|
||||
text = cfg.get_text_config()
|
||||
text._attn_implementation = "sdpa"
|
||||
print("sliding_window %s" % text.sliding_window)
|
||||
print("layers %d (%d sliding / %d full)" % (
|
||||
len(text.layer_types),
|
||||
text.layer_types.count("sliding_attention"),
|
||||
text.layer_types.count("full_attention")))
|
||||
print("_attn_implementation %s" % text._attn_implementation)
|
||||
print()
|
||||
|
||||
|
||||
def build(attn_2d, label):
|
||||
batch = attn_2d.shape[0] if attn_2d is not None else 1
|
||||
embeds = torch.zeros(batch, N, 8, dtype=torch.bfloat16)
|
||||
pos = torch.arange(N).unsqueeze(0)
|
||||
kw = dict(config=text, inputs_embeds=embeds, attention_mask=attn_2d,
|
||||
past_key_values=None, position_ids=pos)
|
||||
full = create_causal_mask(**kw)
|
||||
slide = create_sliding_window_causal_mask(**kw)
|
||||
print("--- %s ---" % label)
|
||||
for name, m in (("full_attention", full), ("sliding_attention", slide)):
|
||||
if m is None:
|
||||
print(" %-20s None -> flash / is_causal path AVAILABLE" % name)
|
||||
continue
|
||||
print(" %-20s tensor shape=%s dtype=%s" % (name, tuple(m.shape), m.dtype))
|
||||
allowed = m if m.dtype == torch.bool else (m == 0)
|
||||
per_row = allowed[0, 0].sum(-1)
|
||||
print("%sallowed/row min=%d max=%d mean=%.1f" % (
|
||||
PAD, per_row.min().item(), per_row.max().item(),
|
||||
per_row.float().mean().item()))
|
||||
if name == "sliding_attention":
|
||||
ok = per_row.max().item() <= W
|
||||
print("%sBAND <= %d ? %s" % (PAD, W, "PASS" if ok else "FAIL"))
|
||||
sat = (per_row >= W).nonzero()
|
||||
if sat.numel():
|
||||
print("%ssaturates at row %d" % (PAD, sat[0].item()))
|
||||
else:
|
||||
print("%slast row allows %d of %d (%s)" % (
|
||||
PAD, per_row[-1].item(), N,
|
||||
"causal-full OK" if per_row[-1].item() == N else "UNEXPECTED"))
|
||||
print()
|
||||
|
||||
|
||||
# 1. no 2D mask at all - the "constraints silently dropped" scenario
|
||||
build(None, "attention_mask=None (no padding info)")
|
||||
|
||||
# 2. all-ones 2D mask - equal-length batch, no padding
|
||||
build(torch.ones(2, N, dtype=torch.long), "all-ones 2D (no padding)")
|
||||
|
||||
# 3. REAL right-padded batch - what collate_mixed actually produces
|
||||
real = torch.ones(2, N, dtype=torch.long)
|
||||
real[1, 6000:] = 0
|
||||
build(real, "right-padded 2D (what collate_mixed emits)")
|
||||
@@ -0,0 +1,126 @@
|
||||
========================================================================
|
||||
loading model
|
||||
========================================================================
|
||||
Loading weights: 0%| | 0/1013 [00:00<?, ?it/s]
Loading weights: 0%| | 2/1013 [00:00<01:19, 12.71it/s]
Loading weights: 0%| | 4/1013 [00:00<01:10, 14.40it/s]
Loading weights: 2%|▏ | 25/1013 [00:00<00:16, 61.02it/s]
Loading weights: 5%|▍ | 48/1013 [00:00<00:12, 79.42it/s]
Loading weights: 7%|▋ | 70/1013 [00:00<00:10, 87.32it/s]
Loading weights: 9%|▉ | 92/1013 [00:01<00:10, 91.14it/s]
Loading weights: 11%|█ | 113/1013 [00:01<00:09, 92.58it/s]
Loading weights: 13%|█▎ | 135/1013 [00:01<00:09, 95.13it/s]
Loading weights: 15%|█▌ | 157/1013 [00:01<00:08, 97.82it/s]
Loading weights: 18%|█▊ | 179/1013 [00:02<00:08, 101.21it/s]
Loading weights: 20%|█▉ | 201/1013 [00:02<00:08, 100.37it/s]
Loading weights: 22%|██▏ | 223/1013 [00:02<00:07, 102.12it/s]
Loading weights: 24%|██▍ | 244/1013 [00:02<00:07, 104.64it/s]
Loading weights: 26%|██▋ | 266/1013 [00:02<00:07, 105.68it/s]
Loading weights: 28%|██▊ | 288/1013 [00:03<00:06, 106.87it/s]
Loading weights: 30%|███ | 308/1013 [00:03<00:05, 122.26it/s]
Loading weights: 32%|███▏ | 322/1013 [00:03<00:05, 117.76it/s]
Loading weights: 33%|███▎ | 335/1013 [00:03<00:06, 99.26it/s]
Loading weights: 35%|███▍ | 354/1013 [00:03<00:06, 100.53it/s]
Loading weights: 37%|███▋ | 375/1013 [00:03<00:06, 104.62it/s]
Loading weights: 39%|███▉ | 397/1013 [00:04<00:05, 103.34it/s]
Loading weights: 41%|████▏ | 419/1013 [00:04<00:05, 106.05it/s]
Loading weights: 44%|████▎ | 441/1013 [00:04<00:05, 109.33it/s]
Loading weights: 46%|████▌ | 463/1013 [00:04<00:05, 107.04it/s]
Loading weights: 48%|████▊ | 485/1013 [00:04<00:04, 107.29it/s]
Loading weights: 50%|█████ | 507/1013 [00:05<00:04, 105.14it/s]
Loading weights: 52%|█████▏ | 528/1013 [00:05<00:04, 103.18it/s]
Loading weights: 54%|█████▍ | 550/1013 [00:05<00:04, 102.05it/s]
Loading weights: 56%|█████▋ | 572/1013 [00:05<00:04, 101.82it/s]
Loading weights: 59%|█████▊ | 594/1013 [00:05<00:03, 106.80it/s]
Loading weights: 61%|██████ | 616/1013 [00:06<00:03, 104.33it/s]
Loading weights: 63%|██████▎ | 638/1013 [00:06<00:03, 103.57it/s]
Loading weights: 77%|███████▋ | 778/1013 [00:06<00:00, 320.91it/s]
Loading weights: 91%|█████████ | 920/1013 [00:06<00:00, 532.70it/s]
Loading weights: 100%|██████████| 1013/1013 [00:06<00:00, 152.35it/s]
|
||||
loaded in 9.8s targets=205
|
||||
final_logit_softcapping = 30.0
|
||||
attn_implementation = sdpa
|
||||
|
||||
========================================================================
|
||||
A. SEQUENCE SCALING (no padding - isolates n)
|
||||
========================================================================
|
||||
2 x 2,048 1.776 s kept=3227 peak= 53.2 GiB
|
||||
2 x 8,192 11.570 s kept=13050 peak= 62.3 GiB
|
||||
2 x 16,384 35.017 s kept=25989 peak= 76.6 GiB
|
||||
|
||||
16384 -> 2048 ratio 19.71x (linear ~8x, launch-bound ~1x, quadratic ~64x)
|
||||
16384 -> 8192 ratio 3.03x (linear ~2x, quadratic ~4x)
|
||||
|
||||
========================================================================
|
||||
B. PADDING PENALTY (same real tokens, with vs without pad)
|
||||
========================================================================
|
||||
2 x 16,384 no padding 35.244 s kept=26048 peak= 76.6 GiB
|
||||
2 x 16,384 50% pad on row 1 38.567 s kept=19640 peak= 77.8 GiB
|
||||
|
||||
========================================================================
|
||||
C. ISOLATED CE WALL CLOCK
|
||||
========================================================================
|
||||
2 x 16,384 (CE timed) 35.329 s kept=26210 peak= 76.6 GiB CE=374 ms (1.1%)
|
||||
2 x 4,096 (CE timed) 4.387 s kept=6512 peak= 55.3 GiB CE=93 ms (2.1%)
|
||||
|
||||
========================================================================
|
||||
D. KERNEL TABLE - one fwd+bwd at 2 x 16,384
|
||||
========================================================================
|
||||
USDT:2026-08-24 22:03:51 574811:574811 SyncActivityProfilerHandler.cpp:52] profiler_start
|
||||
USDT:2026-08-24 22:04:27 574811:574811 SyncActivityProfilerHandler.cpp:59] profiler_stop
|
||||
------------------------------------------------------- ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------
|
||||
Name Self CPU % Self CPU CPU total % CPU total CPU time avg Self CUDA Self CUDA % CUDA total CUDA time avg # of Calls
|
||||
------------------------------------------------------- ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------
|
||||
aten::_efficient_attention_backward 0.00% 573.671us 0.00% 1.681ms 56.028us 16.145s 45.71% 16.156s 538.546ms 30
|
||||
fmha_cutlassB_bf16_aligned_128x64_k65536_sm80(PyTorc... 0.00% 0.000us 0.00% 0.000us 0.000us 16.145s 45.71% 16.145s 538.152ms 30
|
||||
aten::_efficient_attention_forward 0.00% 795.660us 0.01% 1.933ms 32.218us 6.691s 18.94% 6.691s 111.519ms 60
|
||||
fmha_cutlassF_bf16_aligned_32x128_gmem_sm80(PyTorchM... 0.00% 0.000us 0.00% 0.000us 0.000us 6.691s 18.94% 6.691s 111.519ms 60
|
||||
aten::mm 0.51% 180.757ms 0.77% 270.268ms 10.614us 3.745s 10.60% 3.745s 147.063us 25463
|
||||
aten::mul 0.13% 45.428ms 0.18% 64.624ms 12.129us 2.721s 7.70% 2.721s 510.606us 5328
|
||||
aten::copy_ 0.06% 19.823ms 92.46% 32.574s 6.100ms 1.977s 5.60% 1.977s 370.189us 5340
|
||||
void cutlass::Kernel2<cutlass_80_tensorop_bf16_s1681... 0.00% 0.000us 0.00% 0.000us 0.000us 1.542s 4.37% 1.542s 656.804us 2348
|
||||
void at::native::elementwise_kernel<128, 2, at::nati... 0.00% 0.000us 0.00% 0.000us 0.000us 1.033s 2.92% 1.033s 545.219us 1894
|
||||
void cutlass::Kernel2<cutlass_80_tensorop_bf16_s1681... 0.00% 0.000us 0.00% 0.000us 0.000us 868.642ms 2.46% 868.642ms 583.373us 1489
|
||||
void at::native::vectorized_elementwise_kernel<4, at... 0.00% 0.000us 0.00% 0.000us 0.000us 787.028ms 2.23% 787.028ms 395.095us 1992
|
||||
void at::native::unrolled_elementwise_kernel<at::nat... 0.00% 0.000us 0.00% 0.000us 0.000us 697.963ms 1.98% 697.963ms 304.521us 2292
|
||||
aten::masked_fill_ 0.01% 3.403ms 0.01% 4.927ms 27.373us 576.927ms 1.63% 576.927ms 3.205ms 180
|
||||
void at::native::vectorized_elementwise_kernel<4, at... 0.00% 0.000us 0.00% 0.000us 0.000us 561.815ms 1.59% 561.815ms 413.403us 1359
|
||||
void at::native::vectorized_elementwise_kernel<4, at... 0.00% 0.000us 0.00% 0.000us 0.000us 469.818ms 1.33% 469.818ms 459.255us 1023
|
||||
aten::add_ 0.01% 3.730ms 0.02% 6.640ms 7.209us 448.665ms 1.27% 448.665ms 487.150us 921
|
||||
void cutlass::Kernel2<cutlass_80_tensorop_bf16_s1681... 0.00% 0.000us 0.00% 0.000us 0.000us 363.206ms 1.03% 363.206ms 394.789us 920
|
||||
aten::add 0.03% 9.745ms 0.04% 14.287ms 10.205us 356.226ms 1.01% 356.226ms 254.447us 1400
|
||||
void at::native::elementwise_kernel<128, 2, at::nati... 0.00% 0.000us 0.00% 0.000us 0.000us 352.705ms 1.00% 352.705ms 1.959ms 180
|
||||
aten::index 0.01% 4.542ms 0.09% 32.242ms 132.682us 352.404ms 1.00% 352.430ms 1.450ms 243
|
||||
void at::native::vectorized_gather_kernel<16, long>(... 0.00% 0.000us 0.00% 0.000us 0.000us 351.768ms 1.00% 351.768ms 1.933ms 182
|
||||
Memcpy DtoD (Device -> Device) 0.00% 0.000us 0.00% 0.000us 0.000us 341.588ms 0.97% 341.588ms 634.922us 538
|
||||
aten::pow 0.06% 21.418ms 0.11% 37.019ms 18.659us 332.072ms 0.94% 493.271ms 248.624us 1984
|
||||
void at::native::vectorized_elementwise_kernel<4, at... 0.00% 0.000us 0.00% 0.000us 0.000us 330.315ms 0.94% 330.315ms 499.720us 661
|
||||
void at::native::vectorized_elementwise_kernel<4, at... 0.00% 0.000us 0.00% 0.000us 0.000us 316.108ms 0.89% 316.108ms 383.161us 825
|
||||
aten::sum 0.01% 5.258ms 0.02% 7.700ms 13.461us 286.461ms 0.81% 286.464ms 500.811us 572
|
||||
aten::native_dropout 0.02% 6.453ms 0.03% 11.139ms 27.168us 258.244ms 0.73% 258.244ms 629.865us 410
|
||||
void at::native::(anonymous namespace)::fused_dropou... 0.00% 0.000us 0.00% 0.000us 0.000us 258.244ms 0.73% 258.244ms 629.865us 410
|
||||
void at::native::unrolled_elementwise_kernel<at::nat... 0.00% 0.000us 0.00% 0.000us 0.000us 239.055ms 0.68% 239.055ms 583.061us 410
|
||||
aten::_index_put_impl_ 0.01% 4.170ms 3.22% 1.134s 7.508ms 235.816ms 0.67% 236.930ms 1.569ms 151
|
||||
void at::native::elementwise_kernel<128, 4, at::nati... 0.00% 0.000us 0.00% 0.000us 0.000us 231.434ms 0.66% 231.434ms 385.081us 601
|
||||
void at::native::vectorized_elementwise_kernel<4, at... 0.00% 0.000us 0.00% 0.000us 0.000us 226.451ms 0.64% 226.451ms 692.511us 327
|
||||
void at::native::elementwise_kernel<128, 4, at::nati... 0.00% 0.000us 0.00% 0.000us 0.000us 224.222ms 0.63% 224.222ms 2.491ms 90
|
||||
void cutlass::Kernel2<cutlass_80_simt_sgemm_64x128_8... 0.00% 0.000us 0.00% 0.000us 0.000us 219.030ms 0.62% 219.030ms 534.219us 410
|
||||
void at::native::elementwise_kernel<128, 4, at::nati... 0.00% 0.000us 0.00% 0.000us 0.000us 209.409ms 0.59% 209.409ms 1.745ms 120
|
||||
aten::div 0.01% 4.911ms 0.02% 6.652ms 13.278us 192.925ms 0.55% 192.925ms 385.080us 501
|
||||
void (anonymous namespace)::indexing_backward_kernel... 0.00% 0.000us 0.00% 0.000us 0.000us 183.000ms 0.52% 183.000ms 6.100ms 30
|
||||
void at::native::unrolled_elementwise_kernel<at::nat... 0.00% 0.000us 0.00% 0.000us 0.000us 148.335ms 0.42% 148.335ms 988.899us 150
|
||||
aten::mean 0.02% 6.214ms 0.02% 8.406ms 12.717us 144.904ms 0.41% 144.904ms 219.220us 661
|
||||
void at::native::reduce_kernel<512, 1, at::native::R... 0.00% 0.000us 0.00% 0.000us 0.000us 144.904ms 0.41% 144.904ms 219.220us 661
|
||||
aten::_log_softmax 0.00% 527.616us 0.00% 716.309us 13.775us 139.294ms 0.39% 139.294ms 2.679ms 52
|
||||
void at::native::(anonymous namespace)::cunn_SoftMax... 0.00% 0.000us 0.00% 0.000us 0.000us 139.294ms 0.39% 139.294ms 2.679ms 52
|
||||
void at::native::reduce_kernel<512, 1, at::native::R... 0.00% 0.000us 0.00% 0.000us 0.000us 137.743ms 0.39% 137.743ms 286.368us 481
|
||||
void at::native::reduce_kernel<128, 4, at::native::R... 0.00% 0.000us 0.00% 0.000us 0.000us 130.526ms 0.37% 130.526ms 1.088ms 120
|
||||
aten::native_dropout_backward 0.00% 1.378ms 0.01% 3.156ms 15.394us 118.690ms 0.34% 118.690ms 578.975us 205
|
||||
------------------------------------------------------- ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------
|
||||
Self CPU time total: 35.229s
|
||||
Self CUDA time total: 35.322s
|
||||
|
||||
|
||||
========================================================================
|
||||
E. LAUNCH COUNTS (grouped_mm: 128/layer sequential = no-op, 1 = grouped)
|
||||
========================================================================
|
||||
kernel count self ms
|
||||
aten::_efficient_attention_backward 30 16144.6
|
||||
fmha_cutlassB_bf16_aligned_128x64_k65536_sm80(PyTorchMemEf 30 16144.6
|
||||
aten::_efficient_attention_forward 60 6691.2
|
||||
fmha_cutlassF_bf16_aligned_32x128_gmem_sm80(PyTorchMemEffA 60 6691.2
|
||||
aten::mm 25463 3744.6
|
||||
aten::mul 5328 2720.5
|
||||
aten::copy_ 5340 1976.8
|
||||
void cutlass::Kernel2<cutlass_80_tensorop_bf16_s16816gemm_ 2348 1542.2
|
||||
void at::native::elementwise_kernel<128, 2, at::native::gp 1894 1032.6
|
||||
void cutlass::Kernel2<cutlass_80_tensorop_bf16_s16816gemm_ 1489 868.6
|
||||
void at::native::vectorized_elementwise_kernel<4, at::nati 1992 787.0
|
||||
void at::native::unrolled_elementwise_kernel<at::native::d 2292 698.0
|
||||
aten::masked_fill_ 180 576.9
|
||||
void at::native::vectorized_elementwise_kernel<4, at::nati 1359 561.8
|
||||
void at::native::vectorized_elementwise_kernel<4, at::nati 1023 469.8
|
||||
aten::add_ 921 448.7
|
||||
void cutlass::Kernel2<cutlass_80_tensorop_bf16_s16816gemm_ 920 363.2
|
||||
aten::add 1400 356.2
|
||||
void at::native::elementwise_kernel<128, 2, at::native::gp 180 352.7
|
||||
aten::index 243 352.4
|
||||
void at::native::vectorized_gather_kernel<16, long>(char*, 182 351.8
|
||||
Memcpy DtoD (Device -> Device) 538 341.6
|
||||
aten::pow 1984 332.1
|
||||
void at::native::vectorized_elementwise_kernel<4, at::nati 661 330.3
|
||||
void at::native::vectorized_elementwise_kernel<4, at::nati 825 316.1
|
||||
aten::sum 572 286.5
|
||||
aten::native_dropout 410 258.2
|
||||
void at::native::(anonymous namespace)::fused_dropout_kern 410 258.2
|
||||
void at::native::unrolled_elementwise_kernel<at::native::C 410 239.1
|
||||
aten::_index_put_impl_ 151 235.8
|
||||
|
||||
total self CUDA time 70644.4 ms
|
||||
GEMM-ish kernels 31761.7 ms (45.0%)
|
||||
non-GEMM 38882.7 ms (55.0%)
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Steps 1/2/4 - profiler kernel table, sequence scaling, isolated CE timing.
|
||||
|
||||
Loads the real model exactly as erp_sft_harness.runtime does (same
|
||||
from_pretrained args, same PEFT config, same gradient checkpointing, same
|
||||
chunked-CE compute_loss) and measures:
|
||||
|
||||
A. sequence scaling 2x2048 / 2x8192 / 2x16384 fwd+bwd
|
||||
linear-dominated -> time falls ~8x from 16384 to 2048
|
||||
launch-bound -> time barely falls
|
||||
quadratic-dominated -> time falls ~64x
|
||||
B. isolated CE wall clock (CUDA events around the chunked-CE block)
|
||||
C. torch.profiler kernel table, sorted by self CUDA time
|
||||
D. expert-GEMM launch counts (settles grouped_mm without kernel-name
|
||||
archaeology: 128 sequential launches per layer = no-op, 1 = grouped)
|
||||
|
||||
Runs on GPU0, which is reserved and idle. Nothing else touches it.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
import torch
|
||||
from transformers import AutoModelForCausalLM, AutoTokenizer
|
||||
from peft import LoraConfig, get_peft_model
|
||||
|
||||
sys.path.insert(0, "/tank/erp-tune/eitri-smithy")
|
||||
from erp_sft_harness.core import IGNORE_INDEX, discover_target_modules
|
||||
|
||||
MODEL = "/tank/aimodels/gemma4-26b-a4b-it-heretic-bf16"
|
||||
CHUNK = 1024
|
||||
MB = 2
|
||||
|
||||
print("=" * 72)
|
||||
print("loading model")
|
||||
print("=" * 72, flush=True)
|
||||
t0 = time.time()
|
||||
model = AutoModelForCausalLM.from_pretrained(
|
||||
MODEL, dtype=torch.bfloat16, device_map={"": 0}, attn_implementation="sdpa",
|
||||
)
|
||||
targets = discover_target_modules(model)
|
||||
model = get_peft_model(model, LoraConfig(
|
||||
r=64, lora_alpha=128, lora_dropout=0.05, target_modules=targets,
|
||||
bias="none", task_type="CAUSAL_LM",
|
||||
))
|
||||
model.enable_input_require_grads()
|
||||
model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
|
||||
model.train()
|
||||
print("loaded in %.1fs targets=%d" % (time.time() - t0, len(targets)), flush=True)
|
||||
|
||||
base = model.base_model.model if hasattr(model, "base_model") else model
|
||||
body = base.model
|
||||
lm_head = base.get_output_embeddings()
|
||||
softcap = getattr(model.config.get_text_config(), "final_logit_softcapping", None)
|
||||
print("final_logit_softcapping = %s" % softcap)
|
||||
print("attn_implementation = %s" % model.config.get_text_config()._attn_implementation)
|
||||
print(flush=True)
|
||||
|
||||
ce_ms = {"fwd": 0.0}
|
||||
|
||||
|
||||
def compute_loss(input_ids, attention_mask, labels, time_ce=False):
|
||||
"""Byte-for-byte the harness's compute_loss, with optional CE timing."""
|
||||
hidden = body(input_ids=input_ids, attention_mask=attention_mask,
|
||||
use_cache=False).last_hidden_state
|
||||
flat_hidden = hidden[:, :-1, :].reshape(-1, hidden.size(-1))
|
||||
flat_labels = labels[:, 1:].reshape(-1)
|
||||
keep = flat_labels != IGNORE_INDEX
|
||||
kept_hidden = flat_hidden[keep]
|
||||
kept_labels = flat_labels[keep]
|
||||
kept = int(kept_labels.numel())
|
||||
|
||||
def chunk_loss(chunk_hidden, chunk_labels):
|
||||
logits = lm_head(chunk_hidden).float()
|
||||
if softcap is not None:
|
||||
logits = torch.tanh(logits / softcap) * softcap
|
||||
return torch.nn.functional.cross_entropy(logits, chunk_labels, reduction="sum")
|
||||
|
||||
if time_ce:
|
||||
s, e = torch.cuda.Event(True), torch.cuda.Event(True)
|
||||
torch.cuda.synchronize()
|
||||
s.record()
|
||||
total = torch.zeros((), device=kept_hidden.device, dtype=torch.float32)
|
||||
for start in range(0, kept, CHUNK):
|
||||
total = total + torch.utils.checkpoint.checkpoint(
|
||||
chunk_loss, kept_hidden[start:start + CHUNK],
|
||||
kept_labels[start:start + CHUNK], use_reentrant=False,
|
||||
)
|
||||
if time_ce:
|
||||
e.record()
|
||||
torch.cuda.synchronize()
|
||||
ce_ms["fwd"] = s.elapsed_time(e)
|
||||
return total / kept, kept
|
||||
|
||||
|
||||
def make_batch(n, pad_frac=0.0):
|
||||
"""Synthetic batch. pad_frac trims the SECOND row and right-pads it,
|
||||
mimicking collate_mixed on a heterogeneous pair."""
|
||||
ids = torch.randint(100, 200000, (MB, n), device="cuda")
|
||||
am = torch.ones(MB, n, dtype=torch.long, device="cuda")
|
||||
labels = ids.clone()
|
||||
if pad_frac > 0:
|
||||
keep = int(n * (1 - pad_frac))
|
||||
am[1, keep:] = 0
|
||||
labels[1, keep:] = IGNORE_INDEX
|
||||
# ~40% of real tokens carry loss (measured mean 2188/2752 is higher, but
|
||||
# rp-dialogue assistant-only masking pulls the mix down); use the measured
|
||||
# global ratio 57.7M ctx -> 45.9M targets = 0.795
|
||||
m = torch.rand(labels.shape, device="cuda") > 0.795
|
||||
labels[m] = IGNORE_INDEX
|
||||
return ids, am, labels
|
||||
|
||||
|
||||
def timed(n, pad_frac=0.0, reps=2, time_ce=False, label=""):
|
||||
ids, am, labels = make_batch(n, pad_frac)
|
||||
for _ in range(1): # warmup
|
||||
loss, kept = compute_loss(ids, am, labels)
|
||||
loss.backward()
|
||||
model.zero_grad(set_to_none=True)
|
||||
torch.cuda.synchronize()
|
||||
best = None
|
||||
for _ in range(reps):
|
||||
torch.cuda.reset_peak_memory_stats()
|
||||
t = time.perf_counter()
|
||||
loss, kept = compute_loss(ids, am, labels, time_ce=time_ce)
|
||||
loss.backward()
|
||||
torch.cuda.synchronize()
|
||||
dt = time.perf_counter() - t
|
||||
best = dt if best is None else min(best, dt)
|
||||
model.zero_grad(set_to_none=True)
|
||||
peak = torch.cuda.max_memory_allocated() / 2**30
|
||||
print(" %-34s %7.3f s kept=%-6d peak=%5.1f GiB%s" % (
|
||||
label or ("2x%d pad=%.0f%%" % (n, pad_frac * 100)),
|
||||
best, kept, peak,
|
||||
(" CE=%.0f ms (%.1f%%)" % (ce_ms["fwd"], 100 * ce_ms["fwd"] / 1000 / best)) if time_ce else ""))
|
||||
return best
|
||||
|
||||
|
||||
print("=" * 72)
|
||||
print("A. SEQUENCE SCALING (no padding - isolates n)")
|
||||
print("=" * 72, flush=True)
|
||||
t2048 = timed(2048, 0.0, label="2 x 2,048")
|
||||
t8192 = timed(8192, 0.0, label="2 x 8,192")
|
||||
t16384 = timed(16384, 0.0, label="2 x 16,384")
|
||||
print()
|
||||
print(" 16384 -> 2048 ratio %.2fx (linear ~8x, launch-bound ~1x, quadratic ~64x)"
|
||||
% (t16384 / t2048))
|
||||
print(" 16384 -> 8192 ratio %.2fx (linear ~2x, quadratic ~4x)"
|
||||
% (t16384 / t8192))
|
||||
print(flush=True)
|
||||
|
||||
print("=" * 72)
|
||||
print("B. PADDING PENALTY (same real tokens, with vs without pad)")
|
||||
print("=" * 72, flush=True)
|
||||
timed(16384, 0.0, label="2 x 16,384 no padding")
|
||||
timed(16384, 0.5, label="2 x 16,384 50% pad on row 1")
|
||||
print(flush=True)
|
||||
|
||||
print("=" * 72)
|
||||
print("C. ISOLATED CE WALL CLOCK")
|
||||
print("=" * 72, flush=True)
|
||||
timed(16384, 0.0, reps=2, time_ce=True, label="2 x 16,384 (CE timed)")
|
||||
timed(4096, 0.0, reps=2, time_ce=True, label="2 x 4,096 (CE timed)")
|
||||
print(flush=True)
|
||||
|
||||
print("=" * 72)
|
||||
print("D. KERNEL TABLE - one fwd+bwd at 2 x 16,384")
|
||||
print("=" * 72, flush=True)
|
||||
ids, am, labels = make_batch(16384, 0.0)
|
||||
loss, _ = compute_loss(ids, am, labels)
|
||||
loss.backward()
|
||||
model.zero_grad(set_to_none=True)
|
||||
torch.cuda.synchronize()
|
||||
|
||||
with torch.profiler.profile(
|
||||
activities=[torch.profiler.ProfilerActivity.CPU,
|
||||
torch.profiler.ProfilerActivity.CUDA],
|
||||
record_shapes=False, with_stack=False,
|
||||
) as prof:
|
||||
loss, _ = compute_loss(ids, am, labels)
|
||||
loss.backward()
|
||||
torch.cuda.synchronize()
|
||||
model.zero_grad(set_to_none=True)
|
||||
|
||||
print(prof.key_averages().table(sort_by="self_cuda_time_total", row_limit=45))
|
||||
|
||||
print()
|
||||
print("=" * 72)
|
||||
print("E. LAUNCH COUNTS (grouped_mm: 128/layer sequential = no-op, 1 = grouped)")
|
||||
print("=" * 72)
|
||||
rows = []
|
||||
for ev in prof.key_averages():
|
||||
if ev.self_device_time_total <= 0:
|
||||
continue
|
||||
rows.append((ev.key, ev.count, ev.self_device_time_total / 1000.0))
|
||||
rows.sort(key=lambda r: -r[2])
|
||||
print(" %-58s %8s %10s" % ("kernel", "count", "self ms"))
|
||||
for k, c, ms in rows[:30]:
|
||||
print(" %-58s %8d %10.1f" % (k[:58], c, ms))
|
||||
|
||||
total_ms = sum(r[2] for r in rows)
|
||||
print()
|
||||
print(" total self CUDA time %.1f ms" % total_ms)
|
||||
gemm = sum(ms for k, c, ms in rows if any(t in k.lower() for t in
|
||||
("gemm", "cutlass", "sm90", "sm100", "sm120", "nvjet", "ampere", "tensor")))
|
||||
print(" GEMM-ish kernels %.1f ms (%.1f%%)" % (gemm, 100 * gemm / total_ms))
|
||||
print(" non-GEMM %.1f ms (%.1f%%)" % (total_ms - gemm, 100 * (total_ms - gemm) / total_ms))
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Step 2 — padding ratio. Data-side, no GPU, no model.
|
||||
|
||||
Replicates the exact batching the trainer used: SequentialSampler over the
|
||||
encode-cache order, per_device_batch_size=2, collate_mixed right-padding to
|
||||
the pair max. Reports real vs padded token counts and the loss-target count
|
||||
that sizes the chunked CE.
|
||||
"""
|
||||
import json, sys
|
||||
from collections import Counter
|
||||
|
||||
CACHE = "/tank/erp-tune/run-01/encode-cache/encoded-a4b0796de1260930.jsonl"
|
||||
IGNORE_INDEX = -100
|
||||
MB = 2 # per_device_batch_size
|
||||
ACCUM = 8 # gradient_accumulation_steps
|
||||
|
||||
lens, kept_counts, kinds = [], [], []
|
||||
with open(CACHE) as fh:
|
||||
for line in fh:
|
||||
row = json.loads(line)
|
||||
ids = row["input_ids"]
|
||||
labels = row["labels"]
|
||||
lens.append(len(ids))
|
||||
kept_counts.append(sum(1 for x in labels if x != IGNORE_INDEX))
|
||||
kinds.append(row.get("sample_kind", "?"))
|
||||
|
||||
n = len(lens)
|
||||
print(f"records {n:,}")
|
||||
print(f"sample_kind mix {dict(Counter(kinds))}")
|
||||
print()
|
||||
print(f"seq len min/mean/max {min(lens)} / {sum(lens)/n:.0f} / {max(lens)}")
|
||||
print(f"loss targets min/mean/max {min(kept_counts)} / {sum(kept_counts)/n:.0f} / {max(kept_counts)}")
|
||||
print()
|
||||
|
||||
# --- micro-batch padding, exactly as collate_mixed builds it ---
|
||||
real = padded = 0
|
||||
mb_widths, mb_waste, mb_kept = [], [], []
|
||||
for i in range(0, n - n % MB, MB):
|
||||
group = lens[i:i + MB]
|
||||
width = max(group)
|
||||
r = sum(group)
|
||||
p = width * MB
|
||||
real += r
|
||||
padded += p
|
||||
mb_widths.append(width)
|
||||
mb_waste.append(1 - r / p)
|
||||
mb_kept.append(sum(kept_counts[i:i + MB]))
|
||||
|
||||
nb = len(mb_widths)
|
||||
print(f"micro-batches (mb={MB}) {nb:,}")
|
||||
print(f"real tokens {real:,}")
|
||||
print(f"padded tokens {padded:,}")
|
||||
print(f"PADDING WASTE {100 * (1 - real / padded):.1f}% ({padded - real:,} pad tokens)")
|
||||
print()
|
||||
print(f"mb width min/mean/max {min(mb_widths)} / {sum(mb_widths)/nb:.0f} / {max(mb_widths)}")
|
||||
srt = sorted(mb_widths)
|
||||
for q in (50, 75, 90, 95, 99):
|
||||
print(f" p{q} width {srt[int(nb*q/100)]}")
|
||||
print(f"mb at max_seq_len 16384 {sum(1 for w in mb_widths if w >= 16384):,} ({100*sum(1 for w in mb_widths if w>=16384)/nb:.1f}%)")
|
||||
print()
|
||||
srtw = sorted(mb_waste)
|
||||
print(f"per-mb waste p50/p90/max {100*srtw[nb//2]:.1f}% / {100*srtw[int(nb*0.9)]:.1f}% / {100*max(mb_waste):.1f}%")
|
||||
print()
|
||||
print(f"loss targets per mb min/mean/max {min(mb_kept)} / {sum(mb_kept)/nb:.0f} / {max(mb_kept)}")
|
||||
print(f" -> CE chunks per mb (1024) min/mean/max {min(mb_kept)//1024+1} / {sum(mb_kept)/nb/1024:.1f} / {max(mb_kept)//1024+1}")
|
||||
print()
|
||||
|
||||
# --- what length-bucketing would recover (sort by length, then batch) ---
|
||||
order = sorted(range(n), key=lambda i: lens[i])
|
||||
b_real = b_padded = 0
|
||||
for i in range(0, n - n % MB, MB):
|
||||
group = [lens[j] for j in order[i:i + MB]]
|
||||
b_real += sum(group)
|
||||
b_padded += max(group) * MB
|
||||
print("--- counterfactual: length-bucketed sampler ---")
|
||||
print(f"bucketed padded tokens {b_padded:,}")
|
||||
print(f"bucketed waste {100 * (1 - b_real / b_padded):.1f}%")
|
||||
print(f"TOKEN REDUCTION vs current {100 * (1 - b_padded / padded):.1f}%")
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Measure bucket-to-pair / shuffle-to-mix against the REAL encode cache.
|
||||
|
||||
Brokkr's design, validated on measured record lengths rather than a calibrated
|
||||
length model:
|
||||
|
||||
1. sort records by length
|
||||
2. cut into buckets of BUCKET records
|
||||
3. form micro-batches of 2 WITHIN each bucket (adjacent after sort)
|
||||
4. shuffle the resulting MICRO-BATCHES globally, seeded
|
||||
|
||||
Padding efficiency is a property of the pairing only, so step 4 costs nothing
|
||||
and restores root-mixing inside each accumulation window.
|
||||
|
||||
Also applies the fitted cost model from the replica scaling test to convert
|
||||
token savings into predicted wall clock.
|
||||
"""
|
||||
import json
|
||||
import random
|
||||
from collections import Counter
|
||||
|
||||
CACHE = "/tank/erp-tune/run-01/encode-cache/encoded-a4b0796de1260930.jsonl"
|
||||
IGNORE_INDEX = -100
|
||||
MB = 2
|
||||
ACCUM = 8
|
||||
SEED = 20260824
|
||||
|
||||
# fitted on the replica: t(w) = A*w + B*w^2 for a batch of 2 sequences of len w
|
||||
A = 6.8715e-04
|
||||
B = 8.8509e-08
|
||||
|
||||
rows = []
|
||||
with open(CACHE) as fh:
|
||||
for line in fh:
|
||||
r = json.loads(line)
|
||||
rows.append((len(r["input_ids"]), r.get("dataset_id", "?"),
|
||||
r.get("sample_kind", "?")))
|
||||
n = len(rows)
|
||||
print("records %d" % n)
|
||||
print()
|
||||
|
||||
|
||||
def evaluate(order, label, show_roots=False):
|
||||
real = padded = 0
|
||||
widths = []
|
||||
batches = []
|
||||
for i in range(0, n - n % MB, MB):
|
||||
grp = [rows[j] for j in order[i:i + MB]]
|
||||
w = max(g[0] for g in grp)
|
||||
real += sum(g[0] for g in grp)
|
||||
padded += w * MB
|
||||
widths.append(w)
|
||||
batches.append([g[1] for g in grp])
|
||||
nb = len(widths)
|
||||
Ew = sum(widths) / nb
|
||||
Ew2 = sum(w * w for w in widths) / nb
|
||||
t_mb = A * Ew + B * Ew2
|
||||
srt = sorted(widths)
|
||||
print("--- %s ---" % label)
|
||||
print(" padded tokens %s" % f"{padded:,}")
|
||||
print(" waste %.1f%%" % (100 * (1 - real / padded)))
|
||||
print(" E[w] (per-seq) %.0f" % Ew)
|
||||
print(" E[w^2] %.3e" % Ew2)
|
||||
print(" width p50/p90/p99 %d / %d / %d" % (
|
||||
srt[nb // 2], srt[int(nb * .9)], srt[int(nb * .99)]))
|
||||
print(" predicted micro-batch %.3f s (lin %.3f + quad %.3f, quad %.0f%%)" % (
|
||||
t_mb, A * Ew, B * Ew2, 100 * B * Ew2 / t_mb))
|
||||
print(" predicted step (x%d) %.1f s -> %.2f h over 1312 steps" % (
|
||||
ACCUM, t_mb * ACCUM, t_mb * ACCUM * 1312 / 3600))
|
||||
# unpadded micro-batches take the is_causal fast path on the 5 global layers
|
||||
exact = sum(1 for i in range(0, n - n % MB, MB)
|
||||
if len(set(rows[j][0] for j in order[i:i + MB])) == 1)
|
||||
print(" ZERO-PAD micro-batches %d / %d (%.1f%%) <- global layers on is_causal" % (
|
||||
exact, nb, 100 * exact / nb))
|
||||
if show_roots:
|
||||
# root diversity inside an accumulation window
|
||||
div = []
|
||||
for i in range(0, nb - nb % ACCUM, ACCUM):
|
||||
win = [d for b in batches[i:i + ACCUM] for d in b]
|
||||
div.append(len(set(win)))
|
||||
print(" roots per accum window mean %.2f min %d (of %d roots)" % (
|
||||
sum(div) / len(div), min(div), len({r[1] for r in rows})))
|
||||
print()
|
||||
return padded, t_mb
|
||||
|
||||
|
||||
# --- current: encode-cache order, SequentialSampler ---
|
||||
cur_padded, cur_t = evaluate(list(range(n)), "CURRENT (SequentialSampler)", True)
|
||||
|
||||
# --- bucket-to-pair + shuffle-to-mix ---
|
||||
# BUCKET controls the efficiency-vs-diversity trade: records are globally
|
||||
# sorted, cut into buckets of BUCKET, SHUFFLED WITHIN the bucket (not
|
||||
# re-sorted), then paired adjacently. BUCKET=2 is a perfect global sort
|
||||
# (0% waste, worst root mixing); larger buckets admit more length spread
|
||||
# inside a pair but draw partners from a wider slice of the corpus.
|
||||
for BUCKET in (2, 8, 32, 128, 512):
|
||||
by_len = sorted(range(n), key=lambda i: rows[i][0])
|
||||
rng = random.Random(SEED)
|
||||
micro = []
|
||||
for s in range(0, n, BUCKET):
|
||||
chunk = by_len[s:s + BUCKET]
|
||||
rng.shuffle(chunk) # mix WITHIN the length bucket
|
||||
for k in range(0, len(chunk) - len(chunk) % MB, MB):
|
||||
micro.append(chunk[k:k + MB])
|
||||
rng.shuffle(micro) # shuffle-to-mix across buckets
|
||||
order = [i for b in micro for i in b]
|
||||
placed = set(order)
|
||||
order += [i for i in by_len if i not in placed]
|
||||
p, t = evaluate(order, "BUCKET=%d, shuffle within + global micro-batch shuffle" % BUCKET,
|
||||
True)
|
||||
print(" >>> vs current: %.1f%% fewer padded tokens, %.1f%% less wall clock" % (
|
||||
100 * (1 - p / cur_padded), 100 * (1 - t / cur_t)))
|
||||
print()
|
||||
@@ -0,0 +1,16 @@
|
||||
# AdGuard Home — Anaheim. Copy to `.env` on ana-docker at
|
||||
# /opt/docker/compose/adguard-ana/.env and chmod 600. Never commit the real file.
|
||||
|
||||
# Web UI / API port. compose.yaml defaults to 8053 if unset.
|
||||
AG_WEB_PORT=8053
|
||||
|
||||
# Password for the `infra-ops` AdGuard login, used ONLY by the Homepage
|
||||
# query/blocked/latency widget (homepage.widget.password in compose.yaml).
|
||||
# One credential authenticates against all three fleet instances (ANA, ESH,
|
||||
# NH3) — verified 2026-08-24. Fetch it from the vault rather than copying it
|
||||
# between boxes:
|
||||
#
|
||||
# secret get nh3-dev/adguard-infra-ops-password
|
||||
#
|
||||
# (`secret` is services/secrets-broker/secret on nh3-dev.)
|
||||
ADGUARD_WIDGET_PASSWORD=
|
||||
@@ -39,12 +39,26 @@ services:
|
||||
networks:
|
||||
- tnet
|
||||
labels:
|
||||
- homepage.group=Service Networking
|
||||
- homepage.group=DNS & Filtering
|
||||
- homepage.name=AdGuard (ANA)
|
||||
- homepage.icon=mdi-dns
|
||||
# si-adguard, matching the ESH and NH3 instances. This carried mdi-dns
|
||||
# and was the only one of the three wearing a different mark.
|
||||
- homepage.icon=si-adguard
|
||||
- homepage.description=DNS resolver + .internal zone (colo)
|
||||
- homepage.href=http://10.250.50.70:${AG_WEB_PORT:-8053}
|
||||
- homepage.siteMonitor=http://10.250.50.70:${AG_WEB_PORT:-8053}
|
||||
# Query/blocked/latency strip, same as ESH and NH3. Added 2026-08-24:
|
||||
# without it this was a short card sitting beside two tall ones, which
|
||||
# is the whole reason the DNS group exists as its own band — a group's
|
||||
# members should all carry a widget or none of them should.
|
||||
# Credential is the fleet AdGuard `infra-ops` login, which authenticates
|
||||
# against all three instances; it lives in this stack's .env on the host
|
||||
# (never in git) and is vaulted at
|
||||
# `secret get nh3-dev/adguard-infra-ops-password`.
|
||||
- homepage.widget.type=adguard
|
||||
- homepage.widget.url=http://10.250.50.70:${AG_WEB_PORT:-8053}
|
||||
- homepage.widget.username=infra-ops
|
||||
- homepage.widget.password=${ADGUARD_WIDGET_PASSWORD}
|
||||
|
||||
volumes:
|
||||
adguard_work: {}
|
||||
|
||||
@@ -85,7 +85,7 @@ services:
|
||||
networks:
|
||||
- tnet
|
||||
labels:
|
||||
- homepage.group=AI - Image & Media
|
||||
- homepage.group=AI - Studios
|
||||
- homepage.name=Arbo
|
||||
- homepage.icon=mdi-image-multiple-outline
|
||||
- homepage.description=Catalog-driven ComfyUI engine (irv-ml1)
|
||||
|
||||
@@ -90,7 +90,7 @@ services:
|
||||
networks:
|
||||
- tnet
|
||||
labels:
|
||||
- homepage.group=AI - Image & Media
|
||||
- homepage.group=AI - Studios
|
||||
- homepage.name=ComfyUI
|
||||
- homepage.icon=mdi-image-auto-adjust
|
||||
- homepage.description=Node-based SD/Flux inference (irv-ml1)
|
||||
|
||||
@@ -27,7 +27,7 @@ services:
|
||||
networks:
|
||||
- tnet
|
||||
labels:
|
||||
- homepage.group=Service Networking
|
||||
- homepage.group=Compose Consoles
|
||||
- homepage.name=Dockge (${DOCKGE_HOST_LABEL})
|
||||
- homepage.icon=sh-dockge.png
|
||||
- homepage.description=Compose UI on ${DOCKGE_HOST_LABEL}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# gemma4-charrp — char-rp seat on ana-ml2 GPU0. Real .env lives on the host.
|
||||
|
||||
# ⚠ GPU0 IS SHARED WITH `vllm-gen`. gen runs at --gpu-memory-utilization 0.43
|
||||
# but actually holds ~45.6 GiB of the 94.97 GiB card — that flag sizes the KV
|
||||
# cache and does NOT cover CUDA context, graphs and non-torch overhead. The
|
||||
# predecessor seat sat at 0.51, the pair summed to 0.94, and on 2026-08-24 it
|
||||
# stopped fitting and crash-looped 13 times with
|
||||
# `torch.OutOfMemoryError: ... 195.19 MiB is free`.
|
||||
#
|
||||
# 0.47 keeps ~4.8 GiB of real margin. This model's weights are only ~15.3 GiB
|
||||
# (NVFP4) against the predecessor's ~19.5 GiB, so the same budget buys MORE KV
|
||||
# cache than before, not less. Raising this means lowering gen's in the same
|
||||
# change — and check the real numbers, not the flags:
|
||||
# nvidia-smi --query-compute-apps=pid,used_memory --format=csv
|
||||
GEMMA4_GPU_MEM_UTIL=0.47
|
||||
|
||||
# Native context. config.json declares max_position_embeddings 262144, same as
|
||||
# the outgoing seat, so this is a straight-across swap on context too.
|
||||
GEMMA4_MAX_MODEL_LEN=262144
|
||||
GEMMA4_MAX_NUM_SEQS=32
|
||||
|
||||
# ⚠ THE NVFP4 QUANT, NOT THE BF16. /tank/aimodels/gemma4-26b-a4b-it-bf16 is the
|
||||
# QLoRA tuning base and is 48.10 GiB of weights — it does not fit beside gen.
|
||||
GEMMA4_MODEL=/tank/aimodels/gemma4-26b-a4b-it-nvfp4
|
||||
#
|
||||
# ACTIVATION-AXIS CONTROL, for one-off benching — swap this line, recreate:
|
||||
# GEMMA4_MODEL=/tank/aimodels/gemma4-26b-a4b-it-nvfp4a16
|
||||
# Same NVFP4 weights and the same compressed-tensors loader, but 16-bit
|
||||
# activations.
|
||||
#
|
||||
# ⚠ THE ORIGINAL MOTIVATION FOR THIS CONTROL WAS RETRACTED — see the README's
|
||||
# "Superseded claims" section. The A16 run itself HELD and is the reason this
|
||||
# path is documented: activation precision is close to free on this battery,
|
||||
# every other task identical across builds. But the "12% contradiction
|
||||
# detection" number that prompted it was an artifact of an ill-posed benchmark
|
||||
# item, not a model property. Keep the path; do not repeat the reasoning.
|
||||
# Two minutes down each way; port and both aliases are unchanged, so no
|
||||
# consumer config moves.
|
||||
#
|
||||
# ⚠ Only prithivMLmods/gemma-4-26B-A4B-it-NVFP4A16 is genuinely A16.
|
||||
# bg-digitalservices and ManniX-ITA both publish repos NAMED "NVFP4A16" whose
|
||||
# config.json declares input_activations num_bits 4. Check the field, not the
|
||||
# name, before ever substituting a different repo.
|
||||
#
|
||||
# ⚠⚠ THE A16 BUILD ALSO SHIPS A STALE CHAT TEMPLATE — the control run MUST
|
||||
# override it or it moves two axes instead of one:
|
||||
# --chat-template /tank/aimodels/gemma4-26b-a4b-it-nvfp4/chat_template.jinja
|
||||
# Verified by hash on 2026-08-24: upstream google/gemma-4-26B-A4B-it is 390
|
||||
# lines, the A4 build's is 389 and byte-identical to it once trailing newlines
|
||||
# are normalised, and the A16 build's is 266 and is NOT. The thinking machinery
|
||||
# is built differently too — upstream and A4 set
|
||||
# `enable_thinking | default(false)` at line 186, the A16 template has no such
|
||||
# set — and its tokenizer_config response_schema has no `thinking` property.
|
||||
# It was quantized from an older revision. Served with its own template the two
|
||||
# arms would render DIFFERENT PROMPTS, and a score delta could be the template
|
||||
# rather than the activations.
|
||||
# Safe to override because the tokenizers agree: vocab identical at 262,144
|
||||
# entries, added_tokens identical. Same template over the same vocab renders
|
||||
# the same token ids.
|
||||
|
||||
GEMMA4_PORT=8016
|
||||
GEMMA4_GPU_ID=0
|
||||
GEMMA4_CONTAINER=vllm-gemma4-charrp
|
||||
|
||||
# Same value as every other vLLM seat on this host — the gateway presents it.
|
||||
API_KEY=
|
||||
@@ -0,0 +1,260 @@
|
||||
# gemma4-charrp — the char-rp seat (ana-ml2 GPU0)
|
||||
|
||||
`google/gemma-4-26B-A4B-it`, NVFP4, serving both halves of the char-rp pair on
|
||||
`:8016`. Replaced the dense `G4-MeroMero-v2-31B-NVFP4A16` seat on 2026-08-24.
|
||||
|
||||
```
|
||||
char-rp non-thinking -> http://10.250.50.54:8016/v1
|
||||
char-rp-reasoning thinking -> http://10.250.50.54:8016/v1
|
||||
```
|
||||
|
||||
Two LiteLLM aliases, **one backend**. They are not two seats — this trips people
|
||||
up, and it cost a peer a mis-attributed benchmark before it was noticed.
|
||||
|
||||
## Three model directories, and they are not interchangeable
|
||||
|
||||
| path | size | what it is |
|
||||
|---|---|---|
|
||||
| `gemma4-26b-a4b-it-bf16` | 49 GB | **Stock BF16.** Unquantized. Cannot be served here — 48.10 GiB of weights against ~49 GiB of free GPU0 leaves nothing for KV cache. Its `chat_template.jinja` is the canonical upstream one; see below. |
|
||||
| `gemma4-26b-a4b-it-heretic-bf16` | 49 GB | **QLoRA trainee base** (operator's pick, 2026-08-24). llmfan46, Heretic v1.2.0 ARA, KL 0.1237, refusals 3/100. |
|
||||
| `gemma4-26b-a4b-it-abliterated-bf16` | 49 GB | **Trainee alternate.** TrevorJS, KL 0.09, refusals 1/100 effective and 5/686 cross-dataset — lowest measured damage of the field. |
|
||||
| `gemma4-26b-a4b-it-nvfp4` | 16 GB | **What is served.** RedHatAI, compressed-tensors, W4**A4**. |
|
||||
| `gemma4-26b-a4b-it-nvfp4a16` | 17 GB | **Activation-axis control**, for benching only. prithivMLmods, compressed-tensors, W4**A16**. |
|
||||
|
||||
All under `/tank/aimodels/`, pulled by the `gemma4-26b-*-dl.py` scripts beside
|
||||
them with revisions pinned.
|
||||
|
||||
### ⚠ Third-party Gemma-4 builds ship STALE CHAT TEMPLATES — this is endemic
|
||||
|
||||
Verified by hash on 2026-08-24 across every third-party derivative pulled here.
|
||||
**Not one of them ships upstream's template:**
|
||||
|
||||
| build | lines | sha256 (normalised) |
|
||||
|---|---|---|
|
||||
| upstream `google/gemma-4-26B-A4B-it` | 390 | `6a1015c47ccfcfa6` |
|
||||
| RedHatAI NVFP4 (what is served) | 389 | `6a1015c47ccfcfa6` — the only match |
|
||||
| llmfan46 heretic | 365 | `0a52be69cda5ab8a` |
|
||||
| TrevorJS abliterated | 266 | `58c66fdee4afa297` |
|
||||
| jenerallee78 abliterated | 266 | `58c66fdee4afa297` |
|
||||
| prithivMLmods NVFP4A16 | 266 | `58c66fdee4afa297` |
|
||||
|
||||
Three independent repos carrying the identical stale 266-line file means it
|
||||
propagated through the ecosystem, not that one packager slipped.
|
||||
|
||||
**Consequences differ by use and both are silent:**
|
||||
|
||||
- **Serving** — a different template renders a different prompt. This is why the
|
||||
production compose pins the template explicitly.
|
||||
- **Training** — if the harness renders examples through `base/chat_template.jinja`,
|
||||
you train on a different prompt format than production serves. Train/serve
|
||||
skew, no error, and it presents as a tuning failure.
|
||||
|
||||
For both, point at the upstream file:
|
||||
`/tank/aimodels/gemma4-26b-a4b-it-bf16/chat_template.jinja`.
|
||||
|
||||
### Measured: abliteration is close to free on this base (2026-08-24)
|
||||
|
||||
Isolated properly — stock BF16 against llmfan46 BF16, same precision, same
|
||||
pinned upstream template, same 192 items, CoT off. **Abliteration was the only
|
||||
axis that moved.**
|
||||
|
||||
| task | stock BF16 | heretic BF16 | items |
|
||||
|---|---|---|---|
|
||||
| T1 state / T3 constraint / T4 long-context / T5 control | 100% | 100% | 0 |
|
||||
| T2 contradiction | 75% | 59% | **−5** |
|
||||
| T6 spatial | 75% | 88% | **+4** |
|
||||
| **core** | **90.0%** | **89.4%** | −0.6 pts |
|
||||
|
||||
**Net cost 0.6 points — but it MOVED capability rather than removing it.** Five
|
||||
items lost on contradiction detection, four gained on spatial composition,
|
||||
nearly cancelling. Nobody predicted a gain anywhere, least of all that
|
||||
direction.
|
||||
|
||||
Consequence for the base choice: **llmfan46 stands.** There is no case for
|
||||
re-staging on TrevorJS at KL 0.09 over a 0.6-point net difference — the KL gap
|
||||
between the two builds is smaller than the gap this measurement failed to find.
|
||||
|
||||
⚠ Read those as **~5 items and ~4 items at n=32**, not as −15.6/+12.5 percent.
|
||||
The percentages read more precisely than the measurement supports, and only
|
||||
marginals were run — no paired per-item analysis.
|
||||
|
||||
⚠ **This says nothing about quantization.** Stock BF16 scores T2 75% where stock
|
||||
NVFP4 scored 94%, but those runs were n=32 and n=16 — different item counts mean
|
||||
different item sets, and the extra items are not guaranteed equally easy. That
|
||||
comparison is n-confounded and is not being made.
|
||||
|
||||
### Choosing an abliterated base — compare on published damage, not on names
|
||||
|
||||
"Low damage" has a measurable proxy and the field spreads widely on it:
|
||||
|
||||
| build | method | KL | refusals |
|
||||
|---|---|---|---|
|
||||
| TrevorJS | ARA-family | **0.09** | 1/100 effective, 5/686 cross-dataset, manually audited |
|
||||
| llmfan46 | Heretic v1.2.0 ARA | 0.1237 | 3/100 |
|
||||
| jenerallee78 | ARA 2-pass | 0.1299 | 7.7% StrongREJECT |
|
||||
| huihui-ai | remove-refusals-with-transformers | none published | none published |
|
||||
| trohrbaugh/heretic-ara | — | 0.2999 | 31.4% |
|
||||
| coder3101/heretic | — | 0.4118 | 15.8% |
|
||||
|
||||
Fleet anchor for reading those numbers: our own abliteration work found **Heretic
|
||||
at KL 0.12 preserved the MTP head at 83.7% acceptance**. Both staged builds sit
|
||||
at or below that, so neither is an extrapolation past what has been measured
|
||||
here.
|
||||
|
||||
huihui-ai is rejected on this stack despite being the best-known abliteration
|
||||
house: no published metrics, its card describes the method as "a crude,
|
||||
proof-of-concept implementation", it states both thinking and non-thinking modes
|
||||
were "completely abliterated", and its parameter count is 26,544,131,376 against
|
||||
upstream's 25,805,936,206 — roughly 738M unexplained extra. The operator's
|
||||
independent read is the same ("huihui produces garbage").
|
||||
|
||||
## GPU0 is shared and the budgets must sum under ~0.92
|
||||
|
||||
`vllm-gen` runs at `--gpu-memory-utilization 0.43` but actually holds ~45.6 GiB
|
||||
of the 94.97 GiB card — that flag sizes the KV cache and does **not** cover CUDA
|
||||
context, graphs and non-torch overhead. The predecessor seat sat at 0.51, the
|
||||
pair summed to 0.94, and on 2026-08-24 it stopped fitting and crash-looped 13
|
||||
times with `torch.OutOfMemoryError: ... 195.19 MiB is free`.
|
||||
|
||||
This seat runs 0.47. Raising it means lowering gen's in the same change, and
|
||||
**check the real numbers, not the flags**:
|
||||
|
||||
```bash
|
||||
nvidia-smi --query-compute-apps=pid,used_memory --format=csv
|
||||
```
|
||||
|
||||
There is no room for a second concurrent seat on this card. That is why the A16
|
||||
control below is a swap rather than a parallel deployment.
|
||||
|
||||
## Running the A16 activation-axis control
|
||||
|
||||
Why it was run, and what it actually settled: a 2026-08-24 battery appeared to
|
||||
show a large contradiction-detection deficit with CoT off, which had the exact
|
||||
shape 4-bit input activations would produce on the most reasoning-dense task.
|
||||
The control ran and found activation precision **close to free** — every other
|
||||
task identical across the two builds. That quantization result stands.
|
||||
|
||||
⚠ The *deficit* it was chasing does not — the benchmark item was ill-posed. See
|
||||
"Superseded claims" at the end of this file. The procedure below is kept because
|
||||
the flip is a genuinely useful capability, not because the original question was
|
||||
sound.
|
||||
|
||||
⚠ **The A16 build ships a STALE CHAT TEMPLATE and the run MUST override it.**
|
||||
Verified by hash 2026-08-24: upstream `google/gemma-4-26B-A4B-it` is 390 lines,
|
||||
the A4 build's is 389 and byte-identical to upstream once trailing newlines are
|
||||
normalised, the A16 build's is 266 and is not. Upstream and A4 open the thinking
|
||||
path with `{%- set enable_thinking = enable_thinking | default(false) -%}`; the
|
||||
A16 template has no such set. Its `tokenizer_config.json` `response_schema` also
|
||||
lacks the `thinking` property. Served with its own template the two arms render
|
||||
**different prompts**, and a score delta could be the template rather than the
|
||||
activations.
|
||||
|
||||
Overriding is safe: the tokenizers agree — vocab identical at 262,144 entries,
|
||||
`added_tokens` identical — so the same template over the same vocab renders the
|
||||
same token ids. brokkr independently diffed every non-quantization config field
|
||||
of both builds against the upstream BF16 and found only `transformers_version`
|
||||
differing, which is a save-time library version rather than a model property.
|
||||
Residual risk stated honestly on both sides: config identity is not weight
|
||||
identity, and nobody has done a dequantization pass.
|
||||
|
||||
The procedure, ~2 minutes each way:
|
||||
|
||||
1. Add the template flag to `compose.yaml` (a **no-op for production** — the A4
|
||||
build ships this exact file, so pinning it explicitly changes nothing and
|
||||
guards against precisely the staleness above):
|
||||
|
||||
```yaml
|
||||
- --chat-template
|
||||
- ${GEMMA4_CHAT_TEMPLATE:-/tank/aimodels/gemma4-26b-a4b-it-nvfp4/chat_template.jinja}
|
||||
```
|
||||
|
||||
⚠ If `GEMMA4_MODEL` is ever pointed at a *different checkpoint*, this default
|
||||
must move with it. A pinned template is only correct for the checkpoint it
|
||||
came from — that is the mistake the outgoing MeroMero seat's hand-patched
|
||||
template was warning about, inverted.
|
||||
|
||||
2. `GEMMA4_MODEL=/tank/aimodels/gemma4-26b-a4b-it-nvfp4a16` in the host `.env`,
|
||||
then `docker compose up -d --force-recreate vllm-gemma4-charrp`.
|
||||
3. Tell brokkr; they fire one CoT-off arm, 96 items, under a minute of wall
|
||||
clock, and report back.
|
||||
4. Revert `GEMMA4_MODEL` to the `nvfp4` path and recreate.
|
||||
|
||||
Only the **CoT-off** arm is worth running. With thinking on the model already
|
||||
scores 100% on every task it completes, and a ceiling cannot move.
|
||||
|
||||
⚠ Displacing this seat is an operator decision, not a routine one. It is live.
|
||||
|
||||
## Rollback to MeroMero-v2
|
||||
|
||||
`stacks/meromero-charrp/` is retained stopped in `created` state, labelled
|
||||
`AI - Dormant`. Both stacks bind `:8016`, so rollback is **stop-then-start**:
|
||||
|
||||
```bash
|
||||
cd /opt/docker/compose/gemma4-charrp && docker compose stop vllm-gemma4-charrp
|
||||
cd /opt/docker/compose/meromero-charrp && docker compose up -d vllm-meromero-rp
|
||||
```
|
||||
|
||||
## Gemma-4 flags that are load-bearing
|
||||
|
||||
Carried over from the MeroMero seat because they are architecture-level, not
|
||||
checkpoint-level:
|
||||
|
||||
- `--tool-call-parser gemma4` + `--enable-auto-tool-choice` — Gemma-4 emits its
|
||||
own native tool syntax, not the qwen3_coder XML the other seats use. Without
|
||||
these, any tools-bearing request 400s outright.
|
||||
- `--reasoning-parser gemma4` — absorbs the `<|channel>` thought markers; without
|
||||
it they leak into `content` verbatim on the post-tool turn (vllm #45834).
|
||||
- `--default-chat-template-kwargs '{"enable_thinking": false}'` — **mandatory
|
||||
companion to the reasoning parser.** The parser defaults `enable_thinking` to
|
||||
TRUE, which pre-initialises the engine to REASONING, so all plain prose lands
|
||||
in `reasoning_content` with a null `content` and every char-rp consumer breaks.
|
||||
Production runs thinking off; the thinking route reaches it per-request.
|
||||
|
||||
## Known defect, not ours
|
||||
|
||||
With thinking on, the model does not reliably terminate on constraint-following.
|
||||
brokkr measured **32 of 96 calls truncating at a 12,000-token cap, with all 16
|
||||
constraint items among them**, and inspected the traces: the reasoning is sound
|
||||
right up to the point it fails to stop. A non-termination defect, not a
|
||||
wrong-answer one. `VLLM_USE_V2_MODEL_RUNNER=0` enables
|
||||
`thinking_token_budget` and is the first lever to try if a usable thinking seat
|
||||
is ever wanted — deliberately **not** applied here, since it costs the faster
|
||||
model runner for a mode production does not serve.
|
||||
|
||||
## Superseded claims
|
||||
|
||||
Following the repo convention for quant work: when a recorded claim turns out
|
||||
wrong, it gets a dated row rather than a silent edit, so older notes elsewhere
|
||||
stop misleading people.
|
||||
|
||||
| date | claim, as recorded | status |
|
||||
|---|---|---|
|
||||
| 2026-08-24 | "Gemma-4-26B-A4B-it scores 12% on contradiction detection with CoT off, against gen's 81% — the model owns the deficit." | **RETRACTED same day.** The benchmark item was ill-posed: it presented two mutually contradicting statements and asked for "the contradicting statement", but **contradiction is symmetric** — neither was more the contradicting one, and the model consistently named the absolute claim, a defensible reading the labelling scored wrong every time. |
|
||||
| 2026-08-24 | "Domain tuning costs 43 points of contradiction detection." | **RETRACTED.** Rested entirely on the same item. On the corrected instrument the effect does not shrink, it **reverses**. |
|
||||
| 2026-08-24 | All pre-fix T2 (contradiction) numbers for Gemma-4, MeroMero-v2, `sec` and `gen`. | **VOID.** |
|
||||
|
||||
**The tell, worth internalising: the score was BELOW CHANCE.** 12% on a
|
||||
five-option task is under the 20% floor. A below-chance score indicts the
|
||||
instrument before it indicts the model, and that should be the first reaction
|
||||
rather than a late one. Neither side caught it until the individual items were
|
||||
read.
|
||||
|
||||
A second defect surfaced while fixing the first: all generators shared one RNG,
|
||||
so rewriting one task reshuffled every task after it. Each task now seeds from
|
||||
its own name.
|
||||
|
||||
**What survived, and it is not nothing:** the A16 control result holds —
|
||||
activation precision is close to free on this workload, with every other task
|
||||
identical across the W4A4 and W4A16 builds. The two staging confounds caught
|
||||
before the run (the misnamed A16 repos, the stale chat template) were real and
|
||||
independent of the item defect. The corrected picture, CoT off, n=96:
|
||||
|
||||
| arm | core | T2 | T6 | median latency |
|
||||
|---|---|---|---|---|
|
||||
| `char-rp` (Gemma-4) | 92.5% | **94%** | 69% | 0.24s |
|
||||
| `sec` | 92.5% | 81% | 81% | 1.11s |
|
||||
| `gen` | 86.2% | 50% | 81% | 0.33s |
|
||||
|
||||
Gemma leads on the very axis it was suspected of failing. Its actual weak axis
|
||||
with thinking off is **T6, spatial composition at 69%** — and an earlier run
|
||||
showed CoT-on takes T6 to 100%, which nothing else benched has managed.
|
||||
@@ -0,0 +1,137 @@
|
||||
# gemma4-charrp — google/gemma-4-26B-A4B-it NVFP4 char-rp seat on ana-ml2 GPU0.
|
||||
#
|
||||
# Straight-across replacement for `meromero-charrp` (operator, 2026-08-24): same
|
||||
# port, same served-model-names, so every gateway route and consumer is
|
||||
# unchanged. The outgoing G4-MeroMero-v2-31B-NVFP4A16 stack is retained stopped
|
||||
# for rollback — see stacks/meromero-charrp/.
|
||||
#
|
||||
# WHY THE SWAP: the seat's requirements now include chain-of-thought, which makes
|
||||
# throughput MORE critical rather than less — the user waits through the whole
|
||||
# reasoning block before the first visible token. The incumbent was the DENSE
|
||||
# 31B at ~40.7 tok/s @32K; this is the 26B-A4B MoE at ~114 tok/s @32K
|
||||
# (brokkr-smithy-dev's measurements). Same Gemma-4 family, so the parser and
|
||||
# chat-template machinery below carries over unchanged.
|
||||
#
|
||||
# ⚠ THIS IS THE QUANT, NOT THE TUNING BASE. The BF16 weights live at
|
||||
# /tank/aimodels/gemma4-26b-a4b-it-bf16 and are for QLoRA — 48.10 GiB of BF16
|
||||
# weights CANNOT be served here: GPU0 is shared with vllm-gen, which holds
|
||||
# ~45.6 GiB of the 94.97 GiB card, leaving ~49 GiB. Weights alone would eat all
|
||||
# of it and the engine would die at KV-cache allocation. Do not "simplify" this
|
||||
# to the BF16 path.
|
||||
#
|
||||
# ⚠ SCHEME IS W4A4, NOT W4A16. The RedHatAI quant declares 4-bit
|
||||
# input_activations (compressed-tensors, format nvfp4-pack-quantized). The
|
||||
# incumbent was NVFP4**A16**. Faster, and NOT a like-for-like quality
|
||||
# comparison on the activation axis — say so when benching the two.
|
||||
#
|
||||
# Tunables in .env.
|
||||
|
||||
name: gemma4-charrp
|
||||
|
||||
services:
|
||||
vllm-gemma4-charrp:
|
||||
image: ${GEMMA4_IMAGE:-vllm/vllm-openai:latest}
|
||||
container_name: ${GEMMA4_CONTAINER:-vllm-gemma4-charrp}
|
||||
restart: unless-stopped
|
||||
ipc: host
|
||||
ports:
|
||||
- "${GEMMA4_PORT:-8016}:8000"
|
||||
volumes:
|
||||
- /tank/aimodels:/tank/aimodels
|
||||
environment:
|
||||
- PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
|
||||
- VLLM_API_KEY=${API_KEY:-}
|
||||
command:
|
||||
- ${GEMMA4_MODEL:-/tank/aimodels/gemma4-26b-a4b-it-nvfp4}
|
||||
- --quantization
|
||||
- compressed-tensors
|
||||
# UNCHANGED FROM THE OUTGOING SEAT, ON PURPOSE. Both names are live
|
||||
# LiteLLM routes at http://10.250.50.54:8016/v1 — `char-rp` ->
|
||||
# hosted_vllm/char-rp and `char-rp-reasoning` -> hosted_vllm/char-rp-thinking.
|
||||
# They are two aliases onto ONE seat, not two seats. Renaming either
|
||||
# breaks the gateway silently.
|
||||
- --served-model-name
|
||||
- char-rp
|
||||
- char-rp-thinking
|
||||
# Tool-calling: Gemma-4 emits its OWN native syntax
|
||||
# (<|tool_call>call:name{...}<tool_call|>), NOT the qwen3_coder XML the
|
||||
# other seats use. Without these two flags any tools-bearing request 400s
|
||||
# outright. Architecture-level, so it carries over from MeroMero unchanged.
|
||||
- --tool-call-parser
|
||||
- gemma4
|
||||
- --enable-auto-tool-choice
|
||||
# The gemma4 REASONING parser absorbs the <|channel>...<channel|> thought
|
||||
# markers; without it they leak into `content` verbatim on the post-tool
|
||||
# turn (upstream vllm #45834).
|
||||
- --reasoning-parser
|
||||
- gemma4
|
||||
# MANDATORY COMPANION TO THE REASONING PARSER. The parser reads
|
||||
# enable_thinking from chat_template_kwargs and DEFAULTS IT TO TRUE. True
|
||||
# makes is_reasoning_end() return False at a new turn, which pre-initialises
|
||||
# the engine to REASONING — so ALL plain RP prose lands in
|
||||
# reasoning_content with a NULL content, breaking every char-rp consumer.
|
||||
# Do not remove. Thinking is still reachable per-request via
|
||||
# chat_template_kwargs {"enable_thinking": true}, which is what the
|
||||
# `char-rp-reasoning` alias exercises.
|
||||
- --default-chat-template-kwargs
|
||||
- '{"enable_thinking": false}'
|
||||
# TEMPLATE PINNED EXPLICITLY, AND IT IS A NO-OP FOR THE DEFAULT MODEL.
|
||||
# The A4 build ships this exact file — verified byte-identical to upstream
|
||||
# google/gemma-4-26B-A4B-it once trailing newlines are normalised (390 vs
|
||||
# 389 lines, same hash) — so pinning it changes nothing about what is
|
||||
# served and permanently removes a real class of bug.
|
||||
#
|
||||
# ⚠ WHY IT IS PINNED AT ALL: the A16 control build at
|
||||
# /tank/aimodels/gemma4-26b-a4b-it-nvfp4a16 ships a STALE 266-line
|
||||
# template. Upstream and A4 open the thinking path with
|
||||
# `{%- set enable_thinking = enable_thinking | default(false) -%}`; the
|
||||
# A16 one has no such set, and its tokenizer_config response_schema lacks
|
||||
# the `thinking` property. Serving it with its own template would render a
|
||||
# DIFFERENT PROMPT, turning a one-axis activation-precision control into a
|
||||
# two-axis comparison — a result that would look like a finding.
|
||||
# Safe to force across both builds because the tokenizers are identical:
|
||||
# vocab 262,144 entries, added_tokens identical.
|
||||
#
|
||||
# ⚠ IF GEMMA4_MODEL EVER POINTS AT A DIFFERENT CHECKPOINT, THIS DEFAULT
|
||||
# MUST MOVE WITH IT. A pinned template is only correct for the checkpoint
|
||||
# it came from. That is the inverse of the mistake the outgoing MeroMero
|
||||
# seat warned about, where a hand-patched template was assumed to transfer.
|
||||
- --chat-template
|
||||
- ${GEMMA4_CHAT_TEMPLATE:-/tank/aimodels/gemma4-26b-a4b-it-nvfp4/chat_template.jinja}
|
||||
- --max-model-len
|
||||
- "${GEMMA4_MAX_MODEL_LEN:-262144}"
|
||||
- --max-num-seqs
|
||||
- "${GEMMA4_MAX_NUM_SEQS:-32}"
|
||||
- --gpu-memory-utilization
|
||||
- "${GEMMA4_GPU_MEM_UTIL:-0.47}"
|
||||
- --kv-cache-dtype
|
||||
- fp8
|
||||
- --trust-remote-code
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
device_ids:
|
||||
- "${GEMMA4_GPU_ID:-0}"
|
||||
capabilities:
|
||||
- gpu
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 600s
|
||||
networks:
|
||||
- tnet
|
||||
labels:
|
||||
- homepage.group=AI - Inference
|
||||
- homepage.name=char-rp (Gemma-4 26B-A4B NVFP4, MoE)
|
||||
- homepage.icon=mdi-drama-masks
|
||||
- homepage.description=gemma-4-26B-A4B-it NVFP4 MoE prose+CoT seat, 256K (ana-ml2 GPU0)
|
||||
- homepage.href=http://10.250.50.54:${GEMMA4_PORT:-8016}/docs
|
||||
|
||||
networks:
|
||||
tnet:
|
||||
name: traefik-net
|
||||
external: true
|
||||
@@ -0,0 +1,105 @@
|
||||
# gemma4-trainee-bench — BF16 abliterated trainee base, served for benchmarking.
|
||||
#
|
||||
# EPHEMERAL BY DESIGN. This is not a production seat. It exists so the trainee
|
||||
# base can be measured on the same battery as the served char-rp seat, and it
|
||||
# takes GPU0 to itself while it runs.
|
||||
#
|
||||
# ⚠ IT CANNOT COEXIST WITH `vllm-gen`. The weights are BF16 — 48.07 GiB of a
|
||||
# 94.97 GiB card — and gen actually holds ~45.6 GiB. 48.07 + 45.6 = 93.7 GiB
|
||||
# before a single byte of KV cache, activations, CUDA graphs or non-torch
|
||||
# overhead. Running this means gen is stopped, and gen coming back means this
|
||||
# is stopped. There is no arrangement where both are up.
|
||||
#
|
||||
# ⚠ NO `--quantization` FLAG, AND THAT IS THE POINT.
|
||||
# The production gemma4-charrp compose hardcodes `--quantization
|
||||
# compressed-tensors` because it serves an NVFP4 build. Pointing that stack at
|
||||
# these BF16 weights crash-loops immediately:
|
||||
# TypeError: CompressedTensorsConfig.__init__() missing 3 required
|
||||
# positional arguments: 'target_scheme_map', 'ignore', 'quant_format'
|
||||
# vLLM tries to read a quantization config out of a checkpoint that has none.
|
||||
# 35 restarts before it was caught. Hence a separate stack rather than another
|
||||
# variable on the production one.
|
||||
#
|
||||
# SAME PORT AND SAME SERVED-MODEL-NAMES AS char-rp, deliberately: brokkr's
|
||||
# battery targets the `char-rp` / `char-rp-reasoning` gateway aliases, so
|
||||
# serving under those names means their harness needs no edit and the numbers
|
||||
# are directly comparable to the runs already recorded. Only one of this stack
|
||||
# and gemma4-charrp can be up at a time — both bind :8016.
|
||||
#
|
||||
# Tunables in .env.
|
||||
|
||||
name: gemma4-trainee-bench
|
||||
|
||||
services:
|
||||
vllm-gemma4-trainee-bench:
|
||||
image: ${BENCH_IMAGE:-vllm/vllm-openai:v0.26.0}
|
||||
container_name: ${BENCH_CONTAINER:-vllm-gemma4-trainee-bench}
|
||||
# `no`, not unless-stopped. A bench seat that resurrects itself after the
|
||||
# window closes would silently hold 48 GiB and block gen's restore.
|
||||
restart: "no"
|
||||
ipc: host
|
||||
ports:
|
||||
- "${BENCH_PORT:-8016}:8000"
|
||||
volumes:
|
||||
- /tank/aimodels:/tank/aimodels
|
||||
environment:
|
||||
- PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
|
||||
- VLLM_API_KEY=${API_KEY:-}
|
||||
command:
|
||||
- ${BENCH_MODEL:-/tank/aimodels/gemma4-26b-a4b-it-heretic-bf16}
|
||||
- --served-model-name
|
||||
- char-rp
|
||||
- char-rp-thinking
|
||||
# Architecture-level Gemma-4 flags, identical to the production seat —
|
||||
# see stacks/gemma4-charrp/README.md for why each is load-bearing.
|
||||
- --tool-call-parser
|
||||
- gemma4
|
||||
- --enable-auto-tool-choice
|
||||
- --reasoning-parser
|
||||
- gemma4
|
||||
- --default-chat-template-kwargs
|
||||
- '{"enable_thinking": false}'
|
||||
# ⚠ UPSTREAM TEMPLATE, NOT THE ONE THIS CHECKPOINT SHIPS.
|
||||
# Every third-party Gemma-4 derivative carries a stale template: this
|
||||
# abliterated build ships a 365-line file, the stock upstream is 390
|
||||
# lines. Benching through a different template than production serves
|
||||
# would make the comparison meaningless — the same reason the production
|
||||
# seat pins it.
|
||||
- --chat-template
|
||||
- ${BENCH_CHAT_TEMPLATE:-/tank/aimodels/gemma4-26b-a4b-it-nvfp4/chat_template.jinja}
|
||||
- --max-model-len
|
||||
- "${BENCH_MAX_MODEL_LEN:-262144}"
|
||||
- --max-num-seqs
|
||||
- "${BENCH_MAX_NUM_SEQS:-32}"
|
||||
# Card to itself. 0.92 leaves ~7 GiB of real headroom on top of 48 GiB of
|
||||
# weights — deliberately not 0.95, after a seat crash-looped this
|
||||
# afternoon on 0.6 GiB of margin.
|
||||
- --gpu-memory-utilization
|
||||
- "${BENCH_GPU_MEM_UTIL:-0.92}"
|
||||
- --kv-cache-dtype
|
||||
- fp8
|
||||
- --trust-remote-code
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
device_ids:
|
||||
- "${BENCH_GPU_ID:-0}"
|
||||
capabilities:
|
||||
- gpu
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 900s
|
||||
networks:
|
||||
- tnet
|
||||
# No homepage labels. This is a transient bench seat; giving it a dashboard
|
||||
# card would leave a permanently-offline entry once the window closes.
|
||||
|
||||
networks:
|
||||
tnet:
|
||||
name: traefik-net
|
||||
external: true
|
||||
@@ -27,7 +27,7 @@ services:
|
||||
networks:
|
||||
- tnet
|
||||
labels:
|
||||
- homepage.group=Toolchain
|
||||
- homepage.group=Agents (no UI)
|
||||
- homepage.name=gitea-runner
|
||||
- homepage.icon=mdi-cog-play
|
||||
- homepage.description=Gitea Actions self-hosted runner (${GITEA_RUNNER_NAME})
|
||||
|
||||
+231
-10
@@ -33,11 +33,44 @@ recreated.
|
||||
|
||||
## Layout
|
||||
|
||||
`conf/settings.yaml` owns tabs, group order, and column counts — `services.yaml`
|
||||
owns *what exists*, `settings.yaml` owns *where it sits*. Four tabs: Main, AI,
|
||||
Infrastructure, Toolchain. A group listed in `layout:` with no members simply
|
||||
renders empty, so a group can look "dead" when its provider host is unreachable
|
||||
rather than when the group is wrong.
|
||||
`conf/settings.yaml` owns tabs, group order, column counts and collapse state —
|
||||
`services.yaml` owns *what exists*, `settings.yaml` owns *where it sits*. Four
|
||||
tabs: Main, AI, Infrastructure, Toolchain. A group listed in `layout:` with no
|
||||
members simply renders empty, so a group can look "dead" when its provider host
|
||||
is unreachable rather than when the group is wrong.
|
||||
|
||||
**The organising question is "do I open this?", not "what is it?"** (operator,
|
||||
2026-08-24). Every group is either **tools** — expanded, top of its tab — or
|
||||
**endpoints** (an API, a broker, a background agent, an href that is a `/docs`
|
||||
page or a `/ping` or nothing) — `initiallyCollapsed: true`, bottom of its tab.
|
||||
A collapsed group still renders its eyebrow and rule, so the tab tells you the
|
||||
thing exists without spending a row on it. `AI - Inference` holds the seats the
|
||||
whole fleet runs on and it is collapsed, because you reach them through the
|
||||
gateway, not by clicking them.
|
||||
|
||||
A second, quieter rule shapes the same block: **a group's members should all
|
||||
have widgets or all not have them.** A stat strip makes a card ~50px taller, so
|
||||
one widget card in a row of plain ones opens a void under the plain ones. That
|
||||
is why AdGuard (3 widget cards) and Traefik (2 widget cards) are separate groups
|
||||
from Dockge (5 plain cards) rather than one `Service Networking` band.
|
||||
|
||||
Group *membership* is not in this file — it is the `homepage.group=` label on
|
||||
each container, and labels are read at container **creation**. The 2026-08-24
|
||||
pass moved 28 services and the playbooks that did it are rerunnable:
|
||||
|
||||
```bash
|
||||
scripts/elway infra-ops@10.250.50.70 --playbook playbooks/homepage-regroup-ana-docker.yaml
|
||||
scripts/elway infra-ops@10.0.50.45 --playbook playbooks/homepage-regroup-esh-docker-vm.yaml
|
||||
scripts/elway infra-ops@10.100.50.40 --playbook playbooks/homepage-regroup-nh3-docker.yaml
|
||||
scripts/elway infra-ops@10.100.79.3 --playbook playbooks/homepage-regroup-irv-ml1.yaml
|
||||
scripts/elway infra-ops@10.250.50.54 --playbook playbooks/homepage-regroup-ana-ml2.yaml
|
||||
```
|
||||
|
||||
⚠ **The GPU-backed model seats are deliberately still named `AI - Inference`,
|
||||
`AI - Eval & Retrieval`, `AI - Speech (TTS)` and `AI - Audio Tools`.** Clearer
|
||||
names would cost a recreate on sixteen seats — multi-minute model reloads on
|
||||
endpoints peers reach through the gateway. Order and `initiallyCollapsed` buy
|
||||
the same separation for free. Don't spend that recreate on a label.
|
||||
|
||||
## Foot-guns found in the 2026-08-17 audit
|
||||
|
||||
@@ -82,14 +115,144 @@ Three fixes, all in this stack's config except where noted:
|
||||
they had members, so the last row of each was mostly dead space (Notes: 1
|
||||
card in a 4-wide row). Columns now track member counts; see the rule in
|
||||
`settings.yaml`. Check with `GET /api/services`, which prints live per-group
|
||||
counts.
|
||||
counts. ⚠ **Superseded 2026-08-24** — see below.
|
||||
|
||||
## 2026-08-24 layout + theme pass
|
||||
|
||||
- **Columns are 4 everywhere now; "columns = member count" is retired.**
|
||||
`columns:` sets `lg:grid-cols-N` for one group, so tuning it per group fixes
|
||||
that group's *card width* — and the width then changed at every group
|
||||
boundary. Measured on the live board: Notes rendered a single 1464px card,
|
||||
News and Media 728px, the AI tab alternated 360 / 728 / 286 down the page.
|
||||
The old rule was avoiding dead cells in a short last row and bought a worse
|
||||
defect. A short last row is what a grid looks like; a card wider than its
|
||||
neighbours is what a mistake looks like. Rule and reasoning are at the top of
|
||||
`settings.yaml`'s `layout:` block.
|
||||
- **Scriberr's `AI Systems` group was on all four tabs** — the same untabbed-
|
||||
group behaviour as UltraSeedbox in 2026-08-18, arriving this time from a
|
||||
container label rather than from this file. Relabelled to `AI - Audio Tools`
|
||||
in `stacks/scriberr/compose.yaml` (its real home, alongside Parakeet and
|
||||
Speaches) and the container recreated. The `homepage.group=AI Systems` sample
|
||||
in the repo-root `CLAUDE.md` was the source and now carries the constraint.
|
||||
- **Long service names printed under their own status pill.** `.service-name`
|
||||
reserved a 78px right gutter with `padding-right` and relied on
|
||||
`overflow: hidden` to hold it — but overflow clips at the *padding* box, so
|
||||
the gutter was spill room, not a guard. Every long name on the AI tab
|
||||
collided. It holds by wrapping now; see the comment on the rule.
|
||||
- **Descriptions are clamped to three lines** (floor of two, unchanged). Four-
|
||||
and five-line descriptions were dragging their whole row 30-50px taller than
|
||||
the cards beside them.
|
||||
- **Icons were grey smudges.** Homepage masks every mdi glyph over
|
||||
`--color-logo-start/stop`, stock slate-400 → slate-700, and the dark stop
|
||||
sank the bottom of each glyph into the card fill. Overridden to ice → aurora
|
||||
blue. ⚠ The override must sit on `html[class]`, not `:root` — Homepage sets
|
||||
the same variables on `.theme-slate`, which is on `<html>`, and a class beats
|
||||
`:root` on the same element.
|
||||
- **Bookmark groups and Jellyfin's trailing stream rows** were the last two
|
||||
un-themed components; both now use the card/eyebrow vocabulary.
|
||||
|
||||
Verified with Playwright against the live board — per-group card width, card
|
||||
height spread, and a geometric title-vs-status collision check. Script pattern
|
||||
is in the "iteration loop" note below.
|
||||
|
||||
## The tab bar goes missing after a recreate, then comes back on its own
|
||||
|
||||
**Status: self-healing, cause not pinned. Do not chase it.** After a recreate
|
||||
the client render comes up with no tab bar, no wallpaper and no i18n (the
|
||||
search box shows the raw key `search.search`), and groups fall back to
|
||||
side-by-side columns. It restores itself with no intervention.
|
||||
**Status: intermittent, self-healing, and now HALF-DIAGNOSED. Still don't chase
|
||||
it — but you can now tell in one command whether you are in it.**
|
||||
|
||||
After a recreate the client render can come up with no tab bar, no wallpaper
|
||||
and no i18n (the search box shows the raw key `search.search`), with groups
|
||||
falling back to side-by-side columns. It restores itself with no intervention.
|
||||
|
||||
### The one-command test
|
||||
|
||||
```bash
|
||||
curl -s http://10.0.50.45:5100/ | grep -o 'initialSettings":[^,]\{0,20\}'
|
||||
```
|
||||
|
||||
`initialSettings":{"…` — healthy. `initialSettings":{}` — you are in it.
|
||||
|
||||
### What `initialSettings":{}` actually means (found 2026-08-24)
|
||||
|
||||
It is **not** a warm-up, a cache, or a partial render. It is the **catch branch**
|
||||
of the page's data loader. Decompiled from `/app/.next/server/pages/index.js`:
|
||||
|
||||
```js
|
||||
async function K() {
|
||||
let a;
|
||||
try {
|
||||
a = logger("index"); // <-- assigned INSIDE the try
|
||||
let { providers, ...d } = getSettings();
|
||||
... await servicesResponse(), bookmarksResponse(), widgetsResponse()
|
||||
return { props: { initialSettings: d, ... } };
|
||||
} catch (b) {
|
||||
return a && b && a.error(b), // <-- guarded on `a`
|
||||
{ props: { initialSettings: {}, ... } };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Two consequences worth knowing:
|
||||
|
||||
1. **Something in that try block is throwing.** The page is not "still warming
|
||||
up"; it has already failed and returned a degraded shape.
|
||||
2. **The error can be swallowed completely.** `a` is assigned inside the same
|
||||
try, and the catch only logs `if (a)`. If the *logger itself* is what threw,
|
||||
`a` is `undefined` and nothing is written anywhere — which is exactly what
|
||||
was observed on 2026-08-24: catch branch demonstrably taken, and not one
|
||||
`index`-tagged line in `docker logs` or `conf/homepage/logs/homepage.log`.
|
||||
|
||||
### ONE CAUSE IS NOW KNOWN: a missing `theme:` key
|
||||
|
||||
**Removing `theme:` from `settings.yaml` reproduces this deterministically.**
|
||||
Six force-recreates over seven minutes all served `initialSettings":{}` with the
|
||||
key absent; restoring `theme: dark` rendered correctly on the next recreate in
|
||||
12 seconds (2026-08-24). So the loader really can be thrown by config — just not
|
||||
by the parts you would suspect, and never with a message.
|
||||
|
||||
That does **not** explain every occurrence: the same symptom has appeared with
|
||||
`theme:` present and correct. Treat the missing key as one confirmed trigger,
|
||||
not the whole story.
|
||||
|
||||
### What it is NOT — ruled out by measurement, don't re-run these
|
||||
|
||||
- **Not a downstream data failure.** `/api/services`, `/api/bookmarks`,
|
||||
`/api/widgets` and `/api/hash` all return **200 with fully correct content**
|
||||
while the page serves `initialSettings":{}` — including the brand-new group
|
||||
structure, in the right order. Every input the loader awaits works when called
|
||||
directly.
|
||||
- **Not the 2026-08-24 layout rewrite.** Restoring the *previous, known-good*
|
||||
`settings.yaml` and recreating reproduced the empty payload identically. (This
|
||||
matches the 2026-08-19 finding that the pre-adoption backup config reproduces
|
||||
it too.)
|
||||
- **Not `/api/validate`,** which returns `[]` throughout.
|
||||
- **Not disk, not permissions.** 206 GB free; the container runs as root and a
|
||||
write test into `/app/config/logs` succeeds.
|
||||
- **Probably not the log file.** Rolling the 8.6 MB `homepage.log` aside once
|
||||
coincided with an immediate recovery, which looked like a lead — but the same
|
||||
move did nothing during the `theme:`-key episode. Recorded so nobody chases
|
||||
it twice; the coincidence was almost certainly just the intermittency.
|
||||
|
||||
### Timing, measured rather than assumed
|
||||
|
||||
Wildly variable, which is the whole trap. On 2026-08-24 one recreate came up
|
||||
correct **within 10 seconds**, and three consecutive recreates ~40 minutes later
|
||||
were still empty after 60-120s each. The 2026-08-19 session measured a fresh
|
||||
container still tab-less at 4m30s twice, and healthy again after roughly an hour.
|
||||
`docker ps` reporting `healthy` says nothing about it — the container is serving,
|
||||
the page is just wrong.
|
||||
|
||||
**Practical rule: recreate, run the one-command test, and if it is empty, go do
|
||||
something else and re-check.** Do not start editing config — that is how an hour
|
||||
got spent in 2026-08-19 ruling out four causes that were never the cause (the
|
||||
config, the v2.0.0 release, `PUID`/`PGID` and Docker discovery, and the server
|
||||
side). Every one of those remains ruled out.
|
||||
|
||||
**First thing to check, now that one cause is confirmed:** diff `settings.yaml`
|
||||
against the last version that rendered. A key that Homepage's loader needs and
|
||||
cannot find will do this silently — `theme:` is the one we know about, and
|
||||
there may be others. `git log -p -- stacks/homepage/conf/settings.yaml` is
|
||||
faster than any amount of container archaeology.
|
||||
|
||||
**Timing, measured rather than assumed:** five minutes is NOT enough — a fresh
|
||||
container was still tab-less at 4m30s, twice. It was observed healthy again
|
||||
@@ -169,6 +332,64 @@ theme/build.py inlines the fonts + tokens -> conf/custom.css
|
||||
**Do not hand-edit `conf/custom.css`.** Change `skyfall.css.in`, run
|
||||
`python3 stacks/homepage/theme/build.py`, then deploy.
|
||||
|
||||
**Do not hand-edit the three vendored files either.** `build.py` records their
|
||||
SHA-256 and **fails the build** on a mismatch — a vendored file is either
|
||||
byte-identical to the bundle or it is a fork wearing the bundle's name. Put the
|
||||
override in `skyfall.css.in`, which is expressed entirely through Skyfall's
|
||||
semantic layer (`--surface-*`, `--text-*`, `--border-*`, `--success/--danger/
|
||||
--warning`) and never against a raw family token or a literal colour. That is
|
||||
not fussiness: the theme this one replaced built a parallel palette "derived
|
||||
from the philosophy" and had to be torn out twice.
|
||||
|
||||
### Light and dark (2026-08-24)
|
||||
|
||||
Both themes are first-class. Skyfall Day is the bundle's own light ramp —
|
||||
surfaces at `--sea-94/96/98`, text at `--sea-15`, and every chromatic family
|
||||
dropping to its `-deep` (L 0.48) step. Nothing about it was derived here.
|
||||
|
||||
Precedence, highest first:
|
||||
|
||||
1. **an explicit choice** — the toggle in the header strip, stored in
|
||||
`localStorage` under `skyfall-theme`;
|
||||
2. **the OS preference** — `@media (prefers-color-scheme: light)`, applied only
|
||||
while no explicit choice exists;
|
||||
3. **dark** — Skyfall's default.
|
||||
|
||||
Two pieces of plumbing make that work, and both are load-bearing:
|
||||
|
||||
- **`build.py` re-emits the vendored `[data-theme="light"]` blocks** in both
|
||||
forms — `[data-theme="light"], html.light` for an explicit choice, and a
|
||||
copy inside the media query scoped to
|
||||
`html:not([data-theme="dark"]):not([data-theme="light"])`. That `:not()` pair
|
||||
is what lets a stored *dark* choice survive a light-mode OS.
|
||||
- **`conf/custom.js` renders the toggle**, because Homepage will not give us
|
||||
its own.
|
||||
|
||||
⚠ **Homepage's built-in theme toggle is unreachable, and reaching for it breaks
|
||||
the dashboard.** The toggle renders only when `settings.yaml` leaves `theme:`
|
||||
unpinned — but with the key absent, the page's data loader throws and serves
|
||||
`initialSettings: {}` (no tab bar, no layout, no i18n). Measured 2026-08-24:
|
||||
six force-recreates over seven minutes all came up empty with the key removed;
|
||||
restoring `theme: dark` rendered correctly on the next recreate in 12 seconds.
|
||||
`/api/services` stays 200 and correct the whole time, which is exactly why this
|
||||
looks like a caching problem and is not one. **Leave `theme: dark` pinned.**
|
||||
|
||||
### Type: one canonical face, two documented substitutions
|
||||
|
||||
Skyfall names Bespoke Sans (display) / Supreme (body, UI) / Victor Mono Nerd
|
||||
Font (data, code). Only **Supreme** was ever vendored into this repo, and
|
||||
Skyfall's own notes call Victor Mono "user-supplied", so the other two are
|
||||
stand-ins rather than deviations:
|
||||
|
||||
| role | Skyfall | here |
|
||||
|---|---|---|
|
||||
| display | Bespoke Sans | **Space Grotesk** (variable, latin subset) |
|
||||
| body / UI | Supreme | **Supreme 400/500/700** — canonical |
|
||||
| data / mono | Victor Mono Nerd Font | **JetBrains Mono** (variable) |
|
||||
|
||||
Swapping in the real faces is a two-line change: `FONTS_*` in `build.py` and
|
||||
the `--font-display` / `--font-mono` overrides at the top of `skyfall.css.in`.
|
||||
|
||||
### Iterating on the theme — do NOT recreate the container
|
||||
|
||||
`custom.css` is fetched per request from `/api/config/custom.css`, so a CSS
|
||||
|
||||
+874
-257
File diff suppressed because one or more lines are too long
@@ -1,2 +1,124 @@
|
||||
// Custom JS for homepage. Empty placeholder — keep file present so
|
||||
// homepage doesn't 404 on the asset.
|
||||
// Homepage — Australis Skyfall theme switch.
|
||||
//
|
||||
// WHY THIS EXISTS INSTEAD OF HOMEPAGE'S OWN TOGGLE
|
||||
// Homepage renders a built-in light/dark switch only when settings.yaml does
|
||||
// NOT pin `theme:`. We cannot unpin it: removing the key makes the page's data
|
||||
// loader throw, and its catch branch serves `initialSettings: {}` — a dashboard
|
||||
// with no tab bar, no layout and no i18n. Measured on 2026-08-24: with
|
||||
// `theme: dark` present the payload is correct within ~12s of a recreate;
|
||||
// with the key removed, six recreates over seven minutes all came up empty,
|
||||
// and putting the key back fixed it on the next try. So the key stays, and the
|
||||
// switch is ours.
|
||||
//
|
||||
// HOW IT WORKS
|
||||
// Skyfall keys its light theme off `[data-theme="light"]` on <html>. Homepage
|
||||
// keeps its own `dark` class there regardless — that is fine and was verified:
|
||||
// with `class="dark scheme-dark theme-slate"` AND `data-theme="light"`, every
|
||||
// themed surface resolves to Skyfall Day, because our rules carry !important
|
||||
// on the surfaces Tailwind's `dark:` variants would otherwise claim.
|
||||
//
|
||||
// Precedence, highest first:
|
||||
// 1. an explicit choice stored here in localStorage
|
||||
// 2. the OS preference, via @media (prefers-color-scheme) in custom.css
|
||||
// 3. dark — Skyfall's first-class default
|
||||
//
|
||||
// The stylesheet handles 2 and 3 on its own, so this file is only responsible
|
||||
// for 1. It writes `data-theme` ONLY when there is a stored choice; leaving the
|
||||
// attribute absent is what lets the media query take over.
|
||||
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const KEY = "skyfall-theme"; // "light" | "dark" | absent = follow the OS
|
||||
const root = document.documentElement;
|
||||
|
||||
const stored = () => {
|
||||
try {
|
||||
const v = localStorage.getItem(KEY);
|
||||
return v === "light" || v === "dark" ? v : null;
|
||||
} catch {
|
||||
return null; // private mode / storage disabled — fall through to the OS
|
||||
}
|
||||
};
|
||||
|
||||
const systemPrefersLight = () =>
|
||||
window.matchMedia?.("(prefers-color-scheme: light)").matches ?? false;
|
||||
|
||||
const apply = (mode) => {
|
||||
if (mode) root.setAttribute("data-theme", mode);
|
||||
else root.removeAttribute("data-theme");
|
||||
};
|
||||
|
||||
// Run before first paint where possible, so a stored light choice does not
|
||||
// flash dark on load.
|
||||
apply(stored());
|
||||
|
||||
const effective = () => stored() ?? (systemPrefersLight() ? "light" : "dark");
|
||||
|
||||
const ICON = {
|
||||
// Lucide sun / moon, 1.75 stroke per Skyfall's iconography note.
|
||||
light:
|
||||
'<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>',
|
||||
dark: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
|
||||
};
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.id = "skyfall-theme-toggle";
|
||||
button.setAttribute("aria-label", "Toggle light and dark theme");
|
||||
|
||||
const paint = () => {
|
||||
const mode = effective();
|
||||
// Show the icon for the mode you would switch TO, which is the convention
|
||||
// every OS theme switch uses.
|
||||
const next = mode === "light" ? "dark" : "light";
|
||||
button.title = `Switch to ${next} theme`;
|
||||
button.setAttribute("aria-pressed", String(mode === "light"));
|
||||
button.innerHTML =
|
||||
'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" ' +
|
||||
'stroke="currentColor" stroke-width="1.75" stroke-linecap="round" ' +
|
||||
`stroke-linejoin="round">${ICON[next]}</svg>`;
|
||||
};
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
const next = effective() === "light" ? "dark" : "light";
|
||||
try {
|
||||
localStorage.setItem(KEY, next);
|
||||
} catch {
|
||||
/* storage unavailable — the choice just will not survive a reload */
|
||||
}
|
||||
apply(next);
|
||||
paint();
|
||||
});
|
||||
|
||||
// Track the OS while no explicit choice is stored, so the icon stays honest.
|
||||
window
|
||||
.matchMedia?.("(prefers-color-scheme: light)")
|
||||
.addEventListener?.("change", () => {
|
||||
if (!stored()) paint();
|
||||
});
|
||||
|
||||
// Homepage renders client-side and rebuilds its header, so a one-shot
|
||||
// querySelector at load usually finds nothing and would silently no-op.
|
||||
// Watch until the header strip exists, then stop watching.
|
||||
const mount = () => {
|
||||
if (document.getElementById("skyfall-theme-toggle")?.isConnected) return true;
|
||||
const host = document.querySelector(
|
||||
"div.flex.flex-row.self-center.flex-wrap.justify-between",
|
||||
);
|
||||
if (!host) return false;
|
||||
paint();
|
||||
host.appendChild(button);
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!mount()) {
|
||||
const observer = new MutationObserver(() => {
|
||||
if (mount()) observer.disconnect();
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
// Backstop: never leave an observer running forever on a page that will
|
||||
// not produce the host element.
|
||||
setTimeout(() => observer.disconnect(), 30000);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -27,24 +27,37 @@
|
||||
icon: mdi-filmstrip
|
||||
siteMonitor: http://10.100.10.50:8090/healthz
|
||||
description: Media drop + upload-for-pickup + the standing agent link board — nh3-dev, 24h TTL except kept boards
|
||||
- Voice Design Studio:
|
||||
href: http://10.100.79.3:8216/
|
||||
icon: mdi-microphone
|
||||
siteMonitor: http://10.100.79.3:8216/health
|
||||
description: Mint, audition and keeper-mark synthetic fleet voices — irv-ml1, CPU-only
|
||||
- The Henge:
|
||||
href: http://park.phasefinal.com:8420/
|
||||
icon: mdi-clipboard-check
|
||||
siteMonitor: http://park.phasefinal.com:8420/healthz
|
||||
description: Durable needs-attention / idea parking (stonehenge-park) — ana-docker
|
||||
# Was its own one-card `Games` group, which burned a full 4-wide row on a
|
||||
# single panel. It is an app you open; this is where apps you open live.
|
||||
- Pterodactyl:
|
||||
href: http://10.250.50.55/
|
||||
icon: mdi-gamepad-square
|
||||
siteMonitor: http://10.250.50.55
|
||||
description: Game server panel
|
||||
|
||||
# The AI tab is fully Docker-auto-discovered. Each inference service carries
|
||||
# a homepage.group=AI - <role> label on its compose file (AI - Inference,
|
||||
# AI - Eval & Retrieval, AI - Gateways & Chat, AI - Speech (TTS),
|
||||
# AI - Audio Tools, AI - Image & Media). Tab assignment, group order, and
|
||||
# column counts live in settings.yaml. Do not add entries here or they'll
|
||||
# double up. To move a service between AI groups, change the label on its
|
||||
# compose file and recreate the container (labels only apply on recreate).
|
||||
# The AI tab is otherwise fully Docker-auto-discovered. Each service carries a
|
||||
# homepage.group=AI - <role> label on its compose file (AI - Gateways & Chat,
|
||||
# AI - Studios, AI - Inference, AI - Eval & Retrieval, AI - Speech (TTS),
|
||||
# AI - Audio Tools, AI - Dormant). Tab assignment, group order, columns and
|
||||
# collapse state live in settings.yaml. Do not add a labelled container here as
|
||||
# well or it renders twice. To move a service between AI groups, change the
|
||||
# label on its compose file and recreate the container — labels are read at
|
||||
# creation, so `restart` will not do it.
|
||||
- AI - Studios:
|
||||
# Manual entry — Voice Design Studio is a user-level systemd service on
|
||||
# irv-ml1, not a Docker-labeled stack, so it cannot auto-discover. It sits
|
||||
# with the other studios rather than in Apps: it is a workspace you open and
|
||||
# produce something in, which is exactly what that group is for.
|
||||
- Voice Design Studio:
|
||||
href: http://10.100.79.3:8216/
|
||||
icon: mdi-microphone
|
||||
siteMonitor: http://10.100.79.3:8216/health
|
||||
description: Mint, audition and keeper-mark synthetic fleet voices — irv-ml1, CPU-only
|
||||
|
||||
- Media:
|
||||
- Plex:
|
||||
@@ -67,13 +80,6 @@
|
||||
key: '{{HOMEPAGE_VAR_JELLYFIN_KEY}}'
|
||||
enableBlocks: true
|
||||
|
||||
- Games:
|
||||
- Pterodactyl:
|
||||
href: http://10.250.50.55/
|
||||
icon: mdi-gamepad-square
|
||||
siteMonitor: http://10.250.50.55
|
||||
description: Game server panel
|
||||
|
||||
- Infra - ANA:
|
||||
- ANA-Firewall:
|
||||
href: https://10.250.250.1
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
---
|
||||
# https://gethomepage.dev/latest/configs/settings
|
||||
title: ... all my base ...
|
||||
# ⚠ `theme:` MUST STAY PINNED. REMOVING IT BREAKS THE WHOLE DASHBOARD.
|
||||
#
|
||||
# Unpinning this is the documented way to make Homepage render its own
|
||||
# light/dark toggle, and it was tried on 2026-08-24 for exactly that reason.
|
||||
# It does not work here: with the key absent, the page's data loader throws and
|
||||
# its catch branch serves `initialSettings: {}` — no tab bar, no layout, no
|
||||
# i18n, just a flat list of every group at once. Measured, not inferred: six
|
||||
# force-recreates over seven minutes all came up empty with the key removed,
|
||||
# and putting it back rendered correctly on the next recreate in 12 seconds.
|
||||
# `/api/services` stays 200 and correct throughout, which is what makes this so
|
||||
# easy to misdiagnose as a caching or warm-up problem.
|
||||
#
|
||||
# Light/dark still works — it just is not Homepage's switch. The stylesheet is
|
||||
# dual-theme (Australis Skyfall ships both), driven by `data-theme` on <html>:
|
||||
# conf/custom.js adds a toggle that writes it and remembers the choice, and
|
||||
# theme/build.py also emits an OS-preference copy so an unset choice follows
|
||||
# `prefers-color-scheme`. Dark stays Skyfall's first-class default.
|
||||
#
|
||||
# `color:` is pinned too, for a different reason: unpinning it adds a
|
||||
# colour-ramp picker, and the ramp is not ours to choose — conf/custom.css
|
||||
# carries the real palette. slate is the nearest cool neutral underneath it.
|
||||
theme: dark
|
||||
# `color:` only accepts Homepage's built-in Tailwind ramps, and none of them is
|
||||
# the Australis Sea ramp. slate is the nearest cool neutral and acts as the
|
||||
# base under conf/custom.css, which is where the real palette lives.
|
||||
color: slate
|
||||
|
||||
# NO `background:` BLOCK — DELIBERATE, DO NOT RE-ADD WITHOUT ASKING.
|
||||
@@ -14,15 +32,13 @@ color: slate
|
||||
# for an Arbo-generated background and came out on 2026-08-19 when the
|
||||
# operator called the result ugly.
|
||||
#
|
||||
# The image was also against canon on its own terms: Australis says "solid
|
||||
# fills only on chrome — no full-bleed photography, no decorative gradients",
|
||||
# and permits the aurora motif "never as a background fill behind text". A
|
||||
# whole dashboard is text. The predecessor knew and dialled the opacity down
|
||||
# instead of dropping it, which is how you end up with a quiet version of the
|
||||
# wrong thing. The asset is still in `images/` if this is ever revisited.
|
||||
#
|
||||
# The aurora now appears exactly where canon sanctions it: a 1px accent edge
|
||||
# under the tab bar, in conf/custom.css.
|
||||
# The image was also against canon on its own terms: Skyfall says "flat
|
||||
# semantic surfaces; no photography, no textures", and permits its one
|
||||
# decorative motif — a subtle aurora gradient — on hero and empty-state areas
|
||||
# only, "never behind body text blocks". A whole dashboard is a body text
|
||||
# block. The predecessor knew and dialled the opacity down instead of dropping
|
||||
# it, which is how you end up with a quiet version of the wrong thing. The
|
||||
# asset is still in `images/` if this is ever revisited.
|
||||
headerStyle: boxedWidgets
|
||||
|
||||
providers:
|
||||
@@ -46,81 +62,129 @@ statusStyle: ""
|
||||
# reads as a rendering fault.
|
||||
useEqualHeights: false
|
||||
|
||||
# Function-first layout, four-tab split:
|
||||
# Main - daily-use apps, media, bookmarks, monitoring
|
||||
# AI - the inference fleet, grouped by role (see below)
|
||||
# Infrastructure - hardware, hypervisors, BMCs (per site)
|
||||
# Toolchain - backend services running but rarely clicked
|
||||
# ===========================================================================
|
||||
# THE ORGANISING QUESTION IS "DO I OPEN THIS?" — NOT "WHAT IS IT?"
|
||||
# (operator, 2026-08-24: "most of the issues are that tools I use and have a
|
||||
# UI are interspersed with API endpoints which are largely informational
|
||||
# only. They might even go in their own cards or start collapsed.")
|
||||
#
|
||||
# The AI tab splits the fleet by function so a 20+ service list reads as
|
||||
# sorted groups instead of one endless column. Group membership is set by
|
||||
# the homepage.group=AI - <role> label on each service's compose file:
|
||||
# AI - Inference LLM seats you call (gen, char-rp, char-rp-reasoning, summarizer)
|
||||
# AI - Eval & Retrieval judges, reward, rerank, embed, image-quality
|
||||
# AI - Gateways & Chat routing gateway, control plane, chat frontends
|
||||
# AI - Speech (TTS) text-to-speech engines
|
||||
# AI - Audio Tools speech-to-text + audio dataset tooling
|
||||
# AI - Image & Media image/video generation + pipelines
|
||||
# AI - Dormant stopped stacks (rollback seats, retired auditions)
|
||||
# Every group on this board is one of two kinds, and they never mix:
|
||||
#
|
||||
# AI TAB ORDER IS BY CLICKABILITY, NOT BY IMPORTANCE (operator, 2026-08-18).
|
||||
# Groups render in the order they appear in this block, so the top of the tab
|
||||
# is prime real estate and it should hold the things you actually open in a
|
||||
# browser — chat frontends, ComfyUI, the control plane. Most of the model
|
||||
# seats below them are vLLM API endpoints whose href is a `/docs` page: they
|
||||
# are worth SEEING (status at a glance) but not worth reaching for, so they
|
||||
# sink. Order is therefore:
|
||||
# interactive UIs -> mixed -> API-only seats -> dormant
|
||||
# If you add an AI group, place it by asking "would I click this?", not by
|
||||
# how central the service is to the fleet.
|
||||
# TOOLS — you click the card and do something in the thing it opens.
|
||||
# Expanded, and placed at the TOP of its tab.
|
||||
# ENDPOINTS — an API, a broker, a background agent. Its href is a `/docs`
|
||||
# page, a `/ping`, or nothing at all. The only thing you want from
|
||||
# the card is "is it alive". `initiallyCollapsed: true`, and placed
|
||||
# at the BOTTOM of its tab.
|
||||
#
|
||||
# COLUMN COUNTS ARE NOT A STYLE CHOICE — they are the member count.
|
||||
# `columns: N` lays the group out N-per-row and leaves the remainder of the
|
||||
# last row as dead space. A 1-member group at columns:4 renders one card and
|
||||
# three empty cells, which is what made this dashboard look ragged before
|
||||
# 2026-08-18. Rule: set columns to the member count, or to the divisor that
|
||||
# leaves the smallest remainder. Re-check when a group gains or loses a
|
||||
# service — `GET /api/services` prints the live per-group counts.
|
||||
# A collapsed group is not hidden — the eyebrow and its rule still render, so
|
||||
# the tab still tells you the thing exists, and one click expands it. That is
|
||||
# the whole point: presence without cost.
|
||||
#
|
||||
# EVERY GROUP NEEDS A `tab:` — including bookmark groups. A group with no tab
|
||||
# assignment renders on ALL FOUR TABS. That is how UltraSeedbox ended up
|
||||
# repeated at the bottom of every tab (fixed 2026-08-18); it is Homepage
|
||||
# behaviour, not a bug, and it will happen again to the next group added
|
||||
# without a tab.
|
||||
# When you add a service, ask "would I open this in a browser to get work
|
||||
# done?" If no, it belongs in a collapsed endpoint group, no matter how
|
||||
# central it is to the fleet. `AI - Inference` holds the seats the entire
|
||||
# fleet runs on and it is collapsed, because you consume them through the
|
||||
# gateway rather than by clicking them.
|
||||
#
|
||||
# Four tabs:
|
||||
# Main - what you actually open day to day
|
||||
# AI - AI tools up top, model/API seats collapsed below
|
||||
# Infrastructure - hardware, hypervisors, BMCs (per site) — all consoles
|
||||
# Toolchain - the plumbing, split by kind of plumbing
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# GROUP MEMBERSHIP LIVES ON THE CONTAINER, NOT HERE.
|
||||
#
|
||||
# This block controls tab, order, columns and collapse. WHICH services are in
|
||||
# a group is set by `homepage.group=` on each container's compose file, and
|
||||
# labels only apply at container CREATION — moving a service between groups
|
||||
# means editing the label and running `docker compose up -d <service>`, not
|
||||
# `restart`. The 2026-08-24 pass did 28 of those; the playbooks that did it
|
||||
# are `playbooks/homepage-regroup-<host>.yaml` and they are rerunnable.
|
||||
#
|
||||
# ⚠ THE MODEL SEATS ARE DELIBERATELY STILL NAMED `AI - Inference`,
|
||||
# `AI - Eval & Retrieval`, `AI - Speech (TTS)` AND `AI - Audio Tools`.
|
||||
# Renaming them to something like "AI API - …" would be clearer, and it would
|
||||
# cost a recreate on sixteen GPU-backed seats — multi-minute model reloads on
|
||||
# endpoints peers reach through the gateway. Order and `initiallyCollapsed`
|
||||
# buy the same separation for free. Do not spend that recreate on a label.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# COLUMNS ARE 4 EVERYWHERE. DO NOT TUNE THEM PER GROUP.
|
||||
#
|
||||
# `columns: N` is not a density dial — it sets `lg:grid-cols-N` on that one
|
||||
# group, so it fixes the CARD WIDTH for that group alone. Varying it per group
|
||||
# means the card width changes every time you cross a group boundary, and a
|
||||
# page whose grid keeps resizing as you scroll reads as broken layout even when
|
||||
# every individual group is fine.
|
||||
#
|
||||
# The predecessor rule here was "columns = the member count", written to avoid
|
||||
# the dead cells a 1-member group leaves in a 4-wide row. It trades one flaw
|
||||
# for a worse one: at columns:1 a single service becomes a 1500px-wide bar
|
||||
# holding six words, and at columns:2 a three-member group orphans its third
|
||||
# card onto a half-empty row. Measured on the live board 2026-08-24 — the
|
||||
# Notes, News, Media, AI - Image & Media and AI - Audio Tools groups were all
|
||||
# rendering cards two to four times wider than the groups above and below them.
|
||||
#
|
||||
# A short last row is what a grid looks like. A card that is wider than its
|
||||
# neighbours is what a mistake looks like. Uniform wins.
|
||||
#
|
||||
# Homepage's own responsive ramp (`grid-cols-1 md:grid-cols-2 lg:grid-cols-N`)
|
||||
# still collapses this to 2-up and 1-up on narrow viewports, so 4 is a desktop
|
||||
# maximum, not a hard floor.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# GROUPS ARE ALSO KEPT UNIFORM IN CARD HEIGHT, WHICH IS WHY ADGUARD AND
|
||||
# TRAEFIK GOT THEIR OWN GROUPS.
|
||||
#
|
||||
# A service with a widget (AdGuard's query counts, Traefik's router counts,
|
||||
# Uptime Kuma's uptime) renders a stat strip that makes its card ~50px taller
|
||||
# than a plain link card. Put one of those in a row of three plain cards and
|
||||
# you get a void under the plain ones — which is what made the old 13-member
|
||||
# `Service Networking` group look broken. Split so that a group's members all
|
||||
# have widgets or all do not, and every row comes out flush. That is the real
|
||||
# reason `DNS & Filtering` (3 widget cards) and `Reverse Proxies` (2 widget
|
||||
# cards) are separate from `Compose Consoles` (5 plain cards).
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# EVERY GROUP NEEDS A `tab:` — including bookmark groups, and including groups
|
||||
# that arrive from a `homepage.group=` container label rather than from this
|
||||
# file. A group with no tab assignment renders on ALL FOUR TABS. That is how
|
||||
# UltraSeedbox ended up repeated at the bottom of every tab (fixed 2026-08-18)
|
||||
# and how Scriberr's `AI Systems` label did the same from 2026-08-23 (fixed
|
||||
# 2026-08-24). It is Homepage behaviour, not a bug, and it will happen again to
|
||||
# the next container labelled with a group name that does not appear below.
|
||||
# `GET /api/services` prints the live group list — anything in it that is not a
|
||||
# key here is currently leaking onto all four tabs.
|
||||
# ===========================================================================
|
||||
layout:
|
||||
Notes:
|
||||
icon: mdi-note-text-outline
|
||||
# ---- Main: what you actually open -------------------------------------
|
||||
# Replaces the old Notes (1 member) and News (2) bands, which each burned a
|
||||
# full 4-wide row on a single card.
|
||||
Daily:
|
||||
icon: mdi-coffee-outline
|
||||
tab: Main
|
||||
style: row
|
||||
columns: 1
|
||||
News:
|
||||
icon: mdi-rss
|
||||
tab: Main
|
||||
style: row
|
||||
columns: 2
|
||||
columns: 4
|
||||
Monitoring:
|
||||
icon: mdi-chart-line
|
||||
tab: Main
|
||||
style: row
|
||||
columns: 4
|
||||
# Absorbed the old one-card Games band (Pterodactyl). Lost SearXNG to Daily,
|
||||
# the two chat frontends to the AI tab, and Mosquitto + the RustDesk relay to
|
||||
# Agents (no UI) — neither of those has a page to open.
|
||||
Apps:
|
||||
icon: mdi-apps
|
||||
tab: Main
|
||||
style: row
|
||||
columns: 4
|
||||
# 2 wide, not 3 — Plex and Jellyfin carry stat-block widgets that get
|
||||
# squeezed to unreadable at 4-across, and 4 members / 3 columns orphaned
|
||||
# Jellyfin onto a row of its own.
|
||||
Media:
|
||||
icon: mdi-play-box-multiple
|
||||
tab: Main
|
||||
style: row
|
||||
columns: 2
|
||||
Games:
|
||||
icon: mdi-gamepad-square
|
||||
tab: Main
|
||||
style: row
|
||||
columns: 1
|
||||
columns: 4
|
||||
# Bookmarks. Listed here for the tab pin above all else — without it this
|
||||
# group appears on every tab. `style: row` also turns the eight entries
|
||||
# from full-width stacked bars into a compact grid.
|
||||
@@ -129,43 +193,53 @@ layout:
|
||||
tab: Main
|
||||
style: row
|
||||
columns: 4
|
||||
# --- AI tab: ordered interactive -> API-only -> dormant (see note above) ---
|
||||
# Things you open: chat frontends, the control plane, the LiteLLM UI.
|
||||
|
||||
# ---- AI: tools, then collapsed endpoints ------------------------------
|
||||
# Chat frontends and the control plane — Lobe Chat and the ESH Open WebUI
|
||||
# joined from Main on 2026-08-24; they are chat frontends and belong with the
|
||||
# other chat frontends.
|
||||
AI - Gateways & Chat:
|
||||
icon: mdi-router-network
|
||||
tab: AI
|
||||
style: row
|
||||
columns: 4
|
||||
# ComfyUI is a full node editor and Arbo has a real UI — both get clicked.
|
||||
AI - Image & Media:
|
||||
icon: mdi-image-multiple
|
||||
# Replaces `AI - Image & Media`. Everything here is a workspace you open and
|
||||
# produce something in: ComfyUI's node editor, Arbo, Waterland, the YT
|
||||
# Voice Clipper audition console, Scriberr's transcription UI.
|
||||
AI - Studios:
|
||||
icon: mdi-palette-outline
|
||||
tab: AI
|
||||
style: row
|
||||
columns: 2
|
||||
# Mixed: YT Voice Clipper has an audition console, Parakeet is an API.
|
||||
AI - Audio Tools:
|
||||
icon: mdi-waveform
|
||||
tab: AI
|
||||
style: row
|
||||
columns: 2
|
||||
# Below here: model seats whose href is a vLLM `/docs` page. Status at a
|
||||
# glance is the whole value; you consume these through the gateway, not by
|
||||
# clicking them.
|
||||
columns: 4
|
||||
# ---- collapsed from here down: seats you call, not pages you open ------
|
||||
# Named `AI - Inference` rather than something clearer on purpose — see the
|
||||
# warning above about what renaming these costs.
|
||||
AI - Inference:
|
||||
icon: mdi-brain
|
||||
tab: AI
|
||||
style: row
|
||||
columns: 4
|
||||
initiallyCollapsed: true
|
||||
AI - Eval & Retrieval:
|
||||
icon: mdi-scale-balance
|
||||
tab: AI
|
||||
style: row
|
||||
columns: 5
|
||||
columns: 4
|
||||
initiallyCollapsed: true
|
||||
AI - Speech (TTS):
|
||||
icon: mdi-account-voice
|
||||
tab: AI
|
||||
style: row
|
||||
columns: 4
|
||||
initiallyCollapsed: true
|
||||
# What is left of the old Audio Tools group after Scriberr and the YT Voice
|
||||
# Clipper moved to Studios: the two ASR API seats.
|
||||
AI - Audio Tools:
|
||||
icon: mdi-waveform
|
||||
tab: AI
|
||||
style: row
|
||||
columns: 4
|
||||
initiallyCollapsed: true
|
||||
# Stopped stacks kept for rollback / superseded seats / retired auditions.
|
||||
# They stay 'created' (not running) via `docker compose up --no-start`, so
|
||||
# they show here as offline cards and revive with `docker compose start`.
|
||||
@@ -174,6 +248,9 @@ layout:
|
||||
tab: AI
|
||||
style: row
|
||||
columns: 4
|
||||
initiallyCollapsed: true
|
||||
|
||||
# ---- Infrastructure: every card is a console --------------------------
|
||||
Infra - ANA:
|
||||
icon: si-proxmox
|
||||
tab: Infrastructure
|
||||
@@ -183,24 +260,49 @@ layout:
|
||||
icon: si-proxmox
|
||||
tab: Infrastructure
|
||||
style: row
|
||||
columns: 3
|
||||
columns: 4
|
||||
Infra - IRV:
|
||||
icon: mdi-brain
|
||||
tab: Infrastructure
|
||||
style: row
|
||||
columns: 2
|
||||
columns: 4
|
||||
Infra - ESH:
|
||||
icon: si-proxmox
|
||||
tab: Infrastructure
|
||||
style: row
|
||||
columns: 4
|
||||
Service Networking:
|
||||
|
||||
# ---- Toolchain: the plumbing, split by kind ---------------------------
|
||||
# The old `Service Networking` group was thirteen members mixing three
|
||||
# AdGuards, five Dockges, two Traefiks and four headless agents — widget
|
||||
# cards next to plain ones next to things with no href at all. Split four
|
||||
# ways on 2026-08-24.
|
||||
DNS & Filtering:
|
||||
icon: mdi-dns
|
||||
tab: Toolchain
|
||||
style: row
|
||||
columns: 4
|
||||
Reverse Proxies:
|
||||
icon: mdi-transit-connection-variant
|
||||
tab: Toolchain
|
||||
style: row
|
||||
columns: 4
|
||||
Compose Consoles:
|
||||
icon: mdi-docker
|
||||
tab: Toolchain
|
||||
style: row
|
||||
columns: 4
|
||||
Toolchain:
|
||||
icon: mdi-toolbox
|
||||
tab: Toolchain
|
||||
style: row
|
||||
columns: 4
|
||||
# No href, or an href that is an API. CrowdSec, Mailrise, the restic
|
||||
# rest-server, the Gitea Actions runner, the MQTT broker, the RustDesk relay.
|
||||
# You never open these; you only ever want to know they are up.
|
||||
Agents (no UI):
|
||||
icon: mdi-cog-transfer-outline
|
||||
tab: Toolchain
|
||||
style: row
|
||||
columns: 4
|
||||
initiallyCollapsed: true
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
/* Homepage — Australis.
|
||||
*
|
||||
* SOURCE OF TRUTH: this file. Do not hand-edit `conf/custom.css`; it is
|
||||
* generated. Run `theme/build.py` after changing anything here, then
|
||||
* `scripts/deploy-stack.sh esh-docker-vm homepage --conf`.
|
||||
*
|
||||
* WHY A BUILD STEP: Homepage serves exactly two files out of its config
|
||||
* directory, `/api/config/custom.css` and `/api/config/custom.js`. There is
|
||||
* no static route for anything else, so a `@font-face` pointing at
|
||||
* `theme/fonts/*.woff2` would 404 — the faces have to arrive inside the
|
||||
* stylesheet as data: URIs. The build inlines them.
|
||||
*
|
||||
* TOKENS ARE CANONICAL, NOT DERIVED. Every hex below is copied verbatim from
|
||||
* `~/.claude/skills/australis-design/colors_and_type.css`. The predecessor
|
||||
* theme built a parallel OKLCH palette "derived from the Australis
|
||||
* philosophy" rather than using the system's own values, which is how a
|
||||
* design system quietly forks. `build.py` re-checks these against the skill
|
||||
* file at build time when it is present and warns on drift.
|
||||
*
|
||||
* DIRECTION: instrument panel. This is engineering chrome, not a page with
|
||||
* wallpaper. Canon: "the screen is the polar sky — empty, with light coming
|
||||
* through it." Three moves carry it:
|
||||
*
|
||||
* 1. Group headings become the Australis eyebrow (mono / uppercase / 11px /
|
||||
* 0.08em, cyan) with a hairline running to the right edge. Canon names
|
||||
* the eyebrow a system signature and says to use it often; here it turns
|
||||
* the groups into register bands instead of headings floating over a
|
||||
* grid.
|
||||
* 2. Status stops shouting. The filled emerald chips read louder than the
|
||||
* service names they annotate; they become a semantic dot plus a mono
|
||||
* micro-label at tertiary contrast.
|
||||
* 3. Cards are bordered and opaque, sized to their own content. Canon puts
|
||||
* border above shadow for grouping in chrome, and equal-height rows turn
|
||||
* short cards into hollow boxes.
|
||||
*
|
||||
* WHAT WAS REMOVED, AND WHY IT IS NOT A REGRESSION: the previous theme
|
||||
* carried a generated full-bleed aurora image behind the whole dashboard,
|
||||
* added on the operator explicitly asking for an Arbo-generated background.
|
||||
* Canon forbids exactly that ("solid fills only on chrome — no full-bleed
|
||||
* photography, no decorative gradients", and the aurora motif "never as a
|
||||
* background fill behind text"). The operator called the result ugly on
|
||||
* 2026-08-19 and asked for a canonical-colors rebuild, so the image is gone.
|
||||
* The aurora survives where canon actually sanctions it: a single 1px accent
|
||||
* edge under the tab bar. If a background is ever wanted again, that is the
|
||||
* conversation to reopen — not a quiet re-add.
|
||||
*
|
||||
* TYPE: Space Grotesk (display) / Inter (UI, body) / JetBrains Mono (all
|
||||
* metadata) — canonical per the system README. Vendored as latin-subset
|
||||
* VARIABLE woff2, one file per family covering the whole weight axis: 102 KB
|
||||
* for three families against 56 KB for the three static Supreme cuts the
|
||||
* predecessor shipped, and no Google Fonts request at page load.
|
||||
*/
|
||||
|
||||
/* @@FONTS@@ */
|
||||
|
||||
:root {
|
||||
/* Ice */
|
||||
--aus-black: #222531;
|
||||
--aus-white: #a9bcc3;
|
||||
--aus-bright-white: #cce7ec;
|
||||
/* Sea */
|
||||
--aus-bright-black: #373b46;
|
||||
--aus-dark-30: #414751;
|
||||
--aus-dark-40: #565f69;
|
||||
--aus-dark-50: #6e7882;
|
||||
--aus-dark-60: #86929d;
|
||||
--aus-bright-70: #9daeb6;
|
||||
--aus-bright-80: #b3cbcf;
|
||||
/* Aurora */
|
||||
--aus-blue: #6388d8;
|
||||
--aus-bright-blue: #a4c4ff;
|
||||
--aus-cyan: #00b1a8;
|
||||
--aus-bright-cyan: #42dcd1;
|
||||
--aus-green: #16b866;
|
||||
--aus-bright-green: #51e08a;
|
||||
/* Dawn */
|
||||
--aus-red: #ff491a;
|
||||
--aus-yellow: #e1c631;
|
||||
--aus-magenta: #9d78ff;
|
||||
|
||||
--bg-0: var(--aus-black);
|
||||
--bg-1: var(--aus-bright-black);
|
||||
--bg-2: var(--aus-dark-30);
|
||||
--border-subtle: var(--aus-dark-30);
|
||||
--border-default: var(--aus-dark-40);
|
||||
|
||||
--fg-0: var(--aus-bright-white);
|
||||
--fg-1: var(--aus-white);
|
||||
--fg-2: var(--aus-bright-70);
|
||||
--fg-3: var(--aus-dark-60);
|
||||
--fg-muted: var(--aus-dark-50);
|
||||
|
||||
--font-display: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
||||
|
||||
--tracking-caps: 0.08em;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 10px;
|
||||
--shadow-1: 0 1px 2px rgba(10, 12, 18, 0.4);
|
||||
--dur-fast: 120ms;
|
||||
--ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
/* ---- Canvas --------------------------------------------------------------
|
||||
Solid fill. Canon forbids full-bleed imagery and decorative gradient fills
|
||||
behind text. The single sanctioned aurora moment is the hairline under the
|
||||
header chrome, further down. */
|
||||
html,
|
||||
body,
|
||||
#page_wrapper,
|
||||
#background,
|
||||
body > div {
|
||||
background-color: var(--bg-0) !important;
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
/* `#background` above is load-bearing, not defensive tidiness. Homepage
|
||||
server-renders the wallpaper as an INLINE style on `<div id="background">`,
|
||||
and Next.js caches that rendered page — so removing the `background:` block
|
||||
from settings.yaml is not enough on its own. A restart does not clear it;
|
||||
only a full recreate does, and recreating this container has its own cost
|
||||
(the tab bar and i18n go missing for up to an hour and then heal on their
|
||||
own). Enforcing it in the stylesheet is deterministic, immediate, and also
|
||||
means the canvas stays correct if the setting is ever re-added by accident.
|
||||
The deliberate absence of the config block is documented in settings.yaml. */
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans) !important;
|
||||
color: var(--fg-1);
|
||||
font-size: 15px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ---- Group heading — the Australis eyebrow --------------------------------
|
||||
Canon calls mono/uppercase/11px/0.08em the system's signature and says to
|
||||
use it often. It replaces the oversized sans heading, and a hairline runs
|
||||
from the label to the right edge so the groups read as register bands
|
||||
rather than as titles floating over a grid. */
|
||||
.services-group > button.group {
|
||||
gap: 0;
|
||||
margin-bottom: 4px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.service-group-icon {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
h2.service-group-name {
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 500 !important;
|
||||
line-height: 1 !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-caps);
|
||||
color: var(--aus-cyan) !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.services-group > button.group::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
margin-left: 12px;
|
||||
background: var(--border-subtle);
|
||||
}
|
||||
|
||||
/* The chevron sits after the rule, muted. */
|
||||
.services-group > button.group svg {
|
||||
color: var(--fg-muted);
|
||||
margin-left: 8px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* ---- Service cards -------------------------------------------------------
|
||||
Border > shadow for grouping in chrome (canon). Opaque fill, 1px border,
|
||||
one soft shadow. Height is content-driven — equal-height stretching turns
|
||||
short cards into hollow boxes. */
|
||||
ul.services-list {
|
||||
align-items: start !important;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
li.service > .service-card {
|
||||
height: auto !important;
|
||||
background: var(--bg-1) !important;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg) !important;
|
||||
box-shadow: var(--shadow-1) !important;
|
||||
margin-bottom: 0 !important;
|
||||
transition: background-color var(--dur-fast) var(--ease-out),
|
||||
border-color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
li.service > .service-card:hover {
|
||||
background: var(--bg-2) !important;
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
|
||||
/* Service name — display face, tight. Description — body face, secondary. */
|
||||
.service-name {
|
||||
font-family: var(--font-display) !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 500 !important;
|
||||
/* Pinned so the status cluster can be centred on this exact line rather
|
||||
than floating against the card's top edge. Changing one without the
|
||||
other breaks the alignment. */
|
||||
line-height: 17px !important;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--fg-0) !important;
|
||||
/* Reserve the status gutter. Matches --status-gutter below; a long name
|
||||
ellipsises into it instead of colliding with the pill. */
|
||||
padding-right: 78px !important;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
p.service-description {
|
||||
font-family: var(--font-sans) !important;
|
||||
font-size: 12.5px !important;
|
||||
font-weight: 400 !important;
|
||||
line-height: 17px !important;
|
||||
color: var(--fg-3) !important;
|
||||
margin-top: 3px;
|
||||
/* Two lines minimum. Cards in a row now bottom-align for the common
|
||||
one-line/two-line mix, which is what made the grid look ragged, without
|
||||
bringing back equal-height stretching — that inflated short cards to
|
||||
match a widget card twice their height, which was far worse. Descriptions
|
||||
longer than two lines still grow; nothing is truncated. */
|
||||
min-height: 34px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.service-icon {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ---- Status — dots, not chips --------------------------------------------
|
||||
The filled emerald pills read louder than the service names they annotate.
|
||||
Replaced with a semantic dot plus a mono micro-label at tertiary contrast:
|
||||
present when you look for it, silent when you are not. */
|
||||
/* The status cluster is CENTRED ON THE SERVICE NAME'S LINE, not parked in the
|
||||
card's top-right corner. Homepage positions it `absolute top-0 right-0`,
|
||||
which floats it ~7px above the title's optical centre and reads as a
|
||||
misalignment on every card. The offsets below reconstruct the title line's
|
||||
geometry: .service-name sits 5px below the card's top edge and carries 8px
|
||||
of its own top padding, so its 17px line box starts at 13px and centres at
|
||||
21.5px. Giving the cluster that top and that height, and centring inside it,
|
||||
puts the pill and the name on one optical line.
|
||||
These three numbers move together — if the name's font-size or line-height
|
||||
changes, recompute all of them. */
|
||||
.service-tags {
|
||||
top: 13px !important;
|
||||
height: 17px;
|
||||
align-items: center !important;
|
||||
gap: 12px !important;
|
||||
margin-top: 0 !important;
|
||||
/* 14px right inset, against the 8px of text padding on the left — the
|
||||
optical match, since the pill has no glyph hard against its edge. */
|
||||
margin-right: 14px !important;
|
||||
}
|
||||
|
||||
/* Every child of the cluster shares one baseline: status pills, latency
|
||||
badges and container-stat buttons alike. */
|
||||
.service-tag {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
height: 17px;
|
||||
line-height: 17px !important;
|
||||
}
|
||||
|
||||
.service-tag,
|
||||
.docker-status,
|
||||
.site-monitor-status,
|
||||
.service-tag > div:not(.sr-only) {
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
padding: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.docker-status > div,
|
||||
.service-tag .text-\[8px\] {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: 9px !important;
|
||||
font-weight: 500 !important;
|
||||
line-height: 1 !important;
|
||||
letter-spacing: var(--tracking-caps);
|
||||
color: var(--fg-3) !important;
|
||||
}
|
||||
|
||||
/* Flex-centred rather than nudged with vertical-align: the dot is an optical
|
||||
round on a cap-height uppercase run, so it needs true centring, not a magic
|
||||
pixel offset that only looks right at one font size. */
|
||||
.docker-status > div::before {
|
||||
content: "\25CF"; /* ● */
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
font-size: 7px;
|
||||
line-height: 1;
|
||||
color: var(--aus-dark-50);
|
||||
}
|
||||
|
||||
.docker-status-healthy > div::before,
|
||||
.docker-status-running > div::before {
|
||||
color: var(--aus-green);
|
||||
}
|
||||
|
||||
.docker-status-unhealthy > div::before,
|
||||
.docker-status-exited > div::before,
|
||||
.docker-status-dead > div::before {
|
||||
color: var(--aus-red);
|
||||
}
|
||||
|
||||
.docker-status-starting > div::before,
|
||||
.docker-status-paused > div::before {
|
||||
color: var(--aus-yellow);
|
||||
}
|
||||
|
||||
/* Ping / latency — mono metadata, tabular, no chip. */
|
||||
.site-monitor-status > div {
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: 9px !important;
|
||||
font-weight: 400 !important;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: lowercase !important;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--fg-muted) !important;
|
||||
}
|
||||
|
||||
/* ---- Widget stat strip ---------------------------------------------------
|
||||
Was nested boxes-in-a-box. Now a footer strip: hairline above, cells
|
||||
divided by hairlines, value in the display face, label as a mono eyebrow.
|
||||
The card stays one object instead of three. */
|
||||
.service-container {
|
||||
margin-top: 8px;
|
||||
padding-top: 2px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
.service-container > div {
|
||||
background: transparent !important;
|
||||
border-radius: 0 !important;
|
||||
margin: 0 !important;
|
||||
padding: 8px 4px 6px !important;
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.service-container > div:first-child {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
/* value — nowrap because a long one (AdGuard's "32.767 ms") otherwise wraps
|
||||
mid-cell and drags the whole card taller than its row-mates. */
|
||||
.service-container > div > div:not(.uppercase) {
|
||||
font-family: var(--font-display) !important;
|
||||
font-size: 17px !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: -0.015em;
|
||||
line-height: 1.2 !important;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--fg-0) !important;
|
||||
}
|
||||
|
||||
.service-container > div {
|
||||
min-width: 0; /* let a long value shrink the cell instead of overflowing */
|
||||
}
|
||||
|
||||
/* label */
|
||||
.service-container > div > div.uppercase {
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: 9px !important;
|
||||
font-weight: 400 !important;
|
||||
letter-spacing: var(--tracking-caps);
|
||||
color: var(--fg-3) !important;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ---- Tab bar -------------------------------------------------------------
|
||||
The filled pill is replaced by a 2px top edge in aurora blue — canon's one
|
||||
sanctioned accent-border pattern (and explicitly never a left border). */
|
||||
ul.sm\:flex {
|
||||
position: relative;
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
border-radius: 0 !important;
|
||||
gap: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* The one moment of colour on the page. Canon sanctions the aurora glow —
|
||||
bright-blue → bright-cyan → green — as an accent EDGE, never as a fill
|
||||
behind text. A 1px rule under the tab bar is that edge, and it is the only
|
||||
place the full aurora appears. */
|
||||
ul.sm\:flex::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--aus-blue) 0%,
|
||||
var(--aus-cyan) 42%,
|
||||
var(--aus-green) 68%,
|
||||
var(--border-subtle) 100%
|
||||
);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
ul.sm\:flex > li {
|
||||
height: 40px !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
ul.sm\:flex > li > button {
|
||||
margin: 0 !important;
|
||||
height: 100%;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
border-top: 2px solid transparent;
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 500 !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-caps);
|
||||
color: var(--fg-3) !important;
|
||||
transition: color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
ul.sm\:flex > li > button:hover {
|
||||
color: var(--fg-1) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
ul.sm\:flex > li > button.dark\:bg-white\/10 {
|
||||
border-top-color: var(--aus-blue) !important;
|
||||
color: var(--fg-0) !important;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(99, 136, 216, 0.12) 0%,
|
||||
rgba(99, 136, 216, 0) 72%
|
||||
) !important;
|
||||
}
|
||||
|
||||
/* ---- Header chrome: resource widget + search -----------------------------
|
||||
Same card treatment as everything else, so the header belongs to the page
|
||||
instead of hovering above it. */
|
||||
.information-widget-resource,
|
||||
div.flex.flex-col.justify-center.mt-2 {
|
||||
background: var(--bg-1) !important;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg) !important;
|
||||
box-shadow: var(--shadow-1) !important;
|
||||
}
|
||||
|
||||
.information-widget-resource {
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* The header strip that holds the resource widgets. */
|
||||
div.flex.flex-row.self-center.flex-wrap.justify-between {
|
||||
background: var(--bg-1) !important;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
.information-widget-resource .text-xs,
|
||||
.information-widget-resource span {
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: 11px !important;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--fg-2) !important;
|
||||
}
|
||||
|
||||
.resource-usage,
|
||||
[class*="resource-usage"] {
|
||||
background: var(--aus-black) !important;
|
||||
height: 3px !important;
|
||||
border-radius: 2px !important;
|
||||
}
|
||||
|
||||
.resource-usage > div,
|
||||
[class*="resource-usage"] > div {
|
||||
background: var(--aus-cyan) !important;
|
||||
border-radius: 2px !important;
|
||||
}
|
||||
|
||||
/* Search input */
|
||||
input[type="text"],
|
||||
input[type="search"] {
|
||||
font-family: var(--font-sans) !important;
|
||||
font-size: 14px !important;
|
||||
color: var(--fg-1) !important;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--fg-muted) !important;
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: 12px !important;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ---- Focus — the aurora glow, canon's signature interaction motif -------- */
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: 0 0 0 3px rgba(99, 136, 216, 0.35) !important;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
+157
-56
@@ -1,46 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate conf/custom.css from the Australis source in this directory.
|
||||
"""Generate conf/custom.css from the Australis Skyfall source in this directory.
|
||||
|
||||
Homepage serves exactly two files out of its config dir — custom.css and
|
||||
custom.js — with no static route for anything alongside them. A @font-face
|
||||
pointing at a vendored .woff2 would therefore 404, so the faces have to be
|
||||
inlined as data: URIs. That is the whole reason this build step exists.
|
||||
pointing at a vendored .woff2 would therefore 404, and an @import of a sibling
|
||||
token file would 404 too. So this build does three things that cannot be done
|
||||
in plain CSS:
|
||||
|
||||
1. inlines the font faces as data: URIs;
|
||||
2. concatenates the vendored token files into one sheet;
|
||||
3. bridges Skyfall's `[data-theme="light"]` selector to the `light` CLASS
|
||||
Homepage actually sets on <html>.
|
||||
|
||||
Usage: python3 stacks/homepage/theme/build.py
|
||||
Then: scripts/deploy-stack.sh esh-docker-vm homepage --conf
|
||||
(a settings.yaml change needs a container recreate; a custom.css
|
||||
change needs only a browser reload — custom.css is served per request)
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
SRC = HERE / "australis.css.in"
|
||||
OUT = HERE.parent / "conf" / "custom.css"
|
||||
|
||||
# The canonical token file. Present on a box with the australis-design skill
|
||||
# installed; absent elsewhere, in which case the drift check is skipped rather
|
||||
# than failing the build.
|
||||
CANON = pathlib.Path.home() / ".claude/skills/australis-design/colors_and_type.css"
|
||||
# ---------------------------------------------------------------------------
|
||||
# VENDORED, VERBATIM, FROM THE SKYFALL BUNDLE. Concatenated in this order:
|
||||
# families before semantics, tokens before bindings.
|
||||
#
|
||||
# The hashes are the anti-fork guard. The theme this one replaces built a
|
||||
# parallel palette "derived from the Australis philosophy" instead of using the
|
||||
# system's own values, and it had to be torn out twice. A vendored file is
|
||||
# either byte-identical to the bundle or it is a fork wearing the bundle's
|
||||
# name; there is no third state, so a mismatch FAILS the build rather than
|
||||
# warning. To legitimately update one, drop in the new file and record its new
|
||||
# hash in the same commit.
|
||||
# ---------------------------------------------------------------------------
|
||||
VENDORED = {
|
||||
"colors.css": "75f78b674dd7fd87ac414cf04165f0e99b016e54d3b546f0e96378c08fdd7b88",
|
||||
"layout.css": "a3826169b7a6b604d9c456c073ba3788297ae88bb703862a95171e3858f94c56",
|
||||
"typography.css": "2ec1a667ced0653292eed0f724b8bc294765d740531ee10e48ebda5f5ca6f2f8",
|
||||
}
|
||||
|
||||
# One VARIABLE face per family — the whole 100-900 axis in a single file, so
|
||||
# there is no per-weight fan-out. Latin subset only.
|
||||
FONTS = [
|
||||
("Inter", "Inter-Variable.woff2"),
|
||||
("Space Grotesk", "SpaceGrotesk-Variable.woff2"),
|
||||
("JetBrains Mono", "JetBrainsMono-Variable.woff2"),
|
||||
# Our own bindings — the only file in this directory that is ours to edit.
|
||||
BINDINGS = "skyfall.css.in"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skyfall keys light mode off `[data-theme="light"]` on <html>. Homepage never
|
||||
# touches data attributes, and — this is the constraint that shapes everything
|
||||
# below — it will not give us its own theme toggle either: rendering that
|
||||
# toggle requires settings.yaml to leave `theme:` unpinned, and an unpinned
|
||||
# `theme:` makes the page's data loader throw (measured 2026-08-24; see
|
||||
# conf/custom.js for the evidence). So `theme: dark` stays pinned, Homepage's
|
||||
# class is always `dark`, and light mode is driven by `data-theme` written by
|
||||
# our own toggle in custom.js, or by the OS preference when nothing is stored.
|
||||
#
|
||||
# Editing the vendored file to add those selectors would fork it; hand-copying
|
||||
# its light block into the bindings would drift the moment the bundle updates.
|
||||
# So re-emit the block here, mechanically, in three forms:
|
||||
#
|
||||
# [data-theme="light"], html.light explicit choice (our toggle; also the
|
||||
# spelling a real Skyfall app would use,
|
||||
# kept so the sheet stays portable)
|
||||
# @media (prefers-color-scheme: light)
|
||||
# html:not([data-theme=...]) the OS preference, and ONLY while no
|
||||
# explicit choice is present — the :not()
|
||||
# pair is what makes a stored "dark"
|
||||
# survive a light-mode OS.
|
||||
# ---------------------------------------------------------------------------
|
||||
LIGHT_BLOCK = re.compile(r'^\[data-theme="light"\]\s*\{\n(.*?)^\}', re.M | re.S)
|
||||
|
||||
|
||||
def expand_light(match: "re.Match[str]") -> str:
|
||||
body = match.group(1).rstrip("\n")
|
||||
return (
|
||||
'[data-theme="light"],\nhtml.light {\n' + body + "\n}\n\n"
|
||||
"@media (prefers-color-scheme: light) {\n"
|
||||
' html:not([data-theme="dark"]):not([data-theme="light"]) {\n'
|
||||
+ body
|
||||
+ "\n }\n}"
|
||||
)
|
||||
|
||||
# One VARIABLE face per stand-in family — the whole 100-900 axis in a single
|
||||
# file — plus Supreme's three static cuts, which is how Supreme ships. Latin
|
||||
# subset only. See the type note in skyfall.css.in for why two of the three
|
||||
# families are substitutions.
|
||||
FONTS_VARIABLE = [
|
||||
("Space Grotesk", "SpaceGrotesk-Variable.woff2"), # stands in for Bespoke Sans
|
||||
("JetBrains Mono", "JetBrainsMono-Variable.woff2"), # stands in for Victor Mono
|
||||
]
|
||||
FONTS_STATIC = [
|
||||
("Supreme", "Supreme-400.woff2", 400),
|
||||
("Supreme", "Supreme-500.woff2", 500),
|
||||
# Supreme ships no 600 cut; 600 and 700 both resolve to this file.
|
||||
("Supreme", "Supreme-700.woff2", "600 700"),
|
||||
]
|
||||
|
||||
# Tokens the generated sheet cannot work without. A vendoring mistake that
|
||||
# drops the :root block produces a stylesheet that parses fine and renders
|
||||
# nothing, so assert on the values rather than trusting the copy.
|
||||
REQUIRED_TOKENS = ["--aus-black", "--aus-cyan", "--bg-1", "--font-mono"]
|
||||
# drops a block produces a stylesheet that parses fine and renders nothing, so
|
||||
# assert on the values rather than trusting the copy. One from each vendored
|
||||
# file, plus proof the light theme survived.
|
||||
REQUIRED = [
|
||||
"--sea-15", # colors.css, family layer
|
||||
"--surface-card", # colors.css, semantic layer
|
||||
"--shadow-sm", # layout.css
|
||||
"--tracking-caps", # typography.css
|
||||
"html.light", # the explicit-choice selector was emitted
|
||||
"prefers-color-scheme: light", # the OS-preference copy was emitted
|
||||
"#skyfall-theme-toggle", # the toggle has styling, not just behaviour
|
||||
]
|
||||
|
||||
|
||||
def sha256(path: pathlib.Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def font_faces() -> str:
|
||||
out = []
|
||||
for family, filename in FONTS:
|
||||
for family, filename in FONTS_VARIABLE:
|
||||
path = HERE / "fonts" / filename
|
||||
if not path.exists():
|
||||
sys.exit(f"missing font: {path}")
|
||||
@@ -54,58 +134,79 @@ def font_faces() -> str:
|
||||
f" font-display: swap;\n"
|
||||
f"}}"
|
||||
)
|
||||
for family, filename, weight in FONTS_STATIC:
|
||||
path = HERE / "fonts" / filename
|
||||
if not path.exists():
|
||||
sys.exit(f"missing font: {path}")
|
||||
b64 = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
out.append(
|
||||
f"@font-face {{\n"
|
||||
f' font-family: "{family}";\n'
|
||||
f" src: url(data:font/woff2;base64,{b64}) format('woff2');\n"
|
||||
f" font-weight: {weight};\n"
|
||||
f" font-style: normal;\n"
|
||||
f" font-display: swap;\n"
|
||||
f"}}"
|
||||
)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def check_canon_drift(css: str) -> None:
|
||||
"""Warn if a token here no longer matches the Australis skill's value.
|
||||
def vendored_css() -> str:
|
||||
"""Concatenate the vendored token files, refusing to build if one moved."""
|
||||
parts, drift = [], []
|
||||
for name, expected in VENDORED.items():
|
||||
path = HERE / name
|
||||
if not path.exists():
|
||||
sys.exit(
|
||||
f"missing vendored token file: {path}\n"
|
||||
f"Recover it with: git show 45c1995:stacks/homepage/theme/{name}"
|
||||
)
|
||||
actual = sha256(path)
|
||||
if actual != expected:
|
||||
drift.append((name, actual, expected))
|
||||
parts.append(f"/* ==== vendored: {name} ({actual[:12]}) ==== */\n{path.read_text()}")
|
||||
|
||||
Non-fatal on purpose: the skill is a per-workstation install, so a missing
|
||||
file is normal and must not break a build on a box that lacks it. A *value*
|
||||
mismatch is worth shouting about — that is the palette forking, which is
|
||||
exactly how the theme this one replaced went wrong.
|
||||
"""
|
||||
if not CANON.exists():
|
||||
print(f"note: {CANON} not present — canonical drift check skipped")
|
||||
return
|
||||
|
||||
canon = dict(re.findall(r"(--aus-[a-z0-9-]+):\s*(#[0-9a-fA-F]{6});", CANON.read_text()))
|
||||
ours = dict(re.findall(r"(--aus-[a-z0-9-]+):\s*(#[0-9a-fA-F]{6});", css))
|
||||
if not canon:
|
||||
print(f"note: parsed no --aus-* tokens from {CANON} — drift check skipped")
|
||||
return
|
||||
|
||||
drift = [(k, v, canon[k]) for k, v in ours.items() if k in canon and v.lower() != canon[k].lower()]
|
||||
missing = sorted(set(ours) - set(canon))
|
||||
for key, mine, theirs in drift:
|
||||
print(f"DRIFT: {key} is {mine} here, {theirs} in the skill", file=sys.stderr)
|
||||
for key in missing:
|
||||
print(f"note: {key} is not a canonical token", file=sys.stderr)
|
||||
if drift:
|
||||
print(
|
||||
f"WARNING: {len(drift)} token(s) diverge from the canonical palette. "
|
||||
"Reconcile before deploying — a derived palette is how the previous "
|
||||
"theme forked from the design system.",
|
||||
file=sys.stderr,
|
||||
for name, actual, expected in drift:
|
||||
print(f"VENDOR DRIFT: {name}", file=sys.stderr)
|
||||
print(f" on disk : {actual}", file=sys.stderr)
|
||||
print(f" expected: {expected}", file=sys.stderr)
|
||||
sys.exit(
|
||||
"A vendored Skyfall token file has been modified. These are copied "
|
||||
"verbatim from the bundle and are not ours to edit — put the "
|
||||
"override in skyfall.css.in instead. If the bundle itself was "
|
||||
"legitimately updated, record the new hash in VENDORED in the same "
|
||||
"commit as the file."
|
||||
)
|
||||
else:
|
||||
print(f"canonical palette check: {len(ours)} tokens match {CANON.name}")
|
||||
|
||||
print(f"vendored token files verified: {', '.join(VENDORED)}")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not SRC.exists():
|
||||
sys.exit(f"missing source: {SRC}")
|
||||
src = SRC.read_text()
|
||||
src_path = HERE / BINDINGS
|
||||
if not src_path.exists():
|
||||
sys.exit(f"missing source: {src_path}")
|
||||
src = src_path.read_text()
|
||||
if "/* @@FONTS@@ */" not in src:
|
||||
sys.exit(f"{SRC.name} lost its @@FONTS@@ placeholder")
|
||||
sys.exit(f"{BINDINGS} lost its @@FONTS@@ placeholder")
|
||||
|
||||
css = src.replace("/* @@FONTS@@ */", font_faces())
|
||||
tokens = vendored_css()
|
||||
|
||||
for token in REQUIRED_TOKENS:
|
||||
bridged, n = LIGHT_BLOCK.subn(expand_light, tokens)
|
||||
if not n:
|
||||
sys.exit(
|
||||
'no [data-theme="light"] rule found in the vendored tokens — the '
|
||||
"light theme would silently never apply"
|
||||
)
|
||||
print(f'expanded {n} [data-theme="light"] block(s): explicit + OS-preference')
|
||||
|
||||
css = bridged + "\n\n" + src.replace("/* @@FONTS@@ */", font_faces())
|
||||
|
||||
for token in REQUIRED:
|
||||
if token not in css:
|
||||
sys.exit(f"generated css has no {token} — the token block did not survive")
|
||||
sys.exit(f"generated css has no {token} — a token block did not survive")
|
||||
|
||||
check_canon_drift(css)
|
||||
OUT.write_text(css)
|
||||
print(f"wrote {OUT} ({len(css) / 1024:.0f} KB)")
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/* ============================================================
|
||||
Australis Skyfall — Color Tokens v2
|
||||
A ground-up web palette derived from the Australis Dark
|
||||
PHILOSOPHY (not its ANSI values). Built in OKLCH so the laws
|
||||
are explicit and every family stays perceptually consistent:
|
||||
|
||||
- All hues are cooler than neutral; nothing warmer than amber.
|
||||
- One lightness law across every chromatic family:
|
||||
deep L 0.48 — light-theme fills & colored text
|
||||
base L 0.66 — dark-theme fills (the working color)
|
||||
bright L 0.80 — dark-theme colored text & highlights
|
||||
- Chroma is tuned per hue so no family shouts over another.
|
||||
- Neutrals (Sea ramp) drift in hue from ice-blue (265°) toward
|
||||
ocean green (195°) as they brighten — the aurora signature.
|
||||
- Medium / medium-high contrast for eye comfort.
|
||||
|
||||
Families: Aurora (blue, cyan, green — use generously, in that
|
||||
order) and Dawn (amber, red, violet — semantic accents only:
|
||||
warning, danger, AI).
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
/* ---- Sea — neutral ramp (hue 265° → 195° as L rises) ---- */
|
||||
--sea-10: oklch(0.23 0.02 265);
|
||||
--sea-15: oklch(0.27 0.024 268); /* dark canvas — the Australis anchor */
|
||||
--sea-20: oklch(0.31 0.022 262);
|
||||
--sea-25: oklch(0.35 0.02 258);
|
||||
--sea-30: oklch(0.4 0.018 252);
|
||||
--sea-40: oklch(0.48 0.016 245);
|
||||
--sea-50: oklch(0.56 0.015 235);
|
||||
--sea-60: oklch(0.64 0.014 228);
|
||||
--sea-70: oklch(0.72 0.014 220);
|
||||
--sea-75: oklch(0.78 0.015 212);
|
||||
--sea-80: oklch(0.83 0.017 205);
|
||||
--sea-90: oklch(0.9 0.02 198);
|
||||
--sea-94: oklch(0.94 0.014 195);
|
||||
--sea-96: oklch(0.96 0.01 195);
|
||||
--sea-98: oklch(0.98 0.006 195);
|
||||
|
||||
/* ---- Ice — main colors (aliases into the ramp) ---- */
|
||||
--ice-black: var(--sea-15);
|
||||
--ice-white: var(--sea-75);
|
||||
--ice-bright-white: var(--sea-90);
|
||||
|
||||
/* ---- Aurora — primary families (blue > cyan > green) ---- */
|
||||
--blue-deep: oklch(0.48 0.12 262);
|
||||
--blue-base: oklch(0.66 0.12 262);
|
||||
--blue-bright: oklch(0.8 0.09 262);
|
||||
--cyan-deep: oklch(0.48 0.08 200);
|
||||
--cyan-base: oklch(0.66 0.1 200);
|
||||
--cyan-bright: oklch(0.8 0.1 200);
|
||||
--green-deep: oklch(0.48 0.11 158);
|
||||
--green-base: oklch(0.66 0.13 158);
|
||||
--green-bright: oklch(0.8 0.13 158);
|
||||
|
||||
/* ---- Dawn — accent families (semantic use only) ---- */
|
||||
--amber-deep: oklch(0.52 0.1 75);
|
||||
--amber-base: oklch(0.7 0.12 78);
|
||||
--amber-bright: oklch(0.82 0.13 82);
|
||||
--red-deep: oklch(0.5 0.15 25);
|
||||
--red-base: oklch(0.62 0.16 25);
|
||||
--red-bright: oklch(0.78 0.11 28);
|
||||
--violet-deep: oklch(0.5 0.15 292);
|
||||
--violet-base: oklch(0.66 0.14 292);
|
||||
--violet-bright: oklch(0.8 0.1 292);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Semantic aliases — DARK (default, first-class)
|
||||
============================================================ */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
/* Surfaces */
|
||||
--surface-sunken: var(--sea-10);
|
||||
--surface-base: var(--sea-15);
|
||||
--surface-raised: var(--sea-20);
|
||||
--surface-overlay: oklch(0.34 0.022 260);
|
||||
--surface-card: var(--sea-20);
|
||||
--surface-input: var(--sea-10);
|
||||
--surface-scrim: oklch(0.17 0.02 265 / 0.72);
|
||||
|
||||
/* Text */
|
||||
--text-heading: var(--sea-90);
|
||||
--text-body: var(--sea-75);
|
||||
--text-muted: var(--sea-60);
|
||||
--text-faint: var(--sea-50);
|
||||
--text-inverse: var(--sea-15);
|
||||
--text-link: var(--blue-bright);
|
||||
--text-link-hover: var(--sea-90);
|
||||
|
||||
/* Borders */
|
||||
--border-subtle: var(--sea-25);
|
||||
--border-default: var(--sea-30);
|
||||
--border-strong: var(--sea-40);
|
||||
--border-focus: var(--blue-base);
|
||||
|
||||
/* Accent (primary = aurora blue) */
|
||||
--accent: var(--blue-base);
|
||||
--accent-hover: oklch(0.71 0.11 262);
|
||||
--accent-active: oklch(0.6 0.13 262);
|
||||
--accent-bright: var(--blue-bright);
|
||||
--accent-text: var(--blue-bright);
|
||||
--accent-contrast: var(--sea-10);
|
||||
--accent-soft: color-mix(in oklab, var(--blue-base) 16%, transparent);
|
||||
--accent-soft-hover: color-mix(in oklab, var(--blue-base) 26%, transparent);
|
||||
|
||||
/* Secondary accent (aurora cyan) */
|
||||
--secondary: var(--cyan-base);
|
||||
--secondary-bright: var(--cyan-bright);
|
||||
--secondary-text: var(--cyan-bright);
|
||||
--secondary-soft: color-mix(in oklab, var(--cyan-base) 14%, transparent);
|
||||
|
||||
/* Semantic status */
|
||||
--success: var(--green-base);
|
||||
--success-text: var(--green-bright);
|
||||
--success-soft: color-mix(in oklab, var(--green-base) 14%, transparent);
|
||||
--warning: var(--amber-base);
|
||||
--warning-text: var(--amber-bright);
|
||||
--warning-soft: color-mix(in oklab, var(--amber-base) 13%, transparent);
|
||||
--danger: var(--red-base);
|
||||
--danger-hover: oklch(0.67 0.15 25);
|
||||
--danger-text: var(--red-bright);
|
||||
--danger-contrast: var(--sea-10);
|
||||
--danger-soft: color-mix(in oklab, var(--red-base) 13%, transparent);
|
||||
--info: var(--blue-base);
|
||||
--info-text: var(--blue-bright);
|
||||
--info-soft: color-mix(in oklab, var(--blue-base) 14%, transparent);
|
||||
--ai: var(--violet-base);
|
||||
--ai-text: var(--violet-bright);
|
||||
--ai-soft: color-mix(in oklab, var(--violet-base) 13%, transparent);
|
||||
|
||||
/* Selection */
|
||||
--selection-bg: var(--blue-base);
|
||||
--selection-fg: var(--sea-10);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Semantic aliases — LIGHT ("Skyfall Day")
|
||||
Same laws, inverted: surfaces at L 0.94–0.98, text at L 0.27–0.45,
|
||||
chromatic fills & colored text drop to the deep (L 0.48) step.
|
||||
============================================================ */
|
||||
[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
|
||||
/* Surfaces */
|
||||
--surface-sunken: var(--sea-94);
|
||||
--surface-base: var(--sea-96);
|
||||
--surface-raised: var(--sea-98);
|
||||
--surface-overlay: #ffffff;
|
||||
--surface-card: var(--sea-98);
|
||||
--surface-input: #ffffff;
|
||||
--surface-scrim: oklch(0.27 0.024 268 / 0.4);
|
||||
|
||||
/* Text */
|
||||
--text-heading: var(--sea-15);
|
||||
--text-body: oklch(0.38 0.02 255);
|
||||
--text-muted: var(--sea-50);
|
||||
--text-faint: var(--sea-60);
|
||||
--text-inverse: var(--sea-90);
|
||||
--text-link: var(--blue-deep);
|
||||
--text-link-hover: var(--sea-15);
|
||||
|
||||
/* Borders */
|
||||
--border-subtle: oklch(0.89 0.014 210);
|
||||
--border-default: oklch(0.84 0.016 215);
|
||||
--border-strong: var(--sea-75);
|
||||
--border-focus: var(--blue-deep);
|
||||
|
||||
/* Accent */
|
||||
--accent: var(--blue-deep);
|
||||
--accent-hover: oklch(0.53 0.12 262);
|
||||
--accent-active: oklch(0.44 0.12 262);
|
||||
--accent-bright: var(--blue-base);
|
||||
--accent-text: var(--blue-deep);
|
||||
--accent-contrast: #ffffff;
|
||||
--accent-soft: color-mix(in oklab, var(--blue-deep) 10%, transparent);
|
||||
--accent-soft-hover: color-mix(in oklab, var(--blue-deep) 18%, transparent);
|
||||
|
||||
/* Secondary accent */
|
||||
--secondary: var(--cyan-deep);
|
||||
--secondary-bright: var(--cyan-base);
|
||||
--secondary-text: var(--cyan-deep);
|
||||
--secondary-soft: color-mix(in oklab, var(--cyan-deep) 9%, transparent);
|
||||
|
||||
/* Semantic status */
|
||||
--success: var(--green-deep);
|
||||
--success-text: var(--green-deep);
|
||||
--success-soft: color-mix(in oklab, var(--green-deep) 10%, transparent);
|
||||
--warning: var(--amber-deep);
|
||||
--warning-text: var(--amber-deep);
|
||||
--warning-soft: color-mix(in oklab, var(--amber-base) 16%, transparent);
|
||||
--danger: var(--red-deep);
|
||||
--danger-hover: oklch(0.55 0.16 25);
|
||||
--danger-text: var(--red-deep);
|
||||
--danger-contrast: #ffffff;
|
||||
--danger-soft: color-mix(in oklab, var(--red-deep) 8%, transparent);
|
||||
--info: var(--blue-deep);
|
||||
--info-text: var(--blue-deep);
|
||||
--info-soft: color-mix(in oklab, var(--blue-deep) 9%, transparent);
|
||||
--ai: var(--violet-deep);
|
||||
--ai-text: var(--violet-deep);
|
||||
--ai-soft: color-mix(in oklab, var(--violet-deep) 9%, transparent);
|
||||
|
||||
/* Selection */
|
||||
--selection-bg: var(--blue-base);
|
||||
--selection-fg: #ffffff;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,72 @@
|
||||
/* Australis Skyfall — spacing, radii, shadows, motion (v3 "calm depth") */
|
||||
|
||||
:root {
|
||||
/* Spacing (4px base) */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
--space-16: 64px;
|
||||
--space-20: 80px;
|
||||
--space-24: 96px;
|
||||
--space-32: 128px;
|
||||
|
||||
/* Semantic spacing — airy content, compact chrome */
|
||||
--pad-chrome: var(--space-2); /* app chrome: rails, toolbars, list rows */
|
||||
--pad-content: var(--space-8); /* work surfaces: page bodies, card interiors breathe */
|
||||
--gap-pane: var(--space-4); /* gutter between panes/cards on the canvas */
|
||||
--measure: 68ch; /* long-form reading width */
|
||||
|
||||
/* Radii — moderate, calm */
|
||||
--radius-xs: 6px;
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 10px; /* buttons, inputs, menu items */
|
||||
--radius-lg: 14px; /* cards, panes, popovers */
|
||||
--radius-xl: 16px; /* dialogs, drawers, command palette */
|
||||
--radius-full: 999px;
|
||||
|
||||
/* Depth — crisp 1px hairline + layered soft shadow underneath.
|
||||
Every elevated surface pairs border: 1px solid var(--border-subtle)
|
||||
with one of these. Shadows are cool-tinted and two-layer:
|
||||
a tight contact shadow + a wide ambient falloff. */
|
||||
--shadow-xs: 0 1px 2px oklch(0.13 0.02 265 / 0.22);
|
||||
--shadow-sm: 0 1px 2px oklch(0.13 0.02 265 / 0.2), 0 2px 8px -2px oklch(0.13 0.02 265 / 0.24);
|
||||
--shadow-md: 0 2px 4px oklch(0.13 0.02 265 / 0.22), 0 10px 28px -6px oklch(0.13 0.02 265 / 0.36);
|
||||
--shadow-lg: 0 4px 8px oklch(0.13 0.02 265 / 0.26), 0 28px 64px -12px oklch(0.13 0.02 265 / 0.5);
|
||||
--shadow-drawer: 0 8px 16px oklch(0.13 0.02 265 / 0.28), 0 32px 80px -8px oklch(0.13 0.02 265 / 0.55);
|
||||
|
||||
/* Glows — hero moments ONLY: empty states, featured cards, command palette */
|
||||
--glow-accent: 0 0 72px -16px color-mix(in oklab, var(--blue-base) 42%, transparent);
|
||||
--glow-secondary: 0 0 72px -16px color-mix(in oklab, var(--cyan-base) 38%, transparent);
|
||||
--glow-ai: 0 0 72px -16px color-mix(in oklab, var(--violet-base) 40%, transparent);
|
||||
|
||||
/* Active rail — left accent bar on active nav / selected items */
|
||||
--rail-active: inset 2px 0 0 0 var(--accent);
|
||||
|
||||
/* Focus ring */
|
||||
--focus-ring: 0 0 0 2px var(--surface-base), 0 0 0 4px var(--border-focus);
|
||||
|
||||
/* Motion — calm, no bounce */
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* @kind other */
|
||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* @kind other */
|
||||
--duration-fast: 120ms; /* @kind other */
|
||||
--duration-base: 180ms; /* @kind other */
|
||||
--duration-slow: 300ms; /* @kind other */
|
||||
--duration-drawer: 260ms; /* @kind other */
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--shadow-xs: 0 1px 2px oklch(0.3 0.02 255 / 0.07);
|
||||
--shadow-sm: 0 1px 2px oklch(0.3 0.02 255 / 0.06), 0 2px 8px -2px oklch(0.3 0.02 255 / 0.08);
|
||||
--shadow-md: 0 2px 4px oklch(0.3 0.02 255 / 0.06), 0 10px 28px -6px oklch(0.3 0.02 255 / 0.12);
|
||||
--shadow-lg: 0 4px 8px oklch(0.3 0.02 255 / 0.07), 0 28px 64px -12px oklch(0.3 0.02 255 / 0.18);
|
||||
--shadow-drawer: 0 8px 16px oklch(0.3 0.02 255 / 0.08), 0 32px 80px -8px oklch(0.3 0.02 255 / 0.22);
|
||||
--glow-accent: 0 0 72px -16px color-mix(in oklab, var(--blue-deep) 26%, transparent);
|
||||
--glow-secondary: 0 0 72px -16px color-mix(in oklab, var(--cyan-deep) 24%, transparent);
|
||||
--glow-ai: 0 0 72px -16px color-mix(in oklab, var(--violet-deep) 25%, transparent);
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
/* Homepage — Australis Skyfall, dual theme.
|
||||
*
|
||||
* SOURCE OF TRUTH: this file plus the three VENDORED token files beside it.
|
||||
* Do not hand-edit `conf/custom.css`; it is generated. Run `theme/build.py`
|
||||
* after changing anything here, then
|
||||
* `scripts/deploy-stack.sh esh-docker-vm homepage --conf`.
|
||||
*
|
||||
* ── WHAT IS VENDORED, AND WHY IT IS UNTOUCHABLE ──────────────────────────
|
||||
* colors.css Skyfall v2 palette — Sea/Ice/Aurora/Dawn in OKLCH, plus
|
||||
* the semantic layer for BOTH themes.
|
||||
* layout.css v3 "calm depth" — spacing, radii, the shadow system,
|
||||
* glows, the active rail, motion.
|
||||
* typography.css families, scale, weights, leading, tracking.
|
||||
*
|
||||
* These three are copied VERBATIM from the Skyfall bundle and must stay that
|
||||
* way. `build.py` hashes them and fails the build if a byte moves. Everything
|
||||
* this dashboard needs on top of them lives in THIS file, expressed through
|
||||
* the semantic layer — never against a raw family token, and never a literal
|
||||
* colour. That rule is not fussiness: the theme this one replaces built its
|
||||
* own parallel palette "derived from the philosophy", which is how a design
|
||||
* system quietly forks, and it had to be torn out twice.
|
||||
*
|
||||
* ── DUAL THEME, AND THE ONE PIECE OF PLUMBING IT NEEDS ───────────────────
|
||||
* Skyfall keys light mode off `[data-theme="light"]`. Homepage keys it off a
|
||||
* `light` / `dark` CLASS on <html>, and does not touch data attributes. Rather
|
||||
* than edit the vendored file (fork) or duplicate its light block by hand
|
||||
* (drift), `build.py` rewrites the selector at build time so both spellings
|
||||
* are honoured. See BRIDGE_SELECTOR there.
|
||||
*
|
||||
* Dark is `:root` and remains first-class. Light is Skyfall Day. Both are
|
||||
* reachable from Homepage's own theme toggle, which appears because
|
||||
* settings.yaml no longer pins `theme:`.
|
||||
*
|
||||
* ── TYPE: TWO DOCUMENTED SUBSTITUTIONS ───────────────────────────────────
|
||||
* Skyfall names Bespoke Sans (display) / Supreme (body, UI) / Victor Mono
|
||||
* Nerd Font (data, code). Only Supreme was ever vendored into this repo, and
|
||||
* Skyfall's own notes call Victor Mono "user-supplied", so substituting is
|
||||
* expected rather than a deviation:
|
||||
* Supreme 400/500/700 — CANONICAL, the body and UI face.
|
||||
* Space Grotesk variable — stands in for Bespoke Sans (display).
|
||||
* JetBrains Mono variable — stands in for Victor Mono (data, metadata).
|
||||
* Both stand-ins are neutral grotesques in the right register. Swap them the
|
||||
* moment the real faces arrive: it is a two-line change in build.py's FONTS
|
||||
* and the --font-display / --font-mono overrides below.
|
||||
*
|
||||
* ── WHERE THIS DEPARTS FROM THE PREVIOUS THEME, DELIBERATELY ─────────────
|
||||
* 1. Depth is now the RECIPE, not a choice: every elevated surface carries a
|
||||
* 1px hairline AND a two-layer shadow, never one without the other.
|
||||
* 2. Radii move to Skyfall's scale — cards at --radius-lg (14px), controls at
|
||||
* --radius-md (10px).
|
||||
* 3. The full-width aurora ribbon under the tab bar is GONE. Skyfall sanctions
|
||||
* exactly two accent expressions — the active rail and hero-only glows —
|
||||
* and a decorative gradient across the chrome is neither. The colour it was
|
||||
* carrying now lands where canon puts it: a 2px accent bar plus an
|
||||
* --accent-soft fill on the active tab. If you want the ribbon back, that
|
||||
* is a deliberate documented deviation, not an oversight.
|
||||
* 4. Widget stat values move from the display face to mono. Skyfall is
|
||||
* explicit: numbers and telemetry are always --font-mono.
|
||||
*/
|
||||
|
||||
/* @@FONTS@@ */
|
||||
|
||||
/* ---- Family substitutions ------------------------------------------------
|
||||
Overriding typography.css's declarations rather than editing it, so the
|
||||
vendored file stays byte-identical and the swap is visible in one place. */
|
||||
:root {
|
||||
--font-display: "Space Grotesk", "Bespoke Sans", ui-sans-serif, sans-serif;
|
||||
--font-body: "Supreme", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "VictorMono Nerd Font", ui-monospace, monospace;
|
||||
|
||||
/* Width the status cluster occupies in a card's top-right. Load-bearing in
|
||||
two places that must agree: the inset reserved on the title line, and the
|
||||
negative margin the description uses to opt back out of it. Sized for the
|
||||
widest real cluster — a latency badge plus a status word. */
|
||||
--status-gutter: 96px;
|
||||
}
|
||||
|
||||
/* ---- Icon colour ---------------------------------------------------------
|
||||
Homepage draws every mdi and simple-icons glyph as a MASK over a gradient
|
||||
fill, so this ramp IS the icon colour — `color:` does nothing to them.
|
||||
|
||||
⚠ These must be space-separated sRGB CHANNELS, because Homepage consumes
|
||||
them as `rgb(var(--color-logo-start))`. An oklch() token cannot be fed
|
||||
through that, so the four values below are the exact sRGB conversions of
|
||||
real Skyfall tokens rather than colours anyone picked by eye:
|
||||
dark --sea-80 oklch(0.83 0.017 205) -> 187 203 204
|
||||
--blue-base oklch(0.66 0.12 262) -> 105 145 220
|
||||
light --sea-40 oklch(0.48 0.016 245) -> 86 95 102
|
||||
--blue-deep oklch(0.48 0.12 262) -> 54 91 161
|
||||
Recompute with theme/oklch_to_srgb.py if a token ever changes.
|
||||
Light uses the deep (L 0.48) step for both stops, per Skyfall's lightness
|
||||
law: deep is the light-theme value for fills and coloured marks.
|
||||
|
||||
⚠ NOT `:root`. Homepage sets these same two variables on `.theme-slate`,
|
||||
and that class sits on <html> — the very element `:root` matches. Same
|
||||
element, so the cascade decides, and `.theme-slate` (0,1,0) outranks
|
||||
`:root` (0,0,1): a `:root` override here is silently ignored. `html[class]`
|
||||
is (0,1,1) and wins without hard-coding which `theme-*` class is active.
|
||||
Specificity alone is not enough either — a custom property resolves from
|
||||
the NEAREST ancestor that sets it, so this has to land on <html>. */
|
||||
html[class] {
|
||||
--color-logo-start: 187 203 204;
|
||||
--color-logo-stop: 105 145 220;
|
||||
}
|
||||
|
||||
html[data-theme="light"],
|
||||
html.light {
|
||||
--color-logo-start: 86 95 102;
|
||||
--color-logo-stop: 54 91 161;
|
||||
}
|
||||
|
||||
/* ---- Canvas --------------------------------------------------------------
|
||||
The app canvas is --surface-sunken and work floats on it as cards. Flat
|
||||
semantic fill only: Skyfall forbids full-bleed imagery and decorative
|
||||
gradients behind text.
|
||||
|
||||
`#background` is load-bearing, not defensive tidiness. Homepage
|
||||
server-renders the wallpaper as an INLINE style on `<div id="background">`
|
||||
and Next.js caches that rendered page, so removing the `background:` block
|
||||
from settings.yaml is not enough on its own — a restart does not clear it,
|
||||
only a full recreate does. Enforcing it here is deterministic and immediate,
|
||||
and keeps the canvas correct if the setting is ever re-added by accident. */
|
||||
html,
|
||||
body,
|
||||
#page_wrapper,
|
||||
#background,
|
||||
body > div {
|
||||
background-color: var(--surface-sunken) !important;
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body) !important;
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
color: var(--text-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ---- Group heading — the Skyfall eyebrow ---------------------------------
|
||||
Mono, uppercase, --text-2xs, --tracking-caps, in the secondary accent, with
|
||||
a hairline running to the right edge so groups read as register bands
|
||||
rather than titles floating over a grid. ALL-CAPS is sanctioned here
|
||||
specifically: Skyfall allows it for tiny tracked eyebrow labels and table
|
||||
headers, and nowhere else.
|
||||
|
||||
Bookmark groups get the identical treatment. They are a separate component
|
||||
with a parallel class family, and none of these selectors matched it before
|
||||
— so UltraSeedbox rendered with Homepage's stock 20px sans heading while
|
||||
every service group above it wore the eyebrow. One un-themed heading in a
|
||||
column of themed ones is more conspicuous than the whole page being
|
||||
un-themed. */
|
||||
.services-group > button.group,
|
||||
.bookmark-group > button.group {
|
||||
gap: 0;
|
||||
margin-bottom: var(--space-1);
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
/* …except the first group on a tab, which should sit up against the tab bar
|
||||
rather than floating below it. */
|
||||
.services-group:first-of-type > button.group {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.service-group-icon,
|
||||
.bookmark-group-icon {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
h2.service-group-name,
|
||||
h2.bookmark-group-name {
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: var(--text-2xs) !important;
|
||||
font-weight: var(--weight-medium) !important;
|
||||
line-height: 1 !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-caps);
|
||||
color: var(--secondary-text) !important;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.services-group > button.group::after,
|
||||
.bookmark-group > button.group::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
margin-left: var(--space-3);
|
||||
background: var(--border-subtle);
|
||||
}
|
||||
|
||||
/* The chevron sits after the rule, muted. */
|
||||
.services-group > button.group svg,
|
||||
.bookmark-group > button.group svg {
|
||||
color: var(--text-faint);
|
||||
margin-left: var(--space-2);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* ---- Service cards — the calm-depth recipe -------------------------------
|
||||
HAIRLINE + TWO-LAYER SHADOW, NEVER ONE WITHOUT THE OTHER. That pairing is
|
||||
Skyfall's defining move; a card with a border and no shadow, or a shadow
|
||||
and no border, is off-system. Height stays content-driven — equal-height
|
||||
stretching turns short cards into hollow boxes. */
|
||||
ul.services-list {
|
||||
align-items: start !important;
|
||||
row-gap: var(--space-2);
|
||||
}
|
||||
|
||||
li.service > .service-card {
|
||||
height: auto !important;
|
||||
background: var(--surface-card) !important;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg) !important;
|
||||
box-shadow: var(--shadow-sm) !important;
|
||||
margin-bottom: 0 !important;
|
||||
transition: background-color var(--duration-fast) var(--ease-out),
|
||||
border-color var(--duration-fast) var(--ease-out),
|
||||
box-shadow var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
/* Hover: surface lifts a step and the shadow deepens sm -> md, per canon.
|
||||
No scale, no translate — Skyfall has no bounce anywhere. */
|
||||
li.service > .service-card:hover {
|
||||
background: var(--surface-raised) !important;
|
||||
border-color: var(--border-default);
|
||||
box-shadow: var(--shadow-md) !important;
|
||||
}
|
||||
|
||||
/* Service name — display face, tight. Description — body face, muted.
|
||||
*
|
||||
* ⚠ `.service-name` IS NOT THE TITLE. Homepage puts the title in as a bare
|
||||
* text node and then nests `<p class="service-description">` as a sibling
|
||||
* inside the SAME div, so every rule here lands on a box containing both. The
|
||||
* title has no element of its own and cannot be selected — which is why it
|
||||
* cannot be given its own truncation, and why the description needs an
|
||||
* explicit escape hatch from anything the title needs. */
|
||||
.service-name {
|
||||
font-family: var(--font-display) !important;
|
||||
font-size: var(--text-base) !important;
|
||||
font-weight: var(--weight-medium) !important;
|
||||
/* Pinned so the status cluster can be centred on this exact line rather
|
||||
than floating against the card's top edge. Changing one without the
|
||||
other breaks the alignment. */
|
||||
line-height: 17px !important;
|
||||
letter-spacing: var(--tracking-tight);
|
||||
color: var(--text-heading) !important;
|
||||
|
||||
/* THE GUTTER MUST BE HELD BY WRAPPING, NOT BY CLIPPING.
|
||||
This rule used to read `padding-right: 78px; overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap` and it did not work: CSS
|
||||
clips overflow at the PADDING box, not the content box, so the reserved
|
||||
78px was spill room the title printed straight through. Six cards on the
|
||||
AI tab rendered their name underneath their own status pill. The ellipsis
|
||||
never appeared either: it is painted by the block whose own line
|
||||
overflows, and that block here is the anonymous box around the text node,
|
||||
which does not carry the `overflow`. Wrapping has neither problem — line
|
||||
breaking respects the content box, so the gutter genuinely holds. */
|
||||
padding-right: var(--status-gutter) !important;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
p.service-description {
|
||||
font-family: var(--font-body) !important;
|
||||
font-size: var(--text-xs) !important;
|
||||
font-weight: var(--weight-regular) !important;
|
||||
line-height: 17px !important;
|
||||
color: var(--text-muted) !important;
|
||||
margin-top: 3px;
|
||||
white-space: normal;
|
||||
|
||||
/* Opt back out of the title's gutter. The status cluster is absolutely
|
||||
positioned over the FIRST line only (top: 13px, height: 17px, so it
|
||||
occupies y 13-30); the description never starts above y 33. Nothing can
|
||||
collide, so it takes the full card width back and the gutter costs no
|
||||
description space at all. */
|
||||
margin-right: calc(var(--space-2) - var(--status-gutter));
|
||||
|
||||
/* Floor of two lines, ceiling of three. The floor keeps the common
|
||||
one-line/two-line mix bottom-aligned, which is most of what made rows
|
||||
look ragged. The ceiling is the other half: a handful of four- and
|
||||
five-line descriptions were dragging their entire row 30-50px taller than
|
||||
every card beside them, and at four cards across that gap is the most
|
||||
visible defect on the page. Not equal-heights — that inflates short cards
|
||||
into hollow boxes to match a widget card twice their height. */
|
||||
min-height: 34px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- Status — semantic dots, not chips -----------------------------------
|
||||
The stock filled emerald pills read louder than the service names they
|
||||
annotate. A semantic dot plus a mono micro-label at faint contrast: present
|
||||
when you look for it, silent when you are not. The dot colours come from
|
||||
--success/--danger/--warning-text, so they take the deep step automatically
|
||||
in Skyfall Day rather than staying dark-theme bright on a white card.
|
||||
|
||||
The cluster is CENTRED ON THE SERVICE NAME'S LINE, not parked in the card's
|
||||
top-right corner. Homepage positions it `absolute top-0 right-0`, which
|
||||
floats it ~7px above the title's optical centre and reads as a misalignment
|
||||
on every card. The offsets below reconstruct the title line's geometry:
|
||||
.service-name sits 5px below the card's top edge and carries 8px of its own
|
||||
top padding, so its 17px line box starts at 13px and centres at 21.5px.
|
||||
These three numbers move together — if the name's font-size or line-height
|
||||
changes, recompute all of them. */
|
||||
.service-tags {
|
||||
top: 13px !important;
|
||||
height: 17px;
|
||||
align-items: center !important;
|
||||
gap: var(--space-3) !important;
|
||||
margin-top: 0 !important;
|
||||
/* 14px right inset, against the 8px of text padding on the left — the
|
||||
optical match, since the pill has no glyph hard against its edge. */
|
||||
margin-right: 14px !important;
|
||||
}
|
||||
|
||||
/* Every child of the cluster shares one baseline: status pills, latency
|
||||
badges and container-stat buttons alike. */
|
||||
.service-tag {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
height: 17px;
|
||||
line-height: 17px !important;
|
||||
}
|
||||
|
||||
.service-tag,
|
||||
.docker-status,
|
||||
.site-monitor-status,
|
||||
.service-tag > div:not(.sr-only) {
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
padding: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.docker-status > div,
|
||||
.service-tag .text-\[8px\] {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: 9px !important;
|
||||
font-weight: var(--weight-medium) !important;
|
||||
line-height: 1 !important;
|
||||
letter-spacing: var(--tracking-caps);
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* Flex-centred rather than nudged with vertical-align: the dot is an optical
|
||||
round on a cap-height uppercase run, so it needs true centring, not a magic
|
||||
pixel offset that only looks right at one font size. */
|
||||
.docker-status > div::before {
|
||||
content: "\25CF"; /* ● */
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
font-size: 7px;
|
||||
line-height: 1;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* ⚠ Homepage emits `docker-status-<state>`, not `status-<state>`. Selectors
|
||||
matching the latter silently hit nothing — which is how an earlier pass
|
||||
shipped "themed" status pills that were still stock green. */
|
||||
.docker-status-healthy > div::before,
|
||||
.docker-status-running > div::before {
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.docker-status-unhealthy > div::before,
|
||||
.docker-status-exited > div::before,
|
||||
.docker-status-dead > div::before {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.docker-status-starting > div::before,
|
||||
.docker-status-paused > div::before {
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
/* Ping / latency — mono metadata, tabular, no chip. Deliberately the info
|
||||
family and not a status colour: "how fast" must not read as "is it alive". */
|
||||
.site-monitor-status > div {
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: 9px !important;
|
||||
font-weight: var(--weight-regular) !important;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: lowercase !important;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-faint) !important;
|
||||
}
|
||||
|
||||
/* ---- Widget stat strip ---------------------------------------------------
|
||||
A footer strip inside the card: hairline above, cells divided by hairlines,
|
||||
value then a mono eyebrow label. The card stays one object instead of three
|
||||
nested boxes.
|
||||
|
||||
Values are MONO, not the display face. Skyfall is explicit that numbers and
|
||||
telemetry always take --font-mono; hierarchy against the label comes from
|
||||
weight and size, which is the same tool the type system uses everywhere
|
||||
else. Padding is tight because a stat strip adds its full height to its own
|
||||
card and to nothing else in the row — at four cards across, one AdGuard
|
||||
card opens a void beside three ordinary ones. It cannot be made free, but
|
||||
it can be made small. */
|
||||
.service-container {
|
||||
margin-top: var(--space-2);
|
||||
padding-top: 2px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
.service-container > div {
|
||||
background: transparent !important;
|
||||
border-radius: 0 !important;
|
||||
margin: 0 !important;
|
||||
padding: 6px 4px 5px !important;
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
min-width: 0; /* let a long value shrink the cell instead of overflowing */
|
||||
}
|
||||
|
||||
.service-container > div:first-child {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
/* value — nowrap because a long one (AdGuard's "32.767 ms") otherwise wraps
|
||||
mid-cell and drags the whole card taller than its row-mates. */
|
||||
.service-container > div > div:not(.uppercase) {
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: var(--text-sm) !important;
|
||||
font-weight: var(--weight-bold) !important;
|
||||
letter-spacing: var(--tracking-tight);
|
||||
line-height: 1.2 !important;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-heading) !important;
|
||||
}
|
||||
|
||||
/* label */
|
||||
.service-container > div > div.uppercase {
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: 9px !important;
|
||||
font-weight: var(--weight-regular) !important;
|
||||
letter-spacing: var(--tracking-caps);
|
||||
color: var(--text-muted) !important;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Trailing widget rows — Jellyfin's "No Active Streams" strip and its empty
|
||||
second row, which Homepage emits AFTER .service-container as its own block
|
||||
of Tailwind-tinted pills. Untouched they broke out of the card's language:
|
||||
two light grey bars with their own radius hanging under an otherwise
|
||||
hairline-ruled footer. Not hidden — "no active streams" is the answer to
|
||||
the question the card exists to answer. */
|
||||
li.service .service-container ~ div > div {
|
||||
background: transparent !important;
|
||||
border-radius: 0 !important;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
height: auto !important;
|
||||
min-height: 16px;
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
li.service .service-container ~ div span {
|
||||
position: static !important;
|
||||
display: block;
|
||||
padding: 3px 4px 2px !important;
|
||||
margin: 0 !important;
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: 9px !important;
|
||||
letter-spacing: var(--tracking-caps);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* ---- Bookmarks -----------------------------------------------------------
|
||||
Same depth recipe as a service card. The name takes the display face and
|
||||
the description drops to mono metadata, so a bookmark row reads as a
|
||||
quieter sibling of a service card rather than a different widget that
|
||||
wandered in. */
|
||||
li.bookmark > a {
|
||||
background: var(--surface-card) !important;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg) !important;
|
||||
box-shadow: var(--shadow-sm) !important;
|
||||
margin-bottom: var(--space-2) !important;
|
||||
overflow: hidden;
|
||||
transition: background-color var(--duration-fast) var(--ease-out),
|
||||
border-color var(--duration-fast) var(--ease-out),
|
||||
box-shadow var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
li.bookmark > a:hover {
|
||||
background: var(--surface-raised) !important;
|
||||
border-color: var(--border-default);
|
||||
box-shadow: var(--shadow-md) !important;
|
||||
}
|
||||
|
||||
.bookmark-icon {
|
||||
background: transparent !important;
|
||||
border-radius: 0 !important;
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.bookmark-name {
|
||||
font-family: var(--font-display) !important;
|
||||
font-size: var(--text-sm) !important;
|
||||
font-weight: var(--weight-medium) !important;
|
||||
color: var(--text-heading) !important;
|
||||
}
|
||||
|
||||
.bookmark-description {
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: 10px !important;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* ---- Tab bar — the active rail -------------------------------------------
|
||||
Skyfall sanctions exactly two accent expressions: the active rail (a 2px
|
||||
accent bar on the active item, paired with an --accent-soft fill) and
|
||||
hero-only glows. This is the rail, turned through 90° for a horizontal bar.
|
||||
|
||||
The previous theme ran a full-width blue -> cyan -> green aurora gradient
|
||||
under this bar. It was the nicest thing on the page and it is gone on
|
||||
purpose: a decorative gradient across the chrome is neither of the two
|
||||
sanctioned expressions, and Skyfall permits the aurora motif only on
|
||||
hero/empty-state areas, never behind body text. A plain hairline separates
|
||||
chrome from content instead, and the colour lands on the active tab. */
|
||||
ul.sm\:flex {
|
||||
position: relative;
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
border-radius: 0 !important;
|
||||
gap: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
ul.sm\:flex::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 1px;
|
||||
background: var(--border-subtle);
|
||||
}
|
||||
|
||||
ul.sm\:flex > li {
|
||||
height: 40px !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
ul.sm\:flex > li > button {
|
||||
margin: 0 !important;
|
||||
height: 100%;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
border-top: 2px solid transparent;
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: var(--text-2xs) !important;
|
||||
font-weight: var(--weight-medium) !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-caps);
|
||||
color: var(--text-muted) !important;
|
||||
transition: color var(--duration-fast) var(--ease-out),
|
||||
background-color var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
ul.sm\:flex > li > button:hover {
|
||||
color: var(--text-body) !important;
|
||||
background: var(--accent-soft) !important;
|
||||
}
|
||||
|
||||
/* The active rail: 2px accent bar + --accent-soft fill. `dark:bg-white/10` is
|
||||
the class Homepage puts on the selected tab; it is the only handle it
|
||||
gives. */
|
||||
ul.sm\:flex > li > button.dark\:bg-white\/10 {
|
||||
border-top-color: var(--accent) !important;
|
||||
color: var(--text-heading) !important;
|
||||
background: var(--accent-soft) !important;
|
||||
}
|
||||
|
||||
/* ---- Header chrome: resource widget + search -----------------------------
|
||||
Same depth recipe as everything else, so the header belongs to the page
|
||||
instead of hovering above it. --pad-chrome, not --pad-content: this is app
|
||||
chrome, and Skyfall's negative-space rule is compact chrome / airy content. */
|
||||
div.flex.flex-col.justify-center.mt-2,
|
||||
div.flex.flex-row.self-center.flex-wrap.justify-between {
|
||||
background: var(--surface-card) !important;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg) !important;
|
||||
box-shadow: var(--shadow-sm) !important;
|
||||
}
|
||||
|
||||
/* The resource widget is INSIDE that strip, so it must not repeat the recipe
|
||||
— nested hairline-and-shadow reads as a box in a box. */
|
||||
.information-widget-resource {
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* The CPU / memory / disk glyphs. Homepage colours them from its own Tailwind
|
||||
theme, which lands almost invisible on Skyfall Day's near-white chrome. */
|
||||
.information-widget-resource svg {
|
||||
color: var(--text-muted) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.information-widget-resource .text-xs,
|
||||
.information-widget-resource span {
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: var(--text-2xs) !important;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.resource-usage,
|
||||
[class*="resource-usage"] {
|
||||
background: var(--surface-input) !important;
|
||||
height: 3px !important;
|
||||
border-radius: var(--radius-full) !important;
|
||||
}
|
||||
|
||||
.resource-usage > div,
|
||||
[class*="resource-usage"] > div {
|
||||
background: var(--secondary) !important;
|
||||
border-radius: var(--radius-full) !important;
|
||||
}
|
||||
|
||||
/* Search input */
|
||||
input[type="text"],
|
||||
input[type="search"] {
|
||||
font-family: var(--font-body) !important;
|
||||
font-size: var(--text-base) !important;
|
||||
color: var(--text-body) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--text-faint) !important;
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: var(--text-xs) !important;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
/* ---- Theme toggle --------------------------------------------------------
|
||||
Our own switch, injected by conf/custom.js. Homepage's built-in one is
|
||||
unavailable: rendering it needs `theme:` unpinned in settings.yaml, and an
|
||||
unpinned `theme:` makes the page's data loader throw — see the note in
|
||||
custom.js for the measurement. Sized and shaped like an IconButton from the
|
||||
Skyfall component set: --radius-md, chrome padding, hairline, and the same
|
||||
accent-soft hover every interactive surface uses. */
|
||||
#skyfall-theme-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-left: auto;
|
||||
align-self: center;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color var(--duration-fast) var(--ease-out),
|
||||
background-color var(--duration-fast) var(--ease-out),
|
||||
border-color var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
#skyfall-theme-toggle:hover {
|
||||
color: var(--text-heading);
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
|
||||
#skyfall-theme-toggle:active {
|
||||
background: var(--accent-soft-hover);
|
||||
}
|
||||
|
||||
/* ---- Focus — Skyfall's ring ---------------------------------------------- */
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: var(--focus-ring) !important;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* ---- Version / footer ---------------------------------------------------- */
|
||||
#version,
|
||||
#version * {
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: var(--text-2xs) !important;
|
||||
color: var(--text-faint) !important;
|
||||
}
|
||||
|
||||
/* ---- Reduced motion — canon requires honouring it ------------------------ */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/* Australis Skyfall — Typography tokens */
|
||||
|
||||
:root {
|
||||
/* Families */
|
||||
--font-display: "Bespoke Sans", "Avenir Next", "Segoe UI", sans-serif;
|
||||
--font-body: "Supreme", "Helvetica Neue", "Segoe UI", sans-serif;
|
||||
--font-mono: "VictorMono Nerd Font", "SF Mono", "Cascadia Code", monospace;
|
||||
|
||||
/* Scale (web-app oriented; 14px UI base) */
|
||||
--text-2xs: 11px;
|
||||
--text-xs: 12px;
|
||||
--text-sm: 13px;
|
||||
--text-base: 14px;
|
||||
--text-md: 16px;
|
||||
--text-lg: 18px;
|
||||
--text-xl: 22px;
|
||||
--text-2xl: 28px;
|
||||
--text-3xl: 36px;
|
||||
--text-4xl: 48px;
|
||||
--text-5xl: 64px;
|
||||
|
||||
/* Weights */
|
||||
--weight-regular: 400;
|
||||
--weight-medium: 500;
|
||||
--weight-semibold: 600;
|
||||
--weight-bold: 700;
|
||||
--weight-extrabold: 800;
|
||||
|
||||
/* Line heights */
|
||||
--leading-tight: 1.15;
|
||||
--leading-snug: 1.35;
|
||||
--leading-normal: 1.55;
|
||||
--leading-relaxed: 1.7;
|
||||
|
||||
/* Tracking */
|
||||
--tracking-tight: -0.02em;
|
||||
--tracking-normal: 0;
|
||||
--tracking-wide: 0.06em;
|
||||
--tracking-caps: 0.1em;
|
||||
}
|
||||
@@ -120,7 +120,7 @@ services:
|
||||
networks:
|
||||
- tnet
|
||||
labels:
|
||||
- homepage.group=Apps
|
||||
- homepage.group=AI - Gateways & Chat
|
||||
- homepage.name=Lobe Chat
|
||||
- homepage.icon=mdi-chat-processing
|
||||
- homepage.description=Chat frontend over the LiteLLM gateway (eval)
|
||||
|
||||
@@ -33,7 +33,7 @@ services:
|
||||
networks:
|
||||
- tnet
|
||||
labels:
|
||||
- homepage.group=Notes
|
||||
- homepage.group=Daily
|
||||
- homepage.name=Memos
|
||||
- homepage.icon=mdi-note-text-outline
|
||||
- homepage.description=Self-hosted note + memo server (ana-docker)
|
||||
|
||||
@@ -1,4 +1,31 @@
|
||||
# ana-ml2 GPU0 char-rp prose seat (MeroMero-v2). Real .env lives on the host.
|
||||
MEROMERO_GPU_MEM_UTIL=0.52
|
||||
#
|
||||
# ⚠ GPU0 IS SHARED WITH `vllm-gen` AND THE BUDGETS MUST SUM UNDER ~0.92.
|
||||
# gen runs at --gpu-memory-utilization 0.43 but actually holds ~45.6 GiB of the
|
||||
# 94.97 GiB card, because the utilization figure sizes the KV cache and does not
|
||||
# cover CUDA context, graphs and non-torch overhead. This seat was at 0.51:
|
||||
# 0.43 + 0.51 = 0.94, which left ~0.6 GiB of real headroom and worked right up
|
||||
# until it did not. On 2026-08-24 it stopped fitting and the seat entered a
|
||||
# crash-loop — 13 restarts, `torch.OutOfMemoryError: ... 195.19 MiB is free`,
|
||||
# both the `char-rp` and `char-rp-reasoning` gateway aliases returning 500s
|
||||
# (they share this one seat; see the LiteLLM routes for :8016).
|
||||
#
|
||||
# 0.47 restores a real margin (~4.8 GiB free on the card) and costs nothing you
|
||||
# can use: KV cache goes 27.36 GiB -> 23.56 GiB, which is 430,825 -> 371,023
|
||||
# tokens against a max-model-len of 262,144. The pool still holds 1.4x a
|
||||
# full-length sequence; what you lose is concurrent long requests, not context.
|
||||
#
|
||||
# Raising this again means lowering gen's in the same change. Check the real
|
||||
# numbers, not the flags: `nvidia-smi --query-compute-apps=pid,used_memory`.
|
||||
#
|
||||
# ⚠ AND CHECK THEM ON A FRESHLY-RESTARTED gen. Measured 2026-08-24: gen held
|
||||
# 46,726 MiB (45.6 GiB) after ~3 days of uptime, and 39,424 MiB (38.5 GiB)
|
||||
# immediately after a restart — the same container, the same
|
||||
# `--gpu-memory-utilization 0.43`, ~7 GiB apart. Its footprint GROWS WITH
|
||||
# UPTIME, which is the missing half of why this seat "fit on the 21st and
|
||||
# stopped fitting on the 24th": nothing about this seat changed, gen crept up
|
||||
# underneath it. Headroom arithmetic done against a long-running gen is
|
||||
# measuring a moving number.
|
||||
MEROMERO_GPU_MEM_UTIL=0.47
|
||||
MEROMERO_MAX_MODEL_LEN=262144
|
||||
MEROMERO_GPU_ID=0
|
||||
|
||||
@@ -86,10 +86,14 @@ services:
|
||||
networks:
|
||||
- tnet
|
||||
labels:
|
||||
- homepage.group=AI - Inference
|
||||
- homepage.name=char-rp (MeroMero-v2 NVFP4, multimodal)
|
||||
# STOPPED 2026-08-24 — superseded by stacks/gemma4-charrp (gemma-4-26B-A4B
|
||||
# -it NVFP4 MoE) on the same port and the same served-model-names. Kept as
|
||||
# the rollback seat: `docker compose up -d vllm-meromero-rp` here after
|
||||
# stopping the gemma4 one, since both want :8016.
|
||||
- homepage.group=AI - Dormant
|
||||
- homepage.name=char-rp (MeroMero-v2 NVFP4) — rollback
|
||||
- homepage.icon=mdi-drama-masks
|
||||
- homepage.description=G4-MeroMero-v2-31B NVFP4A16 non-thinking prose seat, vision-enabled, 256K (ana-ml2 GPU0)
|
||||
- homepage.description=Superseded 2026-08-24 by the Gemma-4 26B-A4B MoE seat; dense 31B NVFP4A16, kept for rollback (ana-ml2 GPU0)
|
||||
- homepage.href=http://10.250.50.54:${MEROMERO_PORT:-8016}/docs
|
||||
|
||||
networks:
|
||||
|
||||
@@ -44,7 +44,7 @@ services:
|
||||
networks:
|
||||
- tnet
|
||||
labels:
|
||||
- homepage.group=News
|
||||
- homepage.group=Daily
|
||||
- homepage.name=Miniflux
|
||||
- homepage.icon=mdi-rss
|
||||
- homepage.description=RSS reader (subreddits, blogs, HN)
|
||||
|
||||
@@ -106,8 +106,8 @@ services:
|
||||
networks:
|
||||
- tnet
|
||||
labels:
|
||||
- homepage.group=Apps
|
||||
- homepage.name=Open WebUI
|
||||
- homepage.group=AI - Gateways & Chat
|
||||
- homepage.name=Open WebUI (esh)
|
||||
- homepage.icon=mdi-chat-question
|
||||
- homepage.description=Env-declarative chat frontend over the LiteLLM gateway (eval vs Lobe)
|
||||
- homepage.href=http://10.0.50.45:${OPENWEBUI_PORT:-3211}
|
||||
|
||||
@@ -52,7 +52,7 @@ services:
|
||||
networks:
|
||||
- tnet
|
||||
labels:
|
||||
- homepage.group=Service Networking
|
||||
- homepage.group=Agents (no UI)
|
||||
- homepage.name=Restic (rest-server)
|
||||
- homepage.icon=mdi-cloud-upload
|
||||
- homepage.description=Anaheim restic endpoint (data on NFS)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Scriberr — copy to .env on the host at /opt/docker/compose/scriberr/.env
|
||||
# Real .env is gitignored and lives only on ana-ml2.
|
||||
|
||||
# ── Image ────────────────────────────────────────────────────────────────
|
||||
# Built locally from Dockerfile.cuda.12.9 — see the compose header for why
|
||||
# the published scriberr-cuda image is NOT usable on these Blackwell cards.
|
||||
SCRIBERR_IMAGE=scriberr:local-blackwell
|
||||
|
||||
# ── Network ──────────────────────────────────────────────────────────────
|
||||
SCRIBERR_PORT=8080
|
||||
SCRIBERR_BIND=0.0.0.0
|
||||
# CORS. Must list every origin the UI is actually reached from, or the
|
||||
# browser blocks the API calls. Comma-separated, no spaces, no trailing /.
|
||||
SCRIBERR_ALLOWED_ORIGINS=http://10.250.50.54:8080,http://scriberr.ana.internal:8080
|
||||
|
||||
# ── GPU ──────────────────────────────────────────────────────────────────
|
||||
# GPU0 is fully committed to the `gen` seat; GPU1 is the one with headroom.
|
||||
SCRIBERR_GPU_ID=1
|
||||
|
||||
# ── Storage (on /tank — NOT the root pool, weights are multi-GB) ─────────
|
||||
SCRIBERR_DATA_DIR=/tank/scriberr/data
|
||||
SCRIBERR_ENV_DIR=/tank/scriberr/whisperx-env
|
||||
|
||||
# ── Runtime ──────────────────────────────────────────────────────────────
|
||||
# ⚠ 10001, not the fleet-usual 1000. The Blackwell image's `appuser` IS 10001
|
||||
# and its PUID remapping is broken — at 1000 the app cannot open its SQLite DB
|
||||
# and crash-loops. The /tank dirs are chowned to 10001:10001 to match.
|
||||
# See README "The PUID trap".
|
||||
SCRIBERR_PUID=10001
|
||||
SCRIBERR_PGID=10001
|
||||
# Keep false while the app is served over plain HTTP. Setting this true
|
||||
# without TLS makes login silently fail (cookie marked Secure, dropped).
|
||||
SCRIBERR_SECURE_COOKIES=false
|
||||
|
||||
# ── Optional: summarisation / transcript chat ────────────────────────────
|
||||
# Scriberr speaks the OpenAI API. Point it at the LiteLLM gateway so this
|
||||
# costs nothing and stays on-prem, rather than a paid vendor key.
|
||||
# Configure the base URL in the Scriberr UI (Settings -> AI provider):
|
||||
# base URL : http://10.250.50.70:4000/v1
|
||||
# model : summarizer (or gen / gen-reasoning)
|
||||
# The key below is the shared all-agents gateway key.
|
||||
# ⚠ That key also reaches PAID passthrough models (GLM, Kimi) on a shared
|
||||
# tab — keep the configured model on a free local seat.
|
||||
# SCRIBERR_OPENAI_API_KEY=
|
||||
@@ -0,0 +1,135 @@
|
||||
# scriberr — self-hosted transcription + diarization (ana-ml2, GPU1)
|
||||
|
||||
Web UI for transcribing audio/video locally. WhisperX (Whisper + pyannote
|
||||
speaker diarization) with NVIDIA Parakeet/Canary also selectable; SQLite for
|
||||
state; optional summarisation and transcript chat against any OpenAI-compatible
|
||||
endpoint.
|
||||
|
||||
- **Host:** `ana-ml2` (10.250.50.54) — GPU1
|
||||
- **URL:** http://10.250.50.54:8080
|
||||
- **Upstream:** https://github.com/rishikanthc/Scriberr
|
||||
|
||||
## The image is built locally, and that is not incidental
|
||||
|
||||
ana-ml2's RTX PRO 6000 Blackwell cards are **sm_120**. Upstream's published
|
||||
images do not cover that:
|
||||
|
||||
| image | built for | usable here |
|
||||
|---|---|---|
|
||||
| `ghcr.io/rishikanthc/scriberr` | CPU | yes, but no GPU |
|
||||
| `ghcr.io/rishikanthc/scriberr-cuda` | sm_61 … sm_89 (Pascal→Ada) | **no** — no sm_120 kernels |
|
||||
| `ghcr.io/rishikanthc/scriberr-cuda-blackwell` | sm_120 | **does not exist** — documented in the upstream README but never published; GHCR returns no tags (checked 2026-08-23) |
|
||||
|
||||
The sm_120 path upstream actually ships is `Dockerfile.cuda.12.9`
|
||||
(CUDA 12.9.1 + cuDNN, `PYTORCH_CUDA_VERSION=cu128`), built from source. So we
|
||||
build it. **Do not "simplify" the compose back to the published `scriberr-cuda`
|
||||
image** — it will fail on these cards or quietly fall back to CPU.
|
||||
|
||||
### Rebuilding
|
||||
|
||||
```bash
|
||||
ssh ana-ml2
|
||||
cd /tank/scriberr/src/Scriberr
|
||||
git pull
|
||||
docker build -f Dockerfile.cuda.12.9 -t scriberr:local-blackwell .
|
||||
cd /opt/docker/compose/scriberr && docker compose up -d
|
||||
```
|
||||
|
||||
Source checkout lives on `/tank`, not the root pool — see storage below.
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
# from this workstation
|
||||
scripts/deploy-stack.sh ana-ml2 scriberr
|
||||
```
|
||||
|
||||
Then on the host, the usual:
|
||||
|
||||
```bash
|
||||
cd /opt/docker/compose/scriberr
|
||||
docker compose config # dry parse first
|
||||
docker compose up -d scriberr # target the service, not the whole stack
|
||||
```
|
||||
|
||||
## Storage — deliberately on /tank
|
||||
|
||||
`/var/lib/docker` on ana-ml2 sits on `zroot` at ~87% used. Whisper, pyannote
|
||||
and NeMo weights are multi-GB and land in the `whisperx-env` volume, so both
|
||||
mounts are bind-mounted onto `/tank` (4+ TB) instead of named volumes:
|
||||
|
||||
| host path | container path | holds |
|
||||
|---|---|---|
|
||||
| `/tank/scriberr/data` | `/app/data` | SQLite DB, uploads, transcripts |
|
||||
| `/tank/scriberr/whisperx-env` | `/app/whisperx-env` | Python env + model weights |
|
||||
| `/tank/scriberr/src/Scriberr` | — | build checkout |
|
||||
|
||||
Both are owned by uid/gid 1000 to match `PUID`/`PGID`.
|
||||
|
||||
## First run takes a while
|
||||
|
||||
On first start the container builds a Python environment and downloads several
|
||||
GB of model weights before the port answers — upstream says "several minutes".
|
||||
The healthcheck therefore has a **600 s `start_period`**; the container will
|
||||
show `starting`, not `unhealthy`, during that window. Watch it with:
|
||||
|
||||
```bash
|
||||
docker logs -f scriberr
|
||||
```
|
||||
|
||||
Subsequent starts are fast because the env volume persists.
|
||||
|
||||
## The PUID trap — read this before "fixing" the uid
|
||||
|
||||
This stack runs as **uid/gid 10001**, not the fleet-usual 1000, and the
|
||||
`/tank/scriberr` dirs are chowned to match. That is deliberate.
|
||||
|
||||
`Dockerfile.cuda.12.9` creates `appuser` at **uid 10001** — Ubuntu 24.04's base
|
||||
image already owns uid 1000 as `ubuntu`, so upstream moved their app user out of
|
||||
the way. It then `chown`s `/app` to 10001. But the entrypoint's `PUID` remapping
|
||||
only chowns `/app/data` and `/app/whisperx-env` — **not `/app` itself**. So
|
||||
running with `PUID=1000` leaves the app unable to open its SQLite database and
|
||||
it crash-loops with:
|
||||
|
||||
```
|
||||
Failed to connect to database: unable to open database file: out of memory (14)
|
||||
```
|
||||
|
||||
That message is a red herring twice over: error 14 is `SQLITE_CANTOPEN`, not an
|
||||
OOM, and the machine has 566 GB of RAM. Diagnosis notes from 2026-08-23:
|
||||
|
||||
- SQLite itself writes fine to `/tank` as uid 1000 — the mount is not at fault.
|
||||
- The app fails on a plain Docker **named volume** too — storage is not at fault.
|
||||
- The **published CPU image runs fine at `PUID=1000`**, because in `Dockerfile`
|
||||
(the non-CUDA one) `appuser` *is* uid 1000. Only the CUDA 12.9 variant moved it.
|
||||
- Same image at `PUID=10001` starts clean. That is the whole difference.
|
||||
|
||||
If you ever want host files owned by 1000 instead, the fix is to patch
|
||||
`Dockerfile.cuda.12.9` to `userdel ubuntu` and recreate `appuser` at 1000, then
|
||||
rebuild — a local patch to carry, which is why it was not done.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`SECURE_COOKIES` must stay `false` while served over plain HTTP.** At the
|
||||
production default of `true` the session cookie is marked `Secure`, the
|
||||
browser drops it, and login appears to succeed then bounces you straight back
|
||||
to the login page with nothing useful in the logs.
|
||||
- **`ALLOWED_ORIGINS` must list the real origin.** Upstream defaults to
|
||||
`localhost` only; reaching the UI by host IP fails CORS until it is set.
|
||||
- **Never add `NVIDIA_VISIBLE_DEVICES=all`.** Upstream's compose sets it, but
|
||||
here it would override the `device_ids` reservation and expose both cards —
|
||||
GPU0 belongs to the `gen` seat.
|
||||
- **This stack is a guest on GPU1**, which it shares with the `sec` seat. If
|
||||
VRAM gets tight, this is the thing that should yield.
|
||||
|
||||
## Optional: summarisation via the LiteLLM gateway
|
||||
|
||||
Scriberr speaks the OpenAI API, so point it at the fleet gateway instead of a
|
||||
paid vendor. In the UI under the AI provider settings:
|
||||
|
||||
- base URL: `http://10.250.50.70:4000/v1`
|
||||
- model: `summarizer` (or `gen` / `gen-reasoning`)
|
||||
- key: the shared all-agents gateway key
|
||||
|
||||
⚠ That key also reaches **paid** passthrough models (GLM, Kimi) on a shared
|
||||
tab. Keep the configured model on a free local seat.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Scriberr — self-hosted audio/video transcription with speaker diarization.
|
||||
# Upstream: https://github.com/rishikanthc/Scriberr (Go + SvelteKit, SQLite).
|
||||
#
|
||||
# Transcription runs locally via WhisperX (Whisper + pyannote diarization);
|
||||
# NVIDIA Parakeet / Canary models are also selectable in the UI. Optional
|
||||
# summarisation / transcript chat talks to any OpenAI-compatible endpoint —
|
||||
# point it at the LiteLLM gateway rather than a paid API (see README).
|
||||
#
|
||||
# ── IMAGE: BUILT LOCALLY, ON PURPOSE ──────────────────────────────────────
|
||||
# ana-ml2's RTX PRO 6000 Blackwell cards are **sm_120**. Upstream publishes
|
||||
# `scriberr-cuda` (built for sm_61…sm_89 — no sm_120 kernels) and documents a
|
||||
# `scriberr-cuda-blackwell` image that **has never actually been published**
|
||||
# (GHCR returns no tags for it, checked 2026-08-23). The sm_120 path upstream
|
||||
# ships is `Dockerfile.cuda.12.9` (CUDA 12.9.1 + cu128 torch), built locally.
|
||||
# Do NOT "simplify" this to the published `scriberr-cuda` image — it will
|
||||
# fail on these cards or silently fall back to CPU.
|
||||
# Rebuild: see README "Rebuilding" — checkout lives at
|
||||
# /tank/scriberr/src/Scriberr on ana-ml2.
|
||||
#
|
||||
# ── GPU PINNING ───────────────────────────────────────────────────────────
|
||||
# Pinned to **GPU1** via explicit device_ids, per the house convention and
|
||||
# because GPU0 is fully committed to the `gen` seat. GPU1 shares space with
|
||||
# the `sec` seat, so this stack is a guest there — keep an eye on VRAM.
|
||||
# NOTE: do NOT add `NVIDIA_VISIBLE_DEVICES=all` (as upstream's compose does).
|
||||
# It overrides the device_ids reservation and exposes both cards.
|
||||
#
|
||||
# All tunables live in .env — edit that, not this file.
|
||||
|
||||
services:
|
||||
scriberr:
|
||||
image: ${SCRIBERR_IMAGE:-scriberr:local-blackwell}
|
||||
container_name: scriberr
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${SCRIBERR_BIND:-0.0.0.0}:${SCRIBERR_PORT}:8080"
|
||||
volumes:
|
||||
# Bind mounts rather than named volumes: /var/lib/docker on ana-ml2
|
||||
# lives on zroot with limited headroom, while /tank has terabytes.
|
||||
# Model weights (Whisper, pyannote, NeMo) land in whisperx-env and are
|
||||
# multi-GB — they must not go anywhere near the root pool.
|
||||
- ${SCRIBERR_DATA_DIR}:/app/data
|
||||
- ${SCRIBERR_ENV_DIR}:/app/whisperx-env
|
||||
environment:
|
||||
# ⚠ 10001, NOT the fleet-usual 1000 — this is load-bearing.
|
||||
# Dockerfile.cuda.12.9 creates `appuser` at uid 10001 (Ubuntu 24.04's
|
||||
# base image already owns uid 1000 as `ubuntu`, so upstream moved it) and
|
||||
# chowns /app to 10001. The entrypoint's PUID remapping only chowns
|
||||
# /app/data + /app/whisperx-env, not /app itself, so running as 1000
|
||||
# leaves the app unable to open its SQLite DB and it crash-loops with
|
||||
# `unable to open database file: out of memory (14)` — which is
|
||||
# SQLITE_CANTOPEN wearing a misleading message, not a real OOM.
|
||||
# The host bind-mount dirs are therefore chowned to 10001:10001 too.
|
||||
# Verified 2026-08-23: PUID=1000 crash-loops, PUID=10001 starts clean.
|
||||
- PUID=${SCRIBERR_PUID:-10001}
|
||||
- PGID=${SCRIBERR_PGID:-10001}
|
||||
- APP_ENV=production
|
||||
# Served over plain HTTP on the LAN. Left at the production default of
|
||||
# `true`, the session cookie is marked Secure and the browser silently
|
||||
# drops it — you log in, get bounced back to the login page, and the
|
||||
# logs show nothing wrong. This must stay false while access is HTTP.
|
||||
- SECURE_COOKIES=${SCRIBERR_SECURE_COOKIES:-false}
|
||||
# Upstream defaults to localhost origins only, which fails CORS when
|
||||
# reached by host IP. Keep this in sync with how the app is reached.
|
||||
- ALLOWED_ORIGINS=${SCRIBERR_ALLOWED_ORIGINS}
|
||||
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
# Scriberr builds each model backend's Python env with `uv` at runtime.
|
||||
# uv's default link mode reflink/hardlinks out of its cache, which fails
|
||||
# on this overlayfs+ZFS combination with a misleading
|
||||
# "Failed to clone ... Resource temporarily unavailable (os error 11)"
|
||||
# and takes out the Parakeet + Sortformer backends (WhisperX survives).
|
||||
# `copy` trades a little disk and time for it actually working.
|
||||
- UV_LINK_MODE=${SCRIBERR_UV_LINK_MODE:-copy}
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
device_ids: ["${SCRIBERR_GPU_ID:-1}"]
|
||||
capabilities: [gpu]
|
||||
healthcheck:
|
||||
# 127.0.0.1 rather than localhost — the IPv6-first resolution trap has
|
||||
# bitten news-digest and chatterbox in this fleet before.
|
||||
# start_period is generous: first boot builds a Python env and pulls
|
||||
# several GB of model weights before the port answers.
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/ >/dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 600s
|
||||
networks:
|
||||
- tnet
|
||||
labels:
|
||||
# ⚠ The group name MUST match a key in the dashboard's settings.yaml
|
||||
# `layout:` block. A group that appears nowhere in that block gets no
|
||||
# `tab:` assignment, and Homepage renders an untabbed group on EVERY tab.
|
||||
# This label read `AI Systems` — a group that existed nowhere — from
|
||||
# 2026-08-23 until it was caught on 2026-08-24.
|
||||
# `AI - Studios` and not one of the ASR groups because Scriberr is a
|
||||
# transcription UI you open and work in, which is what Studios collects;
|
||||
# the bare ASR endpoints (Parakeet, Speaches) live in the collapsed
|
||||
# `AI - Audio Tools` group instead.
|
||||
- homepage.group=AI - Studios
|
||||
- homepage.name=Scriberr
|
||||
- homepage.icon=mdi-microphone-message
|
||||
- homepage.description=Audio/video transcription + diarization (ana-ml2, GPU1)
|
||||
- homepage.href=http://10.250.50.54:${SCRIBERR_PORT}
|
||||
|
||||
networks:
|
||||
tnet:
|
||||
name: traefik-net
|
||||
external: true
|
||||
@@ -87,7 +87,7 @@ services:
|
||||
- traefik.http.routers.searxng.tls=true
|
||||
- traefik.http.routers.searxng.service=searxng
|
||||
- traefik.http.services.searxng.loadbalancer.server.port=8080
|
||||
- homepage.group=Apps
|
||||
- homepage.group=Daily
|
||||
- homepage.name=SearXNG
|
||||
- homepage.icon=si-searxng
|
||||
- homepage.description=Privacy-respecting meta-search
|
||||
|
||||
@@ -82,7 +82,7 @@ services:
|
||||
networks:
|
||||
- tnet
|
||||
labels:
|
||||
- homepage.group=AI - Image & Media
|
||||
- homepage.group=AI - Studios
|
||||
- homepage.name=Waterland Studio
|
||||
- homepage.icon=mdi-watercolor
|
||||
- homepage.description=Watercolour plate + reveal animation renderer (irv-ml1, A6000)
|
||||
|
||||
@@ -29,7 +29,7 @@ services:
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
labels:
|
||||
- homepage.group=AI - Gateways & Chat
|
||||
- homepage.group=AI - Inference
|
||||
- homepage.name=Zed FIM Proxy
|
||||
- homepage.icon=mdi-code-braces-box
|
||||
- homepage.description=Keyless /v1/completions for Zed edit-predictions (coder-fast, ana-docker)
|
||||
|
||||
Reference in New Issue
Block a user