Gerald Corzo commited on
Commit
446eaf9
·
0 Parent(s):

feat: remora PyTorch ZeroGPU layer-control research space

Browse files
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .env
5
+ .env.*
6
+ !.env.example
README.md ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Remora Layer Lab
3
+ emoji: 🐟
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: gradio
7
+ python_version: "3.12"
8
+ sdk_version: "5.49.1"
9
+ app_file: app.py
10
+ models:
11
+ - LiquidAI/LFM2.5-1.2B-Instruct
12
+ tags:
13
+ - remora
14
+ - layer-control
15
+ - lfm2
16
+ - zerogpu
17
+ ---
18
+
19
+ # Remora Layer Lab
20
+
21
+ A research-only Hugging Face ZeroGPU Space that runs `LiquidAI/LFM2.5-1.2B-Instruct` and applies explicit, reproducible controls to selected hidden-state layers.
22
+
23
+ ## What this is
24
+
25
+ - A **PyTorch experimental adapter** for the existing Remora research method.
26
+ - Captures per-layer hidden-state statistics for each generation run.
27
+ - Applies a scalar gain to selected decoder-block outputs:
28
+
29
+ `controlled = baseline + strength × (baseline - token_mean)`
30
+
31
+ - `strength = 0`: observation only; output is unchanged.
32
+ - negative strength: damp the selected residual signal.
33
+ - positive strength: amplify it.
34
+ - Produces a JSONL trace that can be compared with the Rust/llama.cpp Remora traces.
35
+
36
+ ## What this is not
37
+
38
+ This is **not** the Rust production lane and does not replace local remora on Janus. The HF ZeroGPU Space is a bounded research surface: Gradio/PyTorch only, shared GPU allocation, daily quota, and no persistent runtime disk. The production route remains octopus → local `lfm-fast`.
39
+
40
+ ## Run protocol
41
+
42
+ 1. Use the default `strength = 0` to create a baseline trace.
43
+ 2. Repeat the same prompt with one selected layer and one non-zero strength.
44
+ 3. Compare output and trace statistics outside the Space. Save the downloaded JSONL with the prompt suite and run metadata.
45
+ 4. Keep a request below the declared GPU duration. Start with 32 output tokens.
46
+
47
+ ## Cost boundary
48
+
49
+ For a PRO account, ZeroGPU has a 40-minute daily quota. Usage after the quota costs $1 per 10 minutes from pre-paid HF credits. The Space uses `large` (48 GB, one quota unit), not `xlarge`.
50
+
51
+ ## Local checks
52
+
53
+ ```bash
54
+ python -m unittest discover -s tests -v
55
+ ```
56
+
57
+ ## Deploy
58
+
59
+ Create an HF **Gradio ZeroGPU** Space called `gcorzo/remora-layer-lab`, select ZeroGPU hardware in its Settings, then push this directory to the Space repository. The HF token used for this must have Space write permission; no token is stored in this repository.
app.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import tempfile
5
+ import time
6
+ from pathlib import Path
7
+
8
+ import gradio as gr
9
+ import spaces
10
+ import torch
11
+ from transformers import AutoModelForCausalLM, AutoTokenizer
12
+
13
+ from remora_control import LayerController
14
+
15
+ MODEL_ID = "LiquidAI/LFM2.5-1.2B-Instruct"
16
+ MAX_OUTPUT_TOKENS = 128
17
+ DEVICE = "cuda"
18
+
19
+ # ZeroGPU's CUDA emulation makes module-level placement correct: a real GPU is
20
+ # attached only when `run_experiment` enters the decorator.
21
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
22
+ model = AutoModelForCausalLM.from_pretrained(
23
+ MODEL_ID,
24
+ torch_dtype=torch.bfloat16,
25
+ low_cpu_mem_usage=True,
26
+ ).to(DEVICE).eval()
27
+ LAYER_COUNT = len(model.model.layers)
28
+
29
+
30
+ def parse_layers(text: str) -> list[int]:
31
+ raw = [part.strip() for part in text.split(",") if part.strip()]
32
+ layers = sorted(set(int(part) for part in raw))
33
+ invalid = [value for value in layers if value < 0 or value >= LAYER_COUNT]
34
+ if invalid:
35
+ raise gr.Error(f"Layer values must be between 0 and {LAYER_COUNT - 1}; got {invalid}.")
36
+ return layers
37
+
38
+
39
+ @spaces.GPU(duration=120, size="large")
40
+ def run_experiment(prompt: str, selected_layers: str, gain: float, max_new_tokens: int, seed: int):
41
+ if not prompt.strip():
42
+ raise gr.Error("Enter a prompt.")
43
+ if max_new_tokens < 1 or max_new_tokens > MAX_OUTPUT_TOKENS:
44
+ raise gr.Error(f"Output tokens must be 1–{MAX_OUTPUT_TOKENS}.")
45
+
46
+ layers = parse_layers(selected_layers)
47
+ controller = LayerController(layers, gain)
48
+ generator = torch.Generator(device=DEVICE).manual_seed(int(seed))
49
+ started = time.perf_counter()
50
+ controller.attach(model)
51
+ try:
52
+ messages = [{"role": "user", "content": prompt}]
53
+ rendered = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
54
+ inputs = tokenizer(rendered, return_tensors="pt").to(DEVICE)
55
+ with torch.inference_mode():
56
+ output_ids = model.generate(
57
+ **inputs,
58
+ max_new_tokens=int(max_new_tokens),
59
+ do_sample=True,
60
+ temperature=0.1,
61
+ top_k=50,
62
+ repetition_penalty=1.05,
63
+ generator=generator,
64
+ use_cache=True,
65
+ )
66
+ finally:
67
+ controller.detach()
68
+
69
+ elapsed_s = time.perf_counter() - started
70
+ new_tokens = output_ids[0, inputs.input_ids.shape[1] :]
71
+ text = tokenizer.decode(new_tokens, skip_special_tokens=True)
72
+ trace = {
73
+ "schema": "remora-layer-lab/v1",
74
+ "model": MODEL_ID,
75
+ "layer_count": LAYER_COUNT,
76
+ "selected_layers": layers,
77
+ "gain": gain,
78
+ "seed": seed,
79
+ "prompt": prompt,
80
+ "generated_tokens": int(new_tokens.shape[0]),
81
+ "elapsed_s": round(elapsed_s, 4),
82
+ "tokens_per_second": round(float(new_tokens.shape[0]) / elapsed_s, 3) if elapsed_s else None,
83
+ "events": controller.json_events(),
84
+ }
85
+ artifact = Path(tempfile.mkstemp(prefix="remora-trace-", suffix=".json")[1])
86
+ artifact.write_text(json.dumps(trace, indent=2), encoding="utf-8")
87
+ status = (
88
+ f"{len(layers)} controlled layers; {len(trace['events'])} trace events; "
89
+ f"{trace['generated_tokens']} tokens in {elapsed_s:.2f}s "
90
+ f"({trace['tokens_per_second']} tok/s)."
91
+ )
92
+ return text, status, str(artifact)
93
+
94
+
95
+ with gr.Blocks(title="Remora Layer Lab") as demo:
96
+ gr.Markdown(
97
+ "# Remora Layer Lab\n"
98
+ "PyTorch/ZeroGPU research adapter for controlled LFM2.5 decoder-layer experiments. "
99
+ "`gain=0` is an observation-only baseline. This is not the local Rust production lane."
100
+ )
101
+ with gr.Row():
102
+ prompt = gr.Textbox(label="Prompt", lines=6, value="Explain why water forecasting needs uncertainty.")
103
+ with gr.Column():
104
+ selected_layers = gr.Textbox(label=f"Layers (0–{LAYER_COUNT - 1}, comma-separated)", value="2,5")
105
+ gain = gr.Slider(-1.0, 1.0, value=0.0, step=0.05, label="Layer gain")
106
+ max_new_tokens = gr.Slider(1, MAX_OUTPUT_TOKENS, value=32, step=1, label="Max output tokens")
107
+ seed = gr.Number(value=42, precision=0, label="Random seed")
108
+ run = gr.Button("Run controlled experiment", variant="primary")
109
+ answer = gr.Textbox(label="Generated text", lines=8)
110
+ status = gr.Textbox(label="Run statistics")
111
+ trace = gr.File(label="Download trace JSON")
112
+ run.click(run_experiment, [prompt, selected_layers, gain, max_new_tokens, seed], [answer, status, trace])
113
+
114
+ if __name__ == "__main__":
115
+ demo.launch()
docs/plans/2026-09-02-remora-layer-lab.md ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Remora Layer Lab Implementation Plan
2
+
3
+ > **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
4
+
5
+ **Goal:** provide a bounded HF ZeroGPU laboratory for PyTorch layer-control experiments on public LFM2.5-1.2B weights, while retaining local Rust remora as the production path.
6
+
7
+ **Architecture:** The Space runs `LiquidAI/LFM2.5-1.2B-Instruct` through Transformers and attaches forward hooks to selected `model.model.layers`. A deterministic gain control captures before/after RMS per token in a downloadable JSON trace. The Space uses Gradio plus `@spaces.GPU`, which is an explicitly bounded external Python research adapter and not fleet infrastructure.
8
+
9
+ **Tech Stack:** Python 3.12, PyTorch 2.8+, Transformers 4.57.6, Gradio 5.49.1, Hugging Face ZeroGPU.
10
+
11
+ ---
12
+
13
+ ## Verified inputs
14
+
15
+ - Model: `LiquidAI/LFM2.5-1.2B-Instruct`, public, Transformers-native, 16 layers (10 convolution + 6 attention).
16
+ - ZeroGPU: Gradio only; declare GPU work with `@spaces.GPU`; `large` offers 48 GB; PRO quota is 40 minutes/day.
17
+ - The account's active token cannot write a Space. Deployment requires a new token with Space-write permission.
18
+
19
+ ## Acceptance criteria
20
+
21
+ - `gain=0` preserves exact hidden states while capturing a trace.
22
+ - Selected layer indexes are validated against the 16-layer model.
23
+ - Each run returns text, run speed and a downloadable JSON trace.
24
+ - GPU work is bounded to 120 seconds/request on a 48 GB `large` allocation.
25
+ - The project has unit tests, clear deployment instructions and no embedded credentials.
26
+
27
+ ## Tasks
28
+
29
+ ### Task 1: Core layer controller
30
+
31
+ **Files:** `remora_control.py`, `tests/test_remora_control.py`
32
+
33
+ Implement a hook controller over `model.model.layers`. Test zero-gain identity, non-zero gain effect, trace count and index validation.
34
+
35
+ **Verification:** `python -m unittest discover -s tests -v`
36
+
37
+ ### Task 2: ZeroGPU application
38
+
39
+ **Files:** `app.py`, `requirements.txt`, `README.md`
40
+
41
+ Load the public 1.2B LFM model in bf16, create a Gradio prompt/control surface, decorate execution with `@spaces.GPU(duration=120, size="large")`, and return a trace artifact.
42
+
43
+ **Verification:** `python -m py_compile app.py remora_control.py`
44
+
45
+ ### Task 3: Deploy and bounded benchmark
46
+
47
+ **Files:** HF Space `gcorzo/remora-layer-lab` (external)
48
+
49
+ Create a Gradio Space, choose ZeroGPU hardware in Settings, push this project and wait for build success. Run baseline and one non-zero gain with identical prompt/seed; download both traces and record runtime / generated tokens / quota use.
50
+
51
+ **Verification:** Space build is `Running`; both trace files are downloadable; HF Billing shows resulting ZeroGPU compute usage.
52
+
53
+ ## Rollback
54
+
55
+ Remove the Space or switch it to CPU Basic. No local router, model server or production octopus configuration changes are made by this project.
remora_control.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Layer-level Remora controls for PyTorch decoder stacks.
2
+
3
+ This module is intentionally small and dependency-light. It controls the output
4
+ of a selected decoder block and records scalar trace data; it does not claim
5
+ that the output hook is identical to llama.cpp's internal MoE gate hook.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import asdict, dataclass
10
+ from typing import Any, Iterable
11
+
12
+ import torch
13
+ from torch import nn
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class TraceEvent:
18
+ layer: int
19
+ token_index: int
20
+ baseline_rms: float
21
+ controlled_rms: float
22
+ gain: float
23
+
24
+
25
+ class LayerController:
26
+ """Attach controlled-output hooks to `model.model.layers`.
27
+
28
+ The control is deterministic for fixed model weights, prompt, sampling
29
+ parameters, and RNG seed. A gain of zero is a strict identity operation.
30
+ """
31
+
32
+ def __init__(self, layers: Iterable[int], gain: float) -> None:
33
+ self.layers = tuple(sorted(set(layers)))
34
+ self.gain = float(gain)
35
+ self.events: list[TraceEvent] = []
36
+ self._handles: list[Any] = []
37
+
38
+ def attach(self, model: nn.Module) -> None:
39
+ decoder_layers = getattr(getattr(model, "model", None), "layers", None)
40
+ if decoder_layers is None:
41
+ raise ValueError("model does not expose model.layers for layer control")
42
+ layer_count = len(decoder_layers)
43
+ invalid = [index for index in self.layers if index < 0 or index >= layer_count]
44
+ if invalid:
45
+ raise ValueError(f"layer indexes outside 0..{layer_count - 1}: {invalid}")
46
+ self.detach()
47
+ for index in self.layers:
48
+ self._handles.append(decoder_layers[index].register_forward_hook(self._hook(index)))
49
+
50
+ def detach(self) -> None:
51
+ for handle in self._handles:
52
+ handle.remove()
53
+ self._handles.clear()
54
+
55
+ def json_events(self) -> list[dict[str, float | int]]:
56
+ return [asdict(event) for event in self.events]
57
+
58
+ def _hook(self, layer: int):
59
+ def controlled_output(_module: nn.Module, _inputs: tuple[Any, ...], output: Any) -> Any:
60
+ hidden = output[0] if isinstance(output, tuple) else output
61
+ if not isinstance(hidden, torch.Tensor):
62
+ raise TypeError("decoder layer output must be a Tensor or tuple whose first value is a Tensor")
63
+
64
+ # [batch, sequence, hidden] -> one scalar event per generated/prompt token.
65
+ baseline_rms = hidden.float().pow(2).mean(dim=-1).sqrt()
66
+ token_mean = hidden.mean(dim=-1, keepdim=True)
67
+ controlled = hidden + self.gain * (hidden - token_mean)
68
+ controlled_rms = controlled.float().pow(2).mean(dim=-1).sqrt()
69
+ for token_index, (before, after) in enumerate(zip(baseline_rms[0], controlled_rms[0])):
70
+ self.events.append(
71
+ TraceEvent(
72
+ layer=layer,
73
+ token_index=token_index,
74
+ baseline_rms=float(before.detach().cpu()),
75
+ controlled_rms=float(after.detach().cpu()),
76
+ gain=self.gain,
77
+ )
78
+ )
79
+ if isinstance(output, tuple):
80
+ return (controlled, *output[1:])
81
+ return controlled
82
+
83
+ return controlled_output
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ gradio==5.49.1
2
+ huggingface-hub>=0.30,<1.0
3
+ torch>=2.8.0
4
+ transformers==4.57.6
tests/test_remora_control.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+
3
+ import torch
4
+ from torch import nn
5
+
6
+ from remora_control import LayerController
7
+
8
+
9
+ class ToyModel(nn.Module):
10
+ def __init__(self):
11
+ super().__init__()
12
+ self.model = nn.Module()
13
+ self.model.layers = nn.ModuleList([nn.Identity(), nn.Identity(), nn.Identity()])
14
+
15
+ def forward(self, value):
16
+ for layer in self.model.layers:
17
+ value = layer(value)
18
+ return value
19
+
20
+
21
+ class LayerControllerTests(unittest.TestCase):
22
+ def setUp(self):
23
+ self.model = ToyModel()
24
+ self.value = torch.tensor([[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]])
25
+
26
+ def test_zero_gain_is_identity_and_records_trace(self):
27
+ controller = LayerController([1], gain=0.0)
28
+ controller.attach(self.model)
29
+ try:
30
+ actual = self.model(self.value)
31
+ finally:
32
+ controller.detach()
33
+ self.assertTrue(torch.equal(actual, self.value))
34
+ self.assertEqual(2, len(controller.events))
35
+ self.assertEqual(1, controller.events[0].layer)
36
+ self.assertEqual(controller.events[0].baseline_rms, controller.events[0].controlled_rms)
37
+
38
+ def test_nonzero_gain_changes_selected_layer(self):
39
+ controller = LayerController([1], gain=0.5)
40
+ controller.attach(self.model)
41
+ try:
42
+ actual = self.model(self.value)
43
+ finally:
44
+ controller.detach()
45
+ self.assertFalse(torch.equal(actual, self.value))
46
+ self.assertEqual(2, len(controller.events))
47
+ self.assertGreater(controller.events[0].controlled_rms, controller.events[0].baseline_rms)
48
+
49
+ def test_invalid_layer_is_rejected(self):
50
+ with self.assertRaisesRegex(ValueError, "outside"):
51
+ LayerController([3], 0.0).attach(self.model)
52
+
53
+
54
+ if __name__ == "__main__":
55
+ unittest.main()