frankyy03 commited on
Commit
9409aaa
·
verified ·
1 Parent(s): 85bd34a

Custom gr.Server frontend + 6-teacher model (Nemotron), token streaming, arena

Browse files
Files changed (12) hide show
  1. README.md +18 -6
  2. _boot.py +136 -0
  3. _fig.py +1 -1
  4. _glb.py +1 -0
  5. _html.py +15 -13
  6. _probe.py +253 -13
  7. _three.py +1 -1
  8. app.py +124 -59
  9. frontend/app.js +388 -0
  10. frontend/index.html +507 -0
  11. requirements.txt +5 -1
  12. server_app.py +84 -0
README.md CHANGED
@@ -4,18 +4,30 @@ emoji: 🧬
4
  colorFrom: indigo
5
  colorTo: purple
6
  sdk: gradio
7
- sdk_version: "5.33.2"
8
- app_file: app.py
9
  pinned: false
10
  license: mit
11
  ---
12
 
13
  ![One for All demo](one_for_all.gif)
14
 
15
- **One for All** distills 5 heterogeneous teacher LLMs (Qwen2.5-1.5B, SmolLM2-1.7B, Phi-3.5-mini, gemma-2-2b-it, MiniCPM-2B) into a single Qwen2.5-0.5B student via gated CKA geometry distillation (Path B — geometry-only, tokenizer-agnostic).
16
 
17
  ### Tabs
18
 
19
- - **∀ Almas** — 3D UMAP soul space of all 6 models over 24 probe texts. Type a prompt to run the student live on ZeroGPU and see where it lands in the embedding space.
20
- - ** Geometria** — CKA alignment heatmap across all model pairs.
21
- - ** Treino** — Loss curves and gate evolution over training steps.
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  colorFrom: indigo
5
  colorTo: purple
6
  sdk: gradio
7
+ sdk_version: "6.18.0"
8
+ app_file: server_app.py
9
  pinned: false
10
  license: mit
11
  ---
12
 
13
  ![One for All demo](one_for_all.gif)
14
 
15
+ **One for All** distills 6 heterogeneous teacher LLMs (Qwen2.5-1.5B, SmolLM2-1.7B, Phi-3.5-mini, gemma-2-2b-it, MiniCPM-2B, Nemotron-Mini-4B) into a single Qwen2.5-0.5B student via gated CKA geometry distillation (Path B — geometry-only, tokenizer-agnostic).
16
 
17
  ### Tabs
18
 
19
+ - **∀ Souls** — 3D UMAP soul space of all models over 24 probe texts. Type a prompt and watch the student answer **token by token** while the gate bars show, live, which teacher's geometry is dominant. The pooled prompt lands as a new point in soul space.
20
+ - ** Arena** — one prompt, two models: the untouched Qwen2.5-0.5B base races the distilled student side by side (same weights, LoRA off vs on).
21
+ - ** Geometry** — CKA alignment heatmap across all model pairs.
22
+ - **↗ Training** — Loss curves and gate evolution over training steps.
23
+
24
+ ### UI
25
+
26
+ `server_app.py` is a **custom frontend on `gr.Server`** 🎨 — our own HTML/JS/Three.js
27
+ (`frontend/`) talking to Gradio API endpoints via `@gradio/client`. The legacy
28
+ Blocks UI still works: set `app_file: app.py` (it also runs on sdk 6.18).
29
+
30
+ ### Backends
31
+
32
+ - default — torch + ZeroGPU (per-token gates).
33
+ - `OFA_BACKEND=llamacpp` — the student runs as a **GGUF through llama.cpp** 🦙 (CPU-friendly; gates computed on the prompt embedding via numpy, no torch in the hot path).
_boot.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ space/_boot.py — shared startup + backend dispatch for both Space entrypoints.
3
+
4
+ app.py (Gradio Blocks, legacy) and server_app.py (gr.Server + custom frontend)
5
+ load the exact same runtime: viz data, UMAP reducer, and the student in either
6
+ backend (torch / llamacpp). Keeping it here means the two UIs can't drift.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+ import _data
15
+ import _probe
16
+
17
+
18
+ @dataclass
19
+ class Runtime:
20
+ hf_token: str | None = None
21
+ backend: str = "torch"
22
+ viz: dict = field(default_factory=dict)
23
+ reducer: Any = None
24
+ coords3d: Any = None
25
+ tok: Any = None
26
+ student: Any = None
27
+ gating: Any = None
28
+ lcs: Any = None
29
+ model_ready: bool = False
30
+
31
+
32
+ def load_runtime() -> Runtime:
33
+ rt = Runtime(hf_token=os.environ.get("HF_TOKEN"), backend=_probe.BACKEND)
34
+
35
+ local_viz = os.environ.get("VIZ_DATA_PATH")
36
+ try:
37
+ if local_viz:
38
+ rt.viz = _data.load_from_path(local_viz)
39
+ print(f"[ofa-space] loaded viz from {local_viz}")
40
+ else:
41
+ rt.viz = _data.load_and_parse(rt.hf_token)
42
+ except Exception as e:
43
+ print(f"[ofa-space] viz_data.json not available ({e}), using empty state")
44
+ rt.viz = _data.make_empty_viz()
45
+
46
+ try:
47
+ if rt.viz["stacked"].shape[0] > 3:
48
+ rt.reducer = _data.fit_umap3d(rt.viz["stacked"])
49
+ rt.coords3d = rt.reducer.embedding_
50
+ print(f"[ofa-space] UMAP done: {rt.coords3d.shape}")
51
+ else:
52
+ print(f"[ofa-space] not enough points for UMAP: {rt.viz['stacked'].shape[0]}")
53
+ except Exception as e:
54
+ print(f"[ofa-space] UMAP failed ({e}), 3D disabled")
55
+ rt.reducer = rt.coords3d = None
56
+
57
+ try:
58
+ if rt.backend == "llamacpp":
59
+ rt.lcs = _probe.load_student_llamacpp(rt.hf_token)
60
+ print(f"[ofa-space] llama.cpp backend ready ({_probe.GGUF_FILE})")
61
+ else:
62
+ rt.tok, rt.student, rt.gating = _probe.load_student(rt.hf_token)
63
+ rt.model_ready = True
64
+ except Exception as e:
65
+ print(f"[ofa-space] Student not available ({e}). Probe disabled.")
66
+ rt.model_ready = False
67
+
68
+ return rt
69
+
70
+
71
+ # ── Backend dispatch: same call sites drive torch and llama.cpp ────────────
72
+
73
+ def to_device(rt: Runtime) -> None:
74
+ if rt.backend != "llamacpp" and rt.student is not None:
75
+ device = "cuda" if __import__("torch").cuda.is_available() else "cpu"
76
+ rt.student.to(device)
77
+
78
+
79
+ def stream(rt: Runtime, text: str):
80
+ if rt.backend == "llamacpp":
81
+ return _probe.stream_generate_llamacpp(text, rt.lcs)
82
+ return _probe.stream_generate(text, rt.student, rt.tok, rt.gating)
83
+
84
+
85
+ def stream_pair(rt: Runtime, text: str):
86
+ if rt.backend == "llamacpp":
87
+ return _probe.stream_pair_llamacpp(text, rt.lcs)
88
+ return _probe.stream_pair(text, rt.student, rt.tok, rt.gating)
89
+
90
+
91
+ def final_probe(rt: Runtime, text: str):
92
+ if rt.backend == "llamacpp":
93
+ return _probe.run_probe_llamacpp(text, rt.lcs, rt.reducer)
94
+ return _probe.run_probe(text, rt.student, rt.tok, rt.gating, rt.reducer)
95
+
96
+
97
+ # ── Payload for the custom frontend (server_app.py) ────────────────────────
98
+
99
+ # Frontend palette — student first, then teachers (qwen, smollm, phi, gemma,
100
+ # minicpm, nemotron). No purple by design; the legacy Blocks UI keeps its own
101
+ # palette in _fig.py.
102
+ MODEL_COLORS = ["#e6edf3", "#38bdf8", "#f59e0b", "#f43f5e", "#2dd4bf", "#f472b6", "#76b900"]
103
+
104
+ # Canonical 6-teacher lineup. viz_data.json exported from an older 5-teacher
105
+ # run is a prefix of this — pad it so the UI already shows nemotron (its meter
106
+ # stays at 0 until a 6-gate checkpoint is published).
107
+ CANONICAL_TEACHERS = ["qwen", "smollm", "phi", "gemma", "minicpm", "nemotron"]
108
+
109
+
110
+ def viz_payload(viz: dict, coords3d, backend: str = "torch",
111
+ model_ready: bool = False) -> dict:
112
+ """Everything the JS frontend needs in one JSON-safe dict."""
113
+ models = []
114
+ if coords3d is not None and viz.get("labels"):
115
+ labels = viz["labels"]
116
+ for i, name in enumerate(viz.get("model_names", [])):
117
+ pts = [list(map(float, coords3d[j]))
118
+ for j, lab in enumerate(labels) if lab == name]
119
+ models.append({
120
+ "name": name,
121
+ "color": MODEL_COLORS[i % len(MODEL_COLORS)],
122
+ "points": pts,
123
+ })
124
+ teachers = viz.get("teacher_names", [])
125
+ if teachers == CANONICAL_TEACHERS[:len(teachers)]:
126
+ teachers = CANONICAL_TEACHERS
127
+ return {
128
+ "models": models,
129
+ "teachers": teachers,
130
+ "teacher_colors": [MODEL_COLORS[1 + i % (len(MODEL_COLORS) - 1)]
131
+ for i in range(len(teachers))],
132
+ "cka": viz.get("cka", {}),
133
+ "curves": viz.get("curves", {}),
134
+ "backend": backend,
135
+ "model_ready": model_ready,
136
+ }
_fig.py CHANGED
@@ -11,7 +11,7 @@ DARK = dict(
11
  GRID_COLOR = "#21262d"
12
 
13
  # index 0 = student, 1-5 = teachers
14
- MODEL_COLORS = ["#e6edf3", "#7c3aed", "#06b6d4", "#f59e0b", "#34d399", "#f472b6"]
15
 
16
 
17
  def build_base_traces(viz: dict, coords3d: np.ndarray) -> list:
 
11
  GRID_COLOR = "#21262d"
12
 
13
  # index 0 = student, 1-5 = teachers
14
+ MODEL_COLORS = ["#e6edf3", "#7c3aed", "#06b6d4", "#f59e0b", "#34d399", "#f472b6", "#76b900"]
15
 
16
 
17
  def build_base_traces(viz: dict, coords3d: np.ndarray) -> list:
_glb.py CHANGED
@@ -30,6 +30,7 @@ _COLORS_RGB: list[tuple[int, int, int]] = [
30
  (245, 158, 11), # teacher2 — #f59e0b
31
  ( 52, 211, 153), # teacher3 — #34d399
32
  (244, 114, 182), # teacher4 — #f472b6
 
33
  ]
34
  _PROBE_COLOR = (255, 255, 255)
35
 
 
30
  (245, 158, 11), # teacher2 — #f59e0b
31
  ( 52, 211, 153), # teacher3 — #34d399
32
  (244, 114, 182), # teacher4 — #f472b6
33
+ (118, 185, 0), # teacher5 — #76b900 (nemotron / NVIDIA green)
34
  ]
35
  _PROBE_COLOR = (255, 255, 255)
36
 
_html.py CHANGED
@@ -1,18 +1,18 @@
1
  from __future__ import annotations
2
 
3
- # index 0-4 = teachers (aligns with MODEL_COLORS[1:] in _fig.py)
4
- _TEACHER_COLORS = ["#7c3aed", "#06b6d4", "#f59e0b", "#34d399", "#f472b6"]
5
 
6
 
7
- def gate_html(gate_weights: list[float], teacher_names: list[str]) -> str:
8
- """Horizontal bar chart sorted by weight descending."""
9
- ranked = sorted(
10
- enumerate(zip(gate_weights, teacher_names)),
11
- key=lambda x: x[1][0],
12
- reverse=True,
13
- )
14
  rows = []
15
- for orig_idx, (w, name) in ranked:
16
  color = _TEACHER_COLORS[orig_idx % len(_TEACHER_COLORS)]
17
  bar_pct = int(w * 100)
