SPB Git

spb/localvm-research Public License

Running LLMs larger than memory on a consumer Mac — falsification-driven research: margin-gated deferred refinement, out-of-core verification on Apple Silicon. TR-01 published.

Python 63.2% JavaScript 23.5% CSS 11.8% Shell 0.9% Makefile 0.5%

Publications: TR-01 technical report + figure generator + site tab

- docs/publications/TR-01: abstract, substrate, expG/expD + three
  refutations, architecture, 1.7B continuum, 32B out-of-core, honest
  prior-art attribution (per novelty_check), limitations, repro
- tools/make_pub_figures.py: 4 SVG figures regenerated from results/
  (validated palette, collision-checked)
- Web: /publications tab, publication cards, /raw asset route, image
  link rewriting in markdown, figure styling

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 2 h ago (Aug 12, 2026) parent 6618764

Showing 10 changed files with +601 and −0

added docs/publications/TR-01-margin-gated-deferred-refinement.md +224 −0
@@ -0,0 +1,224 @@
1 +---
2 +project: localvm-research
3 +document: docs/publications/TR-01
4 +title: "Margin-Gated Deferred Refinement: Streaming Higher-Precision LLM Quality Than Fits in Memory on Consumer Apple Silicon"
5 +author: Simon-Pierre Boucher
6 +contact: contact@spboucher.ai
7 +created: 2026-08-12
8 +status: final
9 +type: technical-report
10 +number: TR-01
11 +---
12 +
13 +# Margin-Gated Deferred Refinement: Streaming Higher-Precision LLM Quality Than Fits in Memory on Consumer Apple Silicon
14 +
15 +**Technical Report TR-01 · August 12, 2026**
16 +**Simon-Pierre Boucher** — contact@spboucher.ai
17 +*All experiments in this report are reproducible from the repository: each result
18 +carries a commit hash, configuration, seed, and hardware manifest. Raw results,
19 +code, and the full research log are browsable on this platform.*
20 +
21 +## Abstract
22 +
23 +We ask whether an existing pretrained LLM whose preferred precision does not fit
24 +in a consumer Mac's unified memory can still contribute its quality locally. On
25 +an Apple M5 Max (48 GB), we first measure the substrate: the internal NVMe
26 +sustains ~13.1 GB/s of sequential reads but only 67 MB/s at 4 KiB random QD1, and
27 +saturated Metal GPU compute perturbs SSD streaming by less than 5%. We then show
28 +that the top-1 logit margin of a low-bit quantized model strongly predicts its
29 +disagreements with the full-precision model (AUROC 0.85–0.98), improving with
30 +scale, while three plausible fine-grained escalation strategies — sensitive
31 +layers, important weight blocks, temporal working sets — are each *refuted* by
32 +measurement on a modern dense SwiGLU architecture. These results select a
33 +specific runtime: a resident 4-bit base generates optimistically and defers
34 +low-margin tokens; a same-checkpoint 8-bit variant, streamed from SSD (layer by
35 +layer when it cannot co-reside), verifies windows and rolls back flips. At 1.7B,
36 +verified output is statistically indistinguishable from resident-q8 while
37 +streaming 237 MB/token — 9× under the checkpoint — with a runtime knob τ trading
38 +bytes for quality. At 32B, where q8 (34.8 GB) cannot reside beside the base at
39 +all, the system improves judge-scored quality by +0.28 nats over the only
40 +configuration that fits, at 3.7 GB/token and 1.7 tok/s. We release all negative
41 +results, and we attribute prior art precisely: the components are anticipated
42 +(QSpec/QuantSpec; Lever; CATS; SpecExec; UGSD); the assembled composition, its
43 +bytes-per-token objective, and its unified-memory instantiation were not found.
44 +
45 +## 1. Problem
46 +
47 +A pretrained checkpoint has one size; a machine has another. Standard practice
48 +compresses the model until it fits (quantization) and accepts the quality loss,
49 +or offloads weights and accepts catastrophic latency. The research question of
50 +this project (charter §2) is whether these are really the only options:
51 +
52 +> Can total model size, resident size, and bytes-read-per-token be decoupled,
53 +> so that RAM bounds the *working set* rather than the *model*?
54 +
55 +This report documents the first architecture selected and validated by our
56 +falsification-driven campaign: eight preregistered micro-experiments (three
57 +confirmed, three refuted, two substrate/prototype), each with explicit kill
58 +criteria, followed by a two-scale prototype and an adversarial novelty check.
59 +
60 +## 2. The substrate: what an Apple SSD will actually give you
61 +
62 +Experiment H measured the internal NVMe (AP2048Z, 2 TB) under genuinely cold
63 +conditions — after discovering that `F_NOCACHE` does **not** bypass pages already
64 +resident in the unified page cache, so naïve "cold" benchmarks measure RAM
65 +(42–115 GB/s readings that exceed the device's physics). With test files written
66 +uncached and an `iostat` ground-truth log, the envelope is:
67 +
68 +![Figure 1 — Cold random-read throughput of the internal NVMe by block size and queue depth (log–log). The device ceiling ≈ 13.1 GB/s is reached at 1 MiB blocks, QD 8; 4 KiB QD1 delivers 67 MB/s — a 200× spread.](figures/f1_ssd_envelope.svg)
69 +
70 +**Figure 1.** Cold random-read throughput by block size and queue depth
71 +(3 repeats/cell, iostat-validated). The fetch contract for any weight-streaming
72 +design on this hardware: **≥ 256 KiB blocks at QD ≥ 4, or sequential**. Saturated
73 +MLX matmul load concurrently costs < 5% — I/O and compute overlap freely on
74 +unified memory.
75 +
76 +Two corollaries shaped everything downstream: storage bandwidth is *not* the
77 +binding constraint (~650 MB/token is realistic at interactive rates), and the
78 +constraint that *is* binding is the access pattern.
79 +
80 +## 3. Decisions are predictable; fine-grained escalation is not
81 +
82 +**Margins predict disagreement (expG, confirmed).** Teacher-forcing quantized
83 +variants of Qwen3-1.7B over 48 bf16 greedy trajectories (6 domains, 6 144
84 +positions/bit-width), the low-bit model's top-1 logit margin predicts whether its
85 +greedy decision matches the reference: AUROC 0.852 (3-bit), 0.898 (4-bit), 0.975
86 +(8-bit), uniform across domains. Disagreements concentrate sharply at small
87 +margins (median disagree-margin 0.75 vs agree-margin 6.5 at 4-bit). Naïve affine
88 +2-bit quantization, by contrast, destroys the model outright (3% agreement,
89 +KL ≈ 11.9) — the known 2–3-bit representational cliff, measured here with an
90 +amplitude perplexity tables understate.
91 +
92 +![Figure 2 — Fraction of tokens that must be escalated to reach 99% greedy agreement, by base precision and model scale. Every point improves at 8B.](figures/f2_escalation_scale.svg)
93 +
94 +**Figure 2.** Escalation needed for 99% agreement falls with both precision and
95 +model scale (36.6% → 22.6% at 4-bit going from 1.7B to 8B). The gating economics
96 +improve exactly where the architecture is needed — larger models.
97 +
98 +**Progressive residual coding works (expD, confirmed).** Affine residual ladders
99 +(3/3+3/3+3+3 and 4/4+4 bits, group 64) converge monotonically (hidden-state
100 +error ÷5 per stage; KL ÷30–60), and a two-tier margin policy over them reaches
101 +97.6% agreement while consulting the refinement for only 35% of tokens. At equal
102 +*static* bytes, flat quantization is mildly better — the ladder's value is that
103 +quality becomes a *runtime* variable, not a compression win.
104 +
105 +**Three escalation strategies die (expF, expA, expB — refuted).** To make
106 +per-token refinement cheap we tested, with preregistered kill criteria:
107 +layer-restricted escalation (sensitivity spread across depth groups is only
108 +1.5×; repairing the top 29% of layers recovers just 24% of the loss); block-level
109 +selection (95% of SwiGLU FFN energy needs 77% of 64-neuron blocks — real
110 +neuron-level concentration exists at 20% but is *scattered*, and bundling to
111 +SSD-viable granularity destroys it); and temporal working sets (the 95%-energy
112 +block set's union covers 99.9% of the layer within 128 tokens; Jaccard between
113 +consecutive tokens equals the random-set null). The DejaVu / LLM-in-a-flash
114 +paradigm measurably does not transfer to dense modern SwiGLU models. What
115 +remains is **batch amortization** — and Figure 1 says sequential amortized
116 +streaming is nearly free.
117 +
118 +## 4. The architecture
119 +
120 +The evidence selects a specific runtime, assembled from measured parts:
121 +
122 +1. **Resident base**: the q4 quantization of the model lives in unified memory
123 + and generates greedily with a KV cache, recording each decision's margin.
124 +2. **Deferral**: decisions with margin < τ are flagged; nothing blocks.
125 +3. **Amortized verification sweeps**: every W tokens, the *same checkpoint's*
126 + q8 variant teacher-forces the window in one pass, feeding only the unseen
127 + suffix into a persistent incremental verify-KV-cache. When q8 fits beside
128 + the base (small models), it is simply resident; when it does not, it is
129 + **streamed layer-by-layer** from SSD (materialize → compute → re-lazify),
130 + one sequential pass over the checkpoint per sweep at the device ceiling.
131 +4. **Rollback**: at the first checked flip (margin mode: deferred positions
132 + only; verify-all mode: every position), the runtime trims both KV caches,
133 + accepts the q8 token, and resumes.
134 +
135 +τ is a user-facing knob: it prices output quality in verification bytes.
136 +
137 +## 5. Results
138 +
139 +### 5.1 Quality–bytes continuum at 1.7B
140 +
141 +All outputs are scored by the bf16 reference model (mean per-token logprob of
142 +the generated text). Token-exact fidelity is *not* a usable target on this
143 +hardware: the q8 model itself flips 1.56% of its own greedy tokens between
144 +prefill and incremental decode (Metal numerics), so even the reference only
145 +"agrees with itself" at ~0.88 sequence similarity over 128 tokens.
146 +
147 +![Figure 3 — Output quality (bf16-judge mean logprob) versus verification bytes streamed per token at 1.7B. τ traces a continuum from pure-q4 to the resident-q8 ceiling.](figures/f3_quality_bytes_continuum.svg)
148 +
149 +**Figure 3.** The τ continuum at Qwen3-1.7B (24 prompts × 128 tokens, W=32):
150 +150 MB/token buys 47% of the q4→q8 quality gap, 211 MB/token buys 83%, and
151 +verify-all (237 MB/token — 9× under the 2.15 GB checkpoint) is statistically
152 +indistinguishable from resident q8 (−0.1297 vs −0.1328). Throughput 69–110
153 +tok/s against 287 (pure q4) and 200 (pure q8).
154 +
155 +### 5.2 The target regime: 32B on a 48 GB machine
156 +
157 +At Qwen3-32B, q4 (17.5 GB) is resident and q8 (34.8 GB) **cannot co-reside**
158 +— total representation 52 GB on a 48 GB machine. Sweeps stream it sequentially
159 +at ~11.6 GB/s (≈ 90% of the device ceiling), 3.0 s per sweep.
160 +
161 +![Figure 4 — Judge-scored quality at 32B: the streamed-verifier configurations versus the only configuration that actually fits.](figures/f4_32b_outofcore.svg)
162 +
163 +**Figure 4.** Qwen3-32B, 12 prompts × 96 tokens, independent 8B-bf16 judge.
164 +Verify-all improves quality by +0.28 nats (38% relative) over pure q4 — the
165 +only alternative that fits — at 3.72 GB/token logical (9.4× under the streamed
166 +checkpoint) and 1.69 tok/s. A window-doubling test (W=64) improved bytes/token
167 +by only 5%: the effective inter-sweep distance is bounded by the rollback rate
168 +(~1/0.12 ≈ 8–15 tokens), not by W — the identified levers are τ calibration and
169 +overlapping sweep I/O with generation.
170 +
171 +## 6. Relation to prior work (from the adversarial novelty check)
172 +
173 +Every *component* of this system is anticipated, and we attribute precisely:
174 +precision-tiered draft/verify with shared weights (QSpec; Apple's QuantSpec;
175 +ML-SpecQD); resident-draft with flash-streamed target on memory-limited devices
176 +(Lever, 2026; CATS, 2026 — the closest systems, both published after our
177 +literature sweep began); uncertainty-gated verification skipping (U-HLM, UGSD —
178 +against cloud verifiers); window-amortized verification economics (SpecExec);
179 +per-layer weight streaming in MLX (mlx-flash); mmap-based weight paging
180 +(llama.cpp, proposed-but-unbuilt resident-speculator variant in discussion
181 +#10466). Against this, four narrow claims survived adversarial search:
182 +**(N1)** margin-gating where the gated resource is the *weight-streaming I/O*
183 +of the same model's higher-precision variant on a single machine; **(N2)**
184 +bytes-streamed-per-token vs checkpoint size as the explicit objective, with τ
185 +exposed as a bytes↔quality knob; **(N3)** the training-free same-checkpoint
186 +q4/q8 split demonstrated where the verifier physically cannot reside; **(N4)**
187 +the unified-memory/Metal instantiation with persistent incremental verify-KV
188 +cache and trim rollback. The margin is thin and shrinking; we recommend
189 +re-running the novelty check before any external submission.
190 +
191 +## 7. Limitations
192 +
193 +Prototype speed (1.69 tok/s at 32B) is below interactive. The 32B quality
194 +ceiling is unmeasurable on this machine by construction; the 8B judge is a
195 +proxy, with no error bars at n=12; no task-level benchmarks (e.g., GSM8K) have
196 +been run yet. We have not baselined against resident q5/q6 at 32B — which *do*
197 +fit in 48 GB and may dominate this system at this scale; the architecture's
198 +necessary regime is larger models (70B-class), not yet tested. All experiments
199 +used one model family (Qwen3) and one machine.
200 +
201 +## 8. Reproducibility
202 +
203 +Hardware: Apple M5 Max, 6P+12E CPU, 40-core GPU, 48 GB unified memory, APPLE
204 +SSD AP2048Z 2 TB, macOS 27.0. Stack: Python 3.14, MLX 0.32.0, mlx-lm 0.31.3.
205 +Every result JSON embeds this manifest. Code paths:
206 +`experiments/micro/exp{A,B,D,F,G,H}_*`, `experiments/candidate_01/`
207 +(runtime.py, streaming_verifier.py, benchmark.py, benchmark_scale.py). Figures
208 +in this report are regenerated from committed results by
209 +`tools/make_pub_figures.py`. The append-only research log
210 +(`research/LOG.md`) records every decision, including the three refuted
211 +hypotheses and both measurement traps (F_NOCACHE semantics; prefill/decode
212 +argmax instability).
213 +
214 +## References
215 +
216 +Key prior art (full 300+ entry bibliography with access dates on the
217 +[Bibliography](/doc/research/bibliography.md) page; complete novelty analysis in
218 +[novelty_check.md](/doc/research/novelty_check.md)):
219 +QSpec (arXiv 2410.11305) · QuantSpec (Apple, arXiv 2502.10424) · Lever (arXiv
220 +2605.16786) · CATS (arXiv 2605.11186) · UGSD (arXiv 2603.11397) · U-HLM (arXiv
221 +2412.12687) · SpecExec (arXiv 2406.02532) · LLM in a flash (Apple, arXiv
222 +2312.11514) · PowerInfer-2 (arXiv 2406.06282) · Any-Precision LLM (ICML 2024) ·
223 +MatQuant (ICLR 2025) · BitStack (arXiv 2410.23918) · ParetoQ (arXiv 2502.02631) ·
224 +SparseGPT/Wanda/DejaVu/TEAL (per state_of_the_art.md).
added docs/publications/figures/f1_ssd_envelope.svg +37 −0
@@ -0,0 +1,37 @@
1 +<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 330' width='640' height='330'>
2 +<rect width='640' height='330' fill='#fcfcfb'/>
3 +<line x1='64' y1='253.5' x2='556' y2='253.5' stroke='#e1e0d9'/>
4 +<text x='56.0' y='257.5' font-family='ui-monospace, Menlo, monospace' font-size='11' fill='#898781' text-anchor='end' font-weight='400'>100 MB/s</text>
5 +<line x1='64' y1='152.0' x2='556' y2='152.0' stroke='#e1e0d9'/>
6 +<text x='56.0' y='156.0' font-family='ui-monospace, Menlo, monospace' font-size='11' fill='#898781' text-anchor='end' font-weight='400'>1 GB/s</text>
7 +<line x1='64' y1='50.5' x2='556' y2='50.5' stroke='#e1e0d9'/>
8 +<text x='56.0' y='54.5' font-family='ui-monospace, Menlo, monospace' font-size='11' fill='#898781' text-anchor='end' font-weight='400'>10 GB/s</text>
9 +<text x='64.0' y='302.0' font-family='ui-monospace, Menlo, monospace' font-size='11' fill='#898781' text-anchor='middle' font-weight='400'>4 KiB</text>
10 +<text x='187.0' y='302.0' font-family='ui-monospace, Menlo, monospace' font-size='11' fill='#898781' text-anchor='middle' font-weight='400'>16 KiB</text>
11 +<text x='310.0' y='302.0' font-family='ui-monospace, Menlo, monospace' font-size='11' fill='#898781' text-anchor='middle' font-weight='400'>64 KiB</text>
12 +<text x='433.0' y='302.0' font-family='ui-monospace, Menlo, monospace' font-size='11' fill='#898781' text-anchor='middle' font-weight='400'>256 KiB</text>
13 +<text x='556.0' y='302.0' font-family='ui-monospace, Menlo, monospace' font-size='11' fill='#898781' text-anchor='middle' font-weight='400'>1 MiB</text>
14 +<line x1='64' y1='284' x2='556' y2='284' stroke='#c3c2b7'/>
15 +<path d='M64.0,271.1 L187.0,209.4 L310.0,163.5 L433.0,115.3 L556.0,85.2' fill='none' stroke='#2a78d6' stroke-width='2'/>
16 +<circle cx='64.0' cy='271.1' r='4' fill='#2a78d6' stroke='#fcfcfb' stroke-width='1.5'/>
17 +<circle cx='187.0' cy='209.4' r='4' fill='#2a78d6' stroke='#fcfcfb' stroke-width='1.5'/>
18 +<circle cx='310.0' cy='163.5' r='4' fill='#2a78d6' stroke='#fcfcfb' stroke-width='1.5'/>
19 +<circle cx='433.0' cy='115.3' r='4' fill='#2a78d6' stroke='#fcfcfb' stroke-width='1.5'/>
20 +<circle cx='556.0' cy='85.2' r='4' fill='#2a78d6' stroke='#fcfcfb' stroke-width='1.5'/>
21 +<text x='566.0' y='89.2' font-family='system-ui, -apple-system, sans-serif' font-size='12' fill='#2a78d6' text-anchor='start' font-weight='600'>QD 1</text>
22 +<path d='M64.0,212.4 L187.0,151.1 L310.0,104.8 L433.0,59.6 L556.0,36.9' fill='none' stroke='#eb6834' stroke-width='2'/>
23 +<circle cx='64.0' cy='212.4' r='4' fill='#eb6834' stroke='#fcfcfb' stroke-width='1.5'/>
24 +<circle cx='187.0' cy='151.1' r='4' fill='#eb6834' stroke='#fcfcfb' stroke-width='1.5'/>
25 +<circle cx='310.0' cy='104.8' r='4' fill='#eb6834' stroke='#fcfcfb' stroke-width='1.5'/>
26 +<circle cx='433.0' cy='59.6' r='4' fill='#eb6834' stroke='#fcfcfb' stroke-width='1.5'/>
27 +<circle cx='556.0' cy='36.9' r='4' fill='#eb6834' stroke='#fcfcfb' stroke-width='1.5'/>
28 +<text x='566.0' y='40.9' font-family='system-ui, -apple-system, sans-serif' font-size='12' fill='#eb6834' text-anchor='start' font-weight='600'>QD 4</text>
29 +<path d='M64.0,184.3 L187.0,123.4 L310.0,79.9 L433.0,44.0 L556.0,36.4' fill='none' stroke='#1baf7a' stroke-width='2'/>
30 +<circle cx='64.0' cy='184.3' r='4' fill='#1baf7a' stroke='#fcfcfb' stroke-width='1.5'/>
31 +<circle cx='187.0' cy='123.4' r='4' fill='#1baf7a' stroke='#fcfcfb' stroke-width='1.5'/>
32 +<circle cx='310.0' cy='79.9' r='4' fill='#1baf7a' stroke='#fcfcfb' stroke-width='1.5'/>
33 +<circle cx='433.0' cy='44.0' r='4' fill='#1baf7a' stroke='#fcfcfb' stroke-width='1.5'/>
34 +<circle cx='556.0' cy='36.4' r='4' fill='#1baf7a' stroke='#fcfcfb' stroke-width='1.5'/>
35 +<text x='566.0' y='40.4' font-family='system-ui, -apple-system, sans-serif' font-size='12' fill='#1baf7a' text-anchor='start' font-weight='600'>QD 8</text>
36 +<text x='310.0' y='322.0' font-family='system-ui, -apple-system, sans-serif' font-size='12' fill='#52514e' text-anchor='middle' font-weight='400'>random read block size (F_NOCACHE cold, log–log)</text>
37 +</svg>
\ No newline at end of file
added docs/publications/figures/f2_escalation_scale.svg +30 −0
@@ -0,0 +1,30 @@
1 +<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 560 320' width='560' height='320'>
2 +<rect width='560' height='320' fill='#fcfcfb'/>
3 +<line x1='64' y1='201.4' x2='480' y2='201.4' stroke='#e1e0d9'/>
4 +<text x='56.0' y='205.4' font-family='ui-monospace, Menlo, monospace' font-size='11' fill='#898781' text-anchor='end' font-weight='400'>20%</text>
5 +<line x1='64' y1='128.9' x2='480' y2='128.9' stroke='#e1e0d9'/>
6 +<text x='56.0' y='132.9' font-family='ui-monospace, Menlo, monospace' font-size='11' fill='#898781' text-anchor='end' font-weight='400'>40%</text>
7 +<line x1='64' y1='56.3' x2='480' y2='56.3' stroke='#e1e0d9'/>
8 +<text x='56.0' y='60.3' font-family='ui-monospace, Menlo, monospace' font-size='11' fill='#898781' text-anchor='end' font-weight='400'>60%</text>
9 +<line x1='64' y1='274' x2='480' y2='274' stroke='#c3c2b7'/>
10 +<text x='113.9' y='292.0' font-family='system-ui, -apple-system, sans-serif' font-size='12' fill='#898781' text-anchor='middle' font-weight='400'>3-bit base</text>
11 +<text x='272.0' y='292.0' font-family='system-ui, -apple-system, sans-serif' font-size='12' fill='#898781' text-anchor='middle' font-weight='400'>4-bit base</text>
12 +<text x='430.1' y='292.0' font-family='system-ui, -apple-system, sans-serif' font-size='12' fill='#898781' text-anchor='middle' font-weight='400'>8-bit base</text>
13 +<path d='M113.9,55.7 L272.0,141.2 L430.1,255.5' fill='none' stroke='#2a78d6' stroke-width='2'/>
14 +<circle cx='113.9' cy='55.7' r='4.5' fill='#2a78d6' stroke='#fcfcfb' stroke-width='1.5'/>
15 +<text x='113.9' y='45.7' font-family='ui-monospace, Menlo, monospace' font-size='11' fill='#2a78d6' text-anchor='middle' font-weight='600'>60%</text>
16 +<circle cx='272.0' cy='141.2' r='4.5' fill='#2a78d6' stroke='#fcfcfb' stroke-width='1.5'/>
17 +<text x='272.0' y='131.2' font-family='ui-monospace, Menlo, monospace' font-size='11' fill='#2a78d6' text-anchor='middle' font-weight='600'>37%</text>
18 +<circle cx='430.1' cy='255.5' r='4.5' fill='#2a78d6' stroke='#fcfcfb' stroke-width='1.5'/>
19 +<text x='430.1' y='245.5' font-family='ui-monospace, Menlo, monospace' font-size='11' fill='#2a78d6' text-anchor='middle' font-weight='600'>5%</text>
20 +<text x='442.1' y='259.5' font-family='system-ui, -apple-system, sans-serif' font-size='12' fill='#2a78d6' text-anchor='start' font-weight='600'>Qwen3-1.7B</text>
21 +<path d='M113.9,125.3 L272.0,192.0 L430.1,268.2' fill='none' stroke='#eb6834' stroke-width='2'/>
22 +<circle cx='113.9' cy='125.3' r='4.5' fill='#eb6834' stroke='#fcfcfb' stroke-width='1.5'/>
23 +<text x='113.9' y='115.3' font-family='ui-monospace, Menlo, monospace' font-size='11' fill='#eb6834' text-anchor='middle' font-weight='600'>41%</text>
24 +<circle cx='272.0' cy='192.0' r='4.5' fill='#eb6834' stroke='#fcfcfb' stroke-width='1.5'/>
25 +<text x='272.0' y='182.0' font-family='ui-monospace, Menlo, monospace' font-size='11' fill='#eb6834' text-anchor='middle' font-weight='600'>23%</text>
26 +<circle cx='430.1' cy='268.2' r='4.5' fill='#eb6834' stroke='#fcfcfb' stroke-width='1.5'/>
27 +<text x='430.1' y='258.2' font-family='ui-monospace, Menlo, monospace' font-size='11' fill='#eb6834' text-anchor='middle' font-weight='600'>2%</text>
28 +<text x='442.1' y='272.2' font-family='system-ui, -apple-system, sans-serif' font-size='12' fill='#eb6834' text-anchor='start' font-weight='600'>Qwen3-8B</text>
29 +<text x='272.0' y='312.0' font-family='system-ui, -apple-system, sans-serif' font-size='12' fill='#52514e' text-anchor='middle' font-weight='400'>tokens needing escalation for 99% greedy agreement</text>
30 +</svg>
\ No newline at end of file
added docs/publications/figures/f3_quality_bytes_continuum.svg +28 −0
@@ -0,0 +1,28 @@
1 +<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 620 330' width='620' height='330'>
2 +<rect width='620' height='330' fill='#fcfcfb'/>
3 +<line x1='74' y1='268.8' x2='580' y2='268.8' stroke='#e1e0d9'/>
4 +<text x='66.0' y='272.8' font-family='ui-monospace, Menlo, monospace' font-size='11' fill='#898781' text-anchor='end' font-weight='400'>-0.4</text>
5 +<line x1='74' y1='192.9' x2='580' y2='192.9' stroke='#e1e0d9'/>
6 +<text x='66.0' y='196.9' font-family='ui-monospace, Menlo, monospace' font-size='11' fill='#898781' text-anchor='end' font-weight='400'>-0.3</text>
7 +<line x1='74' y1='117.1' x2='580' y2='117.1' stroke='#e1e0d9'/>
8 +<text x='66.0' y='121.1' font-family='ui-monospace, Menlo, monospace' font-size='11' fill='#898781' text-anchor='end' font-weight='400'>-0.2</text>
9 +<line x1='74' y1='41.2' x2='580' y2='41.2' stroke='#e1e0d9'/>
10 +<text x='66.0' y='45.2' font-family='ui-monospace, Menlo, monospace' font-size='11' fill='#898781' text-anchor='end' font-weight='400'>-0.1</text>
11 +<text x='74.0' y='302.0' font-family='ui-monospace, Menlo, monospace' font-size='11' fill='#898781' text-anchor='middle' font-weight='400'>0 MB</text>
12 +<text x='268.6' y='302.0' font-family='ui-monospace, Menlo, monospace' font-size='11' fill='#898781' text-anchor='middle' font-weight='400'>100 MB</text>
13 +<text x='463.2' y='302.0' font-family='ui-monospace, Menlo, monospace' font-size='11' fill='#898781' text-anchor='middle' font-weight='400'>200 MB</text>
14 +<line x1='74' y1='284' x2='580' y2='284' stroke='#c3c2b7'/>
15 +<line x1='74' y1='66.1' x2='580' y2='66.1' stroke='#898781' stroke-dasharray='5,4'/>
16 +<text x='78.0' y='81.1' font-family='system-ui, -apple-system, sans-serif' font-size='11' fill='#898781' text-anchor='start' font-weight='400'>resident-q8 ceiling (2.15 GB in RAM)</text>
17 +<path d='M74.0,259.1 L365.9,168.0 L484.6,99.0 L535.2,63.7' fill='none' stroke='#2a78d6' stroke-width='2'/>
18 +<circle cx='74.0' cy='259.1' r='5' fill='#2a78d6' stroke='#fcfcfb' stroke-width='2'/>
19 +<text x='83.0' y='273.1' font-family='system-ui, -apple-system, sans-serif' font-size='12' fill='#0b0b0b' text-anchor='start' font-weight='600'>pure q4</text>
20 +<circle cx='365.9' cy='168.0' r='5' fill='#2a78d6' stroke='#fcfcfb' stroke-width='2'/>
21 +<text x='374.9' y='159.0' font-family='system-ui, -apple-system, sans-serif' font-size='12' fill='#0b0b0b' text-anchor='start' font-weight='600'>τ=1.0</text>
22 +<circle cx='484.6' cy='99.0' r='5' fill='#2a78d6' stroke='#fcfcfb' stroke-width='2'/>
23 +<text x='493.6' y='90.0' font-family='system-ui, -apple-system, sans-serif' font-size='12' fill='#0b0b0b' text-anchor='start' font-weight='600'>τ=2.0</text>
24 +<circle cx='535.2' cy='63.7' r='5' fill='#2a78d6' stroke='#fcfcfb' stroke-width='2'/>
25 +<text x='524.2' y='67.7' font-family='system-ui, -apple-system, sans-serif' font-size='12' fill='#0b0b0b' text-anchor='end' font-weight='600'>verify-all</text>
26 +<text x='327.0' y='322.0' font-family='system-ui, -apple-system, sans-serif' font-size='12' fill='#52514e' text-anchor='middle' font-weight='400'>verification bytes streamed per token (Qwen3-1.7B, W=32)</text>
27 +<text x='16.0' y='155.0' font-family='system-ui, -apple-system, sans-serif' font-size='12' fill='#52514e' text-anchor='middle' font-weight='400' transform='rotate(-90 16 155.0)'>bf16-judge mean logprob →</text>
28 +</svg>
\ No newline at end of file
added docs/publications/figures/f4_32b_outofcore.svg +13 −0
@@ -0,0 +1,13 @@
1 +<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 210' width='640' height='210'>
2 +<rect width='640' height='210' fill='#fcfcfb'/>
3 +<text x='26.0' y='36.0' font-family='system-ui, -apple-system, sans-serif' font-size='12.5' fill='#52514e' text-anchor='start' font-weight='400'>pure q4 — only config that fits (17.5 GB)</text>
4 +<line x1='26.0' y1='53' x2='90.7' y2='53' stroke='#898781' stroke-width='9' stroke-linecap='round'/>
5 +<text x='100.7' y='57.0' font-family='ui-monospace, Menlo, monospace' font-size='12' fill='#0b0b0b' text-anchor='start' font-weight='650'>-0.751</text>
6 +<text x='26.0' y='88.0' font-family='system-ui, -apple-system, sans-serif' font-size='12.5' fill='#52514e' text-anchor='start' font-weight='400'>margin τ=2.0 — q8 streamed on low margins</text>
7 +<line x1='26.0' y1='105' x2='358.8' y2='105' stroke='#2a78d6' stroke-width='9' stroke-linecap='round'/>
8 +<text x='368.8' y='109.0' font-family='ui-monospace, Menlo, monospace' font-size='12' fill='#0b0b0b' text-anchor='start' font-weight='650'>-0.546</text>
9 +<text x='26.0' y='140.0' font-family='system-ui, -apple-system, sans-serif' font-size='12.5' fill='#52514e' text-anchor='start' font-weight='400'>verify-all — q8 streamed every window</text>
10 +<line x1='26.0' y1='157' x2='462.7' y2='157' stroke='#2a78d6' stroke-width='9' stroke-linecap='round'/>
11 +<text x='472.7' y='161.0' font-family='ui-monospace, Menlo, monospace' font-size='12' fill='#0b0b0b' text-anchor='start' font-weight='650'>-0.467</text>
12 +<text x='26.0' y='200.0' font-family='system-ui, -apple-system, sans-serif' font-size='11.5' fill='#898781' text-anchor='start' font-weight='400'>8B-judge mean logprob (higher is better) · Qwen3-32B on a 48 GB Mac — resident q8 is impossible</text>
13 +</svg>
\ No newline at end of file
modified research/LOG.md +15 −0
@@ -353,3 +353,18 @@ Format per entry: date/time (local, with timezone) · question · experiment ·
353 353 (fewer checked positions → fewer rollback triggers) and sweep-I/O/generation overlap.
354 354 - **Decision:** Session milestone reached; next cycle: honest baselines (resident q5/q6 at 32B),
355 355 τ calibration on the real q4-32B margin distribution, task-level benchmarks, 70B point.
356 +
357 +---
358 +
359 +## 2026-08-12 20:10 EDT — TR-01 published: first official technical report
360 +
361 +- **Question:** n/a (dissemination).
362 +- **Experiment:** n/a.
363 +- **Result:** `docs/publications/TR-01-margin-gated-deferred-refinement.md` — full write-up of
364 + results to date (substrate, decision stability, three refutations, architecture, 1.7B
365 + continuum, 32B out-of-core run), with 4 SVG figures regenerated from committed results by
366 + `tools/make_pub_figures.py`. Prior-art attribution follows novelty_check.md; limitations
367 + section names the missing baselines (resident q5/q6 at 32B, task benchmarks, 70B).
368 + Published on the platform under the new Publications tab.
369 +- **Interpretation:** n/a.
370 +- **Decision:** TR-02 planned once the honest-baseline campaign (q5/q6, tasks, 70B) completes.
added tools/make_pub_figures.py +192 −0
@@ -0,0 +1,192 @@
1 +#!/usr/bin/env python3
2 +# =============================================================================
3 +# Project : localvm-research
4 +# File : tools/make_pub_figures.py
5 +# Purpose : Generate publication SVG figures from committed results JSON
6 +# Author : Simon-Pierre Boucher
7 +# Contact : contact@spboucher.ai
8 +# Created : 2026-08-12
9 +# Modified : 2026-08-12
10 +# Platform : macOS / Apple Silicon (arm64)
11 +# License : All rights reserved (research code)
12 +# =============================================================================
13 +"""Regenerates docs/publications/figures/*.svg from results/ (reproducible).
14 +
15 +Palette: validated categorical slots (see dataviz reference; CVD-checked):
16 +blue #2a78d6, orange #eb6834, aqua #1baf7a. Ink/grid tokens match the site.
17 +"""
18 +
19 +from __future__ import annotations
20 +
21 +import glob
22 +import json
23 +import math
24 +from pathlib import Path
25 +
26 +ROOT = Path(__file__).resolve().parent.parent
27 +OUT = ROOT / "docs" / "publications" / "figures"
28 +OUT.mkdir(parents=True, exist_ok=True)
29 +
30 +INK, INK2, MUTED, GRID, BASE = "#0b0b0b", "#52514e", "#898781", "#e1e0d9", "#c3c2b7"
31 +BLUE, ORANGE, AQUA = "#2a78d6", "#eb6834", "#1baf7a"
32 +FONT = "font-family='system-ui, -apple-system, sans-serif'"
33 +MONO = "font-family='ui-monospace, Menlo, monospace'"
34 +
35 +
36 +def latest(pattern: str) -> dict:
37 + return json.load(open(sorted(glob.glob(str(ROOT / pattern)))[-1]))
38 +
39 +
40 +def svg_open(w: int, h: int) -> list[str]:
41 + return [f"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 {w} {h}' "
42 + f"width='{w}' height='{h}'>",
43 + f"<rect width='{w}' height='{h}' fill='#fcfcfb'/>"]
44 +
45 +
46 +def text(x, y, s, size=12, fill=INK2, anchor="start", weight="400", mono=False):
47 + f = MONO if mono else FONT
48 + return (f"<text x='{x:.1f}' y='{y:.1f}' {f} font-size='{size}' fill='{fill}' "
49 + f"text-anchor='{anchor}' font-weight='{weight}'>{s}</text>")
50 +
51 +
52 +# ---------------------------------------------------------------- F1: SSD
53 +def fig1_ssd() -> None:
54 + d = latest("results/expH_ssd_feasibility/*/results.json")
55 + cells = [c for c in d["cells"] if c["nocache"] and not c["gpu_load"]
56 + and c["pattern"] == "random" and c["block_bytes"] <= 1 << 20]
57 + series: dict[int, list] = {}
58 + for c in cells:
59 + series.setdefault(c["threads"], []).append((c["block_bytes"], c["mb_per_s_mean"]))
60 + W, H, ML, MR, MT, MB_ = 640, 330, 64, 84, 20, 46
61 + iw, ih = W - ML - MR, H - MT - MB_
62 + xs = lambda b: ML + (math.log2(b) - 12) / 8 * iw
63 + ys = lambda v: MT + ih - (math.log10(v) - math.log10(50)) / (math.log10(20000) - math.log10(50)) * ih
64 + out = svg_open(W, H)
65 + for v in (100, 1000, 10000):
66 + out.append(f"<line x1='{ML}' y1='{ys(v):.1f}' x2='{ML+iw}' y2='{ys(v):.1f}' stroke='{GRID}'/>")
67 + out.append(text(ML - 8, ys(v) + 4, f"{v//1000} GB/s" if v >= 1000 else f"{v} MB/s",
68 + 11, MUTED, "end", mono=True))
69 + for b in (4096, 16384, 65536, 262144, 1048576):
70 + lbl = "1 MiB" if b >= 1048576 else f"{b//1024} KiB"
71 + out.append(text(xs(b), MT + ih + 18, lbl, 11, MUTED, "middle", mono=True))
72 + out.append(f"<line x1='{ML}' y1='{MT+ih}' x2='{ML+iw}' y2='{MT+ih}' stroke='{BASE}'/>")
73 + colors = {1: BLUE, 4: ORANGE, 8: AQUA}
74 + for qd, pts in sorted(series.items()):
75 + pts.sort()
76 + c = colors[qd]
77 + pd = " ".join(f"{'M' if i == 0 else 'L'}{xs(b):.1f},{ys(v):.1f}" for i, (b, v) in enumerate(pts))
78 + out.append(f"<path d='{pd}' fill='none' stroke='{c}' stroke-width='2'/>")
79 + for b, v in pts:
80 + out.append(f"<circle cx='{xs(b):.1f}' cy='{ys(v):.1f}' r='4' fill='{c}' "
81 + f"stroke='#fcfcfb' stroke-width='1.5'/>")
82 + out.append(text(xs(pts[-1][0]) + 10, ys(pts[-1][1]) + 4, f"QD {qd}", 12, c, weight="600"))
83 + out.append(text(ML + iw / 2, H - 8, "random read block size (F_NOCACHE cold, log–log)", 12, INK2, "middle"))
84 + out.append("</svg>")
85 + (OUT / "f1_ssd_envelope.svg").write_text("\n".join(out))
86 +
87 +
88 +# ---------------------------------------------------------------- F2: expG scale
89 +def fig2_escalation() -> None:
90 + runs = sorted(glob.glob(str(ROOT / "results/expG_decision_stability/*/results.json")))
91 + data = {}
92 + for r in runs:
93 + d = json.load(open(r))
94 + if d["n_trajectories"] < 40:
95 + continue
96 + scale = "8B" if "8B" in d["config"]["model"] else "1.7B"
97 + data[scale] = {int(b): s.get("escalation_frac_for_99pct")
98 + for b, s in d["results_by_bits"].items() if int(b) >= 3}
99 + W, H, ML, MR, MT, MB_ = 560, 320, 64, 80, 20, 46
100 + iw, ih = W - ML - MR, H - MT - MB_
101 + xs = {3: ML + iw * 0.12, 4: ML + iw * 0.5, 8: ML + iw * 0.88}
102 + ys = lambda v: MT + ih - v / 0.7 * ih
103 + out = svg_open(W, H)
104 + for v in (0.2, 0.4, 0.6):
105 + out.append(f"<line x1='{ML}' y1='{ys(v):.1f}' x2='{ML+iw}' y2='{ys(v):.1f}' stroke='{GRID}'/>")
106 + out.append(text(ML - 8, ys(v) + 4, f"{int(v*100)}%", 11, MUTED, "end", mono=True))
107 + out.append(f"<line x1='{ML}' y1='{MT+ih}' x2='{ML+iw}' y2='{MT+ih}' stroke='{BASE}'/>")
108 + for b in (3, 4, 8):
109 + out.append(text(xs[b], MT + ih + 18, f"{b}-bit base", 12, MUTED, "middle"))
110 + for scale, color in (("1.7B", BLUE), ("8B", ORANGE)):
111 + pts = [(b, v) for b, v in sorted(data.get(scale, {}).items()) if v]
112 + pd = " ".join(f"{'M' if i == 0 else 'L'}{xs[b]:.1f},{ys(v):.1f}" for i, (b, v) in enumerate(pts))
113 + out.append(f"<path d='{pd}' fill='none' stroke='{color}' stroke-width='2'/>")
114 + for b, v in pts:
115 + out.append(f"<circle cx='{xs[b]:.1f}' cy='{ys(v):.1f}' r='4.5' fill='{color}' "
116 + f"stroke='#fcfcfb' stroke-width='1.5'/>")
117 + dy = 20 if (scale == "8B" and b == 8) else -10 # avoid series-label collision
118 + out.append(text(xs[b], ys(v) + dy, f"{v*100:.0f}%", 11, color, "middle", "600", mono=True))
119 + out.append(text(xs[pts[-1][0]] + 12, ys(pts[-1][1]) + 4, f"Qwen3-{scale}", 12, color, weight="600"))
120 + out.append(text(ML + iw / 2, H - 8, "tokens needing escalation for 99% greedy agreement", 12, INK2, "middle"))
121 + out.append("</svg>")
122 + (OUT / "f2_escalation_scale.svg").write_text("\n".join(out))
123 +
124 +
125 +# ---------------------------------------------------------------- F3: continuum
126 +def fig3_continuum() -> None:
127 + runs = [json.load(open(p)) for p in sorted(glob.glob(str(ROOT / "results/candidate_01/*/results.json")))]
128 + d = [r for r in runs if "quality_bf16_judge" in r][-1]
129 + q = d["quality_bf16_judge"]
130 + pts = [("pure q4", 0.0, q["pure_q4"]["mean_logprob_bf16"]),
131 + ("τ=1.0", 150, q["margin_tau1.0"]["mean_logprob_bf16"]),
132 + ("τ=2.0", 211, q["margin_tau2.0"]["mean_logprob_bf16"]),
133 + ("verify-all", 237, q["verify-all_tau2.0"]["mean_logprob_bf16"])]
134 + ceiling = q["pure_q8"]["mean_logprob_bf16"]
135 + W, H, ML, MR, MT, MB_ = 620, 330, 74, 40, 26, 46
136 + iw, ih = W - ML - MR, H - MT - MB_
137 + xs = lambda v: ML + v / 260 * iw
138 + ys = lambda v: MT + ih - (v + 0.42) / 0.34 * ih
139 + out = svg_open(W, H)
140 + for v in (-0.4, -0.3, -0.2, -0.1):
141 + out.append(f"<line x1='{ML}' y1='{ys(v):.1f}' x2='{ML+iw}' y2='{ys(v):.1f}' stroke='{GRID}'/>")
142 + out.append(text(ML - 8, ys(v) + 4, f"{v:.1f}", 11, MUTED, "end", mono=True))
143 + for v in (0, 100, 200):
144 + out.append(text(xs(v), MT + ih + 18, f"{v} MB", 11, MUTED, "middle", mono=True))
145 + out.append(f"<line x1='{ML}' y1='{MT+ih}' x2='{ML+iw}' y2='{MT+ih}' stroke='{BASE}'/>")
146 + out.append(f"<line x1='{ML}' y1='{ys(ceiling):.1f}' x2='{ML+iw}' y2='{ys(ceiling):.1f}' "
147 + f"stroke='{MUTED}' stroke-dasharray='5,4'/>")
148 + out.append(text(ML + 4, ys(ceiling) + 15, "resident-q8 ceiling (2.15 GB in RAM)", 11, MUTED))
149 + pd = " ".join(f"{'M' if i == 0 else 'L'}{xs(x):.1f},{ys(y):.1f}" for i, (_, x, y) in enumerate(pts))
150 + out.append(f"<path d='{pd}' fill='none' stroke='{BLUE}' stroke-width='2'/>")
151 + for name, x, y in pts:
152 + out.append(f"<circle cx='{xs(x):.1f}' cy='{ys(y):.1f}' r='5' fill='{BLUE}' "
153 + f"stroke='#fcfcfb' stroke-width='2'/>")
154 + if name == "verify-all":
155 + out.append(text(xs(x) - 11, ys(y) + 4, name, 12, INK, "end", "600"))
156 + else:
157 + out.append(text(xs(x) + 9, ys(y) + (14 if name == "pure q4" else -9), name, 12, INK, weight="600"))
158 + out.append(text(ML + iw / 2, H - 8, "verification bytes streamed per token (Qwen3-1.7B, W=32)", 12, INK2, "middle"))
159 + out.append(text(16, MT + ih / 2, "bf16-judge mean logprob →", 12, INK2, "middle")
160 + .replace(">", f" transform='rotate(-90 16 {MT+ih/2})'>", 1))
161 + out.append("</svg>")
162 + (OUT / "f3_quality_bytes_continuum.svg").write_text("\n".join(out))
163 +
164 +
165 +# ---------------------------------------------------------------- F4: 32B
166 +def fig4_scale32b() -> None:
167 + d = latest("results/candidate_01_scale32b/20260812T055506Z/results.json")
168 + q = d["quality_8b_judge"]
169 + rows = [("pure q4 — only config that fits (17.5 GB)", q["pure_q4"]["mean_logprob_8b_judge"]),
170 + ("margin τ=2.0 — q8 streamed on low margins", q["margin_tau2.0"]["mean_logprob_8b_judge"]),
171 + ("verify-all — q8 streamed every window", q["verify-all_tau2.0"]["mean_logprob_8b_judge"])]
172 + W, H, ML, MR, MT = 640, 210, 26, 90, 24
173 + row_h = 52
174 + x0, x1 = -0.80, -0.40
175 + xs = lambda v: ML + (v - x0) / (x1 - x0) * (W - ML - MR)
176 + out = svg_open(W, H)
177 + for i, (name, v) in enumerate(rows):
178 + y = MT + i * row_h + 20
179 + color = BLUE if i else MUTED
180 + out.append(text(ML, y - 8, name, 12.5, INK2))
181 + out.append(f"<line x1='{xs(x0)}' y1='{y+9}' x2='{xs(v):.1f}' y2='{y+9}' "
182 + f"stroke='{color}' stroke-width='9' stroke-linecap='round'/>")
183 + out.append(text(xs(v) + 10, y + 13, f"{v:.3f}", 12, INK, weight="650", mono=True))
184 + out.append(text(ML, H - 10, "8B-judge mean logprob (higher is better) · Qwen3-32B on a 48 GB Mac — "
185 + "resident q8 is impossible", 11.5, MUTED))
186 + out.append("</svg>")
187 + (OUT / "f4_32b_outofcore.svg").write_text("\n".join(out))
188 +
189 +
190 +if __name__ == "__main__":
191 + fig1_ssd(); fig2_escalation(); fig3_continuum(); fig4_scale32b()
192 + print("figures written to", OUT)
modified web/lib/render.js +6 −0
@@ -49,6 +49,11 @@ function markdownToHtml(md, baseRel) {
49 49 },
50 50 },
51 51 walkTokens(token) {
52 + if (token.type === "image" && token.href && !/^(https?:|data:|\/)/.test(token.href)) {
53 + const base = baseRel.includes("/") ? baseRel.slice(0, baseRel.lastIndexOf("/")) : "";
54 + token.href = "/raw/" + (base ? base + "/" : "") + token.href;
55 + return;
56 + }
52 57 if (token.type === "link" && token.href && !/^(https?:|mailto:|#|\/)/.test(token.href)) {
53 58 const base = baseRel.includes("/") ? baseRel.slice(0, baseRel.lastIndexOf("/")) : "";
54 59 const joined = (base ? base + "/" : "") + token.href;
@@ -132,6 +137,7 @@ const NAV = [
132 137 ["/results", "Results"],
133 138 ["/code", "Code"],
134 139 ["/doc/research/bibliography.md", "Bibliography"],
140 + ["/publications", "Publications"],
135 141 ["/comments", "Comments"],
136 142 ["/about", "About"],
137 143 ];
modified web/public/style.css +19 −0
@@ -389,3 +389,22 @@ h2:hover .hanchor, h3:hover .hanchor, h4:hover .hanchor { opacity: 1; text-decor
389 389 @media (max-width: 420px) {
390 390 .hero h1 { font-size: 24px; }
391 391 }
392 +
393 +/* ---- publications ---------------------------------------------------------- */
394 +.pub-list { max-width: 860px; margin: 24px 0; }
395 +.pub-card {
396 + display: block; background: var(--surface); border: 1px solid var(--grid);
397 + border-radius: var(--radius); padding: 26px 30px; margin-bottom: 18px;
398 + color: var(--ink); box-shadow: var(--shadow-1);
399 + transition: box-shadow 0.18s, transform 0.18s, border-color 0.18s;
400 +}
401 +.pub-card:hover { border-color: #b9d2f0; box-shadow: var(--shadow-2); transform: translateY(-2px); text-decoration: none; }
402 +.pub-meta { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; }
403 +.pub-card h2 { margin: 0 0 6px; font-size: 20px; letter-spacing: -0.015em; line-height: 1.3; }
404 +.pub-authors { margin: 0 0 10px; font-size: 14px; color: var(--accent-dark); font-weight: 600; }
405 +.pub-abstract { margin: 0 0 12px; font-size: 14px; color: var(--ink-2); }
406 +.md img {
407 + max-width: 100%; height: auto; display: block; margin: 20px auto 6px;
408 + background: #fff; border: 1px solid var(--grid); border-radius: 10px; padding: 12px;
409 +}
410 +.md img + em, .md p > img ~ em { display: block; text-align: center; }
modified web/server.js +37 −0
@@ -352,6 +352,43 @@ why an approach died, not just what survived.</p>
352 352 page(res, { title: "About", active: "About", body });
353 353 });
354 354
355 +// ---------------------------------------------------------------- publications
356 +app.get("/publications", (req, res) => {
357 + const pubs = (C.listDir("docs/publications") || [])
358 + .filter((f) => f.name.endsWith(".md"))
359 + .map((f) => {
360 + const md = C.readMarkdown(f.rel);
361 + const abstract = (md.content.split(/## Abstract/i)[1] || "").trim().split("\n\n")[0] || "";
362 + return { rel: f.rel, data: md.data, abstract };
363 + })
364 + .sort((a, b) => String(b.data.created).localeCompare(String(a.data.created)));
365 + const cards = pubs.length ? pubs.map((p) => `
366 + <a class="pub-card" href="/doc/${p.rel}">
367 + <div class="pub-meta"><span class="badge badge-done">${R.esc(p.data.number || "report")}</span>
368 + <span class="mono-small">${R.esc(p.data.created || "")}</span>
369 + <span class="badge badge-${p.data.status === "final" ? "done" : "in-progress"}">${R.esc(p.data.status || "draft")}</span></div>
370 + <h2>${R.esc(p.data.title || p.rel)}</h2>
371 + <p class="pub-authors">${R.esc(p.data.author || "")}</p>
372 + <p class="pub-abstract">${R.esc(p.abstract.slice(0, 420))}${p.abstract.length > 420 ? "…" : ""}</p>
373 + <span class="more">Read the full report →</span>
374 + </a>`).join("") : "<p class='lede-small'>No publications yet.</p>";
375 + const body = `<h1 class="page-title">Publications</h1>
376 +<p class="lede-small">Official write-ups of the project's results to date — every number traceable
377 +to a committed result file with hardware manifest, every figure regenerated from raw data.</p>
378 +<div class="pub-list">${cards}</div>`;
379 + page(res, { title: "Publications", active: "Publications", body });
380 +});
381 +
382 +// raw asset serving (figures referenced by publications)
383 +app.get(/^\/raw\/(.+)$/, (req, res) => {
384 + const rel = req.params[0];
385 + const ext = path.extname(rel).toLowerCase();
386 + const types = { ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg" };
387 + const abs = C.safeResolve(rel);
388 + if (!abs || !types[ext] || !require("fs").existsSync(abs)) return notFound(res);
389 + res.type(types[ext]).sendFile(abs);
390 +});
391 +
355 392 // ---------------------------------------------------------------- comments
356 393 app.get("/comments", (req, res) => {
357 394 const AVATAR_COLORS = ["#2a78d6", "#eb6834", "#1baf7a", "#4a3aa7", "#e87ba4", "#008300"];
358 395