18
  rows.append(
@@ -20,7 +20,8 @@ def gate_html(gate_weights: list[float], teacher_names: list[str]) -> str:
20
  f'<span style="width:80px;font-family:monospace;font-size:11px;color:{color};'
21
  f'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{name}</span>'
22
  f'<div style="flex:1;height:5px;background:#21262d;border-radius:3px;overflow:hidden;">'
23
- f'<div style="width:{bar_pct}%;height:100%;background:{color};border-radius:3px;"></div>'
 
24
  f'</div>'
25
  f'<span style="width:36px;font-family:monospace;font-size:11px;'
26
  f'color:#8b949e;text-align:right;">{w:.2f}</span>'
@@ -63,7 +64,7 @@ def task_html(gate_weights: list[float], teacher_names: list[str]) -> str:
63
  )
64
 
65
 
66
- def header_html() -> str:
67
  """App header with ∀ logo, wordmark, and status pills."""
68
  def pill(color: str, dot_color: str, text: str, pulse: bool = False) -> str:
69
  dot_class = ' class="live-dot"' if pulse else ''
@@ -105,8 +106,9 @@ def header_html() -> str:
105
  # Right: pills
106
  '<div style="display:flex;gap:8px;flex-wrap:wrap;">'
107
  + pill("#06b6d4", "#06b6d4", "STUDENT · Qwen2.5-0.5B", pulse=True)
108
- + pill("#7c3aed", "#7c3aed", "5 TEACHERS")
109
  + pill("#f59e0b", "#f59e0b", "PATH B · GEOMETRY")
 
110
  + '</div></div>'
111
 
112
  # Divider with glow
 
1
  from __future__ import annotations
2
 
3
+ # index 0-5 = teachers (aligns with MODEL_COLORS[1:] in _fig.py)
4
+ _TEACHER_COLORS = ["#7c3aed", "#06b6d4", "#f59e0b", "#34d399", "#f472b6", "#76b900"]
5
 
6
 
7
+ def gate_html(gate_weights: list[float], teacher_names: list[str],
8
+ ranked: bool = True) -> str:
9
+ """Horizontal bar chart. ranked=False keeps teacher order fixed — used
10
+ while streaming so bars animate in place instead of swapping rows."""
11
+ pairs = list(enumerate(zip(gate_weights, teacher_names)))
12
+ if ranked:
13
+ pairs.sort(key=lambda x: x[1][0], reverse=True)
14
  rows = []
15
+ for orig_idx, (w, name) in pairs:
16
  color = _TEACHER_COLORS[orig_idx % len(_TEACHER_COLORS)]
17
  bar_pct = int(w * 100)
18
  rows.append(
 
20
  f'<span style="width:80px;font-family:monospace;font-size:11px;color:{color};'
21
  f'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{name}</span>'
22
  f'<div style="flex:1;height:5px;background:#21262d;border-radius:3px;overflow:hidden;">'
23
+ f'<div style="width:{bar_pct}%;height:100%;background:{color};border-radius:3px;'
24
+ f'transition:width 0.18s ease;"></div>'
25
  f'</div>'
26
  f'<span style="width:36px;font-family:monospace;font-size:11px;'
27
  f'color:#8b949e;text-align:right;">{w:.2f}</span>'
 
64
  )
65
 
66
 
67
+ def header_html(n_teachers: int = 6, backend: str = "torch") -> str:
68
  """App header with ∀ logo, wordmark, and status pills."""
69
  def pill(color: str, dot_color: str, text: str, pulse: bool = False) -> str:
70
  dot_class = ' class="live-dot"' if pulse else ''
 
106
  # Right: pills
107
  '<div style="display:flex;gap:8px;flex-wrap:wrap;">'
108
  + pill("#06b6d4", "#06b6d4", "STUDENT · Qwen2.5-0.5B", pulse=True)
109
+ + pill("#7c3aed", "#7c3aed", f"{n_teachers} TEACHERS")
110
  + pill("#f59e0b", "#f59e0b", "PATH B · GEOMETRY")
111
+ + (pill("#76b900", "#76b900", "🦙 llama.cpp · GGUF") if backend == "llamacpp" else "")
112
  + '</div></div>'
113
 
114
  # Divider with glow
_probe.py CHANGED
@@ -8,7 +8,13 @@ import numpy as np
8
  STUDENT_BASE = "Qwen/Qwen2.5-0.5B-Instruct"
9
  ADAPTER_REPO = "build-small-hackathon/deku"
10
  STUDENT_HIDDEN_DIM = 896 # Qwen2.5-0.5B hidden size
11
- N_TEACHERS = 5
 
 
 
 
 
 
12
 
13
 
14
  class GatingNetwork(nn.Module):
@@ -55,13 +61,32 @@ def load_student(hf_token: str | None = None):
55
  repo_type="model",
56
  token=token,
57
  )
58
- gating = GatingNetwork(STUDENT_HIDDEN_DIM, N_TEACHERS)
59
- gating.load_state_dict(torch.load(gating_path, map_location="cpu"))
60
- gating.eval()
61
 
62
  return tok, student, gating
63
 
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  def generate_response(
66
  text: str,
67
  student: nn.Module,
@@ -70,15 +95,7 @@ def generate_response(
70
  ) -> str:
71
  """Run student generation and return decoded answer text."""
72
  device = next(student.parameters()).device
73
- try:
74
- prompt = tok.apply_chat_template(
75
- [{"role": "user", "content": text}],
76
- tokenize=False,
77
- add_generation_prompt=True,
78
- )
79
- except Exception:
80
- prompt = text
81
- enc = tok(prompt, return_tensors="pt").to(device)
82
  with torch.no_grad():
83
  out = student.generate(
84
  input_ids=enc["input_ids"],
@@ -91,6 +108,121 @@ def generate_response(
91
  return tok.decode(new_tokens, skip_special_tokens=True)
92
 
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  def run_probe(
95
  text: str,
96
  student: nn.Module,
@@ -121,3 +253,111 @@ def run_probe(
121
  "z": float(coords3d[0, 2]),
122
  "label": "probe",
123
  }, gate_weights
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  STUDENT_BASE = "Qwen/Qwen2.5-0.5B-Instruct"
9
  ADAPTER_REPO = "build-small-hackathon/deku"
10
  STUDENT_HIDDEN_DIM = 896 # Qwen2.5-0.5B hidden size
11
+
12
+ # Backend: "torch" (default) or "llamacpp" (GGUF via llama-cpp-python, CPU-friendly).
13
+ BACKEND = os.environ.get("OFA_BACKEND", "torch").lower()
14
+ GGUF_REPO = os.environ.get("OFA_GGUF_REPO", "build-small-hackathon/deku-gguf")
15
+ GGUF_FILE = os.environ.get("OFA_GGUF_FILE", "deku-q8_0.gguf")
16
+ BASE_GGUF_REPO = os.environ.get("OFA_BASE_GGUF_REPO", "Qwen/Qwen2.5-0.5B-Instruct-GGUF")
17
+ BASE_GGUF_FILE = os.environ.get("OFA_BASE_GGUF_FILE", "qwen2.5-0.5b-instruct-q8_0.gguf")
18
 
19
 
20
  class GatingNetwork(nn.Module):
 
61
  repo_type="model",
62
  token=token,
63
  )
64
+ gating = _gating_from_state(torch.load(gating_path, map_location="cpu"))
 
 
65
 
66
  return tok, student, gating
67
 
68
 
69
+ def _gating_from_state(state: dict) -> GatingNetwork:
70
+ """Build the gate from a checkpoint, inferring n_teachers from the weight
71
+ shape — keeps the Space compatible with 5- and 6-teacher runs."""
72
+ n_teachers, hidden = state["fc.weight"].shape
73
+ gating = GatingNetwork(hidden, n_teachers)
74
+ gating.load_state_dict(state)
75
+ gating.eval()
76
+ return gating
77
+
78
+
79
+ def _chat_prompt(text: str, tok) -> str:
80
+ try:
81
+ return tok.apply_chat_template(
82
+ [{"role": "user", "content": text}],
83
+ tokenize=False,
84
+ add_generation_prompt=True,
85
+ )
86
+ except Exception:
87
+ return text
88
+
89
+
90
  def generate_response(
91
  text: str,
92
  student: nn.Module,
 
95
  ) -> str:
96
  """Run student generation and return decoded answer text."""
97
  device = next(student.parameters()).device
98
+ enc = tok(_chat_prompt(text, tok), return_tensors="pt").to(device)
 
 
 
 
 
 
 
 
99
  with torch.no_grad():
100
  out = student.generate(
101
  input_ids=enc["input_ids"],
 
108
  return tok.decode(new_tokens, skip_special_tokens=True)
109
 
110
 
111
+ def _decode_step(student: nn.Module, ids: torch.Tensor, past):
112
+ """One greedy decode step. Returns (next_id, new_past, last_hidden)."""
113
+ with torch.no_grad():
114
+ out = student(
115
+ input_ids=ids,
116
+ past_key_values=past,
117
+ use_cache=True,
118
+ output_hidden_states=True,
119
+ )
120
+ next_id = out.logits[:, -1, :].argmax(dim=-1, keepdim=True)
121
+ h_last = out.hidden_states[-1][:, -1, :].float()
122
+ return next_id, out.past_key_values, h_last
123
+
124
+
125
+ def _adapter_off(student: nn.Module):
126
+ """disable_adapter() ctx on PeftModel; no-op for plain models (tests)."""
127
+ disable = getattr(student, "disable_adapter", None)
128
+ if disable is not None:
129
+ return disable()
130
+ import contextlib
131
+ return contextlib.nullcontext()
132
+
133
+
134
+ def stream_generate(
135
+ text: str,
136
+ student: nn.Module,
137
+ tok,
138
+ gating: GatingNetwork,
139
+ max_new_tokens: int = 200,
140
+ ema: float = 0.3,
141
+ ):
142
+ """Greedy decode loop that exposes the gate at every step.
143
+
144
+ Yields (partial_text, gate_weights) per generated token. Gate weights are
145
+ EMA-smoothed so the live bars move instead of flickering. model.generate()
146
+ can't do this — it doesn't surface hidden states mid-stream.
147
+ """
148
+ device = next(student.parameters()).device
149
+ enc = tok(_chat_prompt(text, tok), return_tensors="pt")
150
+ ids = enc["input_ids"].to(device)
151
+ past = None
152
+ gate_smooth: torch.Tensor | None = None
153
+ out_ids: list[int] = []
154
+ for _ in range(max_new_tokens):
155
+ next_id, past, h_last = _decode_step(student, ids, past)
156
+ with torch.no_grad():
157
+ g = gating(h_last).squeeze(0)
158
+ gate_smooth = g if gate_smooth is None else ema * g + (1 - ema) * gate_smooth
159
+ if next_id.item() == tok.eos_token_id:
160
+ break
161
+ out_ids.append(next_id.item())
162
+ ids = next_id
163
+ yield (
164
+ tok.decode(out_ids, skip_special_tokens=True),
165
+ gate_smooth.tolist(),
166
+ )
167
+
168
+
169
+ def stream_pair(
170
+ text: str,
171
+ student: nn.Module,
172
+ tok,
173
+ gating: GatingNetwork,
174
+ max_new_tokens: int = 160,
175
+ ema: float = 0.3,
176
+ ):
177
+ """Race base (LoRA off) vs deku (LoRA on) — one token each per round.
178
+
179
+ Same PeftModel serves both sides via disable_adapter(), so no extra
180
+ memory. Yields (base_text, deku_text, gate_weights); gates come from the
181
+ deku side only.
182
+ """
183
+ device = next(student.parameters()).device
184
+ enc = tok(_chat_prompt(text, tok), return_tensors="pt")
185
+ prompt_ids = enc["input_ids"].to(device)
186
+
187
+ sides = {
188
+ "base": {"ids": prompt_ids, "past": None, "out": [], "done": False},
189
+ "deku": {"ids": prompt_ids, "past": None, "out": [], "done": False},
190
+ }
191
+ gate_smooth: torch.Tensor | None = None
192
+
193
+ for _ in range(max_new_tokens):
194
+ if not sides["base"]["done"]:
195
+ s = sides["base"]
196
+ with _adapter_off(student):
197
+ next_id, s["past"], _ = _decode_step(student, s["ids"], s["past"])
198
+ if next_id.item() == tok.eos_token_id:
199
+ s["done"] = True
200
+ else:
201
+ s["out"].append(next_id.item())
202
+ s["ids"] = next_id
203
+
204
+ if not sides["deku"]["done"]:
205
+ s = sides["deku"]
206
+ next_id, s["past"], h_last = _decode_step(student, s["ids"], s["past"])
207
+ with torch.no_grad():
208
+ g = gating(h_last).squeeze(0)
209
+ gate_smooth = g if gate_smooth is None else ema * g + (1 - ema) * gate_smooth
210
+ if next_id.item() == tok.eos_token_id:
211
+ s["done"] = True
212
+ else:
213
+ s["out"].append(next_id.item())
214
+ s["ids"] = next_id
215
+
216
+ if sides["base"]["done"] and sides["deku"]["done"]:
217
+ break
218
+ yield (
219
+ tok.decode(sides["base"]["out"], skip_special_tokens=True),
220
+ tok.decode(sides["deku"]["out"], skip_special_tokens=True),
221
+ gate_smooth.tolist() if gate_smooth is not None
222
+ else [1.0 / gating.fc.out_features] * gating.fc.out_features,
223
+ )
224
+
225
+
226
  def run_probe(
227
  text: str,
228
  student: nn.Module,
 
253
  "z": float(coords3d[0, 2]),
254
  "label": "probe",
255
  }, gate_weights
256
+
257
+
258
+ # ── llama.cpp backend (🦙 badge — GGUF student, runs on CPU, no torch path) ──
259
+
260
+ def _np_softmax(x: np.ndarray) -> np.ndarray:
261
+ e = np.exp(x - x.max())
262
+ return e / e.sum()
263
+
264
+
265
+ def gate_from_embedding(emb: np.ndarray, weight: np.ndarray, bias: np.ndarray) -> list[float]:
266
+ """Gate in pure numpy: softmax(W @ e + b). Mirrors GatingNetwork.forward."""
267
+ return _np_softmax(weight @ emb + bias).tolist()
268
+
269
+
270
+ class LlamaCppStudent:
271
+ """deku as GGUF: one llama.cpp instance for generation, one in embedding
272
+ mode (mean pooling) for the gate / soul-space probe, numpy gate weights.
273
+ The base model (Arena) is downloaded lazily on first use."""
274
+
275
+ def __init__(self, gen, emb, gate_w: np.ndarray, gate_b: np.ndarray, token=None):
276
+ self.gen = gen
277
+ self.emb = emb
278
+ self.gate_w = gate_w
279
+ self.gate_b = gate_b
280
+ self.base = None
281
+ self._token = token
282
+
283
+ @property
284
+ def n_teachers(self) -> int:
285
+ return self.gate_w.shape[0]
286
+
287
+ def embed(self, text: str) -> np.ndarray:
288
+ return np.asarray(self.emb.embed(text, normalize=False), dtype=np.float32)
289
+
290
+ def prompt_gates(self, text: str) -> list[float]:
291
+ return gate_from_embedding(self.embed(text), self.gate_w, self.gate_b)
292
+
293
+ def ensure_base(self) -> None:
294
+ if self.base is None:
295
+ from llama_cpp import Llama
296
+ from huggingface_hub import hf_hub_download
297
+ path = hf_hub_download(BASE_GGUF_REPO, BASE_GGUF_FILE, token=self._token)
298
+ self.base = Llama(model_path=path, n_ctx=2048, verbose=False)
299
+
300
+
301
+ def load_student_llamacpp(hf_token: str | None = None) -> LlamaCppStudent:
302
+ from llama_cpp import Llama
303
+ from huggingface_hub import hf_hub_download
304
+
305
+ token = hf_token or os.environ.get("HF_TOKEN")
306
+ gguf = hf_hub_download(GGUF_REPO, GGUF_FILE, token=token)
307
+ npz = np.load(hf_hub_download(GGUF_REPO, "gating.npz", token=token))
308
+ gen = Llama(model_path=gguf, n_ctx=2048, verbose=False)
309
+ emb = Llama(model_path=gguf, n_ctx=2048, embedding=True, verbose=False)
310
+ return LlamaCppStudent(gen, emb,
311
+ npz["weight"].astype(np.float32),
312
+ npz["bias"].astype(np.float32), token=token)
313
+
314
+
315
+ def _chat_stream(llm, text: str, max_tokens: int):
316
+ """Yield accumulated text from a llama.cpp chat-completion stream."""
317
+ pieces: list[str] = []
318
+ for chunk in llm.create_chat_completion(
319
+ messages=[{"role": "user", "content": text}],
320
+ max_tokens=max_tokens, temperature=0.0, stream=True,
321
+ ):
322
+ delta = chunk["choices"][0]["delta"].get("content")
323
+ if delta:
324
+ pieces.append(delta)
325
+ yield "".join(pieces)
326
+
327
+
328
+ def stream_generate_llamacpp(text: str, lcs: LlamaCppStudent, max_tokens: int = 200):
329
+ """Yields (partial_text, gate_weights). llama.cpp doesn't expose hidden
330
+ states mid-generation, so gates are computed once on the prompt embedding
331
+ (vs per-token in the torch path) — documented drift, same gate network."""
332
+ gates = lcs.prompt_gates(text)
333
+ for partial in _chat_stream(lcs.gen, text, max_tokens):
334
+ yield partial, gates
335
+
336
+
337
+ def stream_pair_llamacpp(text: str, lcs: LlamaCppStudent, max_tokens: int = 160):
338
+ """Arena on llama.cpp: deku GGUF vs official base GGUF, interleaved.
339
+ Yields (base_text, deku_text, gate_weights)."""
340
+ import itertools
341
+ lcs.ensure_base()
342
+ gates = lcs.prompt_gates(text)
343
+ base_txt, deku_txt = "", ""
344
+ for b, d in itertools.zip_longest(
345
+ _chat_stream(lcs.base, text, max_tokens),
346
+ _chat_stream(lcs.gen, text, max_tokens),
347
+ ):
348
+ base_txt = b if b is not None else base_txt
349
+ deku_txt = d if d is not None else deku_txt
350
+ yield base_txt, deku_txt, gates
351
+
352
+
353
+ def run_probe_llamacpp(text: str, lcs: LlamaCppStudent, reducer) -> tuple[dict, list[float]]:
354
+ """llama.cpp analogue of run_probe: mean-pooled embedding → gate + UMAP point."""
355
+ pooled = lcs.embed(text)
356
+ gates = gate_from_embedding(pooled, lcs.gate_w, lcs.gate_b)
357
+ coords3d = reducer.transform(pooled[None, :])
358
+ return {
359
+ "x": float(coords3d[0, 0]),
360
+ "y": float(coords3d[0, 1]),
361
+ "z": float(coords3d[0, 2]),
362
+ "label": "probe",
363
+ }, gates
_three.py CHANGED
@@ -13,7 +13,7 @@ import html as _html
13
  import json
14
  import numpy as np
15
 
16
- MODEL_COLORS = ["#e6edf3", "#7c3aed", "#06b6d4", "#f59e0b", "#34d399", "#f472b6"]
17
 
18
  _EMPTY = """
19
  <div style="width:100%;height:520px;background:#0d1117;border-radius:8px;
 
13
  import json
14
  import numpy as np
15
 
16
+ MODEL_COLORS = ["#e6edf3", "#7c3aed", "#06b6d4", "#f59e0b", "#34d399", "#f472b6", "#76b900"]
17
 
18
  _EMPTY = """
19
  <div style="width:100%;height:520px;background:#0d1117;border-radius:8px;
app.py CHANGED
@@ -10,47 +10,20 @@ import html as _html_stdlib
10
  import gradio as gr
11
  import spaces
12
 
13
- import _data
14
  import _fig
15
  import _glb
16
  import _html
17
- import _probe
18
-
19
- # ── Startup: load data, fit UMAP, load student ───────────────────────────
20
- HF_TOKEN = os.environ.get("HF_TOKEN")
21
-
22
- _local_viz = os.environ.get("VIZ_DATA_PATH")
23
- try:
24
- if _local_viz:
25
- VIZ = _data.load_from_path(_local_viz)
26
- print(f"[ofa-space] loaded viz from {_local_viz}")
27
- else:
28
- VIZ = _data.load_and_parse(HF_TOKEN)
29
- except Exception as e:
30
- print(f"[ofa-space] viz_data.json not available ({e}), using empty state")
31
- VIZ = _data.make_empty_viz()
32
-
33
- try:
34
- if VIZ["stacked"].shape[0] > 3:
35
- REDUCER = _data.fit_umap3d(VIZ["stacked"])
36
- COORDS3D = REDUCER.embedding_
37
- print(f"[ofa-space] UMAP done: {COORDS3D.shape}")
38
- else:
39
- print(f"[ofa-space] not enough points for UMAP: {VIZ['stacked'].shape[0]}")
40
- REDUCER = None
41
- COORDS3D = None
42
- except Exception as e:
43
- print(f"[ofa-space] UMAP failed ({e}), 3D disabled")
44
- REDUCER = None
45
- COORDS3D = None
46
-
47
- try:
48
- TOK, STUDENT, GATING = _probe.load_student(HF_TOKEN)
49
- _MODEL_READY = True
50
- except Exception as e:
51
- print(f"[ofa-space] Student not available ({e}). Probe disabled.")
52
- TOK = STUDENT = GATING = None
53
- _MODEL_READY = False
54
 
55
  _INIT_GLB = _glb.build_glb(VIZ, COORDS3D, [])
56
  print(f"[ofa-space] GLB path: {_INIT_GLB}")
@@ -66,37 +39,86 @@ else:
66
  _CAM = (45, 30, 10)
67
 
68
 
69
- def _response_html(text: str) -> str:
70
  safe = _html_stdlib.escape(text).replace("\n", "<br>")
71
  return (
72
  '<div style="background:#0d1117;border:1px solid #30363d;border-radius:6px;'
73
  'padding:14px;margin-top:8px;">'
74
- '<div style="font-size:10px;color:#8b949e;font-family:monospace;'
75
- 'margin-bottom:8px;letter-spacing:0.04em;">MODEL RESPONSE</div>'
76
  f'<div style="font-size:13px;color:#e6edf3;line-height:1.65;">{safe}</div>'
77
  "</div>"
78
  )
79
 
80
 
81
- # ── ZeroGPU probe handler ─────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  @spaces.GPU
83
- def probe_fn(text: str, probe_points: list) -> tuple:
84
- no_change = _glb.build_glb(VIZ, COORDS3D, probe_points), probe_points, "", "", ""
85
  if not text.strip():
86
- return no_change
 
87
  if not _MODEL_READY or REDUCER is None:
88
  msg = _html.gate_html([0.2] * 5, VIZ["teacher_names"] or ["—"] * 5)
89
- return _glb.build_glb(VIZ, COORDS3D, probe_points), probe_points, "", msg, ""
90
- device = "cuda" if __import__("torch").cuda.is_available() else "cpu"
91
- STUDENT.to(device)
92
- answer = _probe.generate_response(text, STUDENT, TOK)
93
- new_pt, gate_weights = _probe.run_probe(text, STUDENT, TOK, GATING, REDUCER)
94
- updated = probe_points + [new_pt]
95
- glb_path = _glb.build_glb(VIZ, COORDS3D, updated)
96
- gate_h = _html.gate_html(gate_weights, VIZ["teacher_names"])
97
- task_h = _html.task_html(gate_weights, VIZ["teacher_names"])
98
- resp_h = _response_html(answer)
99
- return glb_path, updated, resp_h, gate_h, task_h
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
 
102
  # ── CSS ───────────────────────────────────────────────────────────────────
@@ -294,7 +316,10 @@ div[data-testid="model3d"], .model3D-component {
294
  # ── Layout ────────────────────────────────────────────────────────────────
295
  with gr.Blocks(css=CSS, theme=gr.themes.Base(), title="One for All") as demo:
296
 
297
- gr.HTML(_html.header_html())
 
 
 
298
  probe_state = gr.State([])
299
 
300
  with gr.Tabs():
@@ -335,7 +360,42 @@ with gr.Blocks(css=CSS, theme=gr.themes.Base(), title="One for All") as demo:
335
  'font-family:monospace;">↑ new probe point will appear in soul space</div>'
336
  )
337
 
338
- # ── Tab 2: Geometria ──────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  with gr.TabItem("Geometry"):
340
  with gr.Row():
341
  with gr.Column(scale=7):
@@ -371,7 +431,7 @@ with gr.Blocks(css=CSS, theme=gr.themes.Base(), title="One for All") as demo:
371
  f'</div>'
372
  )
373
 
374
- # ── Tab 3: Treino ─────────────────────────────────────────────────
375
  with gr.TabItem("Training"):
376
  with gr.Row():
377
  gr.Plot(
@@ -389,6 +449,11 @@ with gr.Blocks(css=CSS, theme=gr.themes.Base(), title="One for All") as demo:
389
  inputs=[prompt_box, probe_state],
390
  outputs=[umap_plot, probe_state, resp_out, gate_out, task_out],
391
  )
 
 
 
 
 
392
 
393
 
394
  if __name__ == "__main__":
 
10
  import gradio as gr
11
  import spaces
12
 
 
13
  import _fig
14
  import _glb
15
  import _html
16
+
17
+ # ── Startup: shared runtime (viz + UMAP + student) lives in _boot ─────────
18
+ import _boot
19
+
20
+ RT = _boot.load_runtime()
21
+ HF_TOKEN = RT.hf_token
22
+ VIZ = RT.viz
23
+ REDUCER = RT.reducer
24
+ COORDS3D = RT.coords3d
25
+ BACKEND = RT.backend
26
+ _MODEL_READY = RT.model_ready
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  _INIT_GLB = _glb.build_glb(VIZ, COORDS3D, [])
29
  print(f"[ofa-space] GLB path: {_INIT_GLB}")
 
39
  _CAM = (45, 30, 10)
40
 
41
 
42
+ def _response_html(text: str, title: str = "MODEL RESPONSE", accent: str = "#8b949e") -> str:
43
  safe = _html_stdlib.escape(text).replace("\n", "<br>")
44
  return (
45
  '<div style="background:#0d1117;border:1px solid #30363d;border-radius:6px;'
46
  'padding:14px;margin-top:8px;">'
47
+ f'<div style="font-size:10px;color:{accent};font-family:monospace;'
48
+ f'margin-bottom:8px;letter-spacing:0.04em;">{title}</div>'
49
  f'<div style="font-size:13px;color:#e6edf3;line-height:1.65;">{safe}</div>'
50
  "</div>"
51
  )
52
 
53
 
54
+ # ── Backend dispatch: same handler code drives torch and llama.cpp ────────
55
+ def _to_device():
56
+ _boot.to_device(RT)
57
+
58
+
59
+ def _stream(text: str):
60
+ return _boot.stream(RT, text)
61
+
62
+
63
+ def _stream_pair(text: str):
64
+ return _boot.stream_pair(RT, text)
65
+
66
+
67
+ def _final_probe(text: str):
68
+ return _boot.final_probe(RT, text)
69
+
70
+
71
+ # ── ZeroGPU probe handler (streaming generator) ───────────────────────────
72
  @spaces.GPU
73
+ def probe_fn(text: str, probe_points: list):
 
74
  if not text.strip():
75
+ yield gr.skip(), probe_points, "", "", ""
76
+ return
77
  if not _MODEL_READY or REDUCER is None:
78
  msg = _html.gate_html([0.2] * 5, VIZ["teacher_names"] or ["—"] * 5)
79
+ yield _glb.build_glb(VIZ, COORDS3D, probe_points), probe_points, "", msg, ""
80
+ return
81
+ _to_device()
82
+ names = VIZ["teacher_names"]
83
+
84
+ # Stream tokens + live gate bars; skip the 3D plot until the end.
85
+ partial = ""
86
+ for partial, gates in _stream(text):
87
+ yield (
88
+ gr.skip(), probe_points,
89
+ _response_html(partial),
90
+ _html.gate_html(gates, names, ranked=False),
91
+ gr.skip(),
92
+ )
93
+
94
+ # Final pass: pooled probe point in soul space + dominant-teacher badge.
95
+ new_pt, gate_weights = _final_probe(text)
96
+ updated = probe_points + [new_pt]
97
+ yield (
98
+ _glb.build_glb(VIZ, COORDS3D, updated), updated,
99
+ _response_html(partial),
100
+ _html.gate_html(gate_weights, names),
101
+ _html.task_html(gate_weights, names),
102
+ )
103
+
104
+
105
+ # ── ZeroGPU arena handler: base (LoRA off) vs deku, same prompt ───────────
106
+ @spaces.GPU
107
+ def arena_fn(text: str):
108
+ if not text.strip():
109
+ yield "", "", ""
110
+ return
111
+ if not _MODEL_READY:
112
+ yield "", _response_html("model not loaded", "DEKU · DISTILLED"), ""
113
+ return
114
+ _to_device()
115
+ names = VIZ["teacher_names"]
116
+ for base_text, deku_text, gates in _stream_pair(text):
117
+ yield (
118
+ _response_html(base_text, "BASE · QWEN2.5-0.5B", accent="#8b949e"),
119
+ _response_html(deku_text, "DEKU · DISTILLED", accent="#7c3aed"),
120
+ _html.gate_html(gates, names, ranked=False),
121
+ )
122
 
123
 
124
  # ── CSS ───────────────────────────────────────────────────────────────────
 
316
  # ── Layout ────────────────────────────────────────────────────────────────
317
  with gr.Blocks(css=CSS, theme=gr.themes.Base(), title="One for All") as demo:
318
 
319
+ gr.HTML(_html.header_html(
320
+ n_teachers=len(VIZ["teacher_names"]) or 6,
321
+ backend=BACKEND,
322
+ ))
323
  probe_state = gr.State([])
324
 
325
  with gr.Tabs():
 
360
  'font-family:monospace;">↑ new probe point will appear in soul space</div>'
361
  )
362
 
363
+ # ── Tab 2: Arena — base vs deku, same prompt ──────────────────────
364
+ with gr.TabItem("Arena"):
365
+ gr.HTML(
366
+ '<div style="display:flex;align-items:center;gap:8px;'
367
+ 'font-size:14px;font-weight:600;color:#e6edf3;margin:8px 0;">'
368
+ '<span style="color:#f59e0b;">⚔</span>One prompt, two models'
369
+ '<span style="font-family:monospace;font-size:10px;color:#8b949e;">'
370
+ 'same 0.5B weights — LoRA adapter off vs on</span>'
371
+ '</div>'
372
+ )
373
+ arena_box = gr.Textbox(
374
+ lines=3,
375
+ placeholder="Ask something — watch base and distilled race side by side…",
376
+ label="",
377
+ )
378
+ arena_btn = gr.Button("Run both", variant="primary")
379
+ with gr.Row():
380
+ with gr.Column():
381
+ base_out = gr.HTML()
382
+ with gr.Column():
383
+ deku_out = gr.HTML()
384
+ arena_gate_out = gr.HTML()
385
+ gr.Examples(
386
+ examples=[
387
+ "Natalia sold clips to 48 of her friends in April, and then she "
388
+ "sold half as many clips in May. How many clips did Natalia sell "
389
+ "altogether in April and May?",
390
+ "Which property of a mineral can be determined just by looking "
391
+ "at it? (A) luster (B) mass (C) weight (D) hardness",
392
+ "Write a Python function that checks if a word is a palindrome.",
393
+ "Explain why the sky is blue in two sentences.",
394
+ ],
395
+ inputs=[arena_box],
396
+ )
397
+
398
+ # ── Tab 3: Geometria ──────────────────────────────────────────────
399
  with gr.TabItem("Geometry"):
400
  with gr.Row():
401
  with gr.Column(scale=7):
 
431
  f'</div>'
432
  )
433
 
434
+ # ── Tab 4: Treino ─────────────────────────────────────────────────
435
  with gr.TabItem("Training"):
436
  with gr.Row():
437
  gr.Plot(
 
449
  inputs=[prompt_box, probe_state],
450
  outputs=[umap_plot, probe_state, resp_out, gate_out, task_out],
451
  )
452
+ arena_btn.click(
453
+ arena_fn,
454
+ inputs=[arena_box],
455
+ outputs=[base_out, deku_out, arena_gate_out],
456
+ )
457
 
458
 
459
  if __name__ == "__main__":
frontend/app.js ADDED
@@ -0,0 +1,388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* One for All — custom frontend wired to the gr.Server backend.
2
+ * Visual design is unchanged from the static mock; the mock answer engine and
3
+ * the random thought-map are replaced by real calls to /probe, /arena, /viz
4
+ * via @gradio/client (browser-side calls are required for ZeroGPU). */
5
+ import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/+esm";
6
+
7
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
8
+ const cssv = (v) => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
9
+
10
+ // ── teacher lineup (order matches the backend gate vector) ──
11
+ const TEACHERS = [
12
+ { id: "qwen", label: "qwen", varc: "--t-qwen" },
13
+ { id: "smollm", label: "smollm", varc: "--t-smollm" },
14
+ { id: "phi", label: "phi", varc: "--t-phi" },
15
+ { id: "gemma", label: "gemma", varc: "--t-gemma" },
16
+ { id: "minicpm", label: "minicpm", varc: "--t-minicpm" },
17
+ { id: "nemotron", label: "nemotron", varc: "--t-nemo" },
18
+ ];
19
+ const colorFor = (name) =>
20
+ name === "student" ? cssv("--spark")
21
+ : cssv((TEACHERS.find((t) => t.id === name) || {}).varc || "--text-dim");
22
+
23
+ // ── 1. CONVERGENCE DIAGRAM (unchanged) ──────────────────────────────────
24
+ (function buildConverge() {
25
+ const cx = 300, cy = 300, R = 232;
26
+ const threads = document.getElementById("threads");
27
+ const flows = document.getElementById("flows");
28
+ const tnodes = document.getElementById("tnodes");
29
+ TEACHERS.forEach((t, i) => {
30
+ const a = (-90 + i * 60) * Math.PI / 180;
31
+ const x = cx + R * Math.cos(a), y = cy + R * Math.sin(a);
32
+ const mx = (x + cx) / 2, my = (y + cy) / 2;
33
+ const perp = a + Math.PI / 2;
34
+ const ctrlX = mx + 46 * Math.cos(perp), ctrlY = my + 46 * Math.sin(perp);
35
+ const d = `M ${x} ${y} Q ${ctrlX} ${ctrlY} ${cx} ${cy}`;
36
+ const col = cssv(t.varc);
37
+
38
+ const base = document.createElementNS("http://www.w3.org/2000/svg", "path");
39
+ base.setAttribute("d", d); base.setAttribute("class", "thread");
40
+ base.setAttribute("stroke", col);
41
+ threads.appendChild(base);
42
+
43
+ const flow = document.createElementNS("http://www.w3.org/2000/svg", "path");
44
+ flow.setAttribute("d", d); flow.setAttribute("class", "thread-flow");
45
+ flow.setAttribute("stroke", col);
46
+ if (!reduce) flow.style.animation = `flow ${2.4 + i * 0.25}s linear infinite`;
47
+ flows.appendChild(flow);
48
+
49
+ const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
50
+ const halo = document.createElementNS("http://www.w3.org/2000/svg", "circle");
51
+ halo.setAttribute("cx", x); halo.setAttribute("cy", y); halo.setAttribute("r", 13);
52
+ halo.setAttribute("fill", col); halo.setAttribute("opacity", "0.22"); halo.setAttribute("filter", "url(#soft)");
53
+ const node = document.createElementNS("http://www.w3.org/2000/svg", "circle");
54
+ node.setAttribute("cx", x); node.setAttribute("cy", y); node.setAttribute("r", 7);
55
+ node.setAttribute("fill", col); node.setAttribute("class", "tnode");
56
+ const lx = cx + (R + 30) * Math.cos(a), ly = cy + (R + 30) * Math.sin(a);
57
+ const isRight = Math.cos(a) > 0.2, isLeft = Math.cos(a) < -0.2;
58
+ const lbl = document.createElementNS("http://www.w3.org/2000/svg", "text");
59
+ lbl.setAttribute("x", lx); lbl.setAttribute("y", ly + 4);
60
+ lbl.setAttribute("text-anchor", isRight ? "start" : isLeft ? "end" : "middle");
61
+ lbl.setAttribute("class", "tlabel"); lbl.textContent = t.label;
62
+ g.appendChild(halo); g.appendChild(node); g.appendChild(lbl);
63
+ tnodes.appendChild(g);
64
+ });
65
+ })();
66
+
67
+ // ── 2. EXAMPLES + INFLUENCE ROWS (unchanged scaffold) ───────────────────
68
+ const EXAMPLES = [
69
+ "Natalia sold clips to 48 friends in April, then half as many in May. How many altogether?",
70
+ "Which property of a mineral can be determined just by looking at it? (A) luster (B) mass (C) weight (D) hardness",
71
+ "Write a Python function that checks if a word is a palindrome.",
72
+ "Explain why the sky is blue in two sentences.",
73
+ ];
74
+ const exWrap = document.getElementById("examples");
75
+ EXAMPLES.forEach((ex, i) => {
76
+ const li = document.createElement("li");
77
+ const b = document.createElement("button");
78
+ b.className = "chip"; b.type = "button";
79
+ b.innerHTML = `<span class="ix">0${i + 1}</span><span class="qt"></span><span class="go">↵</span>`;
80
+ b.querySelector(".qt").textContent = ex;
81
+ b.addEventListener("click", () => {
82
+ document.getElementById("ask").value = ex;
83
+ document.getElementById("ask").focus();
84
+ });
85
+ li.appendChild(b); exWrap.appendChild(li);
86
+ });
87
+
88
+ const infRows = document.getElementById("infRows");
89
+ const fills = {};
90
+ TEACHERS.forEach((t) => {
91
+ const row = document.createElement("div"); row.className = "inf-row";
92
+ row.style.color = cssv(t.varc);
93
+ row.innerHTML = `<span class="nm">${t.label}</span>
94
+ <span class="track"><span class="fill"></span></span>
95
+ <span class="pc">—</span>`;
96
+ infRows.appendChild(row);
97
+ fills[t.id] = { fill: row.querySelector(".fill"), pc: row.querySelector(".pc") };
98
+ });
99
+
100
+ function setInfluence(gates) {
101
+ if (!gates) return;
102
+ TEACHERS.forEach((t, i) => {
103
+ const val = gates[i] || 0;
104
+ fills[t.id].fill.style.width = (val * 100).toFixed(0) + "%";
105
+ fills[t.id].pc.textContent = val > 0 ? val.toFixed(2).slice(1) : "—";
106
+ });
107
+ }
108
+ function resetInfluence() {
109
+ TEACHERS.forEach((t) => { fills[t.id].fill.style.width = "0%"; fills[t.id].pc.textContent = "—"; });
110
+ }
111
+ function topIdx(g) { let m = 0; for (let i = 1; i < g.length; i++) if (g[i] > g[m]) m = i; return m; }
112
+
113
+ // ── 3. BACKEND-WIRED ASK / COMPARE ──────────────────────────────────────
114
+ let mode = "ask";
115
+ let busy = false;
116
+ let client = null;
117
+ const answers = document.getElementById("answers");
118
+ const beforeCard = document.getElementById("beforeCard");
119
+ const bodyAfter = document.getElementById("bodyAfter");
120
+ const bodyBefore = document.getElementById("bodyBefore");
121
+ const askBtn = document.getElementById("askBtn");
122
+ const infNote = document.getElementById("infNote");
123
+
124
+ document.querySelectorAll(".seg button").forEach((b) => {
125
+ b.addEventListener("click", () => {
126
+ document.querySelectorAll(".seg button").forEach((x) => x.setAttribute("aria-pressed", "false"));
127
+ b.setAttribute("aria-pressed", "true");
128
+ mode = b.dataset.mode;
129
+ answers.classList.toggle("compare", mode === "compare");
130
+ beforeCard.hidden = (mode !== "compare");
131
+ });
132
+ });
133
+
134
+ function render(el, text, streaming) {
135
+ el.textContent = text || "";
136
+ if (streaming) {
137
+ const caret = document.createElement("span");
138
+ caret.className = "caret"; caret.textContent = "▋";
139
+ el.appendChild(caret);
140
+ }
141
+ }
142
+
143
+ async function ask() {
144
+ const text = document.getElementById("ask").value.trim();
145
+ if (!text || busy) return;
146
+ if (!client) { render(bodyAfter, "⚠ backend not connected", false); return; }
147
+
148
+ busy = true;
149
+ askBtn.classList.add("busy"); askBtn.firstChild.textContent = "Thinking ";
150
+ infNote.textContent = "routing through teachers…";
151
+ resetInfluence();
152
+ bodyAfter.innerHTML = '<span class="placeholder-note">distilling…</span>';
153
+ if (mode === "compare") bodyBefore.textContent = "";
154
+
155
+ try {
156
+ let gates = null;
157
+ if (mode === "compare") {
158
+ const job = client.submit("/arena", { text });
159
+ for await (const msg of job) {
160
+ if (msg.type !== "data" || !msg.data?.[0]) continue;
161
+ const d = msg.data[0];
162
+ if (d.error) { render(bodyAfter, "⚠ " + d.error, false); break; }
163
+ gates = d.gates; setInfluence(gates);
164
+ render(bodyBefore, d.base, !d.done);
165
+ render(bodyAfter, d.deku, !d.done);
166
+ if (d.done && gates) dropProbeByGates(gates);
167
+ }
168
+ } else {
169
+ const job = client.submit("/probe", { text });
170
+ for await (const msg of job) {
171
+ if (msg.type !== "data" || !msg.data?.[0]) continue;
172
+ const d = msg.data[0];
173
+ if (d.error) { render(bodyAfter, "⚠ " + d.error, false); break; }
174
+ gates = d.gates; setInfluence(gates);
175
+ render(bodyAfter, d.text, !d.done);
176
+ if (d.done) { if (d.point) dropProbePoint(d.point); else if (gates) dropProbeByGates(gates); }
177
+ }
178
+ }
179
+ if (gates) {
180
+ const t = TEACHERS[topIdx(gates)].label;
181
+ infNote.textContent = `leaning on ${t} · ${(gates[topIdx(gates)] * 100).toFixed(0)}%`;
182
+ } else {
183
+ infNote.textContent = "idle — waiting for a prompt";
184
+ }
185
+ } catch (e) {
186
+ console.error(e);
187
+ render(bodyAfter, "⚠ " + (e.message || e), false);
188
+ infNote.textContent = "error";
189
+ } finally {
190
+ busy = false;
191
+ askBtn.classList.remove("busy"); askBtn.firstChild.textContent = "Ask ";
192
+ }
193
+ }
194
+ askBtn.addEventListener("click", ask);
195
+ document.getElementById("ask").addEventListener("keydown", (e) => {
196
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") ask();
197
+ });
198
+
199
+ // ── 4. THOUGHT MAP (real coords from /viz, procedural fallback offline) ──
200
+ const stage = document.getElementById("mapStage");
201
+ const layers = {
202
+ far: document.getElementById("mapFar"),
203
+ mid: document.getElementById("mapMid"),
204
+ near: document.getElementById("mapNear"),
205
+ };
206
+ const legend = document.getElementById("mapLegend");
207
+
208
+ let VIZ = null;
209
+ let PROJ = null; // {minX,maxX,minY,maxY} bbox of all model points
210
+ let CENTROIDS = {}; // teacher id → [nx, ny] in 0..1, for gate-based probe drop
211
+
212
+ // fallback cluster centers (used only when the backend has no coords)
213
+ const CENTERS = {
214
+ qwen: [0.30, 0.32], smollm: [0.70, 0.26], phi: [0.80, 0.58],
215
+ gemma: [0.58, 0.78], minicpm: [0.30, 0.72], nemotron: [0.46, 0.50],
216
+ };
217
+ const rnd = (n) => (Math.random() - 0.5) * n;
218
+
219
+ function clearLayers() { Object.values(layers).forEach((l) => (l.innerHTML = "")); }
220
+
221
+ function addDot(layer, leftPct, topPct, size, color, opacity) {
222
+ const d = document.createElement("div"); d.className = "dot";
223
+ d.style.left = leftPct + "%"; d.style.top = topPct + "%";
224
+ d.style.width = d.style.height = size + "px";
225
+ d.style.color = color; d.style.background = color; d.style.opacity = opacity;
226
+ layer.appendChild(d);
227
+ return d;
228
+ }
229
+
230
+ function computeProjection(models) {
231
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
232
+ for (const m of models) for (const p of m.points) {
233
+ if (p[0] < minX) minX = p[0]; if (p[0] > maxX) maxX = p[0];
234
+ if (p[1] < minY) minY = p[1]; if (p[1] > maxY) maxY = p[1];
235
+ }
236
+ return { minX, maxX, minY, maxY };
237
+ }
238
+ function project(p) { // 3D point → [leftPct, topPct] with 8% padding
239
+ const { minX, maxX, minY, maxY } = PROJ;
240
+ const nx = (p[0] - minX) / ((maxX - minX) || 1);
241
+ const ny = (p[1] - minY) / ((maxY - minY) || 1);
242
+ return [
243
+ Math.max(0, Math.min(1, 0.08 + nx * 0.84)) * 100,
244
+ Math.max(0, Math.min(1, 0.08 + ny * 0.84)) * 100,
245
+ ];
246
+ }
247
+
248
+ function placeReal() {
249
+ clearLayers();
250
+ PROJ = computeProjection(VIZ.models);
251
+ CENTROIDS = {};
252
+ for (const m of VIZ.models) {
253
+ const col = colorFor(m.name);
254
+ const isStudent = m.name === "student";
255
+ let sx = 0, sy = 0;
256
+ m.points.forEach((p, k) => {
257
+ const [lp, tp] = project(p);
258
+ sx += lp; sy += tp;
259
+ const depth = isStudent ? "near" : (k % 3 === 0 ? "far" : k % 3 === 1 ? "mid" : "near");
260
+ const sz = isStudent ? 7 : depth === "near" ? 6 : depth === "mid" ? 4.5 : 3;
261
+ const op = isStudent ? 1 : depth === "near" ? 1 : depth === "mid" ? 0.8 : 0.55;
262
+ addDot(layers[depth], lp, tp, sz, col, op);
263
+ });
264
+ if (!isStudent && m.points.length) CENTROIDS[m.name] = [sx / m.points.length / 100, sy / m.points.length / 100];
265
+ }
266
+ }
267
+
268
+ function placeFallback() {
269
+ clearLayers();
270
+ PROJ = null;
271
+ CENTROIDS = {};
272
+ TEACHERS.forEach((t) => {
273
+ const c = CENTERS[t.id]; const col = cssv(t.varc);
274
+ CENTROIDS[t.id] = c;
275
+ for (let k = 0; k < 11; k++) {
276
+ const depth = k % 3 === 0 ? "far" : k % 3 === 1 ? "mid" : "near";
277
+ const sz = depth === "near" ? 6 : depth === "mid" ? 4.5 : 3;
278
+ const op = depth === "near" ? 1 : depth === "mid" ? 0.8 : 0.55;
279
+ addDot(layers[depth], (c[0] + rnd(0.13)) * 100, (c[1] + rnd(0.13)) * 100, sz, col, op);
280
+ }
281
+ });
282
+ for (let k = 0; k < 9; k++) {
283
+ addDot(layers.near, (0.5 + rnd(0.22)) * 100, (0.52 + rnd(0.2)) * 100, 7, cssv("--spark"), 1);
284
+ }
285
+ }
286
+
287
+ function placeMap() {
288
+ if (VIZ && VIZ.models && VIZ.models.length) placeReal();
289
+ else placeFallback();
290
+ }
291
+
292
+ function dropProbePoint(point) {
293
+ let lp, tp;
294
+ if (PROJ) { [lp, tp] = project([point.x, point.y, point.z]); }
295
+ else { lp = 50 + rnd(20) * 2; tp = 52 + rnd(18) * 2; }
296
+ const d = addDot(layers.near, lp, tp, 9, cssv("--text"), 1);
297
+ d.classList.add("probe");
298
+ }
299
+ function dropProbeByGates(gates) {
300
+ const id = TEACHERS[topIdx(gates)].id;
301
+ const c = CENTROIDS[id] || [0.5, 0.5];
302
+ const lp = (c[0] * 0.6 + 0.5 * 0.4 + rnd(0.06)) * 100;
303
+ const tp = (c[1] * 0.6 + 0.52 * 0.4 + rnd(0.06)) * 100;
304
+ const d = addDot(layers.near, lp, tp, 9, cssv("--text"), 1);
305
+ d.classList.add("probe");
306
+ }
307
+
308
+ function buildLegend() {
309
+ legend.innerHTML = "";
310
+ [{ label: "student", c: "--spark" }, ...TEACHERS.map((t) => ({ label: t.label, c: t.varc })), { label: "probe", c: "--text" }]
311
+ .forEach((it) => {
312
+ const s = document.createElement("div"); s.className = "lg"; s.style.color = cssv(it.c);
313
+ s.innerHTML = `<span class="s"></span><span style="color:var(--text-dim)">${it.label}</span>`;
314
+ legend.appendChild(s);
315
+ });
316
+ }
317
+ buildLegend();
318
+ placeMap();
319
+ window.addEventListener("resize", () => { clearTimeout(window.__rt); window.__rt = setTimeout(placeMap, 250); });
320
+
321
+ // ── 5. PARALLAX (unchanged) ─────────────────────────────────────────────
322
+ const ambient = document.getElementById("ambient");
323
+ const converge = document.getElementById("converge");
324
+ let sy = 0, ticking = false;
325
+ function onScroll() { sy = window.scrollY || 0; if (!ticking) { requestAnimationFrame(applyScroll); ticking = true; } }
326
+ function applyScroll() {
327
+ if (!reduce) {
328
+ ambient.style.transform = `translate3d(0, ${sy * 0.18}px, 0)`;
329
+ converge.style.transform = `translate3d(0, ${sy * -0.04}px, 0)`;
330
+ }
331
+ ticking = false;
332
+ }
333
+ window.addEventListener("scroll", onScroll, { passive: true });
334
+
335
+ let mx = 0, my = 0, cmx = 0, cmy = 0;
336
+ document.addEventListener("mousemove", (e) => {
337
+ mx = (e.clientX / window.innerWidth - 0.5);
338
+ my = (e.clientY / window.innerHeight - 0.5);
339
+ });
340
+ function rafMouse() {
341
+ cmx += (mx - cmx) * 0.06; cmy += (my - cmy) * 0.06;
342
+ if (!reduce) {
343
+ const tn = document.getElementById("tnodes");
344
+ const fl = document.getElementById("flows");
345
+ if (tn) tn.style.transform = `translate(${cmx * 22}px, ${cmy * 22}px)`;
346
+ if (fl) fl.style.transform = `translate(${cmx * 10}px, ${cmy * 10}px)`;
347
+ const r = stage.getBoundingClientRect();
348
+ if (r.top < window.innerHeight && r.bottom > 0) {
349
+ Object.values(layers).forEach((l) => {
350
+ const dep = parseFloat(l.dataset.depth) * 1;
351
+ l.style.transform = `translate(${cmx * dep * 900}px, ${cmy * dep * 700}px)`;
352
+ });
353
+ }
354
+ }
355
+ requestAnimationFrame(rafMouse);
356
+ }
357
+ requestAnimationFrame(rafMouse);
358
+
359
+ // ── 6. SCROLL REVEAL (unchanged) ────────────────────────────────────────
360
+ const io = new IntersectionObserver((ents) => {
361
+ ents.forEach((en) => { if (en.isIntersecting) { en.target.classList.add("in"); io.unobserve(en.target); } });
362
+ }, { threshold: 0.18 });
363
+ document.querySelectorAll(".reveal").forEach((el) => io.observe(el));
364
+
365
+ // ── 7. smooth scroll buttons (unchanged) ────────────────────────────────
366
+ document.querySelectorAll("[data-scroll]").forEach((b) => {
367
+ b.addEventListener("click", () => {
368
+ const el = document.querySelector(b.dataset.scroll);
369
+ if (el) el.scrollIntoView({ behavior: reduce ? "auto" : "smooth", block: "start" });
370
+ });
371
+ });
372
+
373
+ // ── 0. connect + load viz ───────────────────────────────────────────────
374
+ (async () => {
375
+ try {
376
+ client = await Client.connect(location.origin);
377
+ const res = await client.predict("/viz", {});
378
+ VIZ = res.data[0];
379
+ const n = (VIZ.teachers && VIZ.teachers.length) || 6;
380
+ document.getElementById("statTeachers").innerHTML =
381
+ `<span class="d t"></span>${n} Teachers <b>· 1.5–4B</b>`;
382
+ if (!VIZ.model_ready) infNote.textContent = "model offline — UI preview only";
383
+ placeMap();
384
+ } catch (e) {
385
+ console.error("boot failed", e);
386
+ infNote.textContent = "backend unreachable — UI preview only";
387
+ }
388
+ })();
frontend/index.html ADDED
@@ -0,0 +1,507 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>One for All — the strength of many, carried by one</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
10
+ <style>
11
+ :root {
12
+ /* ── ink base (deep, cool, near-black) ── */
13
+ --ink-0: oklch(0.155 0.012 264);
14
+ --ink-1: oklch(0.195 0.013 264);
15
+ --ink-2: oklch(0.235 0.014 264);
16
+ --line: oklch(0.42 0.02 264 / 0.42);
17
+ --line-soft: oklch(0.42 0.02 264 / 0.20);
18
+
19
+ --text: oklch(0.955 0.008 85);
20
+ --text-dim: oklch(0.72 0.012 264);
21
+ --text-faint: oklch(0.56 0.012 264);
22
+
23
+ /* ── the one warm accent (inherited light / the student) ── */
24
+ --spark: oklch(0.83 0.125 74);
25
+ --spark-deep: oklch(0.74 0.135 66);
26
+ --spark-soft: oklch(0.83 0.125 74 / 0.13);
27
+ --spark-line: oklch(0.83 0.125 74 / 0.30);
28
+
29
+ /* ── 6 teachers: one family, lightness+chroma fixed, hue varies ── */
30
+ --t-qwen: oklch(0.76 0.115 252);
31
+ --t-smollm: oklch(0.76 0.115 200);
32
+ --t-phi: oklch(0.76 0.115 156);
33
+ --t-gemma: oklch(0.76 0.115 122);
34
+ --t-minicpm: oklch(0.76 0.115 38);
35
+ --t-nemo: oklch(0.76 0.115 322);
36
+
37
+ --serif: "Instrument Serif", Georgia, "Times New Roman", serif;
38
+ --ui: "Hanken Grotesk", system-ui, -apple-system, "Segoe UI", sans-serif;
39
+ --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
40
+
41
+ --maxw: 1180px;
42
+ --ease: cubic-bezier(0.22, 1, 0.36, 1);
43
+ }
44
+
45
+ * { box-sizing: border-box; }
46
+ html { scroll-behavior: smooth; }
47
+ body {
48
+ margin: 0;
49
+ background: var(--ink-0);
50
+ color: var(--text);
51
+ font-family: var(--ui);
52
+ font-size: 16px;
53
+ line-height: 1.55;
54
+ -webkit-font-smoothing: antialiased;
55
+ text-rendering: optimizeLegibility;
56
+ overflow-x: hidden;
57
+ }
58
+
59
+ /* layered ambient field — fixed, gets parallax via JS */
60
+ .ambient {
61
+ position: fixed; inset: -10% -10% -10% -10%;
62
+ z-index: 0; pointer-events: none;
63
+ background:
64
+ radial-gradient(42% 38% at 22% 14%, oklch(0.76 0.115 252 / 0.10), transparent 70%),
65
+ radial-gradient(40% 36% at 82% 8%, var(--spark-soft), transparent 72%),
66
+ radial-gradient(50% 46% at 60% 96%, oklch(0.76 0.115 322 / 0.07), transparent 74%);
67
+ will-change: transform;
68
+ }
69
+ .grain {
70
+ position: fixed; inset: 0; z-index: 1; pointer-events: none;
71
+ opacity: 0.035; mix-blend-mode: screen;
72
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
73
+ }
74
+
75
+ ::-webkit-scrollbar { width: 11px; }
76
+ ::-webkit-scrollbar-track { background: transparent; }
77
+ ::-webkit-scrollbar-thumb { background: var(--ink-2); border-radius: 99px; border: 3px solid var(--ink-0); }
78
+ ::-webkit-scrollbar-thumb:hover { background: var(--spark-deep); }
79
+ ::selection { background: var(--spark-soft); color: var(--text); }
80
+
81
+ /* ── top bar ── */
82
+ .topbar {
83
+ position: sticky; top: 0; z-index: 40;
84
+ display: flex; align-items: center; justify-content: space-between;
85
+ padding: 16px clamp(20px, 4vw, 40px);
86
+ backdrop-filter: blur(14px) saturate(1.2);
87
+ background: linear-gradient(180deg, oklch(0.155 0.012 264 / 0.86), oklch(0.155 0.012 264 / 0.4));
88
+ border-bottom: 1px solid var(--line-soft);
89
+ }
90
+ .brand { display: flex; align-items: center; gap: 12px; }
91
+ .mark {
92
+ font-family: var(--serif); font-size: 30px; line-height: 1;
93
+ color: var(--spark);
94
+ text-shadow: 0 0 22px var(--spark-line);
95
+ transform: translateY(-1px);
96
+ }
97
+ .brand .name { font-weight: 600; letter-spacing: -0.01em; font-size: 16px; white-space: nowrap; }
98
+ .brand .name b { font-weight: 600; }
99
+ .stat-row { display: flex; align-items: center; gap: 18px; }
100
+ .stat {
101
+ font-family: var(--mono); font-size: 11px; letter-spacing: 0.06em;
102
+ color: var(--text-faint); display: flex; align-items: center; gap: 7px;
103
+ text-transform: uppercase;
104
+ }
105
+ .stat .d { width: 6px; height: 6px; border-radius: 50%; }
106
+ .stat .d.s { background: var(--spark); box-shadow: 0 0 8px var(--spark); }
107
+ .stat .d.t { background: var(--t-qwen); box-shadow: 0 0 8px var(--t-qwen); }
108
+ .stat b { color: var(--text-dim); font-weight: 500; }
109
+ @media (max-width: 760px){ .stat-row .stat:last-child { display: none; } }
110
+
111
+ /* ── shared layout ── */
112
+ .wrap { position: relative; z-index: 2; max-width: var(--maxw); margin: 0 auto; padding: 0 clamp(20px, 4vw, 40px); }
113
+ .eyebrow {
114
+ font-family: var(--mono); font-size: 11px; letter-spacing: 0.28em;
115
+ text-transform: uppercase; color: var(--text-faint);
116
+ }
117
+
118
+ /* ── HERO ── */
119
+ .hero { position: relative; padding: clamp(56px, 11vh, 130px) 0 clamp(40px, 7vh, 90px); }
120
+ .hero-grid {
121
+ display: grid; grid-template-columns: 1.05fr 0.95fr; gap: clamp(24px, 5vw, 64px);
122
+ align-items: center;
123
+ }
124
+ .hero h1 {
125
+ font-family: var(--serif); font-weight: 400;
126
+ font-size: clamp(46px, 7.6vw, 104px); line-height: 0.96;
127
+ letter-spacing: -0.015em; margin: 18px 0 0;
128
+ }
129
+ .hero h1 .em { font-style: italic; color: var(--spark); }
130
+ .hero .lede {
131
+ margin: 26px 0 0; max-width: 30em;
132
+ font-size: clamp(16px, 1.5vw, 19px); line-height: 1.62; color: var(--text-dim);
133
+ }
134
+ .hero .lede b { color: var(--text); font-weight: 600; }
135
+ .hero-cta { display: flex; align-items: center; gap: 18px; margin-top: 34px; flex-wrap: wrap; }
136
+ .btn {
137
+ appearance: none; border: 0; cursor: pointer; font-family: var(--ui);
138
+ font-weight: 600; font-size: 15px; letter-spacing: 0.005em;
139
+ padding: 13px 22px; border-radius: 99px;
140
+ display: inline-flex; align-items: center; gap: 10px;
141
+ transition: transform .25s var(--ease), box-shadow .25s var(--ease), background .2s;
142
+ }
143
+ .btn-spark {
144
+ color: oklch(0.2 0.04 70);
145
+ background: linear-gradient(180deg, var(--spark), var(--spark-deep));
146
+ box-shadow: 0 1px 0 oklch(1 0 0 / 0.3) inset, 0 10px 30px -10px var(--spark-deep);
147
+ }
148
+ .btn-spark:hover { transform: translateY(-2px); box-shadow: 0 1px 0 oklch(1 0 0 / 0.3) inset, 0 16px 36px -12px var(--spark-deep); }
149
+ .btn-ghost { color: var(--text-dim); background: transparent; border: 1px solid var(--line); }
150
+ .btn-ghost:hover { color: var(--text); border-color: var(--text-faint); }
151
+ .btn .arrow { font-family: var(--mono); }
152
+
153
+ /* convergence diagram */
154
+ .converge { position: relative; aspect-ratio: 1 / 1; width: 100%; }
155
+ .converge svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
156
+ .converge .tlabel {
157
+ font-family: var(--mono); font-size: 11px; letter-spacing: 0.04em; fill: var(--text-dim);
158
+ }
159
+ .thread { fill: none; stroke-width: 1.4; opacity: 0.5; }
160
+ .thread-flow { fill: none; stroke-width: 1.6; stroke-dasharray: 2 13; stroke-linecap: round; }
161
+ @keyframes flow { to { stroke-dashoffset: -150; } }
162
+ .core-ring { animation: spinSlow 38s linear infinite; transform-origin: 300px 300px; }
163
+ @keyframes spinSlow { to { transform: rotate(360deg); } }
164
+ @keyframes breathe { 0%,100% { opacity: .55; r: 30; } 50% { opacity: .9; r: 36; } }
165
+ .core-halo { animation: breathe 4.5s ease-in-out infinite; }
166
+ .tnode { transition: r .3s var(--ease); }
167
+
168
+ .scrollcue {
169
+ margin-top: clamp(34px, 6vh, 70px); display: flex; align-items: center; gap: 12px;
170
+ font-family: var(--mono); font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase;
171
+ color: var(--text-faint);
172
+ }
173
+ .scrollcue .ln { width: 46px; height: 1px; background: var(--line); position: relative; overflow: hidden; }
174
+ .scrollcue .ln::after { content:""; position:absolute; inset:0; width:40%; background: var(--spark); animation: cue 2.6s var(--ease) infinite; }
175
+ @keyframes cue { 0%{ transform: translateX(-120%);} 60%,100%{ transform: translateX(320%);} }
176
+
177
+ /* ── reveal ── */
178
+ .reveal { opacity: 0; transform: translateY(26px); transition: opacity .9s var(--ease), transform .9s var(--ease); }
179
+ .reveal.in { opacity: 1; transform: none; }
180
+
181
+ /* ── section heading ── */
182
+ .section { padding: clamp(60px, 11vh, 130px) 0; position: relative; }
183
+ .sec-head { max-width: 40em; margin-bottom: 40px; }
184
+ .sec-head h2 {
185
+ font-family: var(--serif); font-weight: 400; font-size: clamp(30px, 4.4vw, 50px);
186
+ line-height: 1.04; letter-spacing: -0.01em; margin: 14px 0 0;
187
+ }
188
+ .sec-head p { color: var(--text-dim); margin: 16px 0 0; font-size: 17px; max-width: 34em; }
189
+
190
+ /* ── ask panel ── */
191
+ .ask-shell {
192
+ display: grid; grid-template-columns: 1.55fr 1fr; gap: 22px;
193
+ }
194
+ .card {
195
+ background: linear-gradient(180deg, oklch(0.205 0.013 264 / 0.7), oklch(0.18 0.012 264 / 0.7));
196
+ border: 1px solid var(--line-soft);
197
+ border-radius: 18px;
198
+ padding: 22px;
199
+ position: relative;
200
+ }
201
+ .seg {
202
+ display: inline-flex; padding: 3px; gap: 2px;
203
+ background: var(--ink-0); border: 1px solid var(--line-soft); border-radius: 99px;
204
+ margin-bottom: 18px;
205
+ }
206
+ .seg button {
207
+ appearance: none; border: 0; background: transparent; cursor: pointer;
208
+ font-family: var(--ui); font-weight: 600; font-size: 13px;
209
+ color: var(--text-faint); padding: 7px 16px; border-radius: 99px;
210
+ transition: color .2s, background .2s;
211
+ }
212
+ .seg button[aria-pressed="true"] { background: var(--ink-2); color: var(--text); }
213
+
214
+ .ask-box {
215
+ width: 100%; min-height: 96px; resize: vertical;
216
+ background: var(--ink-0); border: 1px solid var(--line-soft);
217
+ border-radius: 12px; padding: 15px 16px;
218
+ color: var(--text); font-family: var(--ui); font-size: 16px; line-height: 1.5;
219
+ outline: none; transition: border-color .2s, box-shadow .2s;
220
+ }
221
+ .ask-box:focus { border-color: var(--spark-line); box-shadow: 0 0 0 4px var(--spark-soft); }
222
+ .ask-box::placeholder { color: var(--text-faint); }
223
+
224
+ .ask-row { display: flex; align-items: center; justify-content: flex-end; gap: 14px; margin-top: 14px; flex-wrap: wrap; }
225
+ .run-hint { margin-right: auto; font-family: var(--mono); font-size: 11px; color: var(--text-faint); letter-spacing: 0.04em; display: flex; align-items: center; gap: 6px; }
226
+ .run-hint .kbd { border: 1px solid var(--line-soft); border-bottom-width: 2px; border-radius: 4px; padding: 1px 6px; color: var(--text-dim); background: var(--ink-0); }
227
+
228
+ /* example questions — visible list */
229
+ .examples-block { margin-top: 22px; }
230
+ .examples-label { font-family: var(--mono); font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--text-faint); }
231
+ .examples { list-style: none; margin: 12px 0 0; padding: 0; display: flex; flex-direction: column; gap: 1px; border: 1px solid var(--line-soft); border-radius: 12px; overflow: hidden; }
232
+ .chip {
233
+ appearance: none; cursor: pointer; width: 100%; text-align: left;
234
+ font-family: var(--ui); font-size: 14.5px; color: var(--text-dim);
235
+ background: oklch(0.205 0.013 264 / 0.4); border: 0;
236
+ padding: 13px 15px; display: flex; align-items: center; gap: 12px;
237
+ transition: color .18s, background .18s, padding .18s var(--ease);
238
+ }
239
+ .chip + .chip { border-top: 1px solid var(--line-soft); }
240
+ .chip .ix { font-family: var(--mono); font-size: 11px; color: var(--text-faint); flex-shrink: 0; transition: color .18s; }
241
+ .chip .qt { flex: 1; }
242
+ .chip .go { font-family: var(--mono); color: var(--spark); opacity: 0; transform: translateX(-6px); transition: opacity .18s, transform .18s var(--ease); flex-shrink: 0; }
243
+ .chip:hover { color: var(--text); background: oklch(0.235 0.014 264 / 0.7); padding-left: 19px; }
244
+ .chip:hover .ix { color: var(--spark); }
245
+ .chip:hover .go { opacity: 1; transform: none; }
246
+
247
+ .ask-btn {
248
+ appearance: none; border: 0; cursor: pointer; flex-shrink: 0;
249
+ font-family: var(--ui); font-weight: 700; font-size: 15px;
250
+ color: oklch(0.2 0.04 70);
251
+ background: linear-gradient(180deg, var(--spark), var(--spark-deep));
252
+ padding: 12px 24px; border-radius: 12px;
253
+ display: inline-flex; align-items: center; gap: 12px;
254
+ box-shadow: 0 1px 0 oklch(1 0 0 / 0.3) inset, 0 10px 26px -12px var(--spark-deep);
255
+ transition: transform .2s var(--ease), filter .2s;
256
+ }
257
+ .ask-btn:hover { transform: translateY(-1px); filter: brightness(1.04); }
258
+ .ask-btn small { font-family: var(--mono); font-weight: 400; font-size: 10.5px; opacity: 0.7; letter-spacing: 0.04em; }
259
+ .ask-btn.busy { animation: pulseBtn 1.1s ease-in-out infinite; }
260
+ @keyframes pulseBtn { 0%,100%{ filter: brightness(1);} 50%{ filter: brightness(1.18);} }
261
+
262
+ /* answers */
263
+ .answers { margin-top: 22px; display: grid; gap: 16px; }
264
+ .answers.compare { grid-template-columns: 1fr 1fr; }
265
+ .ans {
266
+ border: 1px solid var(--line-soft); border-radius: 12px; padding: 16px;
267
+ background: var(--ink-0);
268
+ }
269
+ .ans .tag {
270
+ font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.14em; text-transform: uppercase;
271
+ display: flex; align-items: center; gap: 8px; margin-bottom: 12px;
272
+ }
273
+ .ans.before .tag { color: var(--text-faint); }
274
+ .ans.after .tag { color: var(--spark); }
275
+ .ans .tag .d { width: 6px; height: 6px; border-radius: 50%; background: currentColor; box-shadow: 0 0 8px currentColor; }
276
+ .ans .body { font-size: 14.5px; line-height: 1.6; color: var(--text-dim); white-space: pre-wrap; min-height: 3em; }
277
+ .ans.after .body { color: var(--text); }
278
+ .ans.before { opacity: 0.72; }
279
+ .ans .body .caret { color: var(--spark); animation: blink 1s step-end infinite; }
280
+ @keyframes blink { 50% { opacity: 0; } }
281
+ .placeholder-note { color: var(--text-faint); font-size: 13.5px; font-style: italic; }
282
+ @media (max-width: 640px){ .answers.compare { grid-template-columns: 1fr; } }
283
+
284
+ /* teacher influence */
285
+ .influence .head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 4px; }
286
+ .influence .head .ttl { font-family: var(--mono); font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--text-faint); }
287
+ .influence > p { color: var(--text-dim); font-size: 13.5px; margin: 0 0 20px; }
288
+ .inf-rows { display: flex; flex-direction: column; gap: 15px; }
289
+ .inf-row { display: grid; grid-template-columns: 78px 1fr 34px; align-items: center; gap: 12px; }
290
+ .inf-row .nm { font-family: var(--mono); font-size: 12.5px; }
291
+ .inf-row .track { height: 5px; border-radius: 99px; background: oklch(0.3 0.01 264 / 0.5); overflow: hidden; position: relative; }
292
+ .inf-row .fill { position: absolute; inset: 0 auto 0 0; width: 0; border-radius: 99px; background: currentColor; box-shadow: 0 0 10px currentColor; transition: width 1s var(--ease); }
293
+ .inf-row .pc { font-family: var(--mono); font-size: 11.5px; color: var(--text-faint); text-align: right; }
294
+ .inf-foot { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--line-soft);
295
+ font-family: var(--mono); font-size: 11px; color: var(--text-faint); display: flex; align-items: center; gap: 8px; }
296
+ .inf-foot .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--spark); box-shadow: 0 0 8px var(--spark); }
297
+
298
+ /* ── thought map ── */
299
+ .map-stage {
300
+ position: relative; height: clamp(440px, 64vh, 680px);
301
+ border-radius: 22px; overflow: hidden;
302
+ border: 1px solid var(--line-soft);
303
+ background:
304
+ radial-gradient(60% 60% at 50% 50%, oklch(0.2 0.013 264 / 0.5), transparent 75%),
305
+ var(--ink-0);
306
+ }
307
+ .map-stage .vignette { position: absolute; inset: 0; pointer-events: none;
308
+ box-shadow: inset 0 0 140px 30px oklch(0.13 0.01 264 / 0.9); }
309
+ .map-layer { position: absolute; inset: 0; will-change: transform; }
310
+ .dot {
311
+ position: absolute; border-radius: 50%; transform: translate(-50%, -50%);
312
+ box-shadow: 0 0 10px currentColor; will-change: transform;
313
+ }
314
+ .dot.probe { background: var(--text); color: var(--text); }
315
+ .dot.probe::after { content:""; position:absolute; inset:-7px; border-radius:50%; border:1px solid var(--text); opacity:0; animation: ping 1.6s var(--ease) 2; }
316
+ @keyframes ping { 0%{ transform: scale(.4); opacity:.8;} 100%{ transform: scale(1.6); opacity:0; } }
317
+ .map-legend {
318
+ position: absolute; left: 20px; bottom: 18px; z-index: 3;
319
+ display: flex; flex-wrap: wrap; gap: 14px;
320
+ }
321
+ .map-legend .lg { display: flex; align-items: center; gap: 7px; font-family: var(--mono); font-size: 11px; color: var(--text-dim); }
322
+ .map-legend .lg .s { width: 8px; height: 8px; border-radius: 50%; box-shadow: 0 0 7px currentColor; background: currentColor; }
323
+ .map-cap { position: absolute; top: 20px; left: 22px; z-index: 3; max-width: 30em; }
324
+ .map-cap .t { font-family: var(--mono); font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--text-faint); }
325
+ .map-cap p { margin: 8px 0 0; color: var(--text-dim); font-size: 14px; }
326
+
327
+ /* ── footer ── */
328
+ footer { position: relative; z-index: 2; border-top: 1px solid var(--line-soft); margin-top: 40px; }
329
+ .foot-in { max-width: var(--maxw); margin: 0 auto; padding: 30px clamp(20px,4vw,40px);
330
+ display: flex; align-items: center; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
331
+ .foot-in .tag { font-family: var(--serif); font-size: 22px; color: var(--text-dim); }
332
+ .foot-in .tag .em { font-style: italic; color: var(--spark); }
333
+ .foot-links { display: flex; gap: 22px; font-family: var(--mono); font-size: 12px; }
334
+ .foot-links a { color: var(--text-faint); text-decoration: none; transition: color .2s; }
335
+ .foot-links a:hover { color: var(--spark); }
336
+
337
+ @media (max-width: 900px){
338
+ .hero-grid { grid-template-columns: 1fr; }
339
+ .converge { max-width: 460px; margin: 8px auto 0; }
340
+ .ask-shell { grid-template-columns: 1fr; }
341
+ }
342
+
343
+ @media (prefers-reduced-motion: reduce){
344
+ *, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; }
345
+ html { scroll-behavior: auto; }
346
+ .reveal { opacity: 1; transform: none; }
347
+ }
348
+ </style>
349
+ </head>
350
+ <body>
351
+ <div class="ambient" id="ambient"></div>
352
+ <div class="grain"></div>
353
+
354
+ <!-- ── TOP BAR ── -->
355
+ <header class="topbar">
356
+ <div class="brand">
357
+ <span class="name">One for <b>All</b></span>
358
+ </div>
359
+ <div class="stat-row">
360
+ <span class="stat"><span class="d s"></span>Student <b>· 0.5B</b></span>
361
+ <span class="stat" id="statTeachers"><span class="d t"></span>6 Teachers <b>· 1.5–4B</b></span>
362
+ </div>
363
+ </header>
364
+
365
+ <!-- ── HERO ── -->
366
+ <section class="hero">
367
+ <div class="wrap hero-grid">
368
+ <div class="hero-copy">
369
+ <span class="eyebrow">Multi-teacher distillation</span>
370
+ <h1>Six minds,<br />one&nbsp;<span class="em">student</span>.</h1>
371
+ <p class="lede">
372
+ Six specialist AIs pour what they know into a single half-billion-parameter
373
+ model — <b>the strength of many, carried by one.</b> Ask it anything and watch
374
+ the inheritance happen, live.
375
+ </p>
376
+ <div class="hero-cta">
377
+ <button class="btn btn-spark" data-scroll="#try">Ask the student <span class="arrow">↓</span></button>
378
+ <button class="btn btn-ghost" data-scroll="#map">See the thought map</button>
379
+ </div>
380
+ <div class="scrollcue"><span class="ln"></span> watch the transfer</div>
381
+ </div>
382
+
383
+ <!-- convergence diagram: 6 teachers → 1 student -->
384
+ <div class="converge" id="converge" aria-hidden="true">
385
+ <svg viewBox="0 0 600 600">
386
+ <defs>
387
+ <radialGradient id="coreG" cx="50%" cy="50%" r="50%">
388
+ <stop offset="0%" stop-color="oklch(0.97 0.05 80)" />
389
+ <stop offset="45%" stop-color="var(--spark)" />
390
+ <stop offset="100%" stop-color="var(--spark-deep)" />
391
+ </radialGradient>
392
+ <filter id="soft" x="-60%" y="-60%" width="220%" height="220%">
393
+ <feGaussianBlur stdDeviation="7" />
394
+ </filter>
395
+ </defs>
396
+
397
+ <!-- threads + flow + nodes are injected by JS -->
398
+ <g id="threads"></g>
399
+ <g id="flows"></g>
400
+
401
+ <!-- student core -->
402
+ <circle class="core-halo" cx="300" cy="300" r="32" fill="var(--spark)" opacity="0.5" filter="url(#soft)" />
403
+ <g class="core-ring">
404
+ <circle cx="300" cy="300" r="58" fill="none" stroke="var(--spark-line)" stroke-width="1" stroke-dasharray="3 9" />
405
+ </g>
406
+ <circle cx="300" cy="300" r="17" fill="url(#coreG)" />
407
+ <circle cx="300" cy="300" r="17" fill="none" stroke="oklch(1 0 0 / 0.5)" stroke-width="1" />
408
+ <text x="300" y="345" text-anchor="middle" class="tlabel" style="fill: var(--spark); font-size: 12px; letter-spacing:0.12em;">STUDENT · 0.5B</text>
409
+
410
+ <g id="tnodes"></g>
411
+ </svg>
412
+ </div>
413
+ </div>
414
+ </section>
415
+
416
+ <!-- ── ASK / COMPARE ── -->
417
+ <section class="section" id="try">
418
+ <div class="wrap">
419
+ <div class="sec-head reveal">
420
+ <span class="eyebrow">Live probe</span>
421
+ <h2>One prompt. Two answers.<br />Before the teachers, and after.</h2>
422
+ <p>The exact same 0.5-billion-parameter model — with and without what it learned from the
423
+ six teachers. Type a question, or pick one below.</p>
424
+ </div>
425
+
426
+ <div class="ask-shell reveal">
427
+ <!-- left: prompt + answers -->
428
+ <div class="card">
429
+ <div class="seg" role="group" aria-label="view mode">
430
+ <button data-mode="ask" aria-pressed="true">Ask</button>
431
+ <button data-mode="compare" aria-pressed="false">Compare before / after</button>
432
+ </div>
433
+
434
+ <textarea class="ask-box" id="ask" placeholder="Ask anything — a math problem, a science question, a bit of code…">Write a Python function that checks if a word is a palindrome.</textarea>
435
+
436
+ <div class="ask-row">
437
+ <span class="run-hint"><span class="kbd">⌘</span><span class="kbd">↵</span> to run</span>
438
+ <button class="ask-btn" id="askBtn">Ask <small>0.5B model</small></button>
439
+ </div>
440
+
441
+ <div class="examples-block">
442
+ <span class="examples-label">Or pick a question</span>
443
+ <ul class="examples" id="examples"></ul>
444
+ </div>
445
+
446
+ <div class="answers" id="answers">
447
+ <div class="ans after">
448
+ <div class="tag"><span class="d"></span>After · taught by 6 teachers</div>
449
+ <div class="body" id="bodyAfter"><span class="placeholder-note">Ask the student to see how its answer changes after distillation.</span></div>
450
+ </div>
451
+ <div class="ans before" id="beforeCard" hidden>
452
+ <div class="tag"><span class="d"></span>Before · plain base model</div>
453
+ <div class="body" id="bodyBefore"></div>
454
+ </div>
455
+ </div>
456
+ </div>
457
+
458
+ <!-- right: teacher influence -->
459
+ <div class="card influence">
460
+ <div class="head"><span class="ttl">Teacher influence</span></div>
461
+ <p>While the student answers, these bars show how much it leans on what each teacher taught it.</p>
462
+ <div class="inf-rows" id="infRows"></div>
463
+ <div class="inf-foot"><span class="dot"></span><span id="infNote">idle — waiting for a prompt</span></div>
464
+ </div>
465
+ </div>
466
+ </div>
467
+ </section>
468
+
469
+ <!-- ── THOUGHT MAP ── -->
470
+ <section class="section" id="map">
471
+ <div class="wrap">
472
+ <div class="sec-head reveal">
473
+ <span class="eyebrow">Representation space</span>
474
+ <h2>Where every mind keeps its thoughts.</h2>
475
+ <p>Each dot is how one model internally represents a piece of text. Teachers cluster by what
476
+ they're good at — and the student drifts toward all of them at once. Your prompts land here
477
+ as bright probes.</p>
478
+ </div>
479
+
480
+ <div class="map-stage reveal" id="mapStage">
481
+ <div class="map-cap">
482
+ <div class="t">Thought map</div>
483
+ <p>Move your cursor — the field has depth.</p>
484
+ </div>
485
+ <div class="map-layer" id="mapFar" data-depth="0.012"></div>
486
+ <div class="map-layer" id="mapMid" data-depth="0.028"></div>
487
+ <div class="map-layer" id="mapNear" data-depth="0.05"></div>
488
+ <div class="vignette"></div>
489
+ <div class="map-legend" id="mapLegend"></div>
490
+ </div>
491
+ </div>
492
+ </section>
493
+
494
+ <footer>
495
+ <div class="foot-in">
496
+ <div class="tag">one for all — <span class="em">the strength of many, carried by one.</span></div>
497
+ <div class="foot-links">
498
+ <a href="https://huggingface.co/build-small-hackathon/deku" target="_blank" rel="noopener">model</a>
499
+ <a href="https://huggingface.co/build-small-hackathon/deku-gguf" target="_blank" rel="noopener">gguf</a>
500
+ <a href="https://huggingface.co/spaces/build-small-hackathon/one-for-all" target="_blank" rel="noopener">↗ space</a>
501
+ </div>
502
+ </div>
503
+ </footer>
504
+
505
+ <script type="module" src="/static/app.js"></script>
506
+ </body>
507
+ </html>
requirements.txt CHANGED
@@ -1,4 +1,4 @@
1
- gradio==5.33.2
2
  plotly>=5.20
3
  umap-learn>=0.5
4
  numpy
@@ -9,3 +9,7 @@ accelerate>=0.30
9
  spaces
10
  huggingface_hub>=0.22
11
  trimesh>=4.0
 
 
 
 
 
1
+ gradio>=6.18
2
  plotly>=5.20
3
  umap-learn>=0.5
4
  numpy
 
9
  spaces
10
  huggingface_hub>=0.22
11
  trimesh>=4.0
12
+ # llama.cpp backend (OFA_BACKEND=llamacpp) is opt-in: it compiles from source on
13
+ # the Spaces builder (~10 min, can fail), and the default torch/ZeroGPU path never
14
+ # imports it. Uncomment only if you switch OFA_BACKEND to "llamacpp".
15
+ # llama-cpp-python>=0.3
server_app.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ space/server_app.py — One for All, custom frontend on gr.Server.
3
+
4
+ gr.Server = FastAPI with Gradio's API engine: @app.api functions become
5
+ queued/streaming endpoints (SSE), and we serve our own HTML/JS at "/" —
6
+ the browser talks to the endpoints via @gradio/client (required for ZeroGPU).
7
+
8
+ Run locally:
9
+ cd space && VIZ_DATA_PATH=/path/to/viz_data.json python server_app.py
10
+
11
+ The legacy Blocks UI (app.py) keeps working — both share _boot.py.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ import spaces
18
+ from fastapi.responses import FileResponse, HTMLResponse
19
+ from gradio import Server
20
+
21
+ import _boot
22
+
23
+ RT = _boot.load_runtime()
24
+ FRONTEND = Path(__file__).parent / "frontend"
25
+
26
+ app = Server()
27
+
28
+
29
+ # ── API: static viz payload (soul space, CKA, curves, meta) ────────────────
30
+ @app.api(name="viz")
31
+ def viz() -> dict:
32
+ return _boot.viz_payload(RT.viz, RT.coords3d, RT.backend, RT.model_ready)
33
+
34
+
35
+ # ── API: streaming probe — text + live gates, final soul-space point ───────
36
+ @app.api(name="probe", stream_every=0.1)
37
+ @spaces.GPU
38
+ def probe(text: str) -> dict: # annotation = type of each streamed chunk
39
+ if not text.strip() or not RT.model_ready:
40
+ yield {"error": "model not ready" if text.strip() else "empty prompt",
41
+ "done": True}
42
+ return
43
+ _boot.to_device(RT)
44
+ partial, gates = "", []
45
+ for partial, gates in _boot.stream(RT, text):
46
+ yield {"text": partial, "gates": gates, "done": False}
47
+ point = None
48
+ if RT.reducer is not None:
49
+ point, gates = _boot.final_probe(RT, text)
50
+ yield {"text": partial, "gates": gates, "point": point, "done": True}
51
+
52
+
53
+ # ── API: streaming arena — base (LoRA off) vs deku, interleaved ────────────
54
+ @app.api(name="arena", stream_every=0.1)
55
+ @spaces.GPU
56
+ def arena(text: str) -> dict: # annotation = type of each streamed chunk
57
+ if not text.strip() or not RT.model_ready:
58
+ yield {"error": "model not ready" if text.strip() else "empty prompt",
59
+ "done": True}
60
+ return
61
+ _boot.to_device(RT)
62
+ base = deku = ""
63
+ gates: list[float] = []
64
+ for base, deku, gates in _boot.stream_pair(RT, text):
65
+ yield {"base": base, "deku": deku, "gates": gates, "done": False}
66
+ yield {"base": base, "deku": deku, "gates": gates, "done": True}
67
+
68
+
69
+ # ── Custom frontend (overrides Gradio's default UI at "/") ────────────────
70
+ @app.get("/", response_class=HTMLResponse)
71
+ async def home():
72
+ return (FRONTEND / "index.html").read_text()
73
+
74
+
75
+ @app.get("/static/{name}")
76
+ async def static(name: str):
77
+ target = (FRONTEND / name).resolve()
78
+ if target.parent != FRONTEND.resolve() or not target.exists():
79
+ return HTMLResponse("not found", status_code=404)
80
+ return FileResponse(target)
81
+
82
+
83
+ if __name__ == "__main__":
84
+ app.launch()