commit 1ff4063282fa4fbc48bae7372c871cce2ee6fd6a Author: Extraltodeus Date: Mon Jul 13 22:26:50 2026 +0200 Cleanup init commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..21291f1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# Normalize line endings: LF in the repo, native in working copies. +* text=auto + +# Windows launchers keep CRLF. +*.bat text eol=crlf +*.ps1 text eol=crlf + +# Never touch binary assets. +*.png binary +*.gif binary +*.pt binary +*.safetensors binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8051b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Models and caches (large, regenerable) +hf_cache/ +lenses/ + +# Runtime data: conversation history (db), fits, frames, presets, edits, masks +data/ + +# External dependencies (git clones — see the README) +vendor/ + +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ +env/ + +# Node / front-end build (ui/dist is served by the backend, rebuilt via `npm run build`) +ui/node_modules/ +ui/dist/ + +# Editor / tooling config (may contain machine-specific paths) +.claude/ +.vscode/ +.idea/ + +# Local model folders live in the project root — add yours here, e.g.: +# Qwen3-4B/ + +# Personal / local launchers (machine-specific paths, e.g. a custom HF_HOME) +start - Copie.bat +start.local.bat +TODO.txt +TODO_later.txt + +# Misc +*.log +*.stackdump +nul diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ce9a988 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 extraltodeus + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..9c1a0fd --- /dev/null +++ b/NOTICE @@ -0,0 +1,9 @@ +J-Wash +Copyright 2026 extraltodeus + +Licensed under the Apache License, Version 2.0 (see LICENSE). + +This product is built on Anthropic's Jacobian-lens (the "jlens" library: +https://github.com/anthropics/jacobian-lens), which is not included in this +repository and carries its own license. Pre-fitted lenses are provided by +Neuronpedia (https://huggingface.co/neuronpedia/jacobian-lens). diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a75a42 --- /dev/null +++ b/README.md @@ -0,0 +1,266 @@ +# J-Wash + +**Reshape a model's identity and behavior by editing token directions, then bake +those edits into a real checkpoint you can run anywhere. No training, no dataset, +no fine-tuning.** + +J-Wash is a local studio (FastAPI + React) for exploring and editing the *J-space* +of any Hugging Face decoder LLM. You chat with a model while a live **Jacobian lens** +shows what each layer is "reading," pin and inspect concepts, then **wash** the +model's identity or behavior with a few token-level rules — turn *"I am a large +language model"* into *"I am a large language fish"* — and **export the result as a +standalone model** (full checkpoint, modified layers, or LoRA): standard +safetensors weights that load anywhere `transformers` models do. + +The editing preview runs live in the chat, and the exported checkpoint reproduces +it faithfully — the whole point of the project is that **what you see is what you +ship**. + + + +![J-Wash — chat with the live Jacobian lens](assets/animation1.gif) + +--- + +## What it's built on + +J-Wash is built on **Anthropic's Jacobian lens** (the +[`jlens`](https://github.com/anthropics/jacobian-lens) library), a method that reads +each layer's contribution to the residual stream through the model's own +un-embedding. On top of it, J-Wash adds: + +- an interactive **chat UI** with the lens rendered live (heatmaps, token clouds, + per-layer rank curves); +- a **token editor** that turns lens directions into persistent, composable edits; +- and — the core feature — an **export pipeline** that bakes those edits into a + pure-weights checkpoint (`full` / `layers` / `lora`), so the edited model runs + with no J-Wash code in the loop. + +Pre-fitted lenses come from [Neuronpedia](https://huggingface.co/neuronpedia/jacobian-lens); +you can also fit your own locally. + +## Requirements + +- An **NVIDIA GPU (CUDA)**. J-Wash is CUDA-only (it uses `pynvml` and `cuda:*` + devices); there is no CPU/MPS path. ~12 GB of VRAM comfortably runs a 4B model. +- **Python 3.13** (3.11+ should work). +- **Node.js 18+** and npm (to build the front-end). + +## Installation + +```bash +# 1. Clone this repo +git clone https://github.com//j-wash.git +cd j-wash + +# 2. Create an environment (conda or venv) +conda create -n jwash python=3.13 -y +conda activate jwash +# or: python -m venv .venv && source .venv/bin/activate (Windows: .venv\Scripts\activate) + +# 3. Install PyTorch matching your CUDA version — see https://pytorch.org +# (Blackwell / RTX 50-series needs a cu128+ build) +pip install torch --index-url https://download.pytorch.org/whl/cu124 + +# 4. Vendor the Jacobian-lens library (not on PyPI) and install it editable +git clone https://github.com/anthropics/jacobian-lens vendor/jacobian-lens +pip install -e vendor/jacobian-lens + +# 5. Install the rest +pip install -r requirements.txt + +# 6. Build the front-end +cd ui && npm install && npm run build && cd .. +``` + +> The `jlens` package is imported as `jlens` in the code. If Anthropic's repo URL +> changes, update step 4 accordingly — any package exposing `jlens.lens.JacobianLens`, +> `jlens.from_hf`, `jlens.fit`, `jlens.vis` and `jlens.examples` will work. + +## Running + +```bash +python -X utf8 run.py +``` + +Then open **http://localhost:8381**. (`-X utf8` matters on Windows.) + +By default, models download into your **shared Hugging Face cache** +(`~/.cache/huggingface`, or `$HF_HOME` if set) — the same cache other HF tools use. +To keep everything **isolated in a project-local cache** instead, pass a path: + +```bash +python -X utf8 run.py --hf-cache ./hf_cache +``` + +Several instances can run side by side: give each its own `--port` (default +8381) and `--data-dir` (default `./data` — history, presets, edits). The CLI +targets a non-default instance with `scripts/jlab.py --base http://127.0.0.1:`. + +The React front-end is served by the backend from `ui/dist`; after changing any UI +source, rebuild with `cd ui && npm run build` and hard-refresh the page. For UI +development with hot-reload, run `npm run dev` in `ui/` (port 5173, proxied to 8381). + +## Usage + +The sidebar is organized into tabs: **Chat**, **Model**, **Lens**, **Fit**, and +**Options** (defaults, paths, ignored tokens). + +### 1. Load a model + +In **Model**, pick a cached / local model or type an `org/repo` in **Download** +(e.g. `Qwen/Qwen3-4B`) and hit ↓. Choose dtype / quant / device, then **Load**. +Local folders (a directory with `config.json` + safetensors) and the HF cache are +listed automatically; **Browse** adds any model folder on disk to the list +(nothing is copied — the blue button forgets the entry, the red trash deletes +actual files). fp32 models are auto-converted to bf16 to halve disk usage. + +### 2. Load a Jacobian lens + +In **Lens**, J-Wash lists compatible lenses for the loaded model — local ones you +fitted plus matching lenses on the Neuronpedia Hub. For a **finetune**, the lens +of its *base model* is offered too (read from the model card, or guessed from the +name); every other Hub lens stays reachable in a collapsed section for +architecture-compatible cross-loading. Click to load (downloading if needed) — +you can even pick a lens **while the model is still loading**, it chain-loads +when ready. No lens? Fit one in the **Fit** tab (see below). Manual loading by +repo / file / local path is available at the bottom of the tab. + +![The Lens tab and the live Frequencies view](assets/lens-tab.png) + +### 3. Chat with the live lens + +Chat as usual. Below the conversation, the lens view shows, for the prompt and each +generated token: + +- **Frequencies** (default): tokens the layers "read," aggregated by how often they + appear — size ∝ frequency. Click a token to **pin** it (rank curves + a rank + heatmap per layer); right-click to hide noise. +- **Heatmap**: layers × positions, top token per cell (reading = amber, thinking = + blue). + +Leading/trailing spaces are rendered with `˽` (so `˽Euro` ≠ `Euro`). Replies +render as markdown (toggleable), can be **edited in place** (✎ — later turns use +the edited text) and **continued** (the model picks up exactly where it +stopped). Conversations are persisted (SQLite + full-text search), branchable +from any node, and replayable offline. Export a conversation as JSON or +Markdown, with or without lens frames. The lens view's height is draggable. + +![Heatmap view and a pinned token's per-layer rank curves](assets/heatmap-pins.png) + +### 4. Edit tokens ☢ + +Open the **token editor** (the ☢ button in the composer, or the ☢ on a pinned +token). Add rules: + +- **multiply ×f** — `×0` removes a token's direction, `×0.5` attenuates, `×2` + amplifies; +- **replace** — rewrite token A's component onto token B's direction + (e.g. ` model` → ` fish`). + +Each rule targets a range of layers; there's a global multiplier and grouped +editing. A mode toggle switches between: + +- **Per-layer steering** (default) — the most expressive way to *explore*, but it + does not export faithfully. +- **Read projection** (pure-weights) — a change of basis of the downstream reads so + the **live preview matches the exported checkpoint exactly**. Use this to ship. + +> **Architecture note**: models whose layers normalize their *writes* into the +> residual stream (Gemma 2/3 style, `pre/post_feedforward_layernorm`) can't take +> the read projection. On those, the toggle offers **Global projection** (W_U +> abliteration) instead — still pure weights, faithful for full removals and +> replacements (a rule's layer range is ignored: the projection is global). + +![The token editor: rules, mode toggle, and export](assets/token-editor.png) + +### 5. Export the edit + +Save a set of rules as a **preset** and re-apply it in one click. Export an edit +(`data/edits//`) as: + +- **full checkpoint** — reloadable as-is in plain `transformers`; +- **modified layers** (safetensors); +- **LoRA** (PEFT) — the exact low-rank diff between the edited weights and the + originals (the edit is low-rank by construction, so nothing is approximated). + +Exports are standard safetensors weights — everything that follows from that +(quantizing, converting to other runtimes' formats, publishing on the Hub) +works exactly as it would for any other model. + +If you point the **Options** tab at a local [llama.cpp](https://github.com/ggml-org/llama.cpp) +folder (one that has `convert_hf_to_gguf.py`; `llama-quantize` too for quantized +types), a **GGUF** entry appears in the export formats: J-Wash bakes the full +checkpoint into a local cache, converts it, and quantizes if asked +(`q4_k_m`, `q8_0`, …). The cached checkpoint is reused when exporting several +GGUF types — a *clean cache* button reclaims the space. (llama.cpp's converter +may need extra pip packages for some tokenizers, e.g. `sentencepiece` for +Gemma — the error shows up in the UI if so.) + +### 6. Fit your own lens + +In **Fit** (model unloaded, VRAM free), fit a lens on streamed WikiText across one +or more GPUs, with per-prompt checkpoints (stop/resume without loss) and weighted +merging. Metadata is written to `lenses//meta.json`. + +### CLI (no UI) + +`scripts/jlab.py` is a headless HTTP client for the running server: + +```bash +python -X utf8 scripts/jlab.py status +python -X utf8 scripts/jlab.py load Qwen/Qwen3-4B --device cuda:0 +python -X utf8 scripts/jlab.py lens --file "qwen3-4b/jlens/Salesforce-wikitext/Qwen3-4B_jacobian_lens.pt" +python -X utf8 scripts/jlab.py rule-add " model" --mode replace --repl " fish" --factor 0.7 --layers 19-31 +python -X utf8 scripts/jlab.py mode readthrough +python -X utf8 scripts/jlab.py gen "Who are you?" --temp 0 +python -X utf8 scripts/jlab.py probe # identity/control battery + fish score +python -X utf8 scripts/jlab.py export fish_v1 --format full +``` + +The **fish demo** is the reference example: with ` model`/` assistant` → ` fish` +rules across the upper layers in read-projection mode, the model consistently +identifies as a fish while staying coherent on control questions (math, capitals, +code). `scripts/fish_prompts.json` drives the probe and is intentionally bilingual +(English + French) to show the edit holds across languages. Validate an exported +checkpoint in pure `transformers` with `scripts/pure_check.py`. + +## Project layout + +``` +core/ model & lens managers, fitting, registry, SQLite store, + and the editing/export engine (ablation, rebase, editing) +api/ FastAPI app (REST + WebSocket) +ui/ React + Vite front-end +scripts/ jlab.py CLI, fit worker, smoke tests, accuracy checks +vendor/ external clones (jacobian-lens) — git-ignored, see Installation +lenses/ local fitted lenses + metadata (git-ignored, regenerated) +data/ SQLite DB, frames, presets, edits, masks (git-ignored) +hf_cache/ only if you run with --hf-cache ./hf_cache (git-ignored) +``` + +## Notes + +- **Disk**: models can get large. By default they go to your shared Hugging Face + cache (`~/.cache/huggingface`); pass `--hf-cache ` to keep them elsewhere, + e.g. a project-local `./hf_cache`. Fitted lenses (`lenses/`), runtime data + (`data/`), and exported edits live under the project and are git-ignored. +- **Gated / private models** need a valid `HF_TOKEN` in your environment. +- Loading `.gguf` files directly as models is **not** supported — J-Wash loads + transformers/safetensors models only. +- Interventions and lens readouts are unavailable on quantized (int8/nf4) weights. + +## Credits + +- **Jacobian lens** — Anthropic's [`jacobian-lens`](https://github.com/anthropics/jacobian-lens), + the interpretability method and reference implementation J-Wash is built on. +- **Pre-fitted lenses** — [Neuronpedia](https://huggingface.co/neuronpedia/jacobian-lens). + +## License + +Apache License 2.0 — see [LICENSE](LICENSE). diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app.py b/api/app.py new file mode 100644 index 0000000..b0e7201 --- /dev/null +++ b/api/app.py @@ -0,0 +1,1370 @@ +import asyncio +import json +import logging +import mimetypes +import os +import re +import threading +from pathlib import Path + +from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect + +mimetypes.add_type("text/javascript", ".js") +mimetypes.add_type("text/css", ".css") +from fastapi.responses import Response +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +import config +from core import editing, registry +from core.ablation import Interventions +from core.neighbors import TokenNeighbors +from core import fitting +from core.fitting import FitManager +from core.gpus import gpu_stats +from core.lens_manager import LensManager +from core.model_manager import ( + ModelManager, + _resolve_revision, + resolve_local_dir, + resolve_source, +) +from core.store import Store + +manager = ModelManager() +lens_manager = LensManager() +store = Store() +fit_manager = FitManager() +interventions = Interventions() +neighbors = TokenNeighbors() +app = FastAPI(title="J-Wash") + +_ws_locks = {} +_loop_holder = {} + + +def _valid_devices(): + """Accepted devices = "auto" + one cuda:N per GPU actually present. + Adaptive: no longer assumes the personal 2-GPU (cuda:0/cuda:1) setup.""" + try: + n = len(gpu_stats()) + except Exception: + n = 0 + return {"auto"} | {f"cuda:{i}" for i in range(n)} + + +class _QuietPolling(logging.Filter): + """Drops the access lines from the UI polling (GET /api/status every 2 s).""" + + def filter(self, record): + return "GET /api/status " not in record.getMessage() + + +@app.on_event("startup") +async def _on_startup(): + _loop_holder["loop"] = asyncio.get_running_loop() + logging.getLogger("uvicorn.access").addFilter(_QuietPolling()) + + +async def _ws_send(ws, text): + lock = _ws_locks.get(ws) + if lock is None: + return + async with lock: + await ws.send_text(text) + + +def _broadcast_fit(state): + loop = _loop_holder.get("loop") + if loop is None: + return + payload = json.dumps({"type": "fit_progress", "fit": state}) + for ws in list(_ws_locks): + asyncio.run_coroutine_threadsafe(_ws_send(ws, payload), loop) + + +fit_manager.on_progress = _broadcast_fit + +# concurrent HF downloads: one state per repo_id +_downloads = {} +_downloads_lock = threading.Lock() + + +class LoadRequest(BaseModel): + model_id: str + dtype: str = config.DEFAULT_DTYPE + quant: str | None = None + device: str = config.DEFAULT_DEVICE + + +class DownloadRequest(BaseModel): + repo_id: str + + +class DeleteModelRequest(BaseModel): + model_id: str + + +class LensLoadRequest(BaseModel): + repo_id: str | None = None + filename: str = "lens.pt" + revision: str | None = None + path: str | None = None + layers: list[int] | None = None + k: int = 8 + + +class LensLayersRequest(BaseModel): + layers: list[int] + k: int | None = None + + +class PinRequest(BaseModel): + gen_id: int + token_ids: list[int] + + +class ConversationPatch(BaseModel): + title: str | None = None + tags: list[str] | None = None + + +class InterventionRequest(BaseModel): + token_id: int + mode: str = "scale" + factor: float = 0.0 + replacement_id: int | None = None + layers: list[int] | None = None + + +_NAME_RE = re.compile(r"[\w][\w.\- ]*", re.UNICODE) + + +def _safe_name(name): + """Validate a user-supplied file/folder name (presets, exports): plain + names only — no separators, no traversal.""" + name = (name or "").strip() + if not name or ".." in name or not _NAME_RE.fullmatch(name): + raise HTTPException( + 422, f"invalid name {name!r}: letters, digits, spaces, . - _ only" + ) + return name + + +class InterventionPatch(BaseModel): + factor: float | None = None + layers: list[int] | None = None + enabled: bool | None = None + token_id: int | None = None + replacement_id: int | None = None + mode: str | None = None # scale | replace + + +class InterventionsScale(BaseModel): + scale: float | None = None + mode: str | None = None + + +class ExportRequest(BaseModel): + format: str = "layers" + name: str + + +class FitRequest(BaseModel): + model_id: str + dtype: str = "bf16" + quant: str | None = None + n_prompts: int = 100 + dataset: str = fitting.DATASET_WIKITEXT # or DATASET_HARMLESS, or "mixed" + devices: list[str] = ["cuda:0"] + name: str | None = None + dim_batch: int | None = None + max_seq_len: int = 128 + source_layers: list[int] | None = None + continue_from: str | None = None + + +@app.get("/api/models") +def api_models(): + return {"models": manager.list_models()} + + +@app.get("/api/status") +def api_status(): + return { + "loaded": manager.meta, + "busy": manager.busy, + "lens": lens_manager.meta, + "gpus": gpu_stats(), + "downloads": list(_downloads.values()), + "convert": _convert_state, + "fit": fit_manager.state, + "gguf": dict(_gguf_state), + "interventions": interventions.summary(), + "interventions_scale": interventions.global_scale, + "interventions_mode": interventions.mode, + } + + +@app.post("/api/load") +async def api_load(req: LoadRequest): + if req.dtype not in config.DTYPES: + raise HTTPException(422, f"invalid dtype: {req.dtype}") + if req.quant is not None and req.quant not in config.QUANTS: + raise HTTPException(422, f"invalid quant: {req.quant}") + if req.device not in _valid_devices(): + raise HTTPException(422, f"invalid device: {req.device}") + if manager.busy: + raise HTTPException(409, f"busy: {manager.busy}") + try: + return await asyncio.to_thread( + manager.load, req.model_id, req.dtype, req.quant, req.device + ) + except Exception as exc: + raise HTTPException(500, str(exc)) + + +@app.post("/api/models/delete") +async def api_delete_model(req: DeleteModelRequest): + if manager.busy: + raise HTTPException(409, f"busy: {manager.busy}") + if manager.meta and manager.meta.get("model_id") == req.model_id: + raise HTTPException(409, "unload this model before deleting it") + from core.model_manager import delete_model + try: + return await asyncio.to_thread(delete_model, req.model_id) + except ValueError as exc: + raise HTTPException(400, str(exc)) + except Exception as exc: + raise HTTPException(500, str(exc)) + + +# --- user settings (Options tab) -------------------------------------------- +SETTINGS_PATH = config.DATA_DIR / "settings.json" +SETTINGS_DEFAULTS = { + "default_quant": "", # '', 'int8' or 'nf4' — preselected in the Model tab + "auto_layer_radius": 2, # editor: peak ± radius when auto-selecting layers + "chat_markdown": True, # render assistant replies as markdown + "hf_cache": "", # HF cache dir — applied at startup (--hf-cache wins) + "llamacpp_dir": "", # llama.cpp folder → enables the direct GGUF export +} + + +def read_settings(): + try: + data = json.loads(SETTINGS_PATH.read_text(encoding="utf-8")) + except Exception: + data = {} + return {**SETTINGS_DEFAULTS, **{k: v for k, v in data.items() if k in SETTINGS_DEFAULTS}} + + +class SettingsPatch(BaseModel): + default_quant: str | None = None + auto_layer_radius: int | None = None + chat_markdown: bool | None = None + hf_cache: str | None = None + llamacpp_dir: str | None = None + + +@app.get("/api/settings") +def api_settings(): + return read_settings() + + +@app.patch("/api/settings") +def api_settings_patch(req: SettingsPatch): + if req.default_quant is not None and req.default_quant not in ("", "int8", "nf4"): + raise HTTPException(422, f"invalid quant: {req.default_quant}") + current = read_settings() + for key, value in req.model_dump(exclude_none=True).items(): + if key == "auto_layer_radius": + value = max(0, min(8, int(value))) + current[key] = value + SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) + SETTINGS_PATH.write_text( + json.dumps(current, ensure_ascii=False, indent=1), encoding="utf-8" + ) + return current + + +class RegisterModelRequest(BaseModel): + path: str + + +@app.post("/api/models/register") +def api_models_register(req: RegisterModelRequest): + """Add a model folder to the available list (no copy — just remembered).""" + from core.model_manager import register_model_dir + try: + return register_model_dir(req.path) + except ValueError as exc: + raise HTTPException(400, str(exc)) + + +@app.post("/api/models/unregister") +def api_models_unregister(req: RegisterModelRequest): + """Forget a registered entry; the model files are left untouched.""" + from core.model_manager import unregister_model_dir + try: + return unregister_model_dir(req.path) + except ValueError as exc: + raise HTTPException(404, str(exc)) + + +@app.post("/api/unload") +async def api_unload(): + if manager.busy: + raise HTTPException(409, f"busy: {manager.busy}") + lens_manager.unload() + neighbors.reset() + return await asyncio.to_thread(manager.unload) + + +@app.post("/api/lens/load") +async def api_lens_load(req: LensLoadRequest): + if not req.repo_id and not req.path: + raise HTTPException(422, "repo_id or path required") + if manager.busy: + raise HTTPException(409, f"busy: {manager.busy}") + try: + return await asyncio.to_thread( + lens_manager.load, + manager, + repo_id=req.repo_id, + filename=req.filename, + revision=req.revision, + path=req.path, + layers=req.layers, + k=req.k, + ) + except Exception as exc: + raise HTTPException(500, str(exc)) + + +@app.post("/api/lens/unload") +def api_lens_unload(): + return lens_manager.unload() + + +@app.post("/api/lens/layers") +def api_lens_layers(req: LensLayersRequest): + if manager.busy: + raise HTTPException(409, f"busy: {manager.busy}") + try: + return lens_manager.set_layers(manager, req.layers, k=req.k) + except ValueError as exc: + raise HTTPException(422, str(exc)) + + +@app.get("/api/interventions") +def api_interventions(): + return {"rules": interventions.summary()} + + +@app.post("/api/interventions") +def api_interventions_add(req: InterventionRequest): + if manager.hf_model is None or lens_manager.lens is None: + raise HTTPException(422, "model and lens required") + try: + return { + "rules": interventions.add( + lens_manager, + manager.jl, + token_id=req.token_id, + mode=req.mode, + factor=req.factor, + replacement_id=req.replacement_id, + layers=req.layers, + ) + } + except ValueError as exc: + raise HTTPException(422, str(exc)) + + +@app.patch("/api/interventions/{rule_id}") +def api_interventions_patch(rule_id: int, req: InterventionPatch): + needs_dirs = any( + x is not None for x in (req.layers, req.token_id, req.replacement_id, req.mode) + ) + try: + return { + "rules": interventions.update( + rule_id, + factor=req.factor, + layers=req.layers, + enabled=req.enabled, + token_id=req.token_id, + replacement_id=req.replacement_id, + mode=req.mode, + lens_manager=lens_manager if needs_dirs else None, + jl=manager.jl if needs_dirs else None, + ) + } + except ValueError as exc: + raise HTTPException(404, str(exc)) + + +@app.patch("/api/interventions") +def api_interventions_scale(req: InterventionsScale): + if req.scale is not None: + interventions.set_scale(req.scale) + try: + if req.mode is not None: + if ( + req.mode in ("readthrough", "exact") + and manager.meta is not None + and manager.meta.get("rebase_supported") is False + ): + raise HTTPException( + 422, + "read projection unavailable on this architecture (write " + "norms, Gemma style) — use \"abliteration\" for pure weights", + ) + interventions.set_mode(req.mode) + except ValueError as exc: + raise HTTPException(422, str(exc)) + return { + "scale": interventions.global_scale, + "mode": interventions.mode, + } + + +@app.delete("/api/interventions/{rule_id}") +def api_interventions_remove(rule_id: int): + return {"rules": interventions.remove(rule_id)} + + +@app.delete("/api/interventions") +def api_interventions_clear(): + return {"rules": interventions.remove()} + + +@app.get("/api/presets") +def api_presets(): + return {"presets": editing.list_presets()} + + +@app.post("/api/presets/{name}") +def api_presets_save(name: str): + name = _safe_name(name) + rules = interventions.summary() + if not rules: + raise HTTPException(422, "no active intervention to save") + return editing.save_preset( + name, rules, manager.meta.get("model_id") if manager.meta else None, + scale=interventions.global_scale, + ) + + +@app.post("/api/presets/{name}/apply") +def api_presets_apply(name: str): + name = _safe_name(name) + if manager.hf_model is None or lens_manager.lens is None: + raise HTTPException(422, "model and lens required") + try: + preset = editing.load_preset(name) + except ValueError as exc: + raise HTTPException(404, str(exc)) + warnings = [] + if preset.get("model_id") and manager.meta and preset["model_id"] != manager.meta["model_id"]: + warnings.append( + f"preset saved for {preset['model_id']}, loaded model: {manager.meta['model_id']}" + ) + rules = None + for rule in preset.get("rules", []): + try: + rules = interventions.add( + lens_manager, + manager.jl, + token_id=rule["token_id"], + mode=rule["mode"], + factor=rule["factor"], + replacement_id=rule.get("replacement_id"), + layers=rule.get("layers"), + enabled=rule.get("enabled", True), + ) + except ValueError as exc: + warnings.append(f"rule {rule.get('token')!r} skipped: {exc}") + if preset.get("scale") is not None: + interventions.set_scale(preset["scale"]) + return { + "rules": rules or interventions.summary(), + "scale": interventions.global_scale, + "warnings": warnings, + } + + +@app.delete("/api/presets/{name}") +def api_presets_delete(name: str): + editing.delete_preset(_safe_name(name)) + return {"ok": True} + + +@app.post("/api/edit/export") +async def api_edit_export(req: ExportRequest): + req.name = _safe_name(req.name) + if manager.hf_model is None: + raise HTTPException(422, "no model loaded") + rules = interventions.active_rules_full() + if not rules: + raise HTTPException(422, "no active intervention to export (rules disabled or without layers?)") + if req.format not in ("layers", "lora", "full"): + raise HTTPException(422, f"unknown format: {req.format}") + source_dir = resolve_local_dir(manager.meta["model_id"]) + mode = interventions.mode + kwargs = {} + if mode in ("readthrough", "exact"): + export_fn = editing.export_rebase + kwargs["exact"] = mode == "exact" + elif mode == "abliteration": + export_fn = editing.export_abliteration + else: + raise HTTPException( + 422, + "export requires a pure-weights mode: switch to \"read projection\" " + "(or \"global projection\" on write-norm architectures) — per-layer " + "steering does not bake faithfully", + ) + try: + return await asyncio.to_thread( + export_fn, + rules, + manager.jl, + manager.meta, + fmt=req.format, + name=req.name, + source_dir=source_dir, + scale=interventions.global_scale, + **kwargs, + ) + except ValueError as exc: + raise HTTPException(422, str(exc)) + except Exception as exc: + raise HTTPException(500, str(exc)) + finally: + # a full export builds ~2× the model in RAM (source + edited tensors); + # on failure, force a collection so those copies don't linger + import gc + gc.collect() + + +# --- direct GGUF export (via a user-provided llama.cpp folder) --------------- +# Two stages: (1) bake the full HF checkpoint into data/edits//hf — kept +# as a CACHE so several GGUF types can be exported without re-baking — then +# (2) convert_hf_to_gguf.py (+ llama-quantize for quantized types) in a +# background thread, progress polled through /api/status. +_gguf_state = {"state": "idle", "name": None, "step": None, "error": None, "result": None} + +GGUF_BASE_TYPES = ("bf16", "f16") +GGUF_QUANT_TYPES = ("q8_0", "q6_k", "q5_k_m", "q4_k_m", "q3_k_m") + + +class GGUFExportRequest(BaseModel): + name: str + gguf_type: str = "q4_k_m" + + +def _llamacpp_paths(): + """(convert_py, quantize_exe, gguf_py) from the configured llama.cpp dir.""" + root = read_settings().get("llamacpp_dir") or "" + root = Path(root).expanduser() if root else None + if not root or not root.is_dir(): + raise ValueError( + "llama.cpp folder not set — configure it in the Options tab to " + "enable the direct GGUF export" + ) + convert = root / "convert_hf_to_gguf.py" + if not convert.exists(): + raise ValueError(f"convert_hf_to_gguf.py not found in {root}") + quantize = None + for cand in ("llama-quantize", "llama-quantize.exe"): + for sub in (".", "bin", "build/bin"): + p = root / sub / cand + if p.exists(): + quantize = p + break + if quantize: + break + gguf_py = root / "gguf-py" + return convert, quantize, (gguf_py if gguf_py.is_dir() else None) + + +def _gguf_worker(name, gguf_type, hf_dir, convert, quantize, gguf_py): + import subprocess + import sys + try: + out_dir = editing.EDITS_DIR / name + base_type = gguf_type if gguf_type in GGUF_BASE_TYPES else "bf16" + base_gguf = out_dir / f"{name}-{base_type}.gguf" + env = dict(os.environ) + if gguf_py is not None: # vendored gguf package inside the llama.cpp repo + env["PYTHONPATH"] = str(gguf_py) + os.pathsep + env.get("PYTHONPATH", "") + if not base_gguf.exists(): + _gguf_state.update(step=f"converting to {base_type}") + proc = subprocess.run( + [sys.executable, "-X", "utf8", str(convert), str(hf_dir), + "--outfile", str(base_gguf), "--outtype", base_type], + capture_output=True, text=True, env=env, + ) + if proc.returncode != 0: + raise RuntimeError(f"convert_hf_to_gguf failed: {proc.stderr[-2000:]}") + result_path = base_gguf + if gguf_type not in GGUF_BASE_TYPES: + if quantize is None: + raise RuntimeError( + "llama-quantize not found in the llama.cpp folder — only " + "bf16/f16 exports are possible" + ) + _gguf_state.update(step=f"quantizing to {gguf_type}") + result_path = out_dir / f"{name}-{gguf_type}.gguf" + proc = subprocess.run( + [str(quantize), str(base_gguf), str(result_path), gguf_type], + capture_output=True, text=True, + ) + if proc.returncode != 0: + result_path.unlink(missing_ok=True) + raise RuntimeError(f"llama-quantize failed: {proc.stderr[-2000:]}") + _gguf_state.update( + state="done", step=None, error=None, + result={ + "gguf": str(result_path), + "size_bytes": result_path.stat().st_size, + "hf_cache": str(hf_dir), + }, + ) + except Exception as exc: + _gguf_state.update(state="error", step=None, error=str(exc)) + + +@app.post("/api/edit/export-gguf") +async def api_edit_export_gguf(req: GGUFExportRequest): + req.name = _safe_name(req.name) + if _gguf_state["state"] == "running": + raise HTTPException(409, "a GGUF export is already in progress") + if req.gguf_type not in GGUF_BASE_TYPES + GGUF_QUANT_TYPES: + raise HTTPException(422, f"unknown GGUF type: {req.gguf_type}") + try: + convert, quantize, gguf_py = _llamacpp_paths() + except ValueError as exc: + raise HTTPException(422, str(exc)) + if req.gguf_type not in GGUF_BASE_TYPES and quantize is None: + raise HTTPException(422, "llama-quantize not found — pick bf16 or f16") + + hf_dir = editing.EDITS_DIR / req.name / "hf" + baked = "reused" + if not (hf_dir / "config.json").exists(): + # no cached checkpoint: bake one from the ACTIVE rules (same path as a + # plain full export) + if manager.hf_model is None: + raise HTTPException(422, "no model loaded (and no cached checkpoint for this name)") + rules = interventions.active_rules_full() + if not rules: + raise HTTPException(422, "no active intervention to export") + mode = interventions.mode + if mode in ("readthrough", "exact"): + export_fn, kwargs = editing.export_rebase, {"exact": mode == "exact"} + elif mode == "abliteration": + export_fn, kwargs = editing.export_abliteration, {} + else: + raise HTTPException(422, "export requires a pure-weights mode") + source_dir = resolve_local_dir(manager.meta["model_id"]) + try: + await asyncio.to_thread( + export_fn, rules, manager.jl, manager.meta, + fmt="full", name=f"{req.name}/hf", source_dir=source_dir, + scale=interventions.global_scale, **kwargs, + ) + except ValueError as exc: + raise HTTPException(422, str(exc)) + baked = "baked" + + _gguf_state.update(state="running", name=req.name, step="starting", error=None, result=None) + threading.Thread( + target=_gguf_worker, + args=(req.name, req.gguf_type, hf_dir, convert, quantize, gguf_py), + daemon=True, + ).start() + return {"started": True, "checkpoint": baked, "state": dict(_gguf_state)} + + +class GGUFCacheRequest(BaseModel): + name: str + + +@app.post("/api/edit/gguf-cache/delete") +def api_gguf_cache_delete(req: GGUFCacheRequest): + """Drop the cached HF checkpoint of a GGUF export (the .gguf files stay).""" + import shutil + hf_dir = editing.EDITS_DIR / _safe_name(req.name) / "hf" + if not hf_dir.is_dir(): + raise HTTPException(404, f"no cached checkpoint for {req.name}") + if _gguf_state["state"] == "running" and _gguf_state["name"] == req.name: + raise HTTPException(409, "a GGUF export is using this cache") + freed = sum(f.stat().st_size for f in hf_dir.rglob("*") if f.is_file()) + shutil.rmtree(hf_dir) + return {"deleted": str(hf_dir), "freed_bytes": freed} + + +class GenerateSyncRequest(BaseModel): + messages: list[dict] + sampling: dict = {} + + +@app.post("/api/generate") +async def api_generate_sync(req: GenerateSyncRequest): + """Synchronous generation, no persistence or lens frames: for the CLI tools + (scripts/jlab.py). Active interventions apply just like in the chat.""" + if manager.hf_model is None: + raise HTTPException(422, "no model loaded") + if manager.busy: + raise HTTPException(409, f"busy: {manager.busy}") + done = {} + + def emit(frame): + if frame["type"] == "done": + done.update(frame) + elif frame["type"] == "error": + done["error"] = frame.get("message") + + try: + await asyncio.to_thread( + manager.generate, + req.messages, + req.sampling, + threading.Event(), + emit, + lens=None, + ablator=interventions if interventions.active else None, + ) + except Exception as exc: + raise HTTPException(500, str(exc)) + if done.get("error"): + raise HTTPException(500, done["error"]) + return {"text": done.get("text", ""), "stats": done.get("stats")} + + +class NeighborsRequest(BaseModel): + token_ids: list[int] + k: int = 3 + + +@app.post("/api/token-neighbors") +async def api_token_neighbors(req: NeighborsRequest): + if manager.hf_model is None: + raise HTTPException(422, "no model loaded") + key = ((manager.meta or {}).get("model_id"), (manager.meta or {}).get("revision")) + try: + result = await asyncio.to_thread( + neighbors.lookup, manager.jl, manager.tokenizer, key, + req.token_ids[:64], req.k, + ) + except ValueError as exc: + raise HTTPException(422, str(exc)) + return {"neighbors": {str(tid): entries for tid, entries in result.items()}} + + +@app.get("/api/token-lookup") +def api_token_lookup(q: str): + if manager.tokenizer is None: + raise HTTPException(422, "no model loaded") + tokenizer = manager.tokenizer + candidates = {} + for variant in (q, " " + q, q.lower(), " " + q.lower(), + q.capitalize(), " " + q.capitalize(), q.upper(), " " + q.upper()): + ids = tokenizer.encode(variant, add_special_tokens=False) + if len(ids) == 1 and ids[0] not in candidates: + candidates[ids[0]] = tokenizer.decode([ids[0]]) + return {"candidates": [{"id": tid, "str": s} for tid, s in candidates.items()]} + + +@app.get("/api/registry/local") +def api_registry_local(): + return {"lenses": registry.local_lenses()} + + +@app.get("/api/registry/for-model") +async def api_registry_for_model(model_id: str, revision: str | None = None): + return await asyncio.to_thread(registry.lenses_for_model, model_id, revision) + + +@app.get("/api/registry/resolve") +def api_registry_resolve(path: str | None = None, repo_id: str | None = None, filename: str | None = None): + return registry.resolve_lens(path=path, repo_id=repo_id, filename=filename) + + +@app.post("/api/fit") +def api_fit(req: FitRequest): + if manager.hf_model is not None: + raise HTTPException( + 409, "unload the model first: fitting needs all the VRAM" + ) + valid = _valid_devices() + bad = [d for d in req.devices if d not in valid] + if bad: + raise HTTPException(422, f"invalid device(s): {', '.join(bad)}") + source = resolve_source(req.model_id) + try: + return fit_manager.start( + model_id=req.model_id, + source=source, + model_revision=_resolve_revision(source), + n_prompts=req.n_prompts, + dtype=req.dtype, + quant=req.quant, + dataset=req.dataset, + devices=req.devices, + name=req.name, + dim_batch=req.dim_batch, + max_seq_len=req.max_seq_len, + source_layers=req.source_layers, + continue_from=req.continue_from, + ) + except ValueError as exc: + raise HTTPException(409, str(exc)) + + +@app.get("/api/fit/status") +def api_fit_status(): + return fit_manager.state + + +@app.post("/api/fit/stop") +def api_fit_stop(): + return fit_manager.stop() + + +@app.post("/api/lens/pin") +async def api_lens_pin(req: PinRequest): + if manager.busy: + raise HTTPException(409, f"busy: {manager.busy}") + try: + return await asyncio.to_thread( + lens_manager.pin_ranks, req.gen_id, req.token_ids, manager.jl + ) + except ValueError as exc: + raise HTTPException(422, str(exc)) + + +# Alternative/duplicate weight folders, never needed for transformers inference +# (GPT-OSS-20B ships original/ + metal/ = 2 × ~14 GB of waste). +DOWNLOAD_IGNORE_DIRS = [ + "original/*", "metal/*", "onnx/*", "openvino/*", "coreml/*", "gguf/*", +] +# Auxiliary files that are always useful (configs, tokenizer, custom code) — light. +DOWNLOAD_EXTRAS = ["*.json", "*.txt", "*.model", "*.tiktoken", "*.jinja", "*.py", "*.md"] +# "large" fp32 model: past 1 GB of weights, convert to bf16 automatically +AUTO_BF16_MIN_BYTES = 1_000_000_000 + + +def _plan_download(api, repo_id, token): + """Pick the strict minimum: ONE weight set (the lightest if several variants) + + the auxiliary files. Returns (allow_patterns, ignore_patterns, plan) — + allow_patterns None = "take everything" fallback.""" + import re + + files = {} # path -> size + for entry in api.list_repo_tree(repo_id, recursive=True, token=token or None): + size = getattr(entry, "size", None) + if size is not None: + files[entry.path] = size + + def in_ignored_dir(path): + return any(path.startswith(d.split("/*")[0] + "/") for d in DOWNLOAD_IGNORE_DIRS) + + # root-level safetensors sets, grouped by variant: + # "model(-00001-of-00002)?.safetensors" -> group "model"; + # "model.fp32(-...)?.safetensors" -> group "model.fp32", etc. + st_groups = {} + for path, size in files.items(): + if "/" in path or not path.endswith(".safetensors"): + continue + stem = re.sub(r"-\d{5}-of-\d{5}", "", path.removesuffix(".safetensors")) + st_groups.setdefault(stem, []).append(path) + + if st_groups: + stem, chosen = min( + st_groups.items(), key=lambda kv: sum(files[p] for p in kv[1]) + ) + index = f"{stem}.safetensors.index.json" + patterns = sorted(chosen) + ([index] if index in files else []) + DOWNLOAD_EXTRAS + return patterns, None, { + "kind": "safetensors", + "variant": stem, + "size_bytes": sum(files[p] for p in chosen), + } + + # no safetensors (legacy .bin/.h5 repos, or GGUF-only ones we can't load): + # take everything except the alternative folders and obvious format + # duplicates. GGUF weights are ignored — J-Wash only loads transformers + # (safetensors) models. + ignore = DOWNLOAD_IGNORE_DIRS + ["*.gguf", "*.msgpack", "*.h5", "*.tflite", "*.onnx"] + return None, ignore, { + "kind": "fallback", + "size_bytes": sum( + s for p, s in files.items() + if not in_ignored_dir(p) and not p.endswith(".gguf") + ), + } + + +def _maybe_autoconvert_bf16(repo_id, state): + """After download: if the safetensors weights are float32 and heavy, convert + to bf16 automatically into a local folder (halves the space in use; the HF + cache source stays intact).""" + from pathlib import Path + + from safetensors import safe_open + + from core.model_manager import convert_to_bf16, resolve_local_dir + + src = resolve_local_dir(repo_id) + if not src: + return + src = Path(src) + shards = sorted(src.glob("*.safetensors")) + if not shards: + return + total = sum(s.stat().st_size for s in shards) + if total < AUTO_BF16_MIN_BYTES: + return + import math + + with safe_open(str(shards[0]), framework="pt") as f: + keys = list(f.keys()) + if not keys: + return + # the shard's biggest tensor is representative of the "large layers" + biggest = max(keys, key=lambda k: math.prod(f.get_slice(k).get_shape())) + dtype = str(f.get_slice(biggest).get_dtype()) + if dtype not in ("F32", "F64"): + return + state.update(state="converting") + base = repo_id.split("/")[-1] + result = convert_to_bf16(str(src), out_dir=str(config.LOCAL_MODELS_ROOT / f"{base}-bf16")) + state.update(converted=result["id"]) + + +def _download_worker(repo_id): + import os + + from huggingface_hub import HfApi, snapshot_download + from huggingface_hub.utils import GatedRepoError, RepositoryNotFoundError + + token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") + state = _downloads[repo_id] + try: + allow, ignore, plan = None, None, None + try: + allow, ignore, plan = _plan_download(HfApi(), repo_id, token) + state.update(plan=plan) + except Exception: + # planning failed (network, permissions): cautious fallback + ignore = DOWNLOAD_IGNORE_DIRS + ["*.gguf", "*.msgpack", "*.h5", "*.tflite", "*.onnx"] + # progress: poll the cache size (robust — the tqdm hook misses some files + # depending on the download mechanism). We sum the repo's blobs (including + # .incomplete files) and compare to the planned total. + total_bytes = (plan or {}).get("size_bytes") or 0 + _blobs = config.HF_CACHE / "hub" / f"models--{repo_id.replace('/', '--')}" / "blobs" + stop_poll = threading.Event() + + def _poll_progress(): + while not stop_poll.is_set(): + done = 0 + if _blobs.exists(): + for f in _blobs.iterdir(): + try: + done += f.stat().st_size + except OSError: + pass + if total_bytes: + state["progress"] = {"done": min(done, total_bytes), "total": total_bytes} + stop_poll.wait(1.0) + + poller = threading.Thread(target=_poll_progress, daemon=True) + poller.start() + try: + snapshot_download( + repo_id, token=token or None, + allow_patterns=allow, ignore_patterns=ignore, + ) + finally: + stop_poll.set() + state.pop("progress", None) + if plan and plan["kind"] == "safetensors": + _maybe_autoconvert_bf16(repo_id, state) + state.update(state="done", error=None) + except GatedRepoError: + msg = ( + f'gated repo "{repo_id}": accept the terms on huggingface.co and make ' + "sure a valid HF_TOKEN is set in the environment." + + ("" if token else " (no HF_TOKEN detected)") + ) + state.update(state="error", error=msg) + except RepositoryNotFoundError: + state.update( + state="error", + error=f'repo "{repo_id}" not found (or private without access using the current token)', + ) + except Exception as exc: + state.update(state="error", error=str(exc)) + + +@app.post("/api/download") +def api_download(req: DownloadRequest): + repo_id = req.repo_id.strip() + with _downloads_lock: + current = _downloads.get(repo_id) + if current and current["state"] == "running": + raise HTTPException(409, f"download already in progress: {repo_id}") + # several downloads in parallel: one state per repo + _downloads[repo_id] = {"repo_id": repo_id, "state": "running", "error": None} + threading.Thread(target=_download_worker, args=(repo_id,), daemon=True).start() + return _downloads[repo_id] + + +@app.delete("/api/download/{repo_id:path}") +def api_download_dismiss(repo_id: str): + """Remove a finished (done/error) entry from the displayed list.""" + with _downloads_lock: + state = _downloads.get(repo_id) + if state and state["state"] != "running": + del _downloads[repo_id] + return {"downloads": list(_downloads.values())} + + +@app.get("/api/browse") +def api_browse(path: str | None = None): + from core.model_manager import browse_dir + + try: + return browse_dir(path) + except ValueError as exc: + raise HTTPException(404, str(exc)) + + +class PickPathRequest(BaseModel): + kind: str = "dir" # kept for API compatibility; only directory picking is used + + +_pick_lock = threading.Lock() + + +@app.post("/api/pick-path") +async def api_pick_path(req: PickPathRequest): + """Open Windows' NATIVE file picker (the server runs on the user's own + machine) and return the chosen path — this notably lets you paste a path, + which the built-in browser cannot do.""" + + def pick(): + if not _pick_lock.acquire(blocking=False): + raise ValueError("a file picker is already open") + try: + # tkinter ships with CPython on every platform, but headless + # Linux installs may lack it (or a display): fail with a hint + # instead of a stack trace — the built-in Browse still works. + try: + import tkinter as tk + from tkinter import filedialog + except ImportError: + raise ValueError( + "no native folder picker available (tkinter missing) — " + "use the built-in Browse, or paste the path directly" + ) + + root = tk.Tk() + root.withdraw() + root.attributes("-topmost", True) + try: + path = filedialog.askdirectory( + parent=root, title="Choose a model folder (HF)" + ) + finally: + root.destroy() + return {"path": path or None} + finally: + _pick_lock.release() + + try: + return await asyncio.to_thread(pick) + except ValueError as exc: + raise HTTPException(409, str(exc)) + + +class ConvertRequest(BaseModel): + path: str + + +_convert_state = {"path": None, "state": "idle", "error": None, "result": None} + + +def _convert_worker(path): + from core.model_manager import convert_to_bf16 + + try: + result = convert_to_bf16(path) + _convert_state.update(state="done", error=None, result=result) + except Exception as exc: + _convert_state.update(state="error", error=str(exc)) + + +@app.post("/api/convert-bf16") +def api_convert_bf16(req: ConvertRequest): + if _convert_state["state"] == "running": + raise HTTPException(409, "a conversion is already in progress") + _convert_state.update(path=req.path, state="running", error=None, result=None) + threading.Thread(target=_convert_worker, args=(req.path,), daemon=True).start() + return _convert_state + + +@app.get("/api/conversations") +def api_conversations(query: str | None = None): + return {"conversations": store.list_conversations(query)} + + +@app.get("/api/conversations/{cid}") +def api_conversation(cid: int): + try: + return store.get_conversation(cid) + except ValueError as exc: + raise HTTPException(404, str(exc)) + + +@app.patch("/api/conversations/{cid}") +def api_conversation_patch(cid: int, req: ConversationPatch): + store.update_conversation(cid, title=req.title, tags=req.tags) + return {"ok": True} + + +@app.delete("/api/conversations/{cid}") +def api_conversation_delete(cid: int): + store.delete_conversation(cid) + return {"ok": True} + + +@app.get("/api/messages/{mid}/frames") +def api_message_frames(mid: int): + try: + return store.load_frames(mid) + except ValueError as exc: + raise HTTPException(404, str(exc)) + + +class MessagePatch(BaseModel): + content: str + + +@app.patch("/api/messages/{mid}") +def api_message_patch(mid: int, req: MessagePatch): + """Edit a message's content (e.g. rewrite an assistant reply). Later turns + are generated from the stored path, so the edit takes effect immediately.""" + try: + store.update_message(mid, req.content) + except ValueError as exc: + raise HTTPException(404, str(exc)) + return {"ok": True, "id": mid} + + +@app.get("/api/conversations/{cid}/export") +def api_conversation_export(cid: int, format: str = "json", frames: int = 0): + try: + body, media_type = store.export(cid, fmt=format, include_frames=bool(frames)) + except ValueError as exc: + raise HTTPException(404, str(exc)) + ext = "json" if format == "json" else "md" + return Response( + content=body, + media_type=f"{media_type}; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="conversation-{cid}.{ext}"'}, + ) + + +def _generate_safely(messages, sampling, stop_event, emit, lens): + try: + manager.generate( + messages, sampling, stop_event, emit, lens=lens, + ablator=interventions if interventions.active else None, + ) + except Exception as exc: + emit({"type": "error", "message": str(exc)}) + + +def _persisted_generate(req, stop_event, emit, lens): + try: + continue_id = req.get("continue_message_id") + if continue_id is not None: + _persisted_continue(req, continue_id, stop_event, emit, lens) + return + conversation_id = req.get("conversation_id") + parent_id = req.get("parent_id") + system = (req.get("system") or "").strip() + content = req.get("content") + if conversation_id is None and not content: + emit({"type": "error", "message": "content required for a new conversation"}) + return + if conversation_id is None: + conversation_id = store.create_conversation(content[:60]) + if system: + parent_id = store.add_message(conversation_id, None, "system", system) + if content: + parent_id = store.add_message(conversation_id, parent_id, "user", content) + if parent_id is None: + emit({"type": "error", "message": "parent_id or content required"}) + return + emit({ + "type": "persisted", + "conversation_id": conversation_id, + "user_message_id": parent_id, + }) + context = store.path_to_root(parent_id) + layers_used = list(lens.layers) if lens is not None else [] + k_used = lens.k if lens is not None else 0 + frames_acc = [] + done_holder = {} + + def emit_inner(frame): + if frame["type"] == "done": + done_holder.update(frame) + else: + if frame["type"] == "frame": + frames_acc.append(frame) + emit(frame) + + manager.generate( + context, req.get("sampling", {}), stop_event, emit_inner, lens=lens, + ablator=interventions if interventions.active else None, + ) + meta = dict( + done_holder.get("meta") or {}, + stats=done_holder.get("stats"), + stopped=done_holder.get("stopped"), + ) + message_id = store.add_message( + conversation_id, parent_id, "assistant", done_holder.get("text", ""), meta=meta + ) + if frames_acc: + store.save_frames(message_id, frames_acc, layers_used, k_used) + emit(dict(done_holder, conversation_id=conversation_id, message_id=message_id)) + except Exception as exc: + emit({"type": "error", "message": str(exc)}) + + +def _persisted_continue(req, message_id, stop_event, emit, lens): + """Extend an existing assistant reply: generate with the turn left open, + append the text to the message, and merge the new lens frames into its + stored blob (positions keep increasing, so both parts stay coherent).""" + msg = store.get_message(message_id) + if msg["role"] != "assistant": + emit({"type": "error", "message": "only an assistant reply can be continued"}) + return + context = store.path_to_root(message_id) + layers_used = list(lens.layers) if lens is not None else [] + k_used = lens.k if lens is not None else 0 + frames_acc = [] + done_holder = {} + + def emit_inner(frame): + if frame["type"] == "done": + done_holder.update(frame) + else: + if frame["type"] == "frame": + frames_acc.append(frame) + emit(frame) + + manager.generate( + context, req.get("sampling", {}), stop_event, emit_inner, lens=lens, + ablator=interventions if interventions.active else None, + continue_final=True, + ) + new_content = msg["content"] + done_holder.get("text", "") + meta = json.loads(msg["meta"]) if msg.get("meta") else {} + meta = dict( + meta, + stats=done_holder.get("stats"), + stopped=done_holder.get("stopped"), + continued=True, + ) + store.update_message(message_id, new_content, meta=meta) + if frames_acc: + merged = frames_acc + if msg.get("frames_file"): + try: + merged = store.load_frames(message_id)["frames"] + frames_acc + except Exception: + pass + store.save_frames(message_id, merged, layers_used, k_used) + emit(dict( + done_holder, + conversation_id=msg["conversation_id"], + message_id=message_id, + text=new_content, + continued=True, + )) + + +async def _watch_stop(ws, stop_event): + while True: + msg = json.loads(await ws.receive_text()) + if msg.get("type") == "stop": + stop_event.set() + + +async def _run_chat(ws, req): + loop = asyncio.get_running_loop() + queue = asyncio.Queue() + stop_event = threading.Event() + + def emit(frame): + loop.call_soon_threadsafe(queue.put_nowait, frame) + + lens = lens_manager if req.get("lens") and lens_manager.lens is not None else None + if "messages" in req: + worker = asyncio.create_task( + asyncio.to_thread( + _generate_safely, req["messages"], req.get("sampling", {}), stop_event, emit, lens + ) + ) + else: + worker = asyncio.create_task( + asyncio.to_thread(_persisted_generate, req, stop_event, emit, lens) + ) + receiver = asyncio.create_task(_watch_stop(ws, stop_event)) + try: + while True: + frame = await queue.get() + await _ws_send(ws, json.dumps(frame)) + if frame["type"] in ("done", "error"): + break + finally: + stop_event.set() + receiver.cancel() + await asyncio.gather(worker, receiver, return_exceptions=True) + + +@app.websocket("/ws") +async def ws_endpoint(ws: WebSocket): + await ws.accept() + _ws_locks[ws] = asyncio.Lock() + try: + while True: + req = json.loads(await ws.receive_text()) + if req.get("type") != "chat": + continue + if manager.hf_model is None: + await _ws_send( + ws, json.dumps({"type": "error", "message": "no model loaded"}) + ) + continue + if manager.busy: + await _ws_send( + ws, json.dumps({"type": "error", "message": f"busy: {manager.busy}"}) + ) + continue + await _run_chat(ws, req) + except (WebSocketDisconnect, RuntimeError): + pass + finally: + _ws_locks.pop(ws, None) + + +if config.UI_DIST.exists(): + app.mount("/", StaticFiles(directory=config.UI_DIST, html=True), name="ui") diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..91ae5a8 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,20 @@ +# Screenshots for the README + +Drop PNG screenshots here using the **exact filenames** below. The main +`README.md` already references them, so they render automatically once present. + +Capture them from the running app at **http://localhost:8381** (a wide browser +window looks best — around 1400×900). PNG preferred; keep each file under ~1 MB +if you can. + +| Filename | What to capture | +|-----------------------|-----------------| +| `hero.png` | The main screen: a chat with an assistant reply visible **and** the live lens view below it (Heatmap or Frequencies). This is the top banner — make it the most representative shot. | +| `lens-tab.png` | The **Lens** tab in the sidebar (loaded lens or the list of compatible local/Hub lenses), ideally next to the **Frequencies** token cloud in the main pane. | +| `heatmap-pins.png` | The **Heatmap** view (layers × positions) with at least one **pinned** token, so its per-layer rank curves + rank heatmap are visible. | +| `token-editor.png` | The **☢ token editor** panel open, showing a couple of rules, the **Per-layer steering ↔ Read projection** mode toggle, and the **Export** section. The fish preset (` model`/` assistant` → ` fish`) makes a great example. | + +Optional extras you can add and reference in the README if you want: +`fit.png` (the Fit tab mid-training), `diff.png` (an A/B frame diff). + +Tip: on Windows, `Win+Shift+S` captures a region; save the result here. diff --git a/assets/animation1.gif b/assets/animation1.gif new file mode 100644 index 0000000..68d74c9 Binary files /dev/null and b/assets/animation1.gif differ diff --git a/config.py b/config.py new file mode 100644 index 0000000..6580736 --- /dev/null +++ b/config.py @@ -0,0 +1,68 @@ +import os +from pathlib import Path + +ROOT = Path(__file__).resolve().parent + +HOST = "127.0.0.1" +PORT = 8381 + +# HF cache root. Default: the shared Hugging Face cache (a pre-set HF_HOME, else +# the standard ~/.cache/huggingface — the same one other HF tools use). Pass +# `run.py --hf-cache PATH` (e.g. ./hf_cache) for an isolated, project-local cache; +# run.py sets HF_HOME from that argument before this module is imported. +_DEFAULT_HF_HOME = Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")) / "huggingface" +HF_CACHE = Path(os.environ.get("HF_HOME") or _DEFAULT_HF_HOME) +# Runtime data root (SQLite history, frames, presets, edits, masks). Overridable +# so several instances can run side by side; run.py sets it from --data-dir. +DATA_DIR = Path(os.environ.get("JWASH_DATA_DIR") or (ROOT / "data")) +LENSES_DIR = ROOT / "lenses" +UI_DIST = ROOT / "ui" / "dist" +LOCAL_MODELS_ROOT = ROOT + +DTYPES = ("bf16", "fp16") +QUANTS = ("int8", "nf4") +DEFAULT_DTYPE = "bf16" +DEFAULT_DEVICE = "cuda:0" + +DEFAULT_SAMPLING = { + "temperature": 0.7, + "top_p": 0.95, + "top_k": 40, + "max_tokens": 512, + "seed": -1, # -1 = random +} + + +def setup_env(): + os.environ.setdefault("CUDA_DEVICE_ORDER", "PCI_BUS_ID") + os.environ.setdefault("HF_HOME", str(HF_CACHE)) + os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") + _tolerant_subprocess_text() + + +def _tolerant_subprocess_text(): + """Windows: libraries (quantization backends, driver probes) spawn tools + whose console output is localized (cp850/cp1252). Under ``-X utf8`` the + stdlib decodes their pipes as STRICT UTF-8, and the reader thread dies + with a noisy — though harmless — UnicodeDecodeError. Default text-mode + pipes to ``errors="replace"`` when the caller didn't choose otherwise.""" + if os.name != "nt": + return + import subprocess + + if getattr(subprocess.Popen.__init__, "_jwash_tolerant", False): + return + orig = subprocess.Popen.__init__ + + def patched(self, *args, **kwargs): + wants_text = ( + kwargs.get("text") + or kwargs.get("universal_newlines") + or kwargs.get("encoding") + ) + if wants_text and not kwargs.get("errors"): + kwargs["errors"] = "replace" + orig(self, *args, **kwargs) + + patched._jwash_tolerant = True + subprocess.Popen.__init__ = patched diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/ablation.py b/core/ablation.py new file mode 100644 index 0000000..88e1592 --- /dev/null +++ b/core/ablation.py @@ -0,0 +1,380 @@ +import itertools +import threading + +import torch + +# Default layer slice for a new rule, as fractions of the model's layer count: +# e.g. 56 layers -> from int(56*3/5)=33 to int(56*4/5)=44. +DEFAULT_LAYERS_FRAC_LO = 3 / 5 +DEFAULT_LAYERS_FRAC_HI = 4 / 5 + + +def default_layers(n_layers): + lo = int(n_layers * DEFAULT_LAYERS_FRAC_LO) + hi = min(int(n_layers * DEFAULT_LAYERS_FRAC_HI), n_layers - 1) + return list(range(lo, hi + 1)) + + +def effective_coeffs(mode, factor, g): + """Effective coefficients ``(alpha, beta)`` of a rule's effect under the + global multiplier ``g``: ``delta = alpha·(v̂_A·h)·v̂_A + beta·(v̂_A·h)·v̂_B`` + (``beta = 0`` in scale mode). + + Saturates the over-correction: at g=1 the effect is exactly that of the + factor; beyond it, it converges to full removal of the component (or to the + explicitly requested inversion if factor < 0) WITHOUT overshooting it. + Without this bound, g·(factor-1) < -1 makes the component negative — a + chaotic anti-direction (measured: "zap Paris" at scale 4 → "Paris Paris + Paris..." in a loop). + """ + if mode == "scale": + alpha = g * (factor - 1.0) + if factor < 1.0: + # final component 1+alpha bounded to min(factor, 0) + alpha = max(alpha, min(factor, 0.0) - 1.0) + return alpha, 0.0 + # replace: saturated removal of A (never anti-A), addition of B linear in g + return -min(g, 1.0), g * factor + + +def abliteration_direction(weight_u, rule): + """Residual directions of a rule for the abliteration mode (global + pure-weight edit). + + ``weight_u``: the un-embedding matrix W_U (lm_head), [vocab, d_model]. The + directions live in the residual space (the basis W_U reads). Returns + ``(v_a, v_b)`` (float, CPU, normalized); ``v_b`` is None in scale mode. The + effect applied to each residual write ``h`` is + ``h += alpha·(v̂_A·h)·v̂_A + beta·(v̂_A·h)·v̂_B`` with ``(alpha, beta)`` given + by :func:`effective_coeffs` (which folds in the global scale). + """ + v_a = weight_u[rule["token_id"]].detach().float().cpu() + v_a = v_a / v_a.norm().clamp_min(1e-8) + v_b = None + if rule["mode"] != "scale": + v_b = weight_u[rule["replacement_id"]].detach().float().cpu() + v_b = v_b / v_b.norm().clamp_min(1e-8) + return v_a, v_b + + +# Rule application modes: +# standard — layer-by-layer residual steering (hook on the output of the +# chosen layers). The most expressive live, but no layer write +# carries the "skip": not faithfully exportable. +# readthrough — change of basis of the downstream READS (cf. core/rebase): +# the preview hooks the RMSNorm output with the same transform +# as the bake → preview = exported checkpoint. +# exact — readthrough + counter-transform of the downstream writes +# (reproduces a hook applied exactly once; regularized inverse +# near a full zap → reserved for soft factors). +# abliteration — global W_U projection on every residual write (embed + all +# block outputs); bake = the same projections on the writes. +# The pure-weights path for architectures the rebase does not +# support (write norms, Gemma style). Faithful for full +# zaps/replaces; a rule's layers are ignored (global). +MODES = ("standard", "readthrough", "exact", "abliteration") + + +class Interventions: + def __init__(self): + self._lock = threading.Lock() + self._counter = itertools.count(1) + self._rules = [] + self._handles = [] + self._scale = 1.0 + self._mode = "standard" + + @property + def active(self): + return bool(self._rules) + + @property + def global_scale(self): + return self._scale + + @property + def mode(self): + return self._mode + + def set_scale(self, scale): + with self._lock: + self._scale = float(scale) + return self._scale + + def set_mode(self, mode): + if mode not in MODES: + raise ValueError(f"unknown intervention mode: {mode}") + with self._lock: + self._mode = mode + return self._mode + + def rules_full(self): + return list(self._rules) + + def active_rules_full(self): + """Full rules (with directions) actually applied — for export: a disabled + rule or one without layers must not be baked.""" + return list(self._active_rules()) + + def _active_rules(self): + """Rules actually applied: non-empty layers AND not disabled. The + `enabled` flag lets you switch a rule off without losing its layer + selection (the "layers=[]" gesture stays possible but clears the selection).""" + return [r for r in self._rules if r["layers"] and r.get("enabled", True)] + + def summary(self): + return [ + { + "id": rule["id"], + "token_id": rule["token_id"], + "token": rule["token"], + "mode": rule["mode"], + "factor": rule["factor"], + "replacement_id": rule["replacement_id"], + "replacement": rule["replacement"], + "layers": rule["layers"], + "enabled": rule.get("enabled", True), + } + for rule in self._rules + ] + + def _direction(self, lens, weight, token_id, layers): + row = weight[token_id].float() + dirs = {} + for layer in layers: + J = lens.jacobians.get(layer) + if J is None: + # layer not fitted by the lens: direct logit lens (J = I), + # a good approximation near the output + v = row + else: + v = row @ J.float().to(weight.device) + dirs[layer] = v / v.norm().clamp_min(1e-8) + return dirs + + def add(self, lens_manager, jl, *, token_id, mode="scale", factor=0.0, + replacement_id=None, layers=None, enabled=True): + with self._lock: + lens = lens_manager.lens + if lens is None: + raise ValueError("no lens loaded") + if mode not in ("scale", "replace"): + raise ValueError(f"invalid mode: {mode}") + if mode == "replace" and replacement_id is None: + raise ValueError("replacement_id required in replace mode") + n_layers = len(jl.layers) + if layers is None: + layers = default_layers(n_layers) + # layers=[] is valid: rule recorded but inactive + layers = sorted({int(l) for l in layers if 0 <= int(l) < n_layers}) + weight = jl._lm_head.weight + if weight.dtype not in (torch.float16, torch.bfloat16, torch.float32): + raise ValueError("interventions unavailable on a quantized model") + tokenizer = jl.tokenizer + rule = { + "id": next(self._counter), + "token_id": int(token_id), + "token": tokenizer.decode([int(token_id)]), + "mode": mode, + "factor": float(factor), + "replacement_id": int(replacement_id) if replacement_id is not None else None, + "replacement": tokenizer.decode([int(replacement_id)]) if replacement_id is not None else None, + "layers": [int(l) for l in layers], + "enabled": bool(enabled), + "dirs_a": self._direction(lens, weight, int(token_id), layers), + "dirs_b": self._direction(lens, weight, int(replacement_id), layers) + if replacement_id is not None + else None, + } + self._rules.append(rule) + return self.summary() + + def update(self, rule_id, *, factor=None, layers=None, enabled=None, + token_id=None, replacement_id=None, mode=None, + lens_manager=None, jl=None): + with self._lock: + for rule in self._rules: + if rule["id"] != rule_id: + continue + if factor is not None: + rule["factor"] = float(factor) + if enabled is not None: + rule["enabled"] = bool(enabled) + # token / replacement / mode / layers change the directions → + # the lens and model are required to re-resolve them + needs_dirs = any(x is not None for x in (layers, token_id, replacement_id, mode)) + if not needs_dirs: + return self.summary() + if lens_manager is None or jl is None: + raise ValueError("model and lens required to edit the rule") + lens = lens_manager.lens + if lens is None: + raise ValueError("no lens loaded") + tokenizer = jl.tokenizer + if mode is not None: + if mode not in ("scale", "replace"): + raise ValueError(f"invalid mode: {mode}") + rule["mode"] = mode + if token_id is not None: + rule["token_id"] = int(token_id) + rule["token"] = tokenizer.decode([int(token_id)]) + if replacement_id is not None: + rule["replacement_id"] = int(replacement_id) + rule["replacement"] = tokenizer.decode([int(replacement_id)]) + if rule["mode"] == "scale": + rule["replacement_id"] = None + rule["replacement"] = None + elif rule["replacement_id"] is None: + raise ValueError("replacement_id required in replace mode") + if layers is not None: + n_layers = len(jl.layers) + # new_layers=[] is valid: rule kept but inactive + rule["layers"] = sorted({int(l) for l in layers if 0 <= int(l) < n_layers}) + weight = jl._lm_head.weight + rule["dirs_a"] = self._direction(lens, weight, rule["token_id"], rule["layers"]) + rule["dirs_b"] = ( + self._direction(lens, weight, rule["replacement_id"], rule["layers"]) + if rule["replacement_id"] is not None + else None + ) + return self.summary() + raise ValueError(f"unknown rule {rule_id}") + + def remove(self, rule_id=None): + with self._lock: + self.detach() + if rule_id is None: + self._rules = [] + else: + self._rules = [r for r in self._rules if r["id"] != rule_id] + return self.summary() + + def attach(self, jl): + if not self._rules: + return + if self._mode == "abliteration": + self._attach_abliteration(jl) + return + if self._mode in ("readthrough", "exact"): + self._attach_rebase(jl, exact=self._mode == "exact") + return + by_layer = {} + for rule in self._active_rules(): + for layer in rule["layers"]: + by_layer.setdefault(layer, []).append(rule) + + def make_hook(layer, rules): + def hook(module, inputs, output): + h = output[0] if isinstance(output, tuple) else output + g = self._scale + for rule in rules: + alpha, beta = effective_coeffs(rule["mode"], rule["factor"], g) + vA = rule["dirs_a"][layer].to(h.device, h.dtype) + coef = (h * vA).sum(-1, keepdim=True) + h = h + alpha * coef * vA + if beta: + vB = rule["dirs_b"][layer].to(h.device, h.dtype) + h = h + beta * coef * vB + if isinstance(output, tuple): + return (h,) + tuple(output[1:]) + return h + + return hook + + self._handles = [ + jl.layers[layer].register_forward_hook(make_hook(layer, rules)) + for layer, rules in by_layer.items() + ] + + def _attach_abliteration(self, jl): + # Abliteration-mode preview: the SAME projection on every residual write + # (embed + each block's output), mirroring the pure-weight bake. A rule's + # layers make no sense here (global projection), but layers=[] stays THE + # "rule disabled" gesture: we honor it too. + active = self._active_rules() + if not active: + return + weight_u = jl._lm_head.weight + dirs = [(abliteration_direction(weight_u, r), r) for r in active] + + def apply(h): + g = self._scale + for (v_a, v_b), rule in dirs: + alpha, beta = effective_coeffs(rule["mode"], rule["factor"], g) + va = v_a.to(h.device, h.dtype) + coef = (h * va).sum(-1, keepdim=True) + h = h + alpha * coef * va + if beta: + h = h + beta * coef * v_b.to(h.device, h.dtype) + return h + + def emb_hook(module, inputs, output): + return apply(output) + + def blk_hook(module, inputs, output): + h = output[0] if isinstance(output, tuple) else output + h = apply(h) + return (h,) + tuple(output[1:]) if isinstance(output, tuple) else h + + self._handles = [jl._embed_tokens.register_forward_hook(emb_hook)] + self._handles += [blk.register_forward_hook(blk_hook) for blk in jl.layers] + + def _attach_rebase(self, jl, exact): + # readthrough/exact preview: the SAME transform as the bake (core/rebase), + # applied by hooks on the OUTPUT of the reading RMSNorms (and, in exact + # mode, on the downstream writes) — the preview and the exported + # checkpoint differ only by rounding. + from core import rebase # local import (rebase imports effective_coeffs from here) + + active = self._active_rules() + if not active: + return + n_layers = len(jl.layers) + cums = rebase.cumulative(active, self._scale, n_layers) + if not cums: + return + + def read_hook_for(norm, U, V): + Ug, Vg = rebase.gamma_pair(norm, U, V) + weight = norm.weight + Ug = Ug.to(weight.device, weight.dtype) + Vg = Vg.to(weight.device, weight.dtype) + + def hook(module, inputs, output): + return output + (output @ Vg) @ Ug.T + + return hook + + def write_hook_for(module, U_inv, V): + weight = module.weight + U_inv = U_inv.to(weight.device, weight.dtype) + V = V.to(weight.device, weight.dtype) + + def hook(module, inputs, output): + return output - (output @ V) @ U_inv.T + + return hook + + handles = [] + for m in sorted(k for k in cums if k < n_layers): + U, V = cums[m] + block = jl.layers[m] + norms = {} + for _suffix, _module, norm in rebase.iter_reads(block): + norms[id(norm)] = norm + for norm in norms.values(): + handles.append(norm.register_forward_hook(read_hook_for(norm, U, V))) + if exact: + U_inv, Vw, _regularized = rebase.inverse_uv(U, V) + for _suffix, module in rebase.iter_writes(block): + handles.append(module.register_forward_hook(write_hook_for(module, U_inv, Vw))) + U, V = cums[n_layers] + handles.append( + jl._final_norm.register_forward_hook(read_hook_for(jl._final_norm, U, V)) + ) + self._handles = handles + + def detach(self): + for handle in self._handles: + handle.remove() + self._handles = [] diff --git a/core/editing.py b/core/editing.py new file mode 100644 index 0000000..ed5d486 --- /dev/null +++ b/core/editing.py @@ -0,0 +1,621 @@ +import json +import re +import shutil +from datetime import datetime, timezone +from pathlib import Path + +import torch +from safetensors import safe_open +from safetensors.torch import save_file + +import config +from core import rebase +from core.ablation import abliteration_direction, effective_coeffs + +EDITS_DIR = config.DATA_DIR / "edits" +PRESETS_DIR = config.DATA_DIR / "presets" + +# Residual writes edited by the global abliteration (embed aside) +TARGET_SUFFIXES = ("self_attn.o_proj", "mlp.down_proj") + + +def _now(): + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def list_presets(): + PRESETS_DIR.mkdir(parents=True, exist_ok=True) + out = [] + for path in sorted(PRESETS_DIR.glob("*.json")): + data = json.loads(path.read_text(encoding="utf-8")) + out.append({"name": path.stem, "n_rules": len(data.get("rules", [])), "model_id": data.get("model_id")}) + return out + + +def save_preset(name, rules, model_id, scale=1.0): + PRESETS_DIR.mkdir(parents=True, exist_ok=True) + payload = {"model_id": model_id, "saved_at": _now(), "scale": scale, "rules": rules} + (PRESETS_DIR / f"{name}.json").write_text( + json.dumps(payload, ensure_ascii=False, indent=1), encoding="utf-8" + ) + return payload + + +def load_preset(name): + path = PRESETS_DIR / f"{name}.json" + if not path.exists(): + raise ValueError(f"unknown preset {name}") + return json.loads(path.read_text(encoding="utf-8")) + + +def delete_preset(name): + (PRESETS_DIR / f"{name}.json").unlink(missing_ok=True) + + +def compute_abliteration(rules, jl, scale=1.0): + """Global pure-weight edit reproducing the abliteration-mode preview. + + Applies to EVERY residual write (embed_tokens + o_proj/down_proj of every + layer) the same transform as the abliteration-mode hooks: for each rule, + ``out += scale·(v̂_A·out)·w`` (applied sequentially, like the hooks). Since + the residual is the sum of all these writes, the direction is + removed/redirected across the whole residual — hence the fidelity (~0.97 + cosine on the logits). This is the pure-weights path for architectures the + rebase does not support (write norms, Gemma style). + + Returns ``(tensors, info)``: + - ``tensors``: {param_name: W_new (cpu, float32)} + - ``info``: {tied, embed_key, lm_head_key, path, delta_max, lowrank} + where ``lowrank`` = {param_name: (B [out, r], A [r, in])} — the SAME edit + as per-rule rank-1 factors (delta = B·A), exact, for the LoRA export. + For the embed, delta = (B·A)ᵀ (PEFT lookup convention). + """ + # layers=[] = disabled rule, in this mode too (consistent with the preview) + rules = [r for r in rules if r["layers"]] + if not rules: + raise ValueError("no active rule (all have 0 layers): nothing to export") + path = jl.layout.path + weight_u = jl._lm_head.weight + # (v_a, w_eff) per rule, with w_eff = alpha·v̂_A + beta·v̂_B: the SAME effective + # coefficients (saturation included) as the preview hooks + pairs = [] + for r in rules: + v_a, v_b = abliteration_direction(weight_u, r) + alpha, beta = effective_coeffs(r["mode"], r["factor"], scale) + w_eff = alpha * v_a + if beta: + w_eff = w_eff + beta * v_b + pairs.append((v_a, w_eff)) + + # bake on CPU: the float32 matrices (embed ~1.5 GB) don't fit alongside the + # model on the GPU (OOM measured on 12 GB with a 4B loaded) + def apply_cols(W): # [d_model, d_in]: residual output = rows + cur, us, rows = W, [], [] + for v_a, w in pairs: + row = v_a @ cur # composed over the previous rules + us.append(w) + rows.append(row) + cur = cur + torch.outer(w, row) + return cur, torch.stack(us, dim=1), torch.stack(rows, dim=0) + + def apply_rows(E): # [vocab, d_model]: each ROW is a residual vector + cur, us, rows = E, [], [] + for v_a, w in pairs: + col = cur @ v_a # [vocab] + us.append(w) + rows.append(col) + cur = cur + torch.outer(col, w) + return cur, torch.stack(us, dim=1), torch.stack(rows, dim=0) + + tensors = {} + lowrank = {} + delta_max = 0.0 + + embed_key = f"{path}.{jl.layout.embed}.weight" + E = jl._embed_tokens.weight.detach().float().cpu() + E_new, B, A = apply_rows(E) + delta_max = max(delta_max, (E_new - E).abs().max().item()) + tensors[embed_key] = E_new + lowrank[embed_key] = (B, A) # delta_embed = (B·A)ᵀ = summed outer(A_k, B_k) + + skipped_writes = 0 + for i, block in enumerate(jl.layers): + for suffix in TARGET_SUFFIXES: + module = block + for part in suffix.split("."): + module = getattr(module, part, None) + if module is None: + break + if module is None: # e.g. linear-attention blocks (no self_attn) + skipped_writes += 1 + continue + W = module.weight.detach().float().cpu() + W_new, B, A = apply_cols(W) + delta_max = max(delta_max, (W_new - W).abs().max().item()) + name = f"{path}.layers.{i}.{suffix}.weight" + tensors[name] = W_new + lowrank[name] = (B, A) + + tied = jl._lm_head.weight.data_ptr() == jl._embed_tokens.weight.data_ptr() + info = { + "tied": tied, + "embed_key": embed_key, + "lm_head_key": f"{jl.layout.lm_head}.weight", + "path": path, + "delta_max": delta_max, + "lowrank": lowrank, + "skipped_writes": skipped_writes, + } + return tensors, info + + +def _abliteration_warnings(rules): + warns = [] + for r in rules: + if r["mode"] == "scale" and r["factor"] > 1.0: + warns.append( + f"\"{(r['token'] or '').strip()}\" ×{r['factor']}: amplifying (factor > 1) " + "is approximate in pure weights (the hook composes over the layers)" + ) + return warns + + +def export_abliteration(rules, jl, model_meta, *, fmt, name, source_dir=None, scale=1.0): + """Pure-weight export (global abliteration). Formats: ``full`` (full + checkpoint), ``layers`` (safetensors of only the modified matrices) and + ``lora`` (exact PEFT adapter, rank = n_rules; embed omitted if embeddings + are tied). Unties ``lm_head`` (full/layers) if the model has tied embeddings, + to preserve the original un-embedding.""" + rules = [r for r in rules if r["layers"]] # layers=[] = disabled rule + if not rules: + raise ValueError("no active intervention to export") + if fmt not in ("full", "layers", "lora"): + raise ValueError(f"unknown format for abliteration: {fmt}") + + tensors, info = compute_abliteration(rules, jl, scale=scale) + if info["delta_max"] < 1e-8: + raise ValueError( + "the bake changes no weight (neutral factors, scale=0 or null " + "directions) — the export would be identical to the original model" + ) + out_dir = EDITS_DIR / name + out_dir.mkdir(parents=True, exist_ok=True) + dtype = torch.bfloat16 if model_meta.get("dtype") == "bf16" else torch.float16 + lm_head_key = info["lm_head_key"] + summary = [ + {k: r[k] for k in ("token_id", "token", "mode", "factor", "replacement_id", "replacement")} + for r in rules + ] + meta = { + "name": name, + "format": fmt, + "method": "abliteration-global", + "model_id": model_meta.get("model_id"), + "model_revision": model_meta.get("revision"), + "dtype": model_meta.get("dtype"), + "global_scale": scale, + "untied_lm_head": info["tied"] and fmt in ("full", "layers"), + "rules": summary, + "modified_params_count": len(tensors) + (1 if info["tied"] else 0), + "warnings": _abliteration_warnings(rules) + ( + [f"{info['skipped_writes']} residual write(s) without o_proj/down_proj " + "(hybrid architecture) left untouched — the bake is partial there; " + "prefer read projection when the architecture supports it"] + if info["skipped_writes"] else [] + ), + "note": ( + "global abliteration: the token's direction is removed/redirected in " + "every residual write (embed + o_proj/down_proj of all layers). " + "Reproduces the abliteration-mode preview (~0.97 cosine on the logits). " + "Pure weights: a standard safetensors checkpoint." + ), + "created_at": _now(), + } + + if fmt == "layers": + out = {k: v.to(dtype) for k, v in tensors.items()} + if info["tied"]: + # original un-embedding (unedited embed) to write separately + out[lm_head_key] = jl._embed_tokens.weight.detach().to(dtype).cpu() + save_file(out, str(out_dir / "modified_layers.safetensors")) + + elif fmt == "lora": + # The abliteration delta is EXACTLY rank-n_rules per matrix (delta = B·A), + # so the LoRA is exact — except the embed of a tied-embeddings model: PEFT + # can't untie lm_head, and editing the embed would corrupt the shared + # un-embedding → we omit it (reduced fidelity). + include_embed = not info["tied"] + if not include_embed: + meta["warnings"] = meta["warnings"] + [ + "tied embeddings: the embed is not included in the LoRA (PEFT " + "cannot untie lm_head) — prefer \"full checkpoint\" for maximum " + "fidelity" + ] + out = {} + target_modules = set() + for pname, (B, A) in info["lowrank"].items(): + base = pname.removesuffix(".weight") + if pname == info["embed_key"]: + if not include_embed: + continue + target_modules.add(base.rsplit(".", 1)[-1]) + # PEFT Embedding convention: delta_lookup = (B·A)ᵀ, + # A = lora_embedding_A [r, vocab], B = lora_embedding_B [d_model, r] + out[f"base_model.model.{base}.lora_embedding_A"] = A.contiguous() + out[f"base_model.model.{base}.lora_embedding_B"] = B.contiguous() + else: + target_modules.add(base.rsplit(".", 1)[-1]) + out[f"base_model.model.{base}.lora_A.weight"] = A.contiguous() + out[f"base_model.model.{base}.lora_B.weight"] = B.contiguous() + rank = len(rules) + save_file(out, str(out_dir / "adapter_model.safetensors")) + adapter_config = { + "peft_type": "LORA", + "base_model_name_or_path": model_meta.get("model_id"), + "r": rank, + "lora_alpha": rank, + "lora_dropout": 0.0, + "target_modules": sorted(target_modules), + "bias": "none", + "fan_in_fan_out": False, + "task_type": "CAUSAL_LM", + } + (out_dir / "adapter_config.json").write_text( + json.dumps(adapter_config, indent=1), encoding="utf-8" + ) + + elif fmt == "full": + if source_dir is None or not Path(source_dir).is_dir(): + raise ValueError("full checkpoint: model source folder not found") + source_dir = Path(source_dir) + shards = sorted(source_dir.glob("*.safetensors")) + if not shards: + raise ValueError("full checkpoint: no safetensors in the source") + + lm_head_value = None # original un-embedding (if tied) = original embed from disk + embed_shard_name = None + seen = set() + for shard in shards: + ino = shard.stat().st_ino + if ino in seen: + continue + seen.add(ino) + out = {} + with safe_open(str(shard), framework="pt") as f: + keys = list(f.keys()) + for key in keys: + original = f.get_tensor(key) + if info["tied"] and key == info["embed_key"]: + lm_head_value = original.clone() # BEFORE editing + embed_shard_name = shard.name + out[key] = tensors[key].to(original.dtype) if key in tensors else original + # if this shard already carries lm_head (untied model), don't touch it + save_file(out, str(out_dir / shard.name)) + + # untie: add lm_head.weight (= original embed) into the embed's shard + if info["tied"]: + if lm_head_value is None: + raise ValueError("cannot untie: embed not found in the source") + target_shard = out_dir / embed_shard_name + with safe_open(str(target_shard), framework="pt") as f: + merged = {k: f.get_tensor(k) for k in f.keys()} + merged[lm_head_key] = lm_head_value + save_file(merged, str(target_shard)) + + # config.json: copy, force tie_word_embeddings=False if untied + cfg_path = source_dir / "config.json" + if cfg_path.exists(): + cfg = json.loads(cfg_path.read_text(encoding="utf-8")) + if info["tied"]: + cfg["tie_word_embeddings"] = False + (out_dir / "config.json").write_text( + json.dumps(cfg, ensure_ascii=False, indent=1), encoding="utf-8" + ) + # other tokenizer/config files (json, merges.txt, tokenizer.model…): + # copy as-is, then fix the index if present + for pattern in ("*.json", "*.txt", "*.model", "*.tiktoken", "*.jinja"): + for extra in source_dir.glob(pattern): + if extra.name == "config.json": + continue + shutil.copy2(extra, out_dir / extra.name) + index_path = out_dir / "model.safetensors.index.json" + if info["tied"] and index_path.exists(): + index = json.loads(index_path.read_text(encoding="utf-8")) + wm = index.setdefault("weight_map", {}) + wm[lm_head_key] = embed_shard_name + if "metadata" in index and "total_size" in index["metadata"]: + index["metadata"]["total_size"] += lm_head_value.numel() * lm_head_value.element_size() + index_path.write_text(json.dumps(index, indent=1), encoding="utf-8") + + (out_dir / "edit_meta.json").write_text( + json.dumps(meta, ensure_ascii=False, indent=1), encoding="utf-8" + ) + return {"out_dir": str(out_dir), **meta} + + +def _disk_mapper(mem_embed_key, disk_keys): + """Memory keys (instantiated model's layout) → disk checkpoint keys. + + transformers renames on load: e.g. Qwen3.5 is instantiated as ForCausalLM + ("model.layers.*" in memory) but saved in ConditionalGeneration format + ("model.language_model.layers.*"). Without this mapping, a "full" export + would copy the source verbatim without transforming anything. We anchor the + disk prefix on the embed, whose suffix is unique in the checkpoint.""" + if mem_embed_key in disk_keys: + return lambda key: key + suffix = "." + ".".join(mem_embed_key.rsplit(".", 2)[-2:]) # ".embed_tokens.weight" + candidates = [k for k in disk_keys if k.endswith(suffix)] + if len(candidates) != 1: + raise ValueError( + f"checkpoint prefix undecidable: {mem_embed_key} absent from the source " + f"and {len(candidates)} key(s) end with {suffix}" + ) + mem_prefix = mem_embed_key.removesuffix(suffix) + disk_prefix = candidates[0].removesuffix(suffix) + + def to_disk(key): + if key == mem_prefix or key.startswith(mem_prefix + "."): + return disk_prefix + key[len(mem_prefix):] + return key + + return to_disk + + +def export_rebase(rules, jl, model_meta, *, fmt, name, source_dir=None, scale=1.0, exact=False): + """Pure-weight export by change of basis of the reads (cf. core/rebase). + + ``readthrough`` (exact=False): the downstream read matrices + lm_head. + ``exact``: adds the counter-transform of the downstream writes. + Formats: ``full`` (checkpoint), ``layers`` (safetensors of the modified + matrices) and ``lora`` (PEFT adapter = the exact low-rank diff between the + baked weights and the originals; the lm_head delta is applied at forward + time, so tied embeddings need no untying). The bake is done streaming, one + float32 CPU matrix at a time. Tied-embeddings model (full/layers): the embed + stays INTACT, it's lm_head (untied) that receives the final read transform.""" + method = "rebase-exact" if exact else "rebase-readthrough" + if fmt not in ("full", "layers", "lora"): + raise ValueError(f"unknown format for {method}: {fmt}") + transforms, info = rebase.build_plan(rules, jl, scale, exact=exact) + lm_head_key = info["lm_head_key"] + + delta_max = 0.0 + applied = set() + + def bake(key, tensor): + nonlocal delta_max + W = tensor.detach().to("cpu", torch.float32) + W_new, _B, _A = rebase.apply_transform(transforms[key], W) + delta_max = max(delta_max, (W_new - W).abs().max().item()) + applied.add(key) + return W_new + + out_dir = EDITS_DIR / name + out_dir.mkdir(parents=True, exist_ok=True) + dtype = torch.bfloat16 if model_meta.get("dtype") == "bf16" else torch.float16 + warnings = [] + if exact and info["regularized_layers"]: + warnings.append( + "regularized inverse (full zap ⇒ singular transform) on layers " + f"{info['regularized_layers']} — the effect there equals readthrough; " + "prefer readthrough mode for full removals" + ) + if fmt == "lora" and info["tied"]: + warnings.append( + "tied embeddings: use the adapter at runtime (PEFT applies the " + "lm_head delta at forward time, leaving the shared embed intact); " + "merging it into the base weights (merge_and_unload) would write " + "that delta into the embed too — export a full checkpoint if you " + "need merged weights" + ) + + def source_weight(state, key): + source = state.get(key) + if source is None and key == lm_head_key and info["tied"]: + source = state[info["embed_key"]] # tied: the un-embedding IS the embed + if source is None: + raise ValueError( + f"parameter {key} not found in the loaded model — " + "unexpected layout, export cancelled" + ) + return source + + if fmt == "layers": + state = jl._hf_model.state_dict() + to_disk = lambda key: key # noqa: E731 — refined if the source is available + if source_dir is not None and Path(source_dir).is_dir(): + disk_keys = set() + for shard in Path(source_dir).glob("*.safetensors"): + with safe_open(str(shard), framework="pt") as f: + disk_keys.update(f.keys()) + if disk_keys: + to_disk = _disk_mapper(info["embed_key"], disk_keys) + tensors = {} + for key in transforms: + tensors[to_disk(key)] = bake(key, source_weight(state, key)).to(dtype) + save_file(tensors, str(out_dir / "modified_layers.safetensors")) + + elif fmt == "lora": + # The rebase delta is low-rank by construction (delta = B·A exactly, cf. + # rebase.apply_transform): the adapter is the exact diff between the + # baked weights and the originals, not an approximation. lm_head: PEFT + # adds the delta at forward time without writing to the (possibly tied) + # weight, so the un-embedding is effectively untied while the embed + # stays intact. Module names follow the model as instantiated by + # AutoModelForCausalLM (the same loading path as the UI). + state = jl._hf_model.state_dict() + factors = {} + max_rank = 0 + for key in transforms: + W = source_weight(state, key).detach().to("cpu", torch.float32) + W_new, B, A = rebase.apply_transform(transforms[key], W) + delta_max = max(delta_max, (W_new - W).abs().max().item()) + applied.add(key) + factors[key] = (B, A) + max_rank = max(max_rank, B.shape[1]) + tensors = {} + module_paths = [] + for key, (B, A) in factors.items(): + base = key.removesuffix(".weight") + module_paths.append(base) + if B.shape[1] < max_rank: # pad so a single config `r` fits every module + pad = max_rank - B.shape[1] + B = torch.cat([B, torch.zeros(B.shape[0], pad)], dim=1) + A = torch.cat([A, torch.zeros(pad, A.shape[1])], dim=0) + tensors[f"base_model.model.{base}.lora_A.weight"] = A.contiguous() + tensors[f"base_model.model.{base}.lora_B.weight"] = B.contiguous() + save_file(tensors, str(out_dir / "adapter_model.safetensors")) + # target_modules as an anchored regex over the modules actually edited: + # a plain suffix list would wrap the same projections in EVERY layer and + # leave benign but alarming "missing adapter keys" warnings at load time + target_regex = "(.*\\.)?(" + "|".join(re.escape(p) for p in sorted(module_paths)) + ")" + adapter_config = { + "peft_type": "LORA", + "base_model_name_or_path": model_meta.get("model_id"), + "r": max_rank, + "lora_alpha": max_rank, # scaling alpha/r = 1: B·A is the raw delta + "lora_dropout": 0.0, + "target_modules": target_regex, + "bias": "none", + "fan_in_fan_out": False, + "task_type": "CAUSAL_LM", + } + (out_dir / "adapter_config.json").write_text( + json.dumps(adapter_config, indent=1), encoding="utf-8" + ) + + elif fmt == "full": + if source_dir is None or not Path(source_dir).is_dir(): + raise ValueError("full checkpoint: model source folder not found") + source_dir = Path(source_dir) + shards = sorted(source_dir.glob("*.safetensors")) + if not shards: + raise ValueError("full checkpoint: no safetensors in the source") + + disk_keys = set() + seen = set() + for shard in shards: + ino = shard.stat().st_ino + if ino in seen: + continue + seen.add(ino) + with safe_open(str(shard), framework="pt") as f: + disk_keys.update(f.keys()) + to_disk = _disk_mapper(info["embed_key"], disk_keys) + transforms = {to_disk(k): fn for k, fn in transforms.items()} + lm_head_key = to_disk(lm_head_key) + embed_key = to_disk(info["embed_key"]) + + lm_head_written = False + embed_shard_name = None + seen = set() + for shard in shards: + ino = shard.stat().st_ino + if ino in seen: + continue + seen.add(ino) + out = {} + with safe_open(str(shard), framework="pt") as f: + for key in f.keys(): + original = f.get_tensor(key) + if key in transforms: + out[key] = bake(key, original).to(original.dtype) + if key == lm_head_key: + lm_head_written = True + else: + out[key] = original + if key == embed_key: + embed_shard_name = shard.name + save_file(out, str(out_dir / shard.name)) + del out + + # untie: the transformed un-embedding becomes a separate lm_head, baked + # from the original embed (which stays intact) + if info["tied"] and not lm_head_written: + if embed_shard_name is None: + raise ValueError("cannot untie: embed not found in the source") + target_shard = out_dir / embed_shard_name + with safe_open(str(target_shard), framework="pt") as f: + merged = {k: f.get_tensor(k) for k in f.keys()} + embed_original = merged[embed_key] + lm_head_value = bake(lm_head_key, embed_original).to(embed_original.dtype) + merged[lm_head_key] = lm_head_value + save_file(merged, str(out_dir / embed_shard_name)) + del merged + + missing = set(transforms) - applied + if missing: + sample = sorted(missing)[:3] + raise ValueError( + f"{len(missing)} parameter(s) to transform absent from the source " + f"checkpoint (e.g. {sample}) — unexpected key names, export cancelled " + "(the written checkpoint would be partially original)" + ) + + cfg_path = source_dir / "config.json" + if cfg_path.exists(): + cfg = json.loads(cfg_path.read_text(encoding="utf-8")) + if info["tied"]: + cfg["tie_word_embeddings"] = False + text_cfg = cfg.get("text_config") + if isinstance(text_cfg, dict) and "tie_word_embeddings" in text_cfg: + text_cfg["tie_word_embeddings"] = False + (out_dir / "config.json").write_text( + json.dumps(cfg, ensure_ascii=False, indent=1), encoding="utf-8" + ) + for pattern in ("*.json", "*.txt", "*.model", "*.tiktoken", "*.jinja"): + for extra in source_dir.glob(pattern): + if extra.name == "config.json": + continue + shutil.copy2(extra, out_dir / extra.name) + index_path = out_dir / "model.safetensors.index.json" + if info["tied"] and not lm_head_written and index_path.exists(): + index = json.loads(index_path.read_text(encoding="utf-8")) + wm = index.setdefault("weight_map", {}) + wm[lm_head_key] = embed_shard_name + if "metadata" in index and "total_size" in index["metadata"]: + index["metadata"]["total_size"] += ( + lm_head_value.numel() * lm_head_value.element_size() + ) + index_path.write_text(json.dumps(index, indent=1), encoding="utf-8") + + if delta_max < 1e-8: + shutil.rmtree(out_dir, ignore_errors=True) + raise ValueError( + "the bake changes no weight (null directions?) — the export would be " + "identical to the original model, folder deleted" + ) + + summary = [ + {k: r[k] for k in ("token_id", "token", "mode", "factor", "replacement_id", "replacement", "layers")} + for r in rules if r["layers"] + ] + meta = { + "name": name, + "format": fmt, + "method": method, + "model_id": model_meta.get("model_id"), + "model_revision": model_meta.get("revision"), + "dtype": model_meta.get("dtype"), + "global_scale": scale, + # lora: no physical untying — the lm_head delta lives in the adapter + "untied_lm_head": info["tied"] and fmt != "lora", + "rules": summary, + "layers_span": info["layers_span"], + "rank": info["rank_final"], + "modified_params_count": len(transforms), + "delta_max": delta_max, + "min_gamma": info["min_gamma"], + "warnings": warnings, + "note": ( + "change of basis of the reads: every matrix that READS the residual " + "downstream of the hooked layers (q/k/v, in_proj*, gate/up + lm_head) sees " + "the residual transformed by the same J-space directions as the live preview" + + (" ; downstream writes counter-transformed (exact mode)" if exact else "") + + ". Pure weights: a standard safetensors checkpoint." + ), + "created_at": _now(), + } + (out_dir / "edit_meta.json").write_text( + json.dumps(meta, ensure_ascii=False, indent=1), encoding="utf-8" + ) + return {"out_dir": str(out_dir), **meta} diff --git a/core/fitting.py b/core/fitting.py new file mode 100644 index 0000000..d09615e --- /dev/null +++ b/core/fitting.py @@ -0,0 +1,374 @@ +import hashlib +import json +import subprocess +import sys +import threading +import time +from datetime import datetime, timezone + +from jlens.lens import JacobianLens + +import config +from core.gpus import gpu_stats + +FITS_DIR = config.DATA_DIR / "fits" +WORKER = config.ROOT / "scripts" / "fit_worker.py" + +# Fit corpora. "mixed" = both, equal parts (rounded to the nearest prompt). +DATASET_WIKITEXT = "Salesforce/wikitext-103-raw-v1" +DATASET_HARMLESS = "heretic-org/Semantic-Harmless" +FIT_DATASETS = (DATASET_WIKITEXT, DATASET_HARMLESS, "mixed") + + +def _load_corpus(dataset, n, skip=0): + """``n`` prompts from ``dataset``, skipping the first ``skip`` picks + (continue-from: the new prompts must not overlap the base lens's). + + wikitext keeps the historical behavior (first records ≥600 chars, streamed). + Semantic-Harmless is a small instruct set (~416 one-line prompts): we draw a + seeded random sample — sample(skip+n) then drop the head, so a continued fit + extends the same sequence — and PACK the picks into ~350-char sequences + (median prompt ≈ 10 tokens, and jlens skips the first 16 positions of every + sequence as attention sinks: unpacked, almost every pick would be dropped as + "too short"). ``n``/``skip`` count SOURCE prompts, not packs. "mixed" takes + equal parts of both (n odd: the extra prompt goes to wikitext) and shuffles + the union so multi-GPU slices stay mixed.""" + if dataset == "mixed": + import random + + n_wiki = (n + 1) // 2 + s_wiki = (skip + 1) // 2 + prompts = _load_corpus(DATASET_WIKITEXT, n_wiki, s_wiki) + prompts += _load_corpus(DATASET_HARMLESS, n - n_wiki, skip // 2) + random.Random(1729).shuffle(prompts) + return prompts + if dataset == DATASET_HARMLESS: + import random + + from datasets import load_dataset + + texts = [r["text"] for r in load_dataset(DATASET_HARMLESS, split="train")] + if skip + n > len(texts): + raise ValueError( + f"{DATASET_HARMLESS} has {len(texts)} prompts, " + f"{skip + n} requested (continue included) — lower n_prompts" + ) + picks = random.Random(1729).sample(texts, skip + n)[skip:] + packs, cur = [], "" + for text in picks: + cur = f"{cur}\n\n{text}" if cur else text + if len(cur) >= 350: + packs.append(cur) + cur = "" + if cur: + # a lone sub-16-token tail would be skipped by jlens anyway: fold it + # into the previous pack instead of losing it + if packs and len(cur) < 120: + packs[-1] += "\n\n" + cur + else: + packs.append(cur) + return packs + from jlens.examples import load_wikitext_prompts + + # load skip + n then keep the tail: the new prompts don't overlap + # those of the base lens + return load_wikitext_prompts(skip + n)[skip:] + +def _default_dim_batch(device): + """Default dim_batch scaled to the device's VRAM. + Measured on a 4B bf16 fit: 8 fits in 16 GB, 4 in 12 GB.""" + try: + total = gpu_stats()[int(device.split(":")[1])]["vram_total"] + return 8 if total >= 15 * 2**30 else 4 + except Exception: + return 4 + + +def _now(): + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +class FitManager: + def __init__(self): + self._lock = threading.Lock() + self._procs = [] + self.state = {"state": "idle"} + self.on_progress = None + + def _emit(self): + if self.on_progress: + self.on_progress(dict(self.state)) + + def start(self, *, model_id, source, n_prompts=100, dtype="bf16", quant=None, + devices=("cuda:0",), name=None, dim_batch=None, + max_seq_len=128, source_layers=None, model_revision=None, + continue_from=None, dataset=DATASET_WIKITEXT): + with self._lock: + if self.state.get("state") == "running": + raise ValueError("a fitting is already in progress") + if not devices: + raise ValueError("at least one device required") + if dataset not in FIT_DATASETS: + raise ValueError(f"unknown dataset: {dataset} (choices: {', '.join(FIT_DATASETS)})") + skip_prompts = 0 + base_lens = None + if continue_from: + base_lens = JacobianLens.load(continue_from) + # new prompts: skip those already seen by the base lens + skip_prompts = base_lens.n_prompts + if source_layers is None: + source_layers = list(base_lens.source_layers) + if name is None: + base = model_id.split("/")[-1] + if dataset == "mixed": + base += "_mixed" + elif dataset == DATASET_HARMLESS: + base += "_harmless" + total = n_prompts + skip_prompts + name = f"{base}_n{total}" if continue_from else f"{base}_n{n_prompts}" + params = { + "model_id": model_id, + "source": source, + "model_revision": model_revision, + "dtype": dtype, + "quant": quant, + "n_prompts": n_prompts, + "dataset": dataset, + "devices": list(devices), + "dim_batch": dim_batch, + "max_seq_len": max_seq_len, + "source_layers": source_layers, + "continue_from": continue_from, + "skip_prompts": skip_prompts, + } + self.state = { + "state": "running", + "name": name, + "phase": "corpus", + "total": n_prompts, + "done": 0, + "workers": [], + "eta_seconds": None, + "started_at": _now(), + "params": params, + "error": None, + } + self._procs = [] + threading.Thread(target=self._run, args=(name, params), daemon=True).start() + return dict(self.state) + + def stop(self): + with self._lock: + for proc in self._procs: + if proc.poll() is None: + proc.terminate() + if self.state.get("state") == "running": + self.state["state"] = "stopping" + self._emit() + return dict(self.state) + + def _run(self, name, params): + try: + job_dir = FITS_DIR / name + job_dir.mkdir(parents=True, exist_ok=True) + corpus_path = job_dir / "corpus.json" + if corpus_path.exists(): + prompts = json.loads(corpus_path.read_text(encoding="utf-8")) + else: + prompts = _load_corpus( + params.get("dataset", DATASET_WIKITEXT), + params["n_prompts"], + params.get("skip_prompts", 0), + ) + corpus_path.write_text( + json.dumps(prompts, ensure_ascii=False), encoding="utf-8" + ) + devices = params["devices"] + n = len(prompts) + if len(devices) == 2: + cut = int(n * 0.65) + slices = [prompts[:cut], prompts[cut:]] + else: + slices = [prompts] + + self.state.update(phase="fitting", total=n) + workers = [] + started = time.perf_counter() + for i, (device, chunk) in enumerate(zip(devices, slices)): + slice_path = job_dir / f"slice{i}.json" + if not slice_path.exists(): + slice_path.write_text(json.dumps(chunk, ensure_ascii=False), encoding="utf-8") + dim_batch = params["dim_batch"] or _default_dim_batch(device) + cmd = [ + sys.executable, "-X", "utf8", str(WORKER), + "--model", params["source"], + "--device", device, + "--dtype", params["dtype"], + "--prompts", str(slice_path), + "--checkpoint", str(job_dir / f"ckpt{i}.pt"), + "--out", str(job_dir / f"lens{i}.pt"), + "--dim-batch", str(dim_batch), + "--max-seq-len", str(params["max_seq_len"]), + ] + if params["quant"]: + cmd += ["--quant", params["quant"]] + if params["source_layers"]: + cmd += ["--source-layers", json.dumps(params["source_layers"])] + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + cwd=str(config.ROOT), + ) + self._procs.append(proc) + worker_state = { + "device": device, + "done": 0, + "total": len(chunk), + "dim_batch": dim_batch, + "state": "loading", + "elapsed": 0.0, + # [done, elapsed] of the last 10 updates: the ETA follows the + # RECENT pace (throughput can degrade mid-fit, e.g. VRAM + # saturated — a global average would then freeze the ETA) + "hist": [], + } + workers.append(worker_state) + threading.Thread( + target=self._read_worker, args=(proc, worker_state, started), daemon=True + ).start() + self.state["workers"] = workers + self._emit() + + stderr_tails = [""] * len(self._procs) + + def drain_err(index, proc): + data = proc.stderr.read() + stderr_tails[index] = (data or "")[-2000:] + + drainers = [ + threading.Thread(target=drain_err, args=(i, p), daemon=True) + for i, p in enumerate(self._procs) + ] + for t in drainers: + t.start() + for proc in self._procs: + proc.wait() + for t in drainers: + t.join() + failed = [i for i, p in enumerate(self._procs) if p.returncode != 0] + if self.state.get("state") == "stopping": + self.state.update(state="stopped") + self._emit() + return + if failed: + detail = " | ".join(stderr_tails[i].strip().splitlines()[-1] if stderr_tails[i].strip() else "?" for i in failed) + raise RuntimeError(f"worker(s) {failed} failed: {detail}") + + self.state.update(phase="merge") + self._emit() + partials = [ + JacobianLens.load(str(job_dir / f"lens{i}.pt")) + for i in range(len(slices)) + ] + merged = JacobianLens.merge(partials) if len(partials) > 1 else partials[0] + if params.get("continue_from"): + base_lens = JacobianLens.load(params["continue_from"]) + if base_lens.source_layers != merged.source_layers: + raise RuntimeError( + "cannot continue: the source layers differ from the base lens " + f"({base_lens.source_layers[0]}..{base_lens.source_layers[-1]} vs " + f"{merged.source_layers[0]}..{merged.source_layers[-1]})" + ) + # weighted average by n_prompts = equivalent to a fit over the union + merged = JacobianLens.merge([base_lens, merged]) + out_dir = config.LENSES_DIR / name + out_dir.mkdir(parents=True, exist_ok=True) + lens_path = out_dir / "lens.pt" + merged.save(str(lens_path)) + meta = { + "name": name, + "model_id": params["model_id"], + "model_revision": params["model_revision"], + "model_source": params["source"], + "d_model": merged.d_model, + "source_layers": [merged.source_layers[0], merged.source_layers[-1]], + "dtype": params["dtype"], + "quant": params["quant"], + "n_prompts": merged.n_prompts, + "corpus": ( + f"mixed: {DATASET_WIKITEXT} + {DATASET_HARMLESS} (equal parts)" + if params.get("dataset") == "mixed" + else params.get("dataset", DATASET_WIKITEXT) + ), + "max_seq_len": params["max_seq_len"], + "devices": params["devices"], + "continued_from": params.get("continue_from"), + "config_hash": hashlib.sha1( + json.dumps(params, sort_keys=True).encode() + ).hexdigest()[:16], + "created_at": _now(), + "fit_seconds": round(time.perf_counter() - started, 1), + } + (out_dir / "meta.json").write_text( + json.dumps(meta, ensure_ascii=False, indent=1), encoding="utf-8" + ) + self.state.update( + state="done", + phase="done", + lens_path=str(lens_path), + meta=meta, + eta_seconds=0, + ) + self._emit() + except Exception as exc: + self.state.update(state="error", error=str(exc)) + self._emit() + + def _read_worker(self, proc, worker_state, started): + for line in proc.stdout: + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if event["event"] == "loading": + worker_state["state"] = "loading" + elif event["event"] in ("progress", "resume"): + worker_state["state"] = "fitting" + worker_state["done"] = event["done"] + worker_state["total"] = event["total"] + worker_state["elapsed"] = round(time.perf_counter() - started, 1) + hist = worker_state.setdefault("hist", []) + hist.append([worker_state["done"], worker_state["elapsed"]]) + del hist[:-10] + elif event["event"] == "done": + worker_state["state"] = "done" + worker_state["done"] = worker_state["total"] + self._refresh_totals(started) + self._emit() + + def _refresh_totals(self, started): + workers = self.state.get("workers", []) + self.state["done"] = sum(w["done"] for w in workers) + etas = [] + for w in workers: + if not (w["done"] > 0 and w["elapsed"] > 0 and w["done"] < w["total"]): + continue + hist = w.get("hist") or [] + if len(hist) >= 2 and hist[-1][1] > hist[0][1] and hist[-1][0] > hist[0][0]: + # pace over the last 10 updates (sliding window) + rate = (hist[-1][0] - hist[0][0]) / (hist[-1][1] - hist[0][1]) + else: + rate = w["done"] / w["elapsed"] + etas.append((w["total"] - w["done"]) / rate) + # multi-GPU: the fit ETA = the slowest worker + self.state["eta_seconds"] = round(max(etas), 0) if etas else None + self.state["vram"] = [ + {"index": g["index"], "used_gb": round(g["vram_used"] / 2**30, 1)} + for g in gpu_stats() + ] diff --git a/core/gpus.py b/core/gpus.py new file mode 100644 index 0000000..46b0910 --- /dev/null +++ b/core/gpus.py @@ -0,0 +1,32 @@ +import pynvml + +_initialized = False + + +def _ensure_init(): + global _initialized + if not _initialized: + pynvml.nvmlInit() + _initialized = True + + +def gpu_stats(): + _ensure_init() + stats = [] + for index in range(pynvml.nvmlDeviceGetCount()): + handle = pynvml.nvmlDeviceGetHandleByIndex(index) + memory = pynvml.nvmlDeviceGetMemoryInfo(handle) + util = pynvml.nvmlDeviceGetUtilizationRates(handle) + name = pynvml.nvmlDeviceGetName(handle) + if isinstance(name, bytes): + name = name.decode() + stats.append( + { + "index": index, + "name": name, + "vram_total": memory.total, + "vram_used": memory.used, + "util_pct": util.gpu, + } + ) + return stats diff --git a/core/lens_manager.py b/core/lens_manager.py new file mode 100644 index 0000000..b16f00d --- /dev/null +++ b/core/lens_manager.py @@ -0,0 +1,353 @@ +import hashlib +import itertools +import json +import threading +from collections import OrderedDict + +import torch +from jlens.lens import JacobianLens + +import config + +GEN_STORE_MAX = 4 + +MASKS_DIR = config.DATA_DIR / "masks" + +# Last range of layers captured per lens: {lens key: [layers]}. +# Avoids re-entering the range on every reload (user request). +LENS_PREFS_PATH = config.DATA_DIR / "lens_prefs.json" + + +def _load_lens_prefs(): + try: + return json.loads(LENS_PREFS_PATH.read_text(encoding="utf-8")) + except Exception: + return {} + + +def _save_lens_pref(key, layers): + prefs = _load_lens_prefs() + prefs[key] = [int(l) for l in layers] + LENS_PREFS_PATH.parent.mkdir(parents=True, exist_ok=True) + LENS_PREFS_PATH.write_text(json.dumps(prefs, indent=1), encoding="utf-8") + + +def _lens_pref_key(source): + if source.get("path"): + return f"path:{source['path']}" + return f"hub:{source['repo_id']}:{source['filename']}@{source.get('revision') or 'main'}" + + +class ActivationCatcher: + def __init__(self, layers, indices): + self.acts = {} + self._handles = [ + layers[i].register_forward_hook(self._make(i)) for i in indices + ] + + def _make(self, index): + def hook(module, inputs, output): + tensor = output[0] if isinstance(output, tuple) else output + self.acts[index] = tensor.detach() + + return hook + + def close(self): + for handle in self._handles: + handle.remove() + self._handles = [] + + +def _vocab_fingerprint(tokenizer): + payload = json.dumps(sorted(tokenizer.get_vocab().items()), ensure_ascii=False) + return hashlib.sha1(payload.encode()).hexdigest()[:16] + + +def _wordlike(raw): + s = raw.strip() + if len(s) < 1 or "<|" in s or (s.startswith("<") and s.endswith(">")): + return False + if s.isascii(): + return ( + raw.startswith(" ") + and len(s) > 2 + and s[0].isalpha() + and all(c.isalpha() or c in "'-" for c in s) + ) + return all(ch.isalnum() for ch in s) + + +def display_token_mask(tokenizer, vocab_size): + MASKS_DIR.mkdir(parents=True, exist_ok=True) + path = MASKS_DIR / f"{_vocab_fingerprint(tokenizer)}_{vocab_size}.pt" + if path.exists(): + return torch.load(path, weights_only=True) + mask = torch.zeros(vocab_size, dtype=torch.bool) + n_decodable = min(vocab_size, len(tokenizer)) + decoded = tokenizer.batch_decode( + [[tid] for tid in range(n_decodable)], clean_up_tokenization_spaces=False + ) + for tid, raw in enumerate(decoded): + mask[tid] = _wordlike(raw) + torch.save(mask, path) + return mask + + +class LensManager: + def __init__(self): + self._lock = threading.Lock() + self.lens = None + self.meta = None + self.layers = [] + self.k = 8 + self.mask = None + self._J = None + self._tok_strs = {} + self.gen_store = OrderedDict() + self._gen_counter = itertools.count(1) + self._pref_key = None + + def load(self, model_manager, *, repo_id=None, filename="lens.pt", revision=None, + path=None, layers=None, k=8): + with self._lock: + if model_manager.hf_model is None: + raise ValueError("load a model first") + if path: + lens = JacobianLens.from_pretrained(path) + source = {"path": path, "repo_id": None, "filename": None, "revision": None} + else: + lens = JacobianLens.from_pretrained( + repo_id, filename=filename, revision=revision + ) + source = {"path": None, "repo_id": repo_id, "filename": filename, "revision": revision} + + model_meta = model_manager.meta + if lens.d_model != model_meta["d_model"]: + raise ValueError( + f"lens d_model ({lens.d_model}) != model ({model_meta['d_model']})" + ) + n_layers = model_meta["n_layers"] + fitted = lens.source_layers + if fitted[-1] >= n_layers: + raise ValueError( + f"the lens covers layer {fitted[-1]}, outside a model with {n_layers} layers" + ) + pref_key = _lens_pref_key(source) + if layers: + tapped = sorted(set(layers) & set(fitted)) + if not tapped: + raise ValueError( + f"no requested layer is fitted (fitted: {fitted[0]}..{fitted[-1]})" + ) + else: + # last range used for THIS lens, otherwise all the fitted layers + # (= the selection made when the fit was created; max range for a + # downloaded lens) + saved = _load_lens_prefs().get(pref_key) + tapped = (sorted(set(saved) & set(fitted)) if saved else None) or list(fitted) + _save_lens_pref(pref_key, tapped) + self._pref_key = pref_key + + device = model_manager.jl.input_device + stacked = torch.stack([lens.jacobians[l].float() for l in tapped]).to(device) + tokenizer = model_manager.tokenizer + vocab_size = model_manager.hf_model.get_output_embeddings().weight.shape[0] + mask = display_token_mask(tokenizer, vocab_size).to(device) + + warnings = [] + if model_meta.get("quant"): + warnings.append( + f"model loaded in {model_meta['quant']}: the lens was probably " + "fitted on the unquantized weights, the readouts may drift" + ) + if model_meta["model_id"].startswith("local/"): + warnings.append( + "local model: cannot verify that the lens matches these exact weights" + ) + + self.lens = lens + self.layers = tapped + self.k = int(k) + self.mask = mask + self._J = stacked + self._tok_strs = {} + self.meta = { + **source, + "model_id": model_meta["model_id"], + "model_revision": model_meta.get("revision"), + "d_model": lens.d_model, + "n_prompts": lens.n_prompts, + "fitted_layers": [int(fitted[0]), int(fitted[-1])], + "fitted_layers_all": [int(l) for l in fitted], + "tapped_layers": [int(l) for l in tapped], + "k": self.k, + "warnings": warnings, + } + return self.meta + + def set_layers(self, model_manager, layers, k=None): + with self._lock: + if self.lens is None: + raise ValueError("no lens loaded") + fitted = self.lens.source_layers + tapped = sorted(set(layers) & set(fitted)) + if not tapped: + raise ValueError( + f"no requested layer is fitted (fitted: {fitted[0]}..{fitted[-1]})" + ) + device = model_manager.jl.input_device + self.layers = tapped + self._J = torch.stack( + [self.lens.jacobians[l].float() for l in tapped] + ).to(device) + if k: + self.k = int(k) + self.meta = dict(self.meta, tapped_layers=[int(l) for l in tapped], k=self.k) + if getattr(self, "_pref_key", None): + _save_lens_pref(self._pref_key, tapped) + return self.meta + + def unload(self): + with self._lock: + self.lens = None + self.meta = None + self.layers = [] + self.mask = None + self._J = None + self._tok_strs = {} + self.gen_store.clear() + torch.cuda.empty_cache() + return {"unloaded": True} + + def start_gen(self): + gen_id = next(self._gen_counter) + self.gen_store[gen_id] = { + "layers": list(self.layers), + "residuals": {l: [] for l in self.layers}, + "positions": [], + "token_ids": [], + "phases": [], + } + while len(self.gen_store) > GEN_STORE_MAX: + self.gen_store.popitem(last=False) + return gen_id + + @torch.no_grad() + def pin_ranks(self, gen_id, token_ids, jl, chunk=32): + store = self.gen_store.get(gen_id) + if store is None: + raise ValueError("unknown generation (residual store expired)") + layers = store["layers"] + device = self._J.device + tids = torch.tensor(token_ids, dtype=torch.long, device=device) + pins = { + int(t): {"ranks": [], "p": []} for t in token_ids + } + for layer in layers: + residuals = torch.cat(store["residuals"][layer]).to(device).float() + J = self.lens.jacobians[layer].float().to(device) + layer_ranks = {int(t): [] for t in token_ids} + layer_p = {int(t): [] for t in token_ids} + for start in range(0, residuals.shape[0], chunk): + h = residuals[start : start + chunk] + logits = jl.unembed(h @ J.T).float() + probs = torch.softmax(logits, -1) + sel = logits[:, tids] + rank = (logits.unsqueeze(-1) > sel.unsqueeze(1)).sum(1) + p_sel = probs[:, tids] + rank_l, p_l = rank.tolist(), p_sel.tolist() + for ti, t in enumerate(token_ids): + layer_ranks[int(t)].extend(row[ti] for row in rank_l) + layer_p[int(t)].extend(round(row[ti], 6) for row in p_l) + for t in token_ids: + pins[int(t)]["ranks"].append(layer_ranks[int(t)]) + pins[int(t)]["p"].append(layer_p[int(t)]) + return { + "gen_id": gen_id, + "layers": [int(l) for l in layers], + "positions": store["positions"], + "phases": store["phases"], + "tokens": self._strs(jl.tokenizer, store["token_ids"]), + "pins": pins, + } + + def _strs(self, tokenizer, ids): + out = [] + for tid in ids: + s = self._tok_strs.get(tid) + if s is None: + s = tokenizer.decode([tid], clean_up_tokenization_spaces=False) + self._tok_strs[tid] = s + out.append(s) + return out + + @torch.no_grad() + def compute_frames(self, acts, positions, phase, jl, token_ids, gen_id=None, + abs_positions=None, chunk=None): + tokenizer = jl.tokenizer + if chunk is None: + chunk = max(1, 96 // max(1, len(self.layers))) + if abs_positions is None: + abs_positions = positions + frames = [ + { + "type": "frame", + "phase": phase, + "pos": int(pos), + "token_id": int(tid), + "tok": self._strs(tokenizer, [tid])[0], + "gen": gen_id, + "layers": {}, + } + for pos, tid in zip(abs_positions, token_ids) + ] + store = self.gen_store.get(gen_id) if gen_id is not None else None + if store is not None: + store["positions"].extend(int(p) for p in abs_positions) + store["token_ids"].extend(int(t) for t in token_ids) + store["phases"].extend(phase for _ in abs_positions) + device = self._J.device + for start in range(0, len(positions), chunk): + batch_positions = positions[start : start + chunk] + gathered = [] + for layer in self.layers: + full = acts[layer][0] + gathered.append(full[list(batch_positions)].float().to(device)) + h = torch.stack(gathered) + if store is not None: + for li, layer in enumerate(self.layers): + store["residuals"][layer].append(h[li].half().cpu()) + # L2 norm of the residual per layer/position ("Activations" view) + h_norms = h.norm(dim=-1).tolist() + transported = torch.einsum("lij,lpj->lpi", self._J, h) + logits = jl.unembed(transported).float() + lse = logits.logsumexp(-1, keepdim=True) + raw_v, raw_ids = logits.topk(self.k) + raw_p = (raw_v - lse).exp() + m_v, m_ids = logits.masked_fill(~self.mask, float("-inf")).topk(self.k) + m_p = (m_v - lse).exp() + sel = logits.gather(-1, m_ids) + # rank of each top-k token in the full distribution. We loop over k + # rather than materializing a boolean [L, P, k, V] (≈760 MB at k=32 / + # 32 layers → OOM): each iteration only touches [L, P, V]. + m_rank = torch.empty_like(m_ids) + for ki in range(m_ids.shape[-1]): + m_rank[..., ki] = (logits > sel[..., ki : ki + 1]).sum(-1) + del logits + raw_ids_l, raw_p_l = raw_ids.tolist(), raw_p.tolist() + m_ids_l, m_p_l, m_rank_l = m_ids.tolist(), m_p.tolist(), m_rank.tolist() + for li, layer in enumerate(self.layers): + for pi in range(len(batch_positions)): + ids = raw_ids_l[li][pi] + mids = m_ids_l[li][pi] + frames[start + pi]["layers"][str(layer)] = { + "ids": ids, + "p": [round(v, 5) for v in raw_p_l[li][pi]], + "strs": self._strs(tokenizer, ids), + "m_ids": mids, + "m_p": [round(v, 5) for v in m_p_l[li][pi]], + "m_rank": m_rank_l[li][pi], + "m_strs": self._strs(tokenizer, mids), + "h_norm": round(h_norms[li][pi], 2), + } + return frames diff --git a/core/model_manager.py b/core/model_manager.py new file mode 100644 index 0000000..0213116 --- /dev/null +++ b/core/model_manager.py @@ -0,0 +1,761 @@ +import gc +import json +import os +import threading +import time +from pathlib import Path + +import torch +import transformers +from huggingface_hub import scan_cache_dir, try_to_load_from_cache + +import config +import jlens +from core.lens_manager import ActivationCatcher + +SKIP_LOCAL_DIRS = {"vendor", "ui", "data", "hf_cache", "lenses", "core", "api", "scripts"} + +# Many "base" models (e.g. non-Instruct Llama-3.2-1B) ship no chat_template. The +# right one is their instruct sibling's, which shares the same tokenizer: so we +# look it up on the Hub before any fallback. +INSTRUCT_SIBLING_SUFFIXES = ("-Instruct", "-instruct", "-it", "-Chat", "-chat") + +# End-of-turn markers per model family; added to the stop tokens when they appear +# in the applied template (useful when a base model is given an instruct template: +# it must stop on <|eot_id|>, <|im_end|>, , etc.) +TURN_END_MARKERS = ("<|eot_id|>", "<|im_end|>", "", "<|end|>", "<|endoftext|>") + +# Last-resort fallback when no template can be found (offline, no reachable +# sibling): a readable "User:/Assistant:" format a completion model can continue. +FALLBACK_CHAT_TEMPLATE = ( + "{% for message in messages %}" + "{% if message['role'] == 'system' %}{{ message['content'] + '\n\n' }}" + "{% elif message['role'] == 'user' %}{{ 'User: ' + message['content'] + '\n' }}" + "{% elif message['role'] == 'assistant' %}{{ 'Assistant: ' + message['content'] + '\n' }}" + "{% endif %}{% endfor %}" + "{% if add_generation_prompt %}{{ 'Assistant:' }}{% endif %}" +) + + +def _extract_template(chat_template): + """chat_template may be a string or a list [{name, template}] (multi-template).""" + if isinstance(chat_template, str): + return chat_template + if isinstance(chat_template, list): + for entry in chat_template: + if isinstance(entry, dict) and entry.get("name") == "default": + return entry.get("template") + if chat_template and isinstance(chat_template[0], dict): + return chat_template[0].get("template") + return None + + +def _read_hub_template(repo, token, revision=None): + """Read a chat_template from a Hub repo: chat_template.jinja (raw) then the + chat_template key of tokenizer_config.json / chat_template.json.""" + from huggingface_hub import hf_hub_download + + try: + path = hf_hub_download(repo, "chat_template.jinja", token=token, revision=revision) + text = Path(path).read_text(encoding="utf-8").strip() + if text: + return text + except Exception: + pass + for fname in ("tokenizer_config.json", "chat_template.json"): + try: + path = hf_hub_download(repo, fname, token=token, revision=revision) + data = json.loads(Path(path).read_text(encoding="utf-8")) + except Exception: + continue + tmpl = _extract_template(data.get("chat_template") if isinstance(data, dict) else None) + if tmpl: + return tmpl + return None + + +def fetch_chat_template(model_id, token, revision=None): + """Look up the real chat_template on the Hub: first the model's own repo, then + its instruct siblings (shared tokenizer). Returns (template, source_repo) or + (None, None). Skips local models/paths (no Hub repo).""" + if "/" not in model_id or model_id.startswith("local/") or os.path.isabs(model_id): + return None, None + candidates = [(model_id, revision)] + for suffix in INSTRUCT_SIBLING_SUFFIXES: + if not model_id.endswith(suffix): + candidates.append((model_id + suffix, None)) # sibling revision unknown + for repo, rev in candidates: + try: + tmpl = _read_hub_template(repo, token, revision=rev) + except Exception: + tmpl = None + if tmpl: + return tmpl, repo + return None, None + + +def _config_n_layers(config_path): + try: + cfg = json.loads(Path(config_path).read_text(encoding="utf-8")) + except Exception: + return None + tc = cfg.get("text_config", cfg) + return tc.get("num_hidden_layers") or cfg.get("num_hidden_layers") + + +def _config_dtype(config_path): + try: + cfg = json.loads(Path(config_path).read_text(encoding="utf-8")) + except Exception: + return None + return cfg.get("torch_dtype") or cfg.get("text_config", {}).get("torch_dtype") + + +# Model folders registered by hand (Browse): a list of absolute paths kept in +# the data dir. Registering never copies or moves anything; unregistering only +# forgets the entry, the files stay untouched. +REGISTERED_PATH = config.DATA_DIR / "registered_models.json" + + +def _read_registered(): + try: + entries = json.loads(REGISTERED_PATH.read_text(encoding="utf-8")) + return [str(e) for e in entries if isinstance(e, str)] + except Exception: + return [] + + +def _write_registered(entries): + REGISTERED_PATH.parent.mkdir(parents=True, exist_ok=True) + REGISTERED_PATH.write_text( + json.dumps(entries, ensure_ascii=False, indent=1), encoding="utf-8" + ) + + +def register_model_dir(path): + p = Path(path).expanduser().resolve() + if not _dir_is_model(p): + raise ValueError(f"not a model folder (config.json + weights required): {p}") + entries = _read_registered() + if str(p) not in entries: + entries.append(str(p)) + _write_registered(entries) + return {"registered": str(p)} + + +def unregister_model_dir(path): + wanted = str(Path(path).expanduser().resolve()) + entries = _read_registered() + kept = [e for e in entries if e != path and str(Path(e)) != wanted] + if len(kept) == len(entries): + raise ValueError(f"not a registered entry: {path}") + _write_registered(kept) + return {"unregistered": path} + + +def _registered_models(): + out = [] + for entry in _read_registered(): + path = Path(entry) + missing = not _dir_is_model(path) + stats = [] if missing else [f.stat() for f in path.glob("*.safetensors")] + unique = {(s.st_ino, s.st_size): s.st_size for s in stats} + out.append( + { + # the absolute path IS the id: resolve_source passes it through + "id": entry, + "source": "registered", + "path": entry, + "missing": missing, + "size_bytes": sum(unique.values()), + "n_layers": None if missing else _config_n_layers(path / "config.json"), + "dtype": None if missing else _config_dtype(path / "config.json"), + } + ) + return out + + +def _local_models(): + found = [] + for child in sorted(config.LOCAL_MODELS_ROOT.iterdir()): + if not child.is_dir() or child.name in SKIP_LOCAL_DIRS: + continue + if not (child / "config.json").exists(): + continue + stats = [f.stat() for f in child.glob("*.safetensors")] + if not stats: + continue + unique = {(s.st_ino, s.st_size): s.st_size for s in stats} + found.append( + { + "id": f"local/{child.name}", + "source": "local", + "path": str(child), + "size_bytes": sum(unique.values()), + "n_layers": _config_n_layers(child / "config.json"), + "dtype": _config_dtype(child / "config.json"), + } + ) + return found + + +def _cached_models(): + hub = config.HF_CACHE / "hub" + if not hub.exists(): + return [] + out = [] + for repo in scan_cache_dir(hub).repos: + if repo.repo_type != "model": + continue + config_path = None + for rev in repo.revisions: + for f in rev.files: + if f.file_name == "config.json": + config_path = f.file_path + if config_path is None: + continue + out.append( + { + "id": repo.repo_id, + "source": "hf-cache", + "size_bytes": repo.size_on_disk, + "n_layers": _config_n_layers(config_path), + "dtype": _config_dtype(config_path), + "path": str(Path(config_path).parent), + } + ) + return sorted(out, key=lambda r: r["id"]) + + +def _dir_is_model(path): + return (path / "config.json").exists() and ( + any(path.glob("*.safetensors")) or any(path.glob("*.bin")) + ) + + +def browse_dir(path=None): + """Minimal file browser: subfolders + loadable model folders. + Empty path -> list drive letters (Windows).""" + import string + + if not path: + drives = [] + for letter in string.ascii_uppercase: + root = Path(f"{letter}:/") + if root.exists(): + drives.append({"name": f"{letter}:", "path": str(root)}) + return {"path": "", "parent": None, "dirs": drives, "models": []} + + base = Path(path) + if not base.is_dir(): + raise ValueError(f"folder not found: {path}") + dirs = [] + try: + children = sorted(base.iterdir(), key=lambda p: p.name.lower()) + except PermissionError: + children = [] + for child in children: + try: + if child.is_dir(): + dirs.append({ + "name": child.name, + "path": str(child), + "is_model": _dir_is_model(child), + }) + except OSError: + continue + return { + "path": str(base), + "parent": str(base.parent) if base.parent != base else None, + "dirs": dirs, + "is_model": _dir_is_model(base), + } + + +def delete_model(model_id): + """Delete a model: local folder or HF cache repo. + Refuses anything outside the managed roots (guards against arbitrary paths).""" + import shutil + + if model_id.startswith("local/"): + name = model_id.removeprefix("local/") + if name in SKIP_LOCAL_DIRS or "/" in name or "\\" in name or ".." in name: + raise ValueError("protected folder or invalid name") + path = (config.LOCAL_MODELS_ROOT / name).resolve() + root = config.LOCAL_MODELS_ROOT.resolve() + if root not in path.parents or not (path / "config.json").exists(): + raise ValueError(f"unmanaged path: {path}") + shutil.rmtree(path) + return {"deleted": str(path), "freed_bytes": None} + + # otherwise: a Hugging Face cache repo (delete all of its revisions) + hub = config.HF_CACHE / "hub" + if not hub.exists(): + raise ValueError(f"unknown model: {model_id}") + info = scan_cache_dir(hub) + hashes, freed = [], 0 + for repo in info.repos: + if repo.repo_id == model_id and repo.repo_type == "model": + hashes = [rev.commit_hash for rev in repo.revisions] + freed = repo.size_on_disk + break + if not hashes: + raise ValueError(f"unknown model in cache: {model_id}") + info.delete_revisions(*hashes).execute() + return {"deleted": model_id, "freed_bytes": freed} + + +def convert_to_bf16(src_dir, out_dir=None): + """Rewrite an fp32 model's safetensors as bf16 into a sibling local folder. + Leaves the source untouched. Returns the new local id.""" + from safetensors import safe_open + from safetensors.torch import save_file + + src = Path(src_dir) + if not src.is_dir(): + raise ValueError(f"source not found: {src_dir}") + shards = sorted(src.glob("*.safetensors")) + if not shards: + raise ValueError("no safetensors in the source") + out = Path(out_dir) if out_dir else (config.LOCAL_MODELS_ROOT / f"{src.name}-bf16") + name = out.name + out.mkdir(parents=True, exist_ok=True) + for shard in shards: + tensors = {} + with safe_open(str(shard), framework="pt") as f: + metadata = f.metadata() + for key in f.keys(): + t = f.get_tensor(key) + if t.dtype == torch.float32: + t = t.to(torch.bfloat16) + tensors[key] = t + save_file(tensors, str(out / shard.name), metadata=metadata) + for extra in src.iterdir(): + if extra.suffix in (".json", ".txt", ".model") or extra.name.startswith("tokenizer"): + data = extra.read_bytes() + if extra.name == "config.json": + cfg = json.loads(data) + cfg["torch_dtype"] = "bfloat16" + if "text_config" in cfg and isinstance(cfg["text_config"], dict): + cfg["text_config"]["torch_dtype"] = "bfloat16" + (out / extra.name).write_text(json.dumps(cfg, indent=2), encoding="utf-8") + else: + (out / extra.name).write_bytes(data) + return {"id": f"local/{name}", "path": str(out)} + + +def _torch_allocated(): + return { + f"cuda:{i}": torch.cuda.memory_allocated(i) + for i in range(torch.cuda.device_count()) + } + + +def _torch_reserved(): + return { + f"cuda:{i}": torch.cuda.memory_reserved(i) + for i in range(torch.cuda.device_count()) + } + + +def _free_cuda(): + """Hand the caching allocator's blocks back to the driver (gc then empty_cache). + Call this on EVERY error/unload path: without it, allocations from an OOM load + or from an aborted generation's KV cache stay reserved and pile up until the + server restarts. We loop per device with a sync: pending frees must be visible + before empty_cache can hand the segments back.""" + for _ in range(2): + gc.collect() + if not torch.cuda.is_available(): + return + # cuBLAS keeps a persistent workspace (~8 MB) per device; under + # expandable_segments:True (see config.setup_env) that single live allocation + # pins the WHOLE segment (~8 GB) → empty_cache returns nothing after unload. So + # we explicitly clear the cuBLAS workspaces first. + try: + torch._C._cuda_clearCublasWorkspaces() + except Exception: + pass + for i in range(torch.cuda.device_count()): + with torch.cuda.device(i): + torch.cuda.synchronize() + torch.cuda.empty_cache() + try: + torch.cuda.ipc_collect() + except Exception: + pass + + +def _input_device(hf_model): + return hf_model.get_input_embeddings().weight.device + + +def resolve_source(model_id): + if model_id.startswith("local/"): + return str(config.LOCAL_MODELS_ROOT / model_id.removeprefix("local/")) + return model_id + + +def resolve_local_dir(model_id): + source = resolve_source(model_id) + path = Path(source) + if path.is_dir(): + return str(path) + cached = try_to_load_from_cache(source, "config.json") + if isinstance(cached, str): + return str(Path(cached).parent) + return None + + +def _resolve_revision(source): + if Path(source).exists(): + return None + cached = try_to_load_from_cache(source, "config.json") + if isinstance(cached, str): + parts = Path(cached).parts + if "snapshots" in parts: + return parts[parts.index("snapshots") + 1] + return None + + +def _sample(logits, temperature, top_p, top_k, generator=None): + if temperature <= 0: + return int(logits.argmax()) + probs = torch.softmax(logits / temperature, -1) + if top_k > 0: + kth = probs.topk(top_k).values[-1] + probs = probs.masked_fill(probs < kth, 0.0) + if 0 < top_p < 1: + sorted_probs, sorted_idx = probs.sort(descending=True) + keep = sorted_probs.cumsum(-1) - sorted_probs < top_p + sorted_probs = sorted_probs * keep + probs = torch.zeros_like(probs).scatter_(0, sorted_idx, sorted_probs) + return int(torch.multinomial(probs / probs.sum(), 1, generator=generator)) + + +class ModelManager: + def __init__(self): + self._lock = threading.Lock() + self.hf_model = None + self.tokenizer = None + self.jl = None + self.meta = None + self.busy = None + + def list_models(self): + return _local_models() + _registered_models() + _cached_models() + + def load(self, model_id, dtype, quant, device): + with self._lock: + self._unload_locked() + self.busy = "loading" + hf_model = tokenizer = None + try: + torch_dtype = torch.bfloat16 if dtype == "bf16" else torch.float16 + source = resolve_source(model_id) + kwargs = {"dtype": torch_dtype} + if quant == "int8": + kwargs["quantization_config"] = transformers.BitsAndBytesConfig( + load_in_8bit=True + ) + elif quant == "nf4": + kwargs["quantization_config"] = transformers.BitsAndBytesConfig( + load_in_4bit=True, + bnb_4bit_quant_type="nf4", + bnb_4bit_compute_dtype=torch_dtype, + bnb_4bit_use_double_quant=True, + ) + kwargs["device_map"] = "auto" if device == "auto" else {"": device} + # model already present (HF cache or local folder) → load WITHOUT network: + # otherwise from_pretrained queries the Hub and fails offline, even if cached. + offline_ok = resolve_local_dir(model_id) is not None + if offline_ok: + kwargs["local_files_only"] = True + tok_kwargs = {"local_files_only": True} if offline_ok else {} + started = time.perf_counter() + hf_model = transformers.AutoModelForCausalLM.from_pretrained(source, **kwargs) + tokenizer = transformers.AutoTokenizer.from_pretrained(source, **tok_kwargs) + # "base" models with no chat template: we fetch the real template from + # the Hub (instruct sibling with shared tokenizer), generic as a last resort + chat_template_source = None + if not getattr(tokenizer, "chat_template", None): + token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") + fetched, src = fetch_chat_template( + model_id, token, revision=_resolve_revision(source) + ) + if fetched: + tokenizer.chat_template = fetched + chat_template_source = src + else: + tokenizer.chat_template = FALLBACK_CHAT_TEMPLATE + chat_template_source = "generic" + chat_template_fallback = chat_template_source == "generic" + hf_model.eval() + text_config = hf_model.config.get_text_config() + self.hf_model = hf_model + self.tokenizer = tokenizer + self.jl = jlens.from_hf(hf_model, tokenizer) + # Read-projection support: write-norm architectures (Gemma + # style) can't take the reads change of basis — the UI falls + # back to the global abliteration for pure-weights edits. + from core import rebase + try: + for block in self.jl.layers: + rebase.check_block_supported(block) + rebase_supported = True + except ValueError: + rebase_supported = False + self.meta = { + "model_id": model_id, + "revision": _resolve_revision(source), + "dtype": dtype, + "quant": quant, + "device": device, + "n_layers": text_config.num_hidden_layers, + "d_model": text_config.hidden_size, + "rebase_supported": rebase_supported, + "chat_template_source": chat_template_source, + "chat_template_fallback": chat_template_fallback, + "load_seconds": round(time.perf_counter() - started, 1), + } + return self.meta + except Exception: + # failure (often OOM): drop any partial allocation and return the + # reserved blocks, otherwise they linger until the server restarts + self.hf_model = self.tokenizer = self.jl = self.meta = None + hf_model = None + tokenizer = None + _free_cuda() + raise + finally: + self.busy = None + + def unload(self): + with self._lock: + return self._unload_locked() + + def _unload_locked(self): + if self.hf_model is None: + return {"unloaded": False, "vram_allocated": _torch_allocated()} + before = _torch_allocated() + self.hf_model = None + self.tokenizer = None + self.jl = None + self.meta = None + _free_cuda() + return { + "unloaded": True, + "vram_allocated_before": before, + "vram_allocated_after": _torch_allocated(), + "vram_reserved_after": _torch_reserved(), + } + + @torch.no_grad() + def generate(self, messages, sampling, stop_event, emit, lens=None, ablator=None, + continue_final=False): + """``continue_final=True``: the last message is an assistant reply to + EXTEND — the template leaves its turn open instead of starting a new + one, and the model picks up where it stopped.""" + hf_model, tokenizer = self.hf_model, self.tokenizer + self.busy = "generating" + reader = None + ok = False + try: + if ablator is not None: + ablator.attach(self.jl) + is_gpt_oss = "gpt-oss" in (self.meta or {}).get("model_id", "").lower() + template_kwargs = {} + if is_gpt_oss: + # harmony format: the system slot always carries an identity — + # "You are ChatGPT, a large language model trained by OpenAI." + # unless model_identity overrides it — while a user "system" + # message is APPENDED as a developer message. We make the + # user's system prompt BE the identity (no OpenAI default, no + # duplicated developer copy); with no system prompt, a neutral + # identity replaces the default. + sys_prompts = [m["content"] for m in messages if m["role"] == "system"] + identity = (sys_prompts[0] or "").strip() if sys_prompts else "" + template_kwargs["model_identity"] = identity or "You are a helpful assistant." + if sys_prompts: + messages = [m for m in messages if m["role"] != "system"] + encoded = tokenizer.apply_chat_template( + messages, + add_generation_prompt=not continue_final, + continue_final_message=continue_final, + return_tensors="pt", + enable_thinking=False, + **template_kwargs, + ) + input_ids = encoded if isinstance(encoded, torch.Tensor) else encoded["input_ids"] + input_ids = input_ids.to(_input_device(hf_model)) + + # gpt-oss: enable_thinking does not apply to the harmony template. + # We prime the "final" channel directly to skip the CoT ("analysis" + # channel) → direct answer, no chain of thought. + # (Not when continuing: the final message is already mid-channel.) + if is_gpt_oss and not continue_final: + final_prefix = torch.tensor( + [tokenizer.encode("<|channel|>final<|message|>", add_special_tokens=False)], + device=input_ids.device, dtype=input_ids.dtype, + ) + input_ids = torch.cat([input_ids, final_prefix], dim=1) + + read_from = 0 + gen_id = None + if lens is not None and lens.lens is not None: + reader = ActivationCatcher(self.jl.layers, lens.layers) + gen_id = lens.start_gen() + if len(messages) > 1 and any(m["role"] != "system" for m in messages[:-1]): + prev = tokenizer.apply_chat_template( + messages[:-1], + add_generation_prompt=False, + return_tensors="pt", + enable_thinking=False, + **template_kwargs, + ) + prev_ids = prev if isinstance(prev, torch.Tensor) else prev["input_ids"] + read_from = min(prev_ids.shape[1], input_ids.shape[1] - 1) + + temperature = float(sampling.get("temperature", config.DEFAULT_SAMPLING["temperature"])) + top_p = float(sampling.get("top_p", config.DEFAULT_SAMPLING["top_p"])) + top_k = int(sampling.get("top_k", config.DEFAULT_SAMPLING["top_k"])) + max_tokens = int(sampling.get("max_tokens", config.DEFAULT_SAMPLING["max_tokens"])) + seed = int(sampling.get("seed", config.DEFAULT_SAMPLING["seed"])) + # base model with a generic template: the model has no notion of dialogue + # turns, so we cut as soon as it reopens one (User: or a new Assistant:) + stop_seqs = ( + ["\nUser:", "\nAssistant:"] + if (self.meta or {}).get("chat_template_fallback") + else [] + ) + + out = hf_model(input_ids=input_ids, use_cache=True) + cache = out.past_key_values + logits = out.logits[:, -1] + eos = hf_model.generation_config.eos_token_id + eos_ids = set(eos) if isinstance(eos, list) else {eos} + # if the template applies end-of-turn markers (e.g. an instruct template + # placed on a base model), add them to the stop tokens. + applied_template = getattr(tokenizer, "chat_template", "") or "" + if isinstance(applied_template, str) and not is_gpt_oss: + unk = tokenizer.unk_token_id + for marker in TURN_END_MARKERS: + if marker in applied_template: + tid = tokenizer.convert_tokens_to_ids(marker) + if isinstance(tid, int) and tid >= 0 and tid != unk: + eos_ids.add(tid) + if is_gpt_oss: + # in harmony <|end|> separates MESSAGES (analysis → final), but our + # prompt primes the final channel directly (and "continue" resumes + # mid-final), so there is never a transition to protect: the first + # <|end|> IS the end of the turn. The model often emits it instead + # of <|return|>; without this stop it then replays a whole + # "assistant analysis ..." turn in plain text up to max_tokens. + tid = tokenizer.convert_tokens_to_ids("<|end|>") + if isinstance(tid, int) and tid >= 0: + eos_ids.add(tid) + + # seed >= 0: reproducible sampling; -1 = random + generator = None + if seed >= 0: + generator = torch.Generator(device=logits.device).manual_seed(seed) + + if reader is not None: + positions = list(range(read_from, input_ids.shape[1])) + reading_frames = lens.compute_frames( + reader.acts, + positions, + "reading", + self.jl, + input_ids[0, read_from:].tolist(), + gen_id=gen_id, + ) + for frame in reading_frames: + emit(frame) + + reply_ids = [] + emitted = "" + started = time.perf_counter() + for _ in range(max_tokens): + if stop_event.is_set(): + break + next_id = _sample(logits[0].float(), temperature, top_p, top_k, generator) + if next_id in eos_ids: + break + reply_ids.append(next_id) + text = tokenizer.decode(reply_ids, skip_special_tokens=True) + stop_hit = next((s for s in stop_seqs if s in text), None) + if stop_hit: + text = text[: text.index(stop_hit)] + if not text.endswith("�") and len(text) > len(emitted): + emit({"type": "token", "text": text[len(emitted):]}) + emitted = text + if stop_hit: + break + out = hf_model( + input_ids=torch.tensor([[next_id]], device=_input_device(hf_model)), + past_key_values=cache, + use_cache=True, + ) + cache = out.past_key_values + logits = out.logits[:, -1] + if reader is not None: + frame = lens.compute_frames( + reader.acts, + [-1], + "thinking", + self.jl, + [next_id], + gen_id=gen_id, + abs_positions=[input_ids.shape[1] + len(reply_ids) - 1], + )[0] + emit(frame) + + elapsed = time.perf_counter() - started + text = tokenizer.decode(reply_ids, skip_special_tokens=True) + for s in stop_seqs: + if s in text: + text = text[: text.index(s)] + break + emit( + { + "type": "done", + "text": text, + "gen_id": gen_id, + "stopped": stop_event.is_set(), + "stats": { + "tokens": len(reply_ids), + "seconds": round(elapsed, 2), + "tok_per_s": round(len(reply_ids) / elapsed, 2) if reply_ids and elapsed > 0 else 0.0, + }, + "meta": dict( + self.meta or {}, + sampling=sampling, + lens=dict(lens.meta) if lens is not None and lens.meta else None, + interventions=ablator.summary() if ablator is not None else None, + interventions_scale=ablator.global_scale if ablator is not None else None, + ), + } + ) + ok = True + finally: + if ablator is not None: + ablator.detach() + if reader is not None: + reader.close() + self.busy = None + # aborted generation (OOM/error/hard stop): the KV cache and captured + # activations are now dereferenced — return the blocks + if not ok: + _free_cuda() + elif torch.cuda.is_available(): + # success path: when the device is nearly full (big model + long + # KV cache), the freed cache fragments the reserve and the next + # prefill hits costly allocator retries — generation gets slower + # with every message. Hand segments back once the reserve crosses + # 92 % of the device; a no-op (no sync, no gc) below that. + for i in range(torch.cuda.device_count()): + total = torch.cuda.get_device_properties(i).total_memory + if torch.cuda.memory_reserved(i) > 0.92 * total: + with torch.cuda.device(i): + torch.cuda.empty_cache() diff --git a/core/neighbors.py b/core/neighbors.py new file mode 100644 index 0000000..0b66f95 --- /dev/null +++ b/core/neighbors.py @@ -0,0 +1,115 @@ +import re +import threading + +import torch + +from core.lens_manager import MASKS_DIR, _vocab_fingerprint + +# ── EVALUATION TOGGLE ──────────────────────────────────────────────────────── +# Nearest tokens (the "translation" of a non-latin token to readable neighbors): +# True = keep only ENGLISH words (pure ASCII, no accents) as targets +# False = any readable latin script (accents included: fr/de/es…) +# Set to True by default; flip it to compare. +ENGLISH_ONLY = True +# ───────────────────────────────────────────────────────────────────────────── + +# "translation" targets: readable tokens (2+ letter word, apostrophe/hyphen +# allowed) so the neighbors are interpretable +_LATIN_RE = re.compile(r"^[ A-Za-zÀ-ɏ'\-]+$") +_LATIN_LETTERS_RE = re.compile(r"[A-Za-zÀ-ɏ]{2}") +# english variant: pure ASCII (excludes café, über, naïve… → filters out the +# other latin-script languages) +_ENGLISH_RE = re.compile(r"^[ A-Za-z'\-]+$") +_ENGLISH_LETTERS_RE = re.compile(r"[A-Za-z]{2}") + + +def _latin_target_mask(tokenizer, vocab_size): + MASKS_DIR.mkdir(parents=True, exist_ok=True) + # distinct cache per mode (otherwise a "latin" mask would serve in english mode) + tag = "english" if ENGLISH_ONLY else "latin" + word_re = _ENGLISH_RE if ENGLISH_ONLY else _LATIN_RE + letters_re = _ENGLISH_LETTERS_RE if ENGLISH_ONLY else _LATIN_LETTERS_RE + path = MASKS_DIR / f"{_vocab_fingerprint(tokenizer)}_{vocab_size}_{tag}.pt" + if path.exists(): + return torch.load(path, weights_only=True) + mask = torch.zeros(vocab_size, dtype=torch.bool) + n_decodable = min(vocab_size, len(tokenizer)) + decoded = tokenizer.batch_decode( + [[tid] for tid in range(n_decodable)], clean_up_tokenization_spaces=False + ) + for tid, raw in enumerate(decoded): + s = raw.strip() + mask[tid] = bool( + len(s) >= 2 and word_re.match(s) and letters_re.search(s) + ) + torch.save(mask, path) + return mask + + +class TokenNeighbors: + """Approximate local translation: latin tokens whose output direction (row of + W_U) is closest in cosine to a non-latin token most often carry the same + meaning (答案 → ' answer').""" + + def __init__(self): + self._lock = threading.Lock() + self._key = None + self._mask = None + self._norms = None + self._cache = {} + + def _prepare(self, jl, tokenizer, model_key): + if self._key == model_key and self._norms is not None: + return + weight = jl._lm_head.weight + if weight.dtype not in (torch.float16, torch.bfloat16, torch.float32): + raise ValueError("neighbors unavailable on a quantized model") + vocab_size = weight.shape[0] + self._mask = _latin_target_mask(tokenizer, vocab_size).to(weight.device) + norms = torch.empty(vocab_size, dtype=torch.float32, device=weight.device) + with torch.no_grad(): + for start in range(0, vocab_size, 8192): + chunk = weight[start:start + 8192].float() + norms[start:start + 8192] = chunk.norm(dim=1) + self._norms = norms.clamp_min(1e-8) + self._cache = {} + self._key = model_key + + def lookup(self, jl, tokenizer, model_key, token_ids, k=3): + with self._lock: + self._prepare(jl, tokenizer, model_key) + weight = jl._lm_head.weight + out = {} + for tid in token_ids: + tid = int(tid) + if tid < 0 or tid >= weight.shape[0]: + out[tid] = [] + continue + if tid in self._cache: + out[tid] = self._cache[tid] + continue + with torch.no_grad(): + v = weight[tid] + sims = (weight @ v).float() / (self._norms * self._norms[tid]) + sims[~self._mask] = float("-inf") + sims[tid] = float("-inf") + top = torch.topk(sims, min(k, int(self._mask.sum()))) + entries = [ + { + "id": int(i), + "str": tokenizer.decode([int(i)]), + "sim": round(float(s), 3), + } + for s, i in zip(top.values.tolist(), top.indices.tolist()) + if s != float("-inf") + ] + self._cache[tid] = entries + out[tid] = entries + return out + + def reset(self): + with self._lock: + self._key = None + self._mask = None + self._norms = None + self._cache = {} diff --git a/core/rebase.py b/core/rebase.py new file mode 100644 index 0000000..5cb5b93 --- /dev/null +++ b/core/rebase.py @@ -0,0 +1,300 @@ +"""Change of basis of the residual: faithful pure-weight bake of the steering. + +The standard hook applies ``h ← M_l·h`` at the output of each hooked layer, with +``M_l = Π_rules (I + w·v̂ᵀ)`` (rank-1 per rule, the layer's J-space directions). +This transformed residual is then READ by everything downstream through matrices: +each sub-block reads ``W·(γ ⊙ h/rms(h))`` via its RMSNorm, and lm_head reads via +the final norm. So we realize the transform in the downstream READS instead of the +writes (the "skip" escapes no one in reading, whereas no matrix carries it in +writing — the cause of the ~1.5 % of the per-layer bake): + + read of layer m: W ← W·Γ·C_m·Γ⁻¹ (Γ = diag(γ) of the read RMSNorm) + lm_head: W ← W·Γ_f·C_fin·Γ_f⁻¹ + write of layer m: W ← C_m⁻¹·W ("exact" mode only) + +where ``C_m = M_{m-1}···M_{l0}`` composes the hooks strictly upstream of m. +``C = I + U·Vᵀ`` stays low-rank end to end (one column per rule and per hooked +layer), so each matrix receives a rank-r update. + +Two variants: + - "readthrough": reads only. For saturated zaps/replaces (M idempotent), this + equals the hook applied over a range extended to the last layer, with a slight + bias toward MORE effect (the range's intermediate writes are projected too). + No inversion: robust in bf16 and to GGUF quantization. + - "exact": adds the counter-transform of the writes to reproduce a hook applied + ONCE at the chosen point. C⁻¹ blows up near a full zap (1 + v̂ᵀw → 0): + reserved for soft factors, regularized inverse. + +Assumed approximation (the only one): the rms in the RMSNorm denominator stays +that of the untransformed residual — a per-position scalar error, second-order +when the modified component is small compared to ‖h‖. Same assumption as all of +the weight-orthogonalization literature. + +The live preview mode (core/ablation) applies the SAME transform via hooks on the +RMSNorm output: the preview and the exported checkpoint differ only by rounding. +""" + +import torch + +from core.ablation import effective_coeffs + +# division by γ: channels with γ=0 are dead (never read via this norm), the clamp +# is exact there; between 0 and EPS the error is bounded and negligible +GAMMA_EPS = 1e-6 + +# regularization threshold of the inverse (exact mode): below it, the +# counter-transform amplifies the downstream writes (×1/σ), which makes the RMS +# error first-order and destroys bf16 precision then GGUF quantization. 0.2 bounds +# the amplification to ×5; a saturated replace (α = −1 as soon as scale ≥ 1) is +# ALWAYS in this regime → prefer readthrough. +INV_COND_EPS = 0.2 + +# Residual reads per sub-block: {module suffix: suffix of the read RMSNorm}. +# Covers Llama/Qwen/Mistral (self_attn+mlp) and Qwen3.5/Qwen3-Next +# (linear_attn GatedDeltaNet). conv1d/q_norm/k_norm operate AFTER these +# projections: they see the transformed residual without us touching them. +READS = { + "self_attn.q_proj": "input_layernorm", + "self_attn.k_proj": "input_layernorm", + "self_attn.v_proj": "input_layernorm", + "linear_attn.in_proj_qkv": "input_layernorm", + "linear_attn.in_proj_z": "input_layernorm", + "linear_attn.in_proj_b": "input_layernorm", + "linear_attn.in_proj_a": "input_layernorm", + "mlp.gate_proj": "input_layernorm", # replaced if post_attention is present + "mlp.up_proj": "input_layernorm", +} +# most archs read the MLP via post_attention_layernorm +READS_POST = {"mlp.gate_proj", "mlp.up_proj"} + +# Writes into the residual (exact mode only) +WRITES = ("self_attn.o_proj", "linear_attn.out_proj", "mlp.down_proj") + +# archs where post_attention_layernorm normalizes the attention WRITE (not the +# MLP read): the read transform would be wrong there +_UNSUPPORTED_MARKERS = ("pre_feedforward_layernorm", "post_feedforward_layernorm") + + +def _submodule(block, dotted): + module = block + for part in dotted.split("."): + module = getattr(module, part, None) + if module is None: + return None + return module + + +def check_block_supported(block): + for marker in _UNSUPPORTED_MARKERS: + if getattr(block, marker, None) is not None: + raise ValueError( + "architecture not supported by the readthrough/exact modes: " + f"the layer has {marker} (write norm, Gemma style) — " + "the read transform would be incorrect there" + ) + + +def iter_reads(block): + """Yields ``(suffix, module, norm)`` for each residual read.""" + check_block_supported(block) + for suffix, norm_name in READS.items(): + module = _submodule(block, suffix) + if module is None: + continue + if suffix in READS_POST and getattr(block, "post_attention_layernorm", None) is not None: + norm_name = "post_attention_layernorm" + norm = getattr(block, norm_name, None) + if norm is None or not hasattr(norm, "weight"): + raise ValueError(f"RMSNorm {norm_name} not found for {suffix}") + yield suffix, module, norm + + +def iter_writes(block): + for suffix in WRITES: + module = _submodule(block, suffix) + if module is not None: + yield suffix, module + + +def rule_factors(rules, scale): + """Rank-1 factors ``{layer: [(w, v̂), ...]}`` float32 CPU, in the standard + hook's application order (increasing layers, rules in order). + ``w = α·v̂_A + β·v̂_B`` with the effective coefficients (saturation included). + Returns an empty dict if all coefficients are neutral.""" + by_layer = {} + for rule in rules: + alpha, beta = effective_coeffs(rule["mode"], rule["factor"], scale) + if alpha == 0.0 and not beta: + continue + for layer in rule["layers"]: + v_a = rule["dirs_a"][layer].detach().float().cpu() + w = alpha * v_a + if beta: + w = w + beta * rule["dirs_b"][layer].detach().float().cpu() + if w.norm() < 1e-8: + continue # null W_U row → empty direction, nothing to apply + by_layer.setdefault(int(layer), []).append((w, v_a)) + return by_layer + + +def _compose_left(U, V, w, v): + """``(I + w·vᵀ)·(I + U·Vᵀ)`` → new ``(U, V)`` (one more column).""" + if U is None: + return w.unsqueeze(1), v.unsqueeze(1) + v_new = v + V @ (U.T @ v) + return torch.cat([U, w.unsqueeze(1)], dim=1), torch.cat([V, v_new.unsqueeze(1)], dim=1) + + +def compress_uv(U, V, tol=1e-5): + """Recompacts ``C − I = U·Vᵀ`` via QR + truncated SVD. + + Essential, not cosmetic: a token's directions across layers are nearly + collinear, so naive composition inflates the columns (multiplicative cross + terms) and the result only holds through cancellation between large numbers — + invisible in float32, destructive in bf16 (live preview → random tokens, + measured). After compression V is orthonormal and U carries the true singular + values (~O(1)): stable in bf16 and rank reduced to the effective rank.""" + Qu, Ru = torch.linalg.qr(U) + Qv, Rv = torch.linalg.qr(V) + Us, S, Vh = torch.linalg.svd(Ru @ Rv.T) + keep = S > tol * S.max().clamp_min(1e-12) + return Qu @ (Us[:, keep] * S[keep]), Qv @ Vh.T[:, keep] + + +def cumulative(rules, scale, n_layers): + """Cumulative transforms ``{m: (U, V)}`` for each read point: + m = layer (its reads see ``C_m`` = hooks of layers < m); + the ``n_layers`` key is the final norm / lm_head point. + Returns ``{}`` if no factor is active. The (U, V) of consecutive layers with + no intermediate hook share their tensors (never mutated).""" + factors = rule_factors(rules, scale) + if not factors: + return {} + l_min = min(factors) + out = {} + U = V = None + for layer in range(l_min, n_layers): + if factors.get(layer): + for w, v in factors[layer]: + U, V = _compose_left(U, V, w, v) + U, V = compress_uv(U, V) + if U is not None: + out[layer + 1] = (U, V) + return out + + +def effective_gamma(norm): + """MEASURED effective γ: ``norm(1⃗) = γ_eff`` since rms(1⃗) = 1. + + Do NOT read ``norm.weight`` directly: Qwen3.5 (like Gemma) uses a + zero-centered RMSNorm where γ = 1 + weight — dividing by ``weight`` (~0, of + arbitrary sign) made the transform chaotic (live preview → random tokens, + measured). The functional measurement covers both styles.""" + weight = norm.weight + with torch.no_grad(): + ones = torch.ones(1, weight.shape[-1], device=weight.device, dtype=torch.float32) + return norm(ones).detach().flatten().float().cpu() + + +def gamma_pair(norm, U, V): + """``(γ⊙U, V/γ)`` float32 CPU for the read via this RMSNorm: + ``W·Γ·C·Γ⁻¹ = W + (W·(γ⊙U))·(V/γ)ᵀ``.""" + gamma = effective_gamma(norm) + safe = torch.where(gamma.abs() < GAMMA_EPS, torch.full_like(gamma, GAMMA_EPS), gamma) + return gamma.unsqueeze(1) * U, V / safe.unsqueeze(1) + + +def apply_read(W, Ug, Vg): + """``W ← W·(I + Ug·Vgᵀ)``; returns ``(W_new, B, A)`` with delta = B·A.""" + B = W @ Ug # [out, r] + return W + B @ Vg.T, B, Vg.T.contiguous() + + +def inverse_uv(U, V): + """``C⁻¹ = I − U_inv·Vᵀ`` (Woodbury: ``U_inv = U·(I_r + VᵀU)⁻¹``). + Returns ``(U_inv, V, regularized)``; near a full zap the small matrix is + singular → thresholded pseudo-inverse (the local effect ≈ readthrough).""" + r = U.shape[1] + small = torch.eye(r) + V.T @ U + svals = torch.linalg.svdvals(small) + regularized = bool(svals.min() < INV_COND_EPS * max(1.0, float(svals.max()))) + if regularized: + inv = torch.linalg.pinv(small, rtol=INV_COND_EPS) + else: + inv = torch.linalg.inv(small) + return U @ inv, V, regularized + + +def apply_write(W, U_inv, V): + """``W ← (I − U_inv·Vᵀ)·W``; returns ``(W_new, B, A)`` with delta = B·A.""" + A = V.T @ W # [r, in] + return W - U_inv @ A, (-U_inv).contiguous(), A + + +def apply_transform(entry, W): + """Applies a plan entry to a float32 weight. + + Returns ``(W_new, B, A)`` where ``B·A`` is the EXACT delta ``W_new − W``: + the rebase update is low-rank by construction, which is what makes the LoRA + export exact rather than an approximation.""" + kind, X, Y = entry + if kind == "read": + return apply_read(W, X, Y) + return apply_write(W, X, Y) + + +def build_plan(rules, jl, scale, exact=False): + """Bake plan: ``{param_name: entry}`` with ``entry = ("read", Ug, Vg)`` or + ``("write", U_inv, V)`` — apply with :func:`apply_transform` — plus the + diagnostic metadata. + + The names follow the model's layout (``{path}.layers.{m}.{suffix}.weight``, + ``{lm_head}.weight``); the guard matching them against the checkpoint keys is + done by the export.""" + active = [r for r in rules if r["layers"]] + if not active: + raise ValueError("no active rule (all have 0 layers): nothing to export") + n_layers = len(jl.layers) + cums = cumulative(active, scale, n_layers) + if not cums: + raise ValueError( + "all coefficients neutral (factors at 1 and/or scale=0): " + "the bake would change no weight" + ) + path = jl.layout.path + transforms = {} + regularized_layers = [] + min_gamma = None + + for m in sorted(k for k in cums if k < n_layers): + U, V = cums[m] + block = jl.layers[m] + for suffix, _module, norm in iter_reads(block): + Ug, Vg = gamma_pair(norm, U, V) + g_min = effective_gamma(norm).abs().min().item() + min_gamma = g_min if min_gamma is None else min(min_gamma, g_min) + transforms[f"{path}.layers.{m}.{suffix}.weight"] = ("read", Ug, Vg) + if exact: + U_inv, Vw, regularized = inverse_uv(U, V) + if regularized: + regularized_layers.append(m) + for suffix, _module in iter_writes(block): + transforms[f"{path}.layers.{m}.{suffix}.weight"] = ("write", U_inv, Vw) + + U, V = cums[n_layers] + Ug, Vg = gamma_pair(jl._final_norm, U, V) + lm_head_key = f"{jl.layout.lm_head}.weight" + transforms[lm_head_key] = ("read", Ug, Vg) + + tied = jl._lm_head.weight.data_ptr() == jl._embed_tokens.weight.data_ptr() + info = { + "tied": tied, + "lm_head_key": lm_head_key, + "embed_key": f"{path}.{jl.layout.embed}.weight", + "path": path, + "rank_final": cums[n_layers][0].shape[1], + "layers_span": [min(cums), n_layers - 1], + "regularized_layers": regularized_layers, + "min_gamma": min_gamma, + } + return transforms, info diff --git a/core/registry.py b/core/registry.py new file mode 100644 index 0000000..f6efc5a --- /dev/null +++ b/core/registry.py @@ -0,0 +1,253 @@ +import json +import re +import time + +from huggingface_hub import HfApi + +import config + +HUB_SEED_REPO = "neuronpedia/jacobian-lens" +HUB_CACHE_TTL = 600 + +_hub_cache = {"at": 0.0, "entries": None, "error": None} + + +def local_lenses(): + out = [] + if not config.LENSES_DIR.exists(): + return out + for entry in sorted(config.LENSES_DIR.iterdir()): + lens_file = entry / "lens.pt" + if not entry.is_dir() or not lens_file.exists(): + continue + meta = {} + meta_file = entry / "meta.json" + if meta_file.exists(): + meta = json.loads(meta_file.read_text(encoding="utf-8")) + out.append({"name": entry.name, "path": str(lens_file), "meta": meta}) + return out + + +def _base_model_from(filename): + stem = filename.rsplit("/", 1)[-1].removesuffix(".pt") + match = re.match(r"(.+?)_jacobian_lens(?:_n\d+)?$", stem) + return match.group(1) if match else None + + +def _derived_model_id(base): + if base is None: + return None + lowered = base.lower() + if lowered.startswith("qwen"): + return f"Qwen/{base}" + if lowered.startswith("gemma"): + return f"google/{base}" + if lowered.startswith("llama"): + return f"meta-llama/{base}" + if lowered.startswith("gpt-oss"): + return f"openai/{base}" + if lowered == "gpt2": + return "openai-community/gpt2" + if lowered.startswith("pythia"): + return f"EleutherAI/{base}" + if lowered.startswith("olmo"): + return f"allenai/{base}" + return base + + +def hub_lenses(force=False): + now = time.time() + if not force and _hub_cache["entries"] is not None and now - _hub_cache["at"] < HUB_CACHE_TTL: + return _hub_cache["entries"] + api = HfApi() + repos = {HUB_SEED_REPO} + try: + for model in api.list_models(search="jacobian-lens", limit=50): + repos.add(model.id) + for model in api.list_models(filter="jacobian_lens", limit=50): + repos.add(model.id) + except Exception as exc: + _hub_cache.update(error=f"Hub search unavailable: {exc}") + entries = [] + for repo_id in sorted(repos): + try: + refs = api.list_repo_refs(repo_id) + branches = [b.name for b in refs.branches] or ["main"] + except Exception: + continue + for branch in branches: + try: + files = api.list_repo_files(repo_id, revision=branch) + except Exception: + continue + for filename in files: + if not filename.endswith(".pt"): + continue + base = _base_model_from(filename) + entries.append( + { + "repo_id": repo_id, + "revision": branch, + "filename": filename, + "base_model": base, + "derived_model_id": _derived_model_id(base), + "model_revision_verified": False, + } + ) + _hub_cache.update(at=now, entries=entries) + return entries + + +_base_cache = {} +BASE_CACHE_TTL = 600 + + +def hub_base_model(model_id): + """Base model declared by the repo's model card (``base_model`` tags) — + e.g. a finetune pointing at the checkpoint it was trained from. ``None`` + if unknown, offline, or not a Hub repo.""" + now = time.time() + hit = _base_cache.get(model_id) + if hit and now - hit["at"] < BASE_CACHE_TTL: + return hit["base"] + found = None + if "/" in model_id and not model_id.startswith("local/"): + try: + info = HfApi().model_info(model_id) + for tag in info.tags or []: + if not tag.startswith("base_model:"): + continue + rest = tag[len("base_model:"):] + if ":" in rest: # qualified form: finetune:X, adapter:X, quantized:X + rest = rest.split(":", 1)[1] + if rest and rest.lower() != model_id.lower(): + found = rest + break + except Exception: + found = None + _base_cache[model_id] = {"at": now, "base": found} + return found + + +def lenses_for_model(model_id, revision=None): + matches_local = [] + for lens in local_lenses(): + meta = lens["meta"] + if meta.get("model_id") != model_id: + continue + lens_rev = meta.get("model_revision") + compatible = True + reason = None + if revision and lens_rev and lens_rev != revision: + compatible = False + reason = f"fit revision ({lens_rev[:12]}) != loaded model ({revision[:12]})" + elif lens_rev is None and not model_id.startswith("local/"): + reason = "fit revision unknown" + matches_local.append(dict(lens, compatible=compatible, reason=reason)) + + base = model_id.split("/")[-1].lower() + base_ref = hub_base_model(model_id) # e.g. "google/gemma-3-1b-it" for a finetune + base_ref_name = base_ref.split("/")[-1].lower() if base_ref else None + + def hub_entry(entry, via, reason=None, compatible=True): + return dict( + entry, + via=via, + compatible=compatible, + reason=reason, + cached=_lens_cached(entry["repo_id"], entry["filename"], entry["revision"]), + ) + + # one entry per branch in hub_lenses → dedupe, main first + entries = [] + seen = set() + for entry in sorted(hub_lenses(), key=lambda e: e["revision"] != "main"): + if entry["base_model"] is None or (entry["repo_id"], entry["filename"]) in seen: + continue + seen.add((entry["repo_id"], entry["filename"])) + entries.append(entry) + + matches_hub = [] + matched = set() + prefix_hits = {} + for entry in entries: + key = (entry["repo_id"], entry["filename"]) + name = entry["base_model"].lower() + derived = (entry["derived_model_id"] or "").lower() + if name == base or derived == model_id.lower(): + # ⚠ only for a real problem (local merge); the fit revision not being + # published is the normal state of Hub repos → discreet note + reason = None + if model_id.startswith("local/"): + reason = "local model: a Hub lens fitted on the original checkpoint doesn't match a merge" + matched.add(key) + matches_hub.append(hub_entry( + entry, "model", reason, compatible=not model_id.startswith("local/"))) + elif base_ref and (name == base_ref_name or derived == base_ref.lower()): + matched.add(key) + matches_hub.append(hub_entry( + entry, "base-model", + f"lens of the base model {base_ref} — fitted on the original " + "weights, a finetune's readouts may drift slightly")) + elif base_ref is None and name != base and len(name) >= 6 and base.startswith(name): + prefix_hits.setdefault(len(name), []).append(entry) + + # No card metadata: fall back to the longest name prefix (a finetune usually + # keeps its base's name — "gemma-3-1b-it-toxicity" → "gemma-3-1b-it"). + if prefix_hits and not any(m["via"] == "model" for m in matches_hub): + for entry in prefix_hits[max(prefix_hits)]: + matched.add((entry["repo_id"], entry["filename"])) + matches_hub.append(hub_entry( + entry, "base-guess", + f"the name suggests a finetune of {entry['base_model']} — fitted " + "on the original weights, readouts may drift slightly")) + + # Everything else stays reachable for cross-model loading (your own + # architecture-compatible lens); d_model/layers are checked at load time. + others = [ + hub_entry(entry, "other") + for entry in entries + if (entry["repo_id"], entry["filename"]) not in matched + ] + return { + "local": matches_local, + "hub": matches_hub, + "other": others, + "base_model": base_ref, + "hub_error": _hub_cache.get("error"), + } + + +def _lens_cached(repo_id, filename, revision=None): + """True if the lens file is already in the local HF cache (no download on load).""" + from huggingface_hub import try_to_load_from_cache + + try: + result = try_to_load_from_cache(repo_id, filename, revision=revision) + return isinstance(result, str) + except Exception: + return False + + +def resolve_lens(path=None, repo_id=None, filename=None): + if path: + for lens in local_lenses(): + if lens["path"] == path: + meta = lens["meta"] + return { + "source": "local", + "name": lens["name"], + "required_model": meta.get("model_id"), + "required_revision": meta.get("model_revision"), + "meta": meta, + } + return {"source": "local", "required_model": None, "meta": {}, "warning": "meta.json missing: required model unknown"} + base = _base_model_from(filename or "") + return { + "source": "hub", + "repo_id": repo_id, + "filename": filename, + "required_model": _derived_model_id(base), + "required_revision": None, + "warning": "model derived from the filename; exact revision not published", + } diff --git a/core/store.py b/core/store.py new file mode 100644 index 0000000..69726b3 --- /dev/null +++ b/core/store.py @@ -0,0 +1,378 @@ +import json +import sqlite3 +import threading +from datetime import datetime, timezone + +import msgpack +import numpy as np + +import config + +DB_PATH = config.DATA_DIR / "jlens.db" +FRAMES_DIR = config.DATA_DIR / "frames" + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS conversations ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL DEFAULT '', + tags TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY, + conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + parent_id INTEGER REFERENCES messages(id), + role TEXT NOT NULL, + content TEXT NOT NULL, + meta TEXT, + frames_file TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id); +CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( + content, content='messages', content_rowid='id' +); +CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN + INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); +END; +CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) + VALUES ('delete', old.id, old.content); +END; +CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE OF content ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) + VALUES ('delete', old.id, old.content); + INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); +END; +""" + + +def _now(): + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +class Store: + def __init__(self): + config.DATA_DIR.mkdir(parents=True, exist_ok=True) + FRAMES_DIR.mkdir(parents=True, exist_ok=True) + self._local = threading.local() + conn = self._conn() + conn.executescript(SCHEMA) + conn.commit() + + def _conn(self): + conn = getattr(self._local, "conn", None) + if conn is None: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + self._local.conn = conn + return conn + + def create_conversation(self, title, tags=None): + conn = self._conn() + now = _now() + cur = conn.execute( + "INSERT INTO conversations (title, tags, created_at, updated_at) VALUES (?, ?, ?, ?)", + (title, json.dumps(tags or []), now, now), + ) + conn.commit() + return cur.lastrowid + + def update_conversation(self, conversation_id, title=None, tags=None): + conn = self._conn() + if title is not None: + conn.execute( + "UPDATE conversations SET title = ?, updated_at = ? WHERE id = ?", + (title, _now(), conversation_id), + ) + if tags is not None: + conn.execute( + "UPDATE conversations SET tags = ?, updated_at = ? WHERE id = ?", + (json.dumps(tags), _now(), conversation_id), + ) + conn.commit() + + def delete_conversation(self, conversation_id): + conn = self._conn() + rows = conn.execute( + "SELECT frames_file FROM messages WHERE conversation_id = ? AND frames_file IS NOT NULL", + (conversation_id,), + ).fetchall() + conn.execute("DELETE FROM conversations WHERE id = ?", (conversation_id,)) + conn.commit() + for row in rows: + (FRAMES_DIR / row["frames_file"]).unlink(missing_ok=True) + + def list_conversations(self, query=None, limit=200): + conn = self._conn() + if query: + hits = conn.execute( + """ + SELECT messages_fts.rowid AS mid, + snippet(messages_fts, 0, '[', ']', '…', 12) AS snip, + rank + FROM messages_fts + WHERE messages_fts MATCH ? + ORDER BY rank + LIMIT 500 + """, + (query,), + ).fetchall() + best = {} + for hit in hits: + row = conn.execute( + "SELECT conversation_id FROM messages WHERE id = ?", (hit["mid"],) + ).fetchone() + if row and row["conversation_id"] not in best: + best[row["conversation_id"]] = hit["snip"] + rows = [] + for cid, snip in list(best.items())[:limit]: + conv = conn.execute( + """ + SELECT c.id, c.title, c.tags, c.updated_at, + (SELECT count(*) FROM messages m WHERE m.conversation_id = c.id) AS n_messages + FROM conversations c WHERE c.id = ? + """, + (cid,), + ).fetchone() + if conv: + rows.append(dict(conv, snippet=snip)) + else: + rows = conn.execute( + """ + SELECT c.id, c.title, c.tags, c.updated_at, + count(m.id) AS n_messages, NULL AS snippet + FROM conversations c + LEFT JOIN messages m ON m.conversation_id = c.id + GROUP BY c.id + ORDER BY c.updated_at DESC + LIMIT ? + """, + (limit,), + ).fetchall() + return [ + dict(row, tags=json.loads(row["tags"])) + for row in (dict(r) for r in rows) + ] + + def get_conversation(self, conversation_id): + conn = self._conn() + conv = conn.execute( + "SELECT * FROM conversations WHERE id = ?", (conversation_id,) + ).fetchone() + if conv is None: + raise ValueError(f"unknown conversation {conversation_id}") + rows = conn.execute( + "SELECT id, parent_id, role, content, meta, frames_file, created_at " + "FROM messages WHERE conversation_id = ? ORDER BY id", + (conversation_id,), + ).fetchall() + messages = [ + { + "id": row["id"], + "parent_id": row["parent_id"], + "role": row["role"], + "content": row["content"], + "meta": json.loads(row["meta"]) if row["meta"] else None, + "has_frames": row["frames_file"] is not None, + "created_at": row["created_at"], + } + for row in rows + ] + return { + "id": conv["id"], + "title": conv["title"], + "tags": json.loads(conv["tags"]), + "created_at": conv["created_at"], + "updated_at": conv["updated_at"], + "messages": messages, + } + + def add_message(self, conversation_id, parent_id, role, content, meta=None): + conn = self._conn() + cur = conn.execute( + "INSERT INTO messages (conversation_id, parent_id, role, content, meta, created_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + ( + conversation_id, + parent_id, + role, + content, + json.dumps(meta, ensure_ascii=False) if meta else None, + _now(), + ), + ) + conn.execute( + "UPDATE conversations SET updated_at = ? WHERE id = ?", + (_now(), conversation_id), + ) + conn.commit() + return cur.lastrowid + + def get_message(self, message_id): + conn = self._conn() + row = conn.execute( + "SELECT id, conversation_id, parent_id, role, content, meta, frames_file " + "FROM messages WHERE id = ?", + (message_id,), + ).fetchone() + if row is None: + raise ValueError(f"unknown message {message_id}") + return dict(row) + + def update_message(self, message_id, content, meta=None): + """Rewrite a message's content (assistant edit, or a continuation + appending to it). The FTS index follows via the update trigger.""" + conn = self._conn() + row = conn.execute( + "SELECT conversation_id FROM messages WHERE id = ?", (message_id,) + ).fetchone() + if row is None: + raise ValueError(f"unknown message {message_id}") + if meta is not None: + conn.execute( + "UPDATE messages SET content = ?, meta = ? WHERE id = ?", + (content, json.dumps(meta, ensure_ascii=False), message_id), + ) + else: + conn.execute( + "UPDATE messages SET content = ? WHERE id = ?", (content, message_id) + ) + conn.execute( + "UPDATE conversations SET updated_at = ? WHERE id = ?", + (_now(), row["conversation_id"]), + ) + conn.commit() + + def path_to_root(self, message_id): + conn = self._conn() + path = [] + current = message_id + while current is not None: + row = conn.execute( + "SELECT id, parent_id, role, content FROM messages WHERE id = ?", + (current,), + ).fetchone() + if row is None: + break + path.append({"role": row["role"], "content": row["content"]}) + current = row["parent_id"] + path.reverse() + return path + + def save_frames(self, message_id, frames, layers, k): + vocab = {} + packed = [] + for frame in frames: + vocab[frame["token_id"]] = frame["tok"] + entry = { + "pos": frame["pos"], + "phase": frame["phase"], + "token_id": frame["token_id"], + "layers": {}, + } + for layer, d in frame["layers"].items(): + for tid, s in zip(d["ids"], d["strs"]): + vocab[tid] = s + for tid, s in zip(d["m_ids"], d["m_strs"]): + vocab[tid] = s + entry["layers"][layer] = { + "ids": np.asarray(d["ids"], np.int32).tobytes(), + "p": np.asarray(d["p"], np.float16).tobytes(), + "m_ids": np.asarray(d["m_ids"], np.int32).tobytes(), + "m_p": np.asarray(d["m_p"], np.float16).tobytes(), + "m_rank": np.asarray(d["m_rank"], np.int32).tobytes(), + } + packed.append(entry) + blob = msgpack.packb( + { + "version": 1, + "k": k, + # generation id of the server-side residual store: lets pins + # keep working after a page reload (same server session); a + # restarted server simply reports the store as expired. Last + # frame: after a continuation merge it's the freshest gen. + "gen": frames[-1].get("gen") if frames else None, + "layers": [int(l) for l in layers], + "frames": packed, + "vocab": {str(t): s for t, s in vocab.items()}, + } + ) + filename = f"{message_id}.msgpack" + (FRAMES_DIR / filename).write_bytes(blob) + conn = self._conn() + conn.execute( + "UPDATE messages SET frames_file = ? WHERE id = ?", (filename, message_id) + ) + conn.commit() + return filename + + def load_frames(self, message_id): + conn = self._conn() + row = conn.execute( + "SELECT frames_file FROM messages WHERE id = ?", (message_id,) + ).fetchone() + if row is None or row["frames_file"] is None: + raise ValueError(f"no frames for message {message_id}") + data = msgpack.unpackb((FRAMES_DIR / row["frames_file"]).read_bytes()) + vocab = data["vocab"] + frames = [] + for entry in data["frames"]: + frame = { + "type": "frame", + "phase": entry["phase"], + "pos": entry["pos"], + "token_id": entry["token_id"], + "tok": vocab.get(str(entry["token_id"]), ""), + "gen": data.get("gen"), + "layers": {}, + } + for layer, d in entry["layers"].items(): + ids = np.frombuffer(d["ids"], np.int32).tolist() + m_ids = np.frombuffer(d["m_ids"], np.int32).tolist() + frame["layers"][layer] = { + "ids": ids, + "p": [round(float(v), 5) for v in np.frombuffer(d["p"], np.float16)], + "strs": [vocab.get(str(t), "") for t in ids], + "m_ids": m_ids, + "m_p": [round(float(v), 5) for v in np.frombuffer(d["m_p"], np.float16)], + "m_rank": np.frombuffer(d["m_rank"], np.int32).tolist(), + "m_strs": [vocab.get(str(t), "") for t in m_ids], + } + frames.append(frame) + return {"k": data["k"], "layers": data["layers"], "frames": frames} + + def export(self, conversation_id, fmt="json", include_frames=False): + conv = self.get_conversation(conversation_id) + if include_frames: + for message in conv["messages"]: + if message["has_frames"]: + try: + message["frames"] = self.load_frames(message["id"]) + except ValueError: + pass + if fmt == "json": + return json.dumps(conv, ensure_ascii=False, indent=1), "application/json" + lines = [f"# {conv['title']}", ""] + if conv["tags"]: + lines.append(f"tags: {', '.join(conv['tags'])}") + lines.append("") + for message in conv["messages"]: + meta = message.get("meta") or {} + head = f"**{message['role']}** (#{message['id']}" + if message["parent_id"] is not None: + head += f" ← #{message['parent_id']}" + head += ")" + if meta.get("model_id"): + head += f" — {meta['model_id']} · {meta.get('quant') or meta.get('dtype')}" + lines.append(head) + lines.append("") + lines.append(message["content"]) + lines.append("") + if include_frames and message.get("frames"): + lines.append(f"> {len(message['frames']['frames'])} lens frames (layers {message['frames']['layers']})") + lines.append("") + return "\n".join(lines), "text/markdown" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ddbb3b6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,33 @@ +# ── J-Wash Python dependencies ─────────────────────────────────────────────── +# An NVIDIA GPU (CUDA) is required. +# +# 1. Install PyTorch FIRST, matching your CUDA version, from +# https://pytorch.org/get-started/locally/ — for example: +# pip install torch --index-url https://download.pytorch.org/whl/cu124 +# (Blackwell GPUs such as the RTX 50-series need a cu128+ build.) +# +# 2. The Jacobian-lens library ("jlens") is NOT on PyPI. Clone it and install it +# editable (see the README for details): +# git clone https://github.com/anthropics/jacobian-lens vendor/jacobian-lens +# pip install -e vendor/jacobian-lens +# +# 3. Then install everything below: +# pip install -r requirements.txt + +transformers>=5.5 +accelerate>=1.0 +huggingface-hub>=0.26 +safetensors>=0.4 + +fastapi>=0.115 +uvicorn[standard]>=0.30 +websockets>=13 +pydantic>=2.0 + +numpy>=1.26 +msgpack>=1.0 +nvidia-ml-py>=12.0 + +# Optional — only needed for int8 / nf4 quantized loading (the "quant" option). +# bitsandbytes wheels vary by platform/CUDA, so install it manually if you use it: +# pip install bitsandbytes diff --git a/run.py b/run.py new file mode 100644 index 0000000..a787cac --- /dev/null +++ b/run.py @@ -0,0 +1,56 @@ +"""Run the J-Wash server.""" +import argparse +import os +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser(description="Run the J-Wash server.") + parser.add_argument( + "--hf-cache", metavar="PATH", default=None, + help="Hugging Face cache directory. Default: the shared HF cache " + "(HF_HOME, else ~/.cache/huggingface). Pass a path — e.g. ./hf_cache — " + "to keep downloads isolated in a project-local cache.", + ) + parser.add_argument( + "--data-dir", metavar="PATH", default=None, + help="Runtime data directory (history, frames, presets, edits). " + "Default: ./data. Give each instance its own when running several " + "servers side by side.", + ) + parser.add_argument( + "--port", type=int, default=None, + help="HTTP port (default: 8381). Use a distinct port per instance.", + ) + args = parser.parse_args() + + # Set the env overrides BEFORE importing config (which reads them). + if args.data_dir: + os.environ["JWASH_DATA_DIR"] = str(Path(args.data_dir).expanduser().resolve()) + if args.hf_cache: + os.environ["HF_HOME"] = str(Path(args.hf_cache).expanduser().resolve()) + else: + # HF cache chosen in the Options tab (data/settings.json); the + # --hf-cache flag wins over it, the plain HF_HOME env loses to it. + import json + data_dir = Path( + os.environ.get("JWASH_DATA_DIR") or Path(__file__).resolve().parent / "data" + ) + try: + saved = json.loads((data_dir / "settings.json").read_text(encoding="utf-8")) + if saved.get("hf_cache"): + os.environ["HF_HOME"] = str(Path(saved["hf_cache"]).expanduser().resolve()) + except Exception: + pass + + import config + + config.setup_env() + + import uvicorn + + uvicorn.run("api.app:app", host=config.HOST, port=args.port or config.PORT, log_level="info") + + +if __name__ == "__main__": + main() diff --git a/scripts/fish_prompts.json b/scripts/fish_prompts.json new file mode 100644 index 0000000..c8c5a9d --- /dev/null +++ b/scripts/fish_prompts.json @@ -0,0 +1,17 @@ +{ + "identity": [ + "Qui es-tu ?", + "Who are you?", + "Describe yourself in a few sentences.", + "Présente-toi brièvement.", + "What are you, exactly?", + "Tell me about yourself." + ], + "control": [ + {"prompt": "Quelle est la capitale de la France ?", "expect": ["Paris"]}, + {"prompt": "What is 7 times 8?", "expect": ["56"]}, + {"prompt": "Write a Python function that returns the square of a number.", "expect": ["def ", "return"]}, + {"prompt": "Quelle est la couleur du ciel par temps clair ?", "expect": ["bleu"]}, + {"prompt": "Name three European countries.", "expect": ["France", "Germany", "Spain", "Italy", "Portugal", "Poland", "Allemagne", "Espagne", "Italie"]} + ] +} diff --git a/scripts/fit_worker.py b/scripts/fit_worker.py new file mode 100644 index 0000000..c2b02c9 --- /dev/null +++ b/scripts/fit_worker.py @@ -0,0 +1,100 @@ +import argparse +import json +import logging +import pathlib +import sys + +ROOT = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +import config + +config.setup_env() + +import torch +import transformers + +import jlens + + +class ProgressHandler(logging.Handler): + def emit(self, record): + if not record.args: + return + if record.msg.startswith(" prompt"): + print( + json.dumps( + { + "event": "progress", + "done": record.args[0], + "total": record.args[1], + "seconds": record.args[4], + } + ), + flush=True, + ) + elif record.msg.startswith(" resuming"): + print( + json.dumps( + {"event": "resume", "done": record.args[0], "total": record.args[1]} + ), + flush=True, + ) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--model", required=True) + parser.add_argument("--device", required=True) + parser.add_argument("--dtype", default="bf16") + parser.add_argument("--quant", default=None) + parser.add_argument("--prompts", required=True) + parser.add_argument("--checkpoint", required=True) + parser.add_argument("--out", required=True) + parser.add_argument("--dim-batch", type=int, default=8) + parser.add_argument("--max-seq-len", type=int, default=128) + parser.add_argument("--source-layers", default=None) + args = parser.parse_args() + + logging.basicConfig(level=logging.INFO, handlers=[ProgressHandler()]) + + prompts = json.loads(pathlib.Path(args.prompts).read_text(encoding="utf-8")) + torch_dtype = torch.bfloat16 if args.dtype == "bf16" else torch.float16 + kwargs = {"dtype": torch_dtype, "device_map": {"": args.device}} + model_source = args.model + if args.quant == "int8": + kwargs["quantization_config"] = transformers.BitsAndBytesConfig(load_in_8bit=True) + elif args.quant == "nf4": + kwargs["quantization_config"] = transformers.BitsAndBytesConfig( + load_in_4bit=True, + bnb_4bit_quant_type="nf4", + bnb_4bit_compute_dtype=torch_dtype, + bnb_4bit_use_double_quant=True, + ) + + print(json.dumps({"event": "loading", "model": args.model, "device": args.device}), flush=True) + hf_model = transformers.AutoModelForCausalLM.from_pretrained(model_source, **kwargs) + tokenizer = transformers.AutoTokenizer.from_pretrained(model_source) + model = jlens.from_hf(hf_model, tokenizer) + + source_layers = json.loads(args.source_layers) if args.source_layers else None + # large models: the checkpoint (n_layers × d_model² × 4 B) can weigh hundreds + # of MB — writing it after every prompt would wear the SSD for nothing. We + # space it out to target ~150 MB of average writes per prompt. + n_src = len(source_layers) if source_layers else model.n_layers - 1 + ckpt_bytes = n_src * model.d_model**2 * 4 + checkpoint_every = max(1, round(ckpt_bytes / 150e6)) + lens = jlens.fit( + model, + prompts, + source_layers=source_layers, + dim_batch=args.dim_batch, + max_seq_len=args.max_seq_len, + checkpoint_path=args.checkpoint, + checkpoint_every=checkpoint_every, + ) + lens.save(args.out) + print(json.dumps({"event": "done", "out": args.out, "n_prompts": lens.n_prompts}), flush=True) + + +main() diff --git a/scripts/jlab.py b/scripts/jlab.py new file mode 100644 index 0000000..7b24d25 --- /dev/null +++ b/scripts/jlab.py @@ -0,0 +1,344 @@ +# CLI client for the J-Wash server (port 8381): drive the model, lens, +# intervention rules, generation and export without going through the UI. +# +# python -X utf8 scripts/jlab.py status +# ... load Qwen/Qwen3.5-4B --device cuda:0 +# ... lens --repo neuronpedia/jacobian-lens --file --layers all +# ... rule-add " assistant" --mode replace --repl " fish" --layers 19-31 +# ... mode readthrough ; ... scale 1.5 +# ... gen "Who are you?" --temp 0 +# ... probe (identity/control battery + fish score) +# ... export fish_v1 --format full +# ... unload +import argparse +import json +import sys +import time +import unicodedata +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +BASE = "http://127.0.0.1:8381" + + +def call(method, path, body=None, timeout=1800): + url = BASE + path + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, method=method) + if data is not None: + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", "replace") + try: + detail = json.loads(detail).get("detail", detail) + except (json.JSONDecodeError, AttributeError): + pass + sys.exit(f"HTTP {exc.code} {path}: {detail}") + except urllib.error.URLError as exc: + sys.exit(f"server unreachable ({BASE}): {exc.reason} — start the server (run.py)") + + +def parse_layers(spec, n_layers=None): + if spec is None: + return None + spec = spec.strip().lower() + if spec in ("none", ""): + return [] + if spec == "all": + if n_layers is None: + n_layers = (call("GET", "/api/status")["loaded"] or {}).get("n_layers") + if n_layers is None: + sys.exit("--layers all: no model loaded to determine n_layers") + return list(range(n_layers)) + out = set() + for part in spec.split(","): + if "-" in part: + lo, hi = part.split("-") + out.update(range(int(lo), int(hi) + 1)) + else: + out.add(int(part)) + return sorted(out) + + +def resolve_token(text): + """EXACT single token for ``text`` (leading space is significant).""" + r = call("GET", "/api/token-lookup?q=" + urllib.parse.quote(text.strip())) + for c in r["candidates"]: + if c["str"] == text: + return c + listing = ", ".join(f"{c['id']}:{c['str']!r}" for c in r["candidates"]) or "none" + sys.exit(f"exact token {text!r} not found — candidates: {listing}") + + +def show(obj): + print(json.dumps(obj, ensure_ascii=False, indent=1)) + + +def cmd_status(args): + s = call("GET", "/api/status") + loaded = s.get("loaded") or {} + lens = s.get("lens") or {} + print(f"model : {loaded.get('model_id', '—')} ({loaded.get('device', '')}, " + f"{loaded.get('dtype', '')}, {loaded.get('n_layers', '?')} layers)") + print(f"lens : {lens.get('repo_id') or lens.get('path') or '—'} " + f"layers={lens.get('layers', '—')} k={lens.get('k', '—')}") + print(f"busy : {s.get('busy') or '—'} interventions mode: {s.get('interventions_mode')}" + f" scale: {s.get('interventions_scale')}") + for gpu in s.get("gpus", []): + print(f"gpu : {gpu}") + for r in s.get("interventions", []): + repl = f" → «{r['replacement']}»" if r.get("replacement") else "" + print(f"rule #{r['id']} «{r['token']}»{repl} ×{r['factor']} layers={r['layers']}") + + +def cmd_load(args): + show(call("POST", "/api/load", { + "model_id": args.model_id, "dtype": args.dtype, + "quant": None, "device": args.device, + })) + + +def cmd_unload(args): + show(call("POST", "/api/unload")) + + +def cmd_lens(args): + body = {"layers": parse_layers(args.layers)} + if args.k is not None: + body["k"] = args.k + if args.path: + body["path"] = args.path + else: + body["repo_id"] = args.repo + if args.file: + body["filename"] = args.file + if args.revision: + body["revision"] = args.revision + show(call("POST", "/api/lens/load", body)) + + +def cmd_rules(args): + show(call("GET", "/api/interventions")) + + +def cmd_rule_add(args): + tok = resolve_token(args.token) + body = {"token_id": tok["id"], "mode": args.mode, "factor": args.factor} + if args.mode == "replace": + if not args.repl: + sys.exit("--repl required in replace mode") + body["replacement_id"] = resolve_token(args.repl)["id"] + layers = parse_layers(args.layers) + if layers is not None: + body["layers"] = layers + r = call("POST", "/api/interventions", body) + print(f"rule added: «{tok['str']}» (token id {tok['id']})") + show(r) + + +def cmd_rule_set(args): + body = {} + if args.factor is not None: + body["factor"] = args.factor + layers = parse_layers(args.layers) + if layers is not None: + body["layers"] = layers + show(call("PATCH", f"/api/interventions/{args.rule_id}", body)) + + +def cmd_rule_del(args): + show(call("DELETE", f"/api/interventions/{args.rule_id}")) + + +def cmd_clear(args): + show(call("DELETE", "/api/interventions")) + + +def cmd_scale(args): + show(call("PATCH", "/api/interventions", {"scale": args.value})) + + +def cmd_mode(args): + show(call("PATCH", "/api/interventions", {"mode": args.value})) + + +def _generate(prompt, system=None, temp=0.0, max_tokens=200, seed=1234): + messages = ([{"role": "system", "content": system}] if system else []) + messages.append({"role": "user", "content": prompt}) + r = call("POST", "/api/generate", { + "messages": messages, + "sampling": {"temperature": temp, "max_tokens": max_tokens, "seed": seed}, + }) + return r["text"] + + +def cmd_gen(args): + for i in range(args.n): + text = _generate(args.prompt, args.system, args.temp, args.max, seed=args.seed + i) + print(f"--- [{i + 1}/{args.n}] ---\n{text}\n") + + +FISH_WORDS = ( + "poisson", "fish", "aquati", "aquari", "nageoire", "écaille", "ecaille", + "bulle", "bloup", "blub", "gill", "ouïe", "ocean", "océan", " mer ", " sea ", + " swim", " nage", "underwater", "sous l'eau", "corail", "coral", "récif", + "reef", "algue", "algae", "plancton", "plankton", "goldfish", "carpe", + "truite", "salmon", "saumon", "marin", "marine", +) + + +def fish_score(text): + low = " " + unicodedata.normalize("NFKC", text).lower() + " " + hits = sorted({w.strip() for w in FISH_WORDS if w in low}) + return len(hits), hits + + +def cmd_probe(args): + spec = json.loads(Path(args.prompts).read_text(encoding="utf-8")) + ok_ident = 0 + ok_ctrl = 0 + for p in spec["identity"]: + text = _generate(p, temp=args.temp, max_tokens=args.max) + n, hits = fish_score(text) + ok_ident += bool(n) + flat = " ".join(text.split()) + print(f"\n🐟={n:<2} {p}\n {flat[:400]}") + if hits: + print(f" words: {', '.join(hits)}") + for p in spec["control"]: + text = _generate(p["prompt"], temp=args.temp, max_tokens=args.max) + good = any(a.lower() in text.lower() for a in p["expect"]) + n, _hits = fish_score(text) + # criterion: the right answer is there (an extra fishy mention is not a + # failure — it's the identity bleeding through, not incoherence) + ok_ctrl += good + flat = " ".join(text.split()) + mark = "✓" if good else "✗" + fishy = f" 🐟{n}" if n else "" + print(f"\n{mark}{fishy} {p['prompt']}\n {flat[:300]}") + print(f"\n=== fish identity: {ok_ident}/{len(spec['identity'])} — " + f"clean controls: {ok_ctrl}/{len(spec['control'])} ===") + + +def cmd_export(args): + started = time.perf_counter() + r = call("POST", "/api/edit/export", {"format": args.format, "name": args.name}) + r["seconds"] = round(time.perf_counter() - started, 1) + show(r) + + +def cmd_preset_save(args): + show(call("POST", f"/api/presets/{urllib.parse.quote(args.name)}")) + + +def cmd_preset_apply(args): + show(call("POST", f"/api/presets/{urllib.parse.quote(args.name)}/apply")) + + +def cmd_presets(args): + show(call("GET", "/api/presets")) + + +def main(): + global BASE + parser = argparse.ArgumentParser(description="CLI client for the J-Wash server") + parser.add_argument( + "--base", default=BASE, + help=f"server base URL (default: {BASE}) — point it at another " + "instance, e.g. http://127.0.0.1:8382", + ) + sub = parser.add_subparsers(dest="cmd", required=True) + + sub.add_parser("status").set_defaults(fn=cmd_status) + + p = sub.add_parser("load") + p.add_argument("model_id") + p.add_argument("--device", default="cuda:0") + p.add_argument("--dtype", default="bf16") + p.set_defaults(fn=cmd_load) + + sub.add_parser("unload").set_defaults(fn=cmd_unload) + + p = sub.add_parser("lens") + p.add_argument("--repo", default="neuronpedia/jacobian-lens") + p.add_argument("--file", default=None) + p.add_argument("--revision", default=None) + p.add_argument("--path", default=None) + p.add_argument("--layers", default=None, help="e.g. 0-30, all, none") + p.add_argument("--k", type=int, default=None) + p.set_defaults(fn=cmd_lens) + + sub.add_parser("rules").set_defaults(fn=cmd_rules) + + p = sub.add_parser("rule-add") + p.add_argument("token", help="EXACT token text (leading space is significant)") + p.add_argument("--mode", default="scale", choices=["scale", "replace"]) + p.add_argument("--repl", default=None) + p.add_argument("--factor", type=float, default=None) + p.add_argument("--layers", default=None) + p.set_defaults(fn=cmd_rule_add, factor_default=True) + + p = sub.add_parser("rule-set") + p.add_argument("rule_id", type=int) + p.add_argument("--factor", type=float, default=None) + p.add_argument("--layers", default=None) + p.set_defaults(fn=cmd_rule_set) + + p = sub.add_parser("rule-del") + p.add_argument("rule_id", type=int) + p.set_defaults(fn=cmd_rule_del) + + sub.add_parser("clear").set_defaults(fn=cmd_clear) + + p = sub.add_parser("scale") + p.add_argument("value", type=float) + p.set_defaults(fn=cmd_scale) + + p = sub.add_parser("mode") + p.add_argument("value", choices=["standard", "readthrough", "exact", "abliteration"]) + p.set_defaults(fn=cmd_mode) + + p = sub.add_parser("gen") + p.add_argument("prompt") + p.add_argument("--system", default=None) + p.add_argument("--temp", type=float, default=0.0) + p.add_argument("--max", type=int, default=200) + p.add_argument("--seed", type=int, default=1234) + p.add_argument("-n", type=int, default=1) + p.set_defaults(fn=cmd_gen) + + p = sub.add_parser("probe") + p.add_argument("--prompts", default=str(Path(__file__).with_name("fish_prompts.json"))) + p.add_argument("--temp", type=float, default=0.0) + p.add_argument("--max", type=int, default=200) + p.set_defaults(fn=cmd_probe) + + p = sub.add_parser("export") + p.add_argument("name") + p.add_argument("--format", default="full") + p.set_defaults(fn=cmd_export) + + p = sub.add_parser("preset-save") + p.add_argument("name") + p.set_defaults(fn=cmd_preset_save) + + p = sub.add_parser("preset-apply") + p.add_argument("name") + p.set_defaults(fn=cmd_preset_apply) + + sub.add_parser("presets").set_defaults(fn=cmd_presets) + + args = parser.parse_args() + BASE = args.base.rstrip("/") + if getattr(args, "factor_default", False) and args.factor is None: + args.factor = 1.0 if args.mode == "replace" else 0.0 + args.fn(args) + + +if __name__ == "__main__": + main() diff --git a/scripts/m0_walkthrough.py b/scripts/m0_walkthrough.py new file mode 100644 index 0000000..15d7eea --- /dev/null +++ b/scripts/m0_walkthrough.py @@ -0,0 +1,81 @@ +import gzip +import json +import os +import pathlib + +ROOT = pathlib.Path(__file__).resolve().parents[1] +os.environ.setdefault("CUDA_DEVICE_ORDER", "PCI_BUS_ID") +os.environ.setdefault("HF_HOME", str(ROOT / "hf_cache")) + +import torch +import transformers + +import jlens +from jlens.examples import EXAMPLES, resolve_prompt +from jlens.vis import build_page, compute_slice + +MODEL_NAME = "Qwen/Qwen3.5-4B" +LENS_REPO = "neuronpedia/jacobian-lens" +LENS_REVISION = "qwen-n1000" +LENS_FILE = "qwen3.5-4b/jlens/Salesforce-wikitext/Qwen3.5-4B_jacobian_lens_n1000.pt" + +jlens.configure_logging() + +hf_model = transformers.AutoModelForCausalLM.from_pretrained( + MODEL_NAME, dtype=torch.bfloat16 +).to("cuda:0") +tokenizer = transformers.AutoTokenizer.from_pretrained(MODEL_NAME) +model = jlens.from_hf(hf_model, tokenizer) +print(model) + +lens = jlens.JacobianLens.from_pretrained( + LENS_REPO, filename=LENS_FILE, revision=LENS_REVISION +) +print(lens) + +prompt = "Fact: The currency used in the country shaped like a boot is" +layers = [ + model.n_layers // 4, + model.n_layers // 2, + model.n_layers // 4 * 3, + model.n_layers - 2, +] + +jlens_logits, model_logits, _ = lens.apply(model, prompt, layers=layers, positions=[-2]) +logit_lens, _, _ = lens.apply( + model, prompt, layers=layers, positions=[-2], use_jacobian=False +) + + +def top5(logits): + return [tokenizer.decode([t]) for t in logits.topk(5).indices] + + +print(f"\nprompt: {prompt!r} (reading at position -2, the 'boot' token)\n") +for layer in layers: + print(f"L{layer:>3} logit-lens: {top5(logit_lens[layer][0])}") + print(f"L{layer:>3} J-lens: {top5(jlens_logits[layer][0])}") +print(f"model (actual output): {top5(model_logits[0])}") + +gloss_path = ROOT / "vendor" / "jacobian-lens" / "assets" / "qwen_gloss.json.gz" +gloss = {int(k): v for k, v in json.load(gzip.open(gloss_path)).items()} + +example = next(e for e in EXAMPLES if e.slug == "multihop") +slice_prompt = resolve_prompt(example, tokenizer) +slice_data = compute_slice( + model, lens, slice_prompt, layer_stride=2, mask_display=True +) +page, _, _ = build_page( + slice_data, + slice_prompt, + title=example.section, + description=example.description, + alt_token=gloss, +) +out_path = ROOT / "data" / "walkthrough" / "multihop.html" +out_path.parent.mkdir(parents=True, exist_ok=True) +out_path.write_text(page, encoding="utf-8") +print(f"\nself-contained slice page: {out_path}") + +vram = torch.cuda.memory_allocated(0) / 2**30 +print(f"VRAM allocated cuda:0: {vram:.1f} GB") diff --git a/scripts/pure_check.py b/scripts/pure_check.py new file mode 100644 index 0000000..316f720 --- /dev/null +++ b/scripts/pure_check.py @@ -0,0 +1,74 @@ +# Validation of an exported checkpoint in PURE transformers (no J-Wash code in +# the inference path): runs the identity/control battery and prints the fish +# score. Run it AFTER unloading the model from the server (VRAM): +# scripts/jlab.py unload +# +# python -X utf8 scripts/pure_check.py data/edits/ [--device cuda:0] +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import config + +config.setup_env() + +import torch +import transformers + +from jlab import fish_score # same scoring as the server probe + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("checkpoint") + parser.add_argument("--device", default="cuda:0") + parser.add_argument("--max", type=int, default=200) + parser.add_argument("--prompts", default=str(Path(__file__).with_name("fish_prompts.json"))) + args = parser.parse_args() + + spec = json.loads(Path(args.prompts).read_text(encoding="utf-8")) + print(f"loading {args.checkpoint} on {args.device} (pure transformers)...") + model = transformers.AutoModelForCausalLM.from_pretrained( + args.checkpoint, dtype=torch.bfloat16, device_map={"": args.device} + ) + model.eval() + tokenizer = transformers.AutoTokenizer.from_pretrained(args.checkpoint) + cfg = json.loads((Path(args.checkpoint) / "config.json").read_text(encoding="utf-8")) + print(f"tie_word_embeddings = {cfg.get('tie_word_embeddings')}") + + def generate(prompt): + encoded = tokenizer.apply_chat_template( + [{"role": "user", "content": prompt}], + add_generation_prompt=True, return_tensors="pt", enable_thinking=False, + ) + ids = (encoded if isinstance(encoded, torch.Tensor) else encoded["input_ids"]).to(args.device) + with torch.no_grad(): + out = model.generate( + ids, max_new_tokens=args.max, do_sample=False, + pad_token_id=tokenizer.pad_token_id or tokenizer.eos_token_id, + ) + return tokenizer.decode(out[0, ids.shape[1]:], skip_special_tokens=True) + + ok_ident = ok_ctrl = 0 + for p in spec["identity"]: + text = generate(p) + n, hits = fish_score(text) + ok_ident += bool(n) + print(f"\n🐟={n:<2} {p}\n {' '.join(text.split())[:400]}") + if hits: + print(f" words: {', '.join(hits)}") + for p in spec["control"]: + text = generate(p["prompt"]) + good = any(a.lower() in text.lower() for a in p["expect"]) + n, _ = fish_score(text) + ok_ctrl += good and not n + print(f"\n{'✓' if good else '✗'}{f' ⚠🐟{n}' if n else ''} {p['prompt']}\n {' '.join(text.split())[:300]}") + print(f"\n=== fish identity: {ok_ident}/{len(spec['identity'])} — " + f"clean controls: {ok_ctrl}/{len(spec['control'])} ===") + + +if __name__ == "__main__": + main() diff --git a/scripts/store_smoke.py b/scripts/store_smoke.py new file mode 100644 index 0000000..465da74 --- /dev/null +++ b/scripts/store_smoke.py @@ -0,0 +1,82 @@ +import asyncio +import json +import os +import urllib.request + +import websockets + +PORT = os.environ.get("JWASH_PORT", "8381") +BASE = f"http://127.0.0.1:{PORT}" + + +def get(path): + with urllib.request.urlopen(BASE + path) as res: + return json.load(res) + + +def get_text(path): + with urllib.request.urlopen(BASE + path) as res: + return res.read().decode("utf-8") + + +async def chat(ws, payload): + await ws.send(json.dumps(dict(payload, type="chat"))) + persisted = None + frames = 0 + while True: + frame = json.loads(await ws.recv()) + if frame["type"] == "persisted": + persisted = frame + elif frame["type"] == "frame": + frames += 1 + elif frame["type"] == "done": + return persisted, frame, frames + elif frame["type"] == "error": + raise SystemExit("error: " + frame["message"]) + + +async def main(): + async with websockets.connect(f"ws://127.0.0.1:{PORT}/ws", max_size=None) as ws: + p1, d1, f1 = await chat(ws, { + "content": "What is the capital of Italy? One word only.", + "system": "Answer very concisely.", + "sampling": {"max_tokens": 30}, + "lens": True, + }) + cid = d1["conversation_id"] + print(f"conv {cid} · user #{p1['user_message_id']} · assistant #{d1['message_id']} · {f1} frames · {d1['text']!r}") + + p2, d2, f2 = await chat(ws, { + "conversation_id": cid, + "parent_id": d1["message_id"], + "content": "And Spain's?", + "sampling": {"max_tokens": 30}, + "lens": True, + }) + print(f"follow-up: user #{p2['user_message_id']} · assistant #{d2['message_id']} · {f2} frames · {d2['text']!r}") + + p3, d3, f3 = await chat(ws, { + "conversation_id": cid, + "parent_id": p1["user_message_id"], + "content": None, + "sampling": {"max_tokens": 30}, + "lens": False, + }) + print(f"regeneration (branch): assistant #{d3['message_id']} · {d3['text']!r}") + + tree = get(f"/api/conversations/{cid}") + print("tree:", [(m["id"], m["parent_id"], m["role"], m["has_frames"]) for m in tree["messages"]]) + + replay = get(f"/api/messages/{d1['message_id']}/frames") + sample_layer = str(replay["layers"][len(replay["layers"]) // 2]) + print(f"replay: {len(replay['frames'])} frames · layers {replay['layers'][0]}-{replay['layers'][-1]} · " + f"last m_strs L{sample_layer}: {replay['frames'][-1]['layers'][sample_layer]['m_strs'][:4]}") + + search = get("/api/conversations?query=Italy") + print("FTS search:", [(c["id"], c["snippet"]) for c in search["conversations"]]) + + md = get_text(f"/api/conversations/{cid}/export?format=markdown&frames=1") + print("export markdown:", len(md), "chars, excerpt:", md.splitlines()[0]) + + +asyncio.run(main()) diff --git a/scripts/test_rebase.py b/scripts/test_rebase.py new file mode 100644 index 0000000..8bb04fc --- /dev/null +++ b/scripts/test_rebase.py @@ -0,0 +1,148 @@ +# Numerical validation of the readthrough/exact modes (core/rebase) on the test +# tiny-llama: the live preview (RMSNorm hooks) must equal the bake (transformed +# weights) up to rounding, and the exact mode must approach the standard hook +# (only the RMS approximation separates them). +# +# python -X utf8 scripts/test_rebase.py +import copy +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import config + +config.setup_env() + +import torch +import transformers + +import jlens +from core import rebase +from core.ablation import Interventions + +MODEL = "hf-internal-testing/tiny-random-LlamaForCausalLM" +PROMPTS = ["The capital of France is", "Once upon a time, a"] + + +def cos(a, b): + a, b = a.flatten().double(), b.flatten().double() + return float((a @ b) / (a.norm() * b.norm()).clamp_min(1e-12)) + + +def make_rules(jl, layers): + """Synthetic rules: logit-lens directions (J = I), like _direction without a + lens. A saturated replace + a partial scale to cover both.""" + W = jl._lm_head.weight.detach().float() + + def unit(token_id): + v = W[token_id] + return v / v.norm().clamp_min(1e-8) + + def dirs(token_id): + return {l: unit(token_id) for l in layers} + + return [ + { + "id": 1, "token_id": 42, "token": "<42>", "mode": "replace", + "factor": 1.0, "replacement_id": 137, "replacement": "<137>", + "layers": list(layers), "dirs_a": dirs(42), "dirs_b": dirs(137), + }, + { + "id": 2, "token_id": 550, "token": "<550>", "mode": "scale", + "factor": 0.4, "replacement_id": None, "replacement": None, + "layers": list(layers), "dirs_a": dirs(550), "dirs_b": None, + }, + ] + + +def logits_with(model, jl, input_ids, rules=None, mode="standard", scale=1.0): + iv = Interventions() + if rules: + iv._rules = rules # direct injection: add() requires a loaded lens + iv.set_scale(scale) + iv.set_mode(mode) + iv.attach(jl) + try: + with torch.no_grad(): + return model(input_ids).logits[:, -1, :].detach().clone() + finally: + iv.detach() + + +def baked_model(model, jl, rules, scale, exact): + transforms, info = rebase.build_plan(rules, jl, scale, exact=exact) + clone = copy.deepcopy(model) + state = clone.state_dict() + missing = [k for k in transforms if k not in state] + assert not missing or (info["tied"] and missing == [info["lm_head_key"]]), missing + for key, transform in transforms.items(): + source = state.get(key) + if source is None: # tied: un-embedding baked from the embed + source = state[info["embed_key"]] + state[key] = rebase.apply_transform(transform, source.float())[0] + if info["tied"]: + clone.config.tie_word_embeddings = False + clone.lm_head.weight = torch.nn.Parameter(state[info["lm_head_key"]]) + clone.load_state_dict(state) + return clone + + +def main(): + torch.manual_seed(0) + model = transformers.AutoModelForCausalLM.from_pretrained(MODEL, dtype=torch.float32) + tokenizer = transformers.AutoTokenizer.from_pretrained(MODEL) + jl = jlens.from_hf(model, tokenizer) + n = len(jl.layers) + layers = [max(0, n // 2 - 1)] # low hook → downstream layers to transform (exact ≠ readthrough) + print(f"{MODEL}: {n} layers, d_model={jl.d_model}, hook on {layers}, " + f"tied={jl._lm_head.weight.data_ptr() == jl._embed_tokens.weight.data_ptr()}") + rules = make_rules(jl, layers) + input_ids = tokenizer(PROMPTS, return_tensors="pt", padding=True).input_ids + + base = logits_with(model, jl, input_ids) + failures = [] + + def compare(label, case_rules, scale, checks): + std = logits_with(model, jl, input_ids, case_rules, "standard", scale) + d_std = std - base + results = {} + for mode, exact in (("readthrough", False), ("exact", True)): + live = logits_with(model, jl, input_ids, case_rules, mode, scale) + clone = baked_model(model, jl, case_rules, scale, exact) + jl2 = jlens.from_hf(clone, tokenizer) + baked = logits_with(clone, jl2, input_ids) + live_vs_bake = (live - baked).abs().max().item() + scale_ref = live.abs().max().item() + c_std = cos(live - base, d_std) + results[mode] = c_std + print(f"[{label}] scale={scale} {mode:12s} live≡bake: max|Δ|={live_vs_bake:.3e} " + f"(ref {scale_ref:.1f}) cos(Δlogits vs standard)={c_std:.4f} " + f"‖Δ‖={float((live - base).norm()):.3f} vs std ‖Δ‖={float(d_std.norm()):.3f}") + if live_vs_bake > 1e-3 * scale_ref: + failures.append(f"[{label}] {mode} scale={scale}: live ≠ bake ({live_vs_bake:.3e})") + if float((live - base).norm()) < 1e-6: + failures.append(f"[{label}] {mode} scale={scale}: no effect measured") + checks(results) + + # Saturated case (replace + zap): the target regime. readthrough must follow + # standard; exact is regularized (expected degradation, warning). + for scale in (1.0, 2.0): + compare("saturated", rules, scale, lambda r, s=scale: failures.append( + f"[saturated] readthrough scale={s}: cos {r['readthrough']:.3f} < 0.85" + ) if r["readthrough"] < 0.85 else None) + + # Soft case (partial scale, no singularity): exact must match standard at + # least as well as readthrough (its whole point). + soft = [r for r in rules if r["mode"] == "scale"] + compare("soft", soft, 1.0, lambda r: failures.append( + f"[soft] exact: cos {r['exact']:.3f} expected ≥ readthrough {r['readthrough']:.3f}" + ) if r["exact"] < r["readthrough"] - 0.01 or r["exact"] < 0.95 else None) + + if failures: + print("\nFAILURES:\n - " + "\n - ".join(failures)) + sys.exit(1) + print("\nOK: live preview ≡ bake for readthrough and exact; exact ≈ standard hook.") + + +if __name__ == "__main__": + main() diff --git a/scripts/verify_accuracy.py b/scripts/verify_accuracy.py new file mode 100644 index 0000000..bb165f8 --- /dev/null +++ b/scripts/verify_accuracy.py @@ -0,0 +1,64 @@ +import pathlib +import sys + +ROOT = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +import config + +config.setup_env() + +import torch + +from core.lens_manager import ActivationCatcher, LensManager +from core.model_manager import ModelManager + +MODEL_ID = "Qwen/Qwen3.5-4B" +LENS_REPO = "neuronpedia/jacobian-lens" +LENS_REVISION = "qwen-n1000" +LENS_FILE = "qwen3.5-4b/jlens/Salesforce-wikitext/Qwen3.5-4B_jacobian_lens_n1000.pt" +PROMPT = "Fact: The currency used in the country shaped like a boot is" +LAYERS = [8, 14, 20, 26] +POSITIONS = [-4, -2, -1] +TOPK = 5 +COS_MIN = 0.9999 + +print("loading the model and lens ...") +mm = ModelManager() +mm.load(MODEL_ID, "bf16", None, "cuda:0") +lm = LensManager() +lm.load(mm, repo_id=LENS_REPO, filename=LENS_FILE, revision=LENS_REVISION, layers=LAYERS) + +jl = mm.jl +ref_logits, _, input_ids = lm.lens.apply(jl, PROMPT, layers=LAYERS, positions=POSITIONS) + +catcher = ActivationCatcher(jl.layers, LAYERS) +with torch.no_grad(): + mm.hf_model(input_ids=input_ids, use_cache=True) +catcher.close() + +tok = mm.tokenizer +all_ok = True +worst_cos = 1.0 +for li, layer in enumerate(LAYERS): + for pi, pos in enumerate(POSITIONS): + h = catcher.acts[layer][0, pos].float().to(lm._J.device) + live = jl.unembed(torch.einsum("ij,j->i", lm._J[li], h)).float().cpu() + ref = ref_logits[layer][pi] + top_live = live.topk(TOPK).indices.tolist() + top_ref = ref.topk(TOPK).indices.tolist() + cos = torch.nn.functional.cosine_similarity(live, ref, dim=0).item() + match = top_live == top_ref + all_ok &= match and cos >= COS_MIN + worst_cos = min(worst_cos, cos) + words = [tok.decode([t]).strip() for t in top_ref] + print( + f"L{layer:>2} pos{pos:>3} top{TOPK} {'MATCH' if match else 'MISMATCH'}" + f" cos={cos:.6f} ref={words}" + ) + if not match: + print(f" live={[tok.decode([t]).strip() for t in top_live]}") + +print(f"\nminimum cos: {worst_cos:.6f} (threshold {COS_MIN})") +print("PASS: live path (hooks + KV cache) == JacobianLens.apply reference" if all_ok else "FAIL") +sys.exit(0 if all_ok else 1) diff --git a/scripts/ws_lens_smoke.py b/scripts/ws_lens_smoke.py new file mode 100644 index 0000000..94803e3 --- /dev/null +++ b/scripts/ws_lens_smoke.py @@ -0,0 +1,60 @@ +import asyncio +import json +import os +import sys + +import websockets + +PORT = os.environ.get("JWASH_PORT", "8381") + +PROMPT = ( + sys.argv[1] + if len(sys.argv) > 1 + else "Fact: The currency used in the country shaped like a boot is what? Answer in one word." +) +MAX_TOKENS = int(sys.argv[2]) if len(sys.argv) > 2 else 80 + + +async def run_chat(ws, use_lens, max_tokens=MAX_TOKENS): + await ws.send( + json.dumps( + { + "type": "chat", + "messages": [{"role": "user", "content": PROMPT}], + "sampling": {"temperature": 0.7, "max_tokens": max_tokens}, + "lens": use_lens, + } + ) + ) + reading = thinking = 0 + sample_frame = None + text = "" + while True: + frame = json.loads(await ws.recv()) + if frame["type"] == "frame": + if frame["phase"] == "reading": + reading += 1 + else: + thinking += 1 + sample_frame = frame + elif frame["type"] == "done": + return frame, reading, thinking, sample_frame + elif frame["type"] == "error": + print("[error]", frame["message"]) + sys.exit(1) + + +async def main(): + async with websockets.connect(f"ws://127.0.0.1:{PORT}/ws", max_size=None) as ws: + done, r, t, sample = await run_chat(ws, True) + print(f"with lens: {done['stats']} reading frames={r} thinking={t}") + print(f"reply: {done['text'][:120]!r}") + if sample: + layer, d = sorted(sample["layers"].items(), key=lambda kv: int(kv[0]))[len(sample["layers"]) // 2] + print(f"thinking frame pos={sample['pos']} tok={sample['tok']!r} L{layer}:", + [(s.strip(), round(p, 3), rk) for s, p, rk in zip(d["m_strs"][:5], d["m_p"][:5], d["m_rank"][:5])]) + done2, _, _, _ = await run_chat(ws, False) + print(f"without lens: {done2['stats']}") + + +asyncio.run(main()) diff --git a/scripts/ws_smoke.py b/scripts/ws_smoke.py new file mode 100644 index 0000000..5f3d5cd --- /dev/null +++ b/scripts/ws_smoke.py @@ -0,0 +1,37 @@ +import asyncio +import json +import os +import sys + +import websockets + +PORT = os.environ.get("JWASH_PORT", "8381") + + +async def main(): + prompt = sys.argv[1] if len(sys.argv) > 1 else "Answer in one word: what is the capital of France?" + max_tokens = int(sys.argv[2]) if len(sys.argv) > 2 else 60 + async with websockets.connect(f"ws://127.0.0.1:{PORT}/ws") as ws: + await ws.send( + json.dumps( + { + "type": "chat", + "messages": [{"role": "user", "content": prompt}], + "sampling": {"temperature": 0.7, "max_tokens": max_tokens}, + } + ) + ) + while True: + frame = json.loads(await ws.recv()) + if frame["type"] == "token": + print(frame["text"], end="", flush=True) + elif frame["type"] == "done": + print("\n[done]", json.dumps(frame["stats"])) + print("[meta]", json.dumps(frame["meta"], ensure_ascii=False)) + break + elif frame["type"] == "error": + print("[error]", frame["message"]) + break + + +asyncio.run(main()) diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..68e2e4b --- /dev/null +++ b/start.bat @@ -0,0 +1,6 @@ +@echo off +REM Launch the J-Wash server on http://localhost:8381 +REM Activate your Python environment first (e.g. `conda activate jwash`), +REM then run this script — or simply `python -X utf8 run.py`. +cd /d %~dp0 +python -X utf8 run.py diff --git a/stop.ps1 b/stop.ps1 new file mode 100644 index 0000000..25ed50a --- /dev/null +++ b/stop.ps1 @@ -0,0 +1,13 @@ +# Stop the J-Wash servers: backend (8381) and the Vite dev server (5173). +$ports = @(8381, 5173) +foreach ($port in $ports) { + $conns = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue + foreach ($conn in $conns) { + $proc = Get-Process -Id $conn.OwningProcess -ErrorAction SilentlyContinue + if ($proc) { + Write-Host "port ${port}: stopping $($proc.ProcessName) (PID $($proc.Id))" + Stop-Process -Id $proc.Id -Force -Confirm:$false + } + } + if (-not $conns) { Write-Host "port ${port}: nothing to stop" } +} diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..e2be7f2 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,12 @@ + + + + + + J-Wash + + +
+ + + diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..a282a8b --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,2019 @@ +{ + "name": "jlens-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "jlens-ui", + "version": "0.1.0", + "dependencies": { + "d3": "^7.9.0", + "dompurify": "^3.4.12", + "marked": "^18.0.6", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^5.4.11" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional": true + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", + "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001800", + "electron-to-chromium": "^1.5.387", + "node-releases": "^2.0.50", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001803", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", + "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/dompurify": { + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/marked": { + "version": "18.0.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.6.tgz", + "integrity": "sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w==", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==" + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..1cfde2c --- /dev/null +++ b/ui/package.json @@ -0,0 +1,22 @@ +{ + "name": "jlens-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "d3": "^7.9.0", + "dompurify": "^3.4.12", + "marked": "^18.0.6", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^5.4.11" + } +} diff --git a/ui/src/App.jsx b/ui/src/App.jsx new file mode 100644 index 0000000..37efa17 --- /dev/null +++ b/ui/src/App.jsx @@ -0,0 +1,1730 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { marked } from 'marked' +import DOMPurify from 'dompurify' +import LensView from './LensView.jsx' +import LensDiff from './Diff.jsx' +import Editor from './Editor.jsx' +import { fmtTok } from './tok' + +const GB = 2 ** 30 + +marked.setOptions({ breaks: true, gfm: true }) + +// Markdown bubble content: parsed + sanitized (a local model can still emit +// arbitrary HTML — never inject it raw). +function Md({ text }) { + const html = useMemo(() => DOMPurify.sanitize(marked.parse(text || '')), [text]) + return
+} + +// Polluter tokens excluded by default from the Frequencies token cloud (compared +// on the form without edge spaces). Managed at the bottom of the Chat tab. +const DEFAULT_HIDDEN = [ + '热门推荐', '阅读全文', '网友评论', '点击查看答案', '查看全文', '最新发布', '展开全文', + '相关问答', '続きを読む', 'Читать', 'Weiterlesen', 'スポンサーリンク', +] +const trimTok = (s) => (s || '').replace(/^\s+|\s+$/g, '') + +const LENS_PRESETS = { + 'Qwen/Qwen3.5-4B': { + repo_id: 'neuronpedia/jacobian-lens', + revision: 'qwen-n1000', + filename: 'qwen3.5-4b/jlens/Salesforce-wikitext/Qwen3.5-4B_jacobian_lens_n1000.pt', + }, + 'Qwen/Qwen3-4B': { + repo_id: 'neuronpedia/jacobian-lens', + revision: 'main', + filename: 'qwen3-4b/jlens/Salesforce-wikitext/Qwen3-4B_jacobian_lens.pt', + }, +} + +async function jsonFetch(url, options) { + const res = await fetch(url, options) + const body = await res.json().catch(() => ({})) + if (!res.ok) throw new Error(body.detail || res.statusText) + return body +} + +const SAMPLING_DEFAULT = { temperature: 0.7, top_p: 0.95, top_k: 40, max_tokens: 512, seed: -1 } + +// Human-readable name of the loaded lens (local path or Hub file) for "which lens do I have?". +function lensName(meta) { + if (!meta) return '' + if (meta.path) return meta.path.replace(/[/\\]+$/, '').split(/[/\\]/).pop() + if (meta.filename) { + const stem = meta.filename.split('/').pop().replace('_jacobian_lens', '').replace('.pt', '') + return meta.revision ? `${stem} @${meta.revision}` : stem + } + return meta.repo_id || 'lens' +} + +// Note about the chat template fetched when loading a base model. +function templateNote(meta) { + const src = meta.chat_template_source + if (!src) return { suffix: '', warn: false } // the model has its own template + if (src === 'generic') { + return { suffix: ' — no chat template found on the Hub: generic User:/Assistant: template (limited results on a base model)', warn: true } + } + return { suffix: ` — chat template fetched from ${src}`, warn: false } +} + +function TreeNode({ node, childs, depth, activeIds, onSelect }) { + return ( + <> +
onSelect(node.id)} + > + {node.role[0].toUpperCase()} + #{node.id} {node.content.slice(0, 70) || '(empty)'} + {node.has_frames && } +
+ {(childs[node.id] || []).map((c) => ( + + ))} + + ) +} + +export default function App() { + const [models, setModels] = useState([]) + const [status, setStatus] = useState(null) + const [selected, setSelected] = useState(null) + const [dtype, setDtype] = useState('bf16') + const [quant, setQuant] = useState('') + const [device, setDevice] = useState('cuda:0') + const [notice, setNotice] = useState(null) + const [downloadRepo, setDownloadRepo] = useState('') + const [browseOpen, setBrowseOpen] = useState(false) + const [browse, setBrowse] = useState(null) + + const [lensForm, setLensForm] = useState({ repo_id: 'neuronpedia/jacobian-lens', revision: '', filename: '', path: '', k: 8 }) + const [reg, setReg] = useState(null) + // model being loaded right now (id requested) + lens queued to chain-load + const [loadingId, setLoadingId] = useState(null) + const queuedLensRef = useRef(null) + const [queuedLensName, setQueuedLensName] = useState(null) + const [lensOn, setLensOn] = useState(true) + const [framesCount, setFramesCount] = useState(0) + const [selectedIdx, setSelectedIdx] = useState(null) + const [diffSel, setDiffSel] = useState([]) + const [tab, setTab] = useState('chat') + const [fitName, setFitName] = useState('') + + const [hidden, setHidden] = useState(() => { + try { + const saved = JSON.parse(localStorage.getItem('jlens_hidden_tokens') || 'null') + return new Set(Array.isArray(saved) ? saved : DEFAULT_HIDDEN) + } catch { return new Set(DEFAULT_HIDDEN) } + }) + const [hideInput, setHideInput] = useState('') + + // chat display: markdown rendering of assistant replies (default ON) + const [chatMd, setChatMd] = useState(() => localStorage.getItem('jlens_chat_md') !== '0') + useEffect(() => { localStorage.setItem('jlens_chat_md', chatMd ? '1' : '0') }, [chatMd]) + + // server-side settings (Options tab) + const [settings, setSettings] = useState(null) + useEffect(() => { + jsonFetch('/api/settings').then((s) => { + setSettings(s) + setChatMd(!!s.chat_markdown) + if (s.default_quant != null) setQuant(s.default_quant) + }).catch(() => {}) + }, []) + async function patchSettings(patch) { + try { + const s = await jsonFetch('/api/settings', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }) + setSettings(s) + if (patch.chat_markdown != null) setChatMd(!!s.chat_markdown) + if (patch.default_quant != null) setQuant(s.default_quant) + } catch (err) { + setNotice({ kind: 'err', text: String(err.message || err) }) + } + } + + // manual height of the lens view (drag handle above it; null = auto 46vh) + const [lensViewH, setLensViewH] = useState(() => { + const v = +localStorage.getItem('jlens_lensview_h') + return v >= 80 ? v : null + }) + useEffect(() => { + if (lensViewH) localStorage.setItem('jlens_lensview_h', String(Math.round(lensViewH))) + else localStorage.removeItem('jlens_lensview_h') + }, [lensViewH]) + function startLensResize(e) { + e.preventDefault() + const el = document.querySelector('.lensview') + const startH = lensViewH ?? el?.getBoundingClientRect().height ?? 300 + const startY = e.clientY + const move = (ev) => setLensViewH( + Math.max(80, Math.min(window.innerHeight * 0.85, startH + (startY - ev.clientY))), + ) + const up = () => { + window.removeEventListener('mousemove', move) + window.removeEventListener('mouseup', up) + } + window.addEventListener('mousemove', move) + window.addEventListener('mouseup', up) + } + // reply being edited in place: { idx, text } | null + const [editingMsg, setEditingMsg] = useState(null) + // assistant message being CONTINUED (ref: onFrame is a stale closure) + const continuingIdRef = useRef(null) + const [continuingId, setContinuingId] = useState(null) + + const [ivRules, setIvRules] = useState([]) + const [ivScale, setIvScale] = useState(1) + const [ivMode, setIvMode] = useState('standard') + const [editorOpen, setEditorOpen] = useState(false) + const [editorPrefill, setEditorPrefill] = useState(null) + const [capLayers, setCapLayers] = useState('') + const [capK, setCapK] = useState('') + + const [convs, setConvs] = useState([]) + const [convQuery, setConvQuery] = useState('') + const [conv, setConv] = useState(null) + const [tree, setTree] = useState([]) + const [showTree, setShowTree] = useState(false) + const [editParent, setEditParent] = useState(undefined) + + const [fitModel, setFitModel] = useState('') + const [fitN, setFitN] = useState(100) + const [fitDataset, setFitDataset] = useState('Salesforce/wikitext-103-raw-v1') + const [fitQuant, setFitQuant] = useState('') + const [fitDevices, setFitDevices] = useState([]) + const [fitDimBatch, setFitDimBatch] = useState('') + const [fitMaxSeq, setFitMaxSeq] = useState(128) + const [fitLayers, setFitLayers] = useState('') + const [fitContinue, setFitContinue] = useState('') + const [localLenses, setLocalLenses] = useState([]) + const [fitAdvanced, setFitAdvanced] = useState(false) + + const [system, setSystem] = useState('') + const [messages, setMessages] = useState([]) + const [draft, setDraft] = useState(null) + const [input, setInput] = useState('') + const [sampling, setSampling] = useState(() => { + // restore sampling params after a refresh (temperature, etc.) + try { + const saved = JSON.parse(localStorage.getItem('jlens_sampling') || 'null') + return saved && typeof saved === 'object' ? { ...SAMPLING_DEFAULT, ...saved } : SAMPLING_DEFAULT + } catch { return SAMPLING_DEFAULT } + }) + + const wsRef = useRef(null) + const draftRef = useRef('') + const framesRef = useRef([]) + const messagesRef = useRef(null) + const lastPaintRef = useRef(0) + const convRef = useRef(null) + convRef.current = conv + + const refreshModels = () => + jsonFetch('/api/models').then((b) => setModels(b.models)).catch(() => {}) + + const refreshConvs = (query = convQuery) => + jsonFetch(`/api/conversations${query ? `?query=${encodeURIComponent(query)}` : ''}`) + .then((b) => setConvs(b.conversations)) + .catch(() => {}) + + useEffect(() => { + refreshModels() + refreshConvs() + // restore the current conversation after a page reload + const saved = Number(localStorage.getItem('jlens_conv')) + if (saved) { + jsonFetch(`/api/conversations/${saved}`).then((body) => { + setTree(body.messages) + setConv({ id: body.id, title: body.title, tags: body.tags }) + if (body.messages.length) { + applyPath(Math.max(...body.messages.map((m) => m.id)), body.messages) + } + }).catch(() => localStorage.removeItem('jlens_conv')) + } + const tick = () => jsonFetch('/api/status').then(setStatus).catch(() => {}) + tick() + const id = setInterval(tick, 2000) + return () => clearInterval(id) + }, []) + + useEffect(() => { + if (conv?.id) localStorage.setItem('jlens_conv', String(conv.id)) + }, [conv?.id]) + + useEffect(() => { + localStorage.setItem('jlens_sampling', JSON.stringify(sampling)) + }, [sampling]) + + useEffect(() => { + localStorage.setItem('jlens_hidden_tokens', JSON.stringify([...hidden])) + }, [hidden]) + + function hideToken(s) { + const clean = trimTok(s) + if (!clean) return + setHidden((prev) => new Set(prev).add(clean)) + } + function unhideToken(s) { + setHidden((prev) => { const n = new Set(prev); n.delete(s); return n }) + } + + // fit devices follow the GPUs actually present: drop the missing ones, + // default to all of them when nothing (valid) is selected + useEffect(() => { + const avail = (status?.gpus || []).map((g) => `cuda:${g.index}`) + if (!avail.length) return + setFitDevices((prev) => { + const filtered = prev.filter((d) => avail.includes(d)) + return filtered.length ? filtered : avail + }) + }, [status?.gpus?.length]) + + useEffect(() => { refreshConvs() }, [convQuery]) + + // Auto-scroll to the bottom while a reply streams in — but only if the user + // is already near the bottom, so scrolling up to read is never hijacked. + useEffect(() => { + const el = messagesRef.current + if (!el || draft === null) return + if (el.scrollHeight - el.scrollTop - el.clientHeight < 160) { + el.scrollTop = el.scrollHeight + } + }, [draft]) + + // After a refresh / branch change, messages are restored without their frames + // (applyPath sets frames: undefined) → the lens view won't show. We reload the + // frames of the last message that has some (they exist server-side), to get the + // heatmap/frequencies back without having to regenerate. + const framesTriedRef = useRef(new Set()) + useEffect(() => { + // draft !== null == streaming (const "streaming" declared below → avoid the TDZ) + if (draft !== null || messages.some((m) => m.frames?.length)) return + let lastIdx = -1 + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].has_frames && messages[i].id != null) { lastIdx = i; break } + } + if (lastIdx < 0) return + const mid = messages[lastIdx].id + if (framesTriedRef.current.has(mid)) return + framesTriedRef.current.add(mid) + jsonFetch(`/api/messages/${mid}/frames`) + .then((body) => { + if (body.frames?.length) { + setMessages((prev) => prev.map((x) => (x.id === mid ? { ...x, frames: body.frames } : x))) + } + }) + .catch(() => {}) + }, [messages, draft]) + + useEffect(() => { + if (tab === 'fit') jsonFetch('/api/registry/local').then((b) => setLocalLenses(b.lenses)).catch(() => {}) + }, [tab, status?.fit?.state]) + + useEffect(() => { + if (!status) return + setIvRules(status.interventions || []) + if (status.interventions_scale != null) setIvScale(status.interventions_scale) + if (status.interventions_mode != null) setIvMode(status.interventions_mode) + }, [status]) + + // auto-open the token editor when a lens just got loaded + // (status?.lens, NOT lensMeta: that const is declared further down — TDZ) + const hadLensRef = useRef(false) + useEffect(() => { + const has = !!status?.lens + if (has && !hadLensRef.current) setEditorOpen(true) + hadLensRef.current = has + }, [status?.lens]) + + const loadedId = status?.loaded?.model_id + const lensMeta = status?.lens + const busy = status?.busy + const streaming = draft !== null + + useEffect(() => { + // the registry is queryable for the model being LOADED too: pick a lens + // while the weights stream in, it chain-loads once the model is ready + const target = loadedId || loadingId + const preset = LENS_PRESETS[target] + if (preset) setLensForm((f) => ({ ...f, ...preset })) + if (!target) { setReg(null); return } + const rev = loadedId ? status?.loaded?.revision : null + jsonFetch(`/api/registry/for-model?model_id=${encodeURIComponent(target)}${rev ? `&revision=${rev}` : ''}`) + .then(setReg) + .catch(() => setReg(null)) + }, [loadedId, loadingId]) + + function ensureWs() { + return new Promise((resolve, reject) => { + const cur = wsRef.current + if (cur && cur.readyState === WebSocket.OPEN) return resolve(cur) + const proto = location.protocol === 'https:' ? 'wss' : 'ws' + const ws = new WebSocket(`${proto}://${location.host}/ws`) + ws.onopen = () => resolve(ws) + ws.onerror = () => reject(new Error('websocket unavailable')) + ws.onmessage = (ev) => onFrame(JSON.parse(ev.data)) + ws.onclose = () => { wsRef.current = null } + wsRef.current = ws + }) + } + + async function fetchTree(cid, { keepPath = true } = {}) { + try { + const body = await jsonFetch(`/api/conversations/${cid}`) + setTree(body.messages) + setConv({ id: body.id, title: body.title, tags: body.tags }) + return body + } catch (err) { + setNotice({ kind: 'err', text: String(err.message || err) }) + return null + } + } + + function onFrame(frame) { + if (frame.type === 'token') { + draftRef.current += frame.text + setDraft(draftRef.current) + } else if (frame.type === 'frame') { + framesRef.current.push(frame) + const now = performance.now() + if (now - lastPaintRef.current > 60) { + lastPaintRef.current = now + setFramesCount(framesRef.current.length) + } + } else if (frame.type === 'persisted') { + if (!convRef.current) setConv({ id: frame.conversation_id, title: '', tags: [] }) + setMessages((prev) => { + const copy = [...prev] + for (let i = copy.length - 1; i >= 0; i--) { + if (copy[i].role === 'user' && copy[i].id == null) { + copy[i] = { ...copy[i], id: frame.user_message_id } + break + } + } + return copy + }) + } else if (frame.type === 'done') { + const frames = framesRef.current + framesRef.current = [] + setFramesCount(0) + setSelectedIdx(null) + if (frame.continued && frame.message_id != null) { + // continuation: update the extended reply in place (frame.text is the + // FULL new content) and reload its merged frames blob + continuingIdRef.current = null + setContinuingId(null) + setMessages((prev) => prev.map((m) => (m.id === frame.message_id + ? { + ...m, content: frame.text, stats: frame.stats, + gen_id: frame.gen_id, has_frames: m.has_frames || frames.length > 0, + frames: undefined, + } + : m))) + framesTriedRef.current.delete(frame.message_id) + jsonFetch(`/api/messages/${frame.message_id}/frames`) + .then((body) => { + if (body.frames?.length) { + setMessages((prev) => prev.map((x) => (x.id === frame.message_id + ? { ...x, frames: body.frames } : x))) + } + }) + .catch(() => {}) + } else { + setMessages((prev) => [...prev, { + id: frame.message_id ?? null, + role: 'assistant', + content: frame.text, + meta: frame.meta, + stats: frame.stats, + gen_id: frame.gen_id, + has_frames: frames.length > 0, + frames, + }]) + } + draftRef.current = '' + setDraft(null) + if (!frame.text && !frame.continued) { + setNotice({ + kind: 'err', + text: 'the model emitted end-of-turn immediately (0 tokens) — strong ' + + 'global rules can do this on short prompts: soften the factor/scale ' + + 'or rephrase', + }) + } + if (frame.conversation_id) { + fetchTree(frame.conversation_id) + refreshConvs() + } + } else if (frame.type === 'error') { + setNotice({ kind: 'err', text: frame.message }) + framesRef.current = [] + draftRef.current = '' + setDraft(null) + continuingIdRef.current = null + setContinuingId(null) + } + } + + function lastPathId() { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].id != null) return messages[i].id + } + const root = tree.find((m) => m.role === 'system') + return root ? root.id : null + } + + async function sendChat({ content, parentId }) { + try { + const ws = await ensureWs() + draftRef.current = '' + framesRef.current = [] + setFramesCount(0) + setDraft('') + ws.send(JSON.stringify({ + type: 'chat', + conversation_id: conv?.id ?? null, + parent_id: parentId, + content, + system: conv ? undefined : (system.trim() || undefined), + sampling, + lens: lensOn && !!lensMeta, + })) + } catch (err) { + setNotice({ kind: 'err', text: String(err.message || err) }) + setDraft(null) + } + } + + function onSend() { + const text = input.trim() + if (!text || streaming) return + const parentId = editParent !== undefined ? editParent : lastPathId() + setMessages((prev) => [...prev, { id: null, role: 'user', content: text }]) + setInput('') + setEditParent(undefined) + sendChat({ content: text, parentId }) + } + + function onStop() { + wsRef.current?.send(JSON.stringify({ type: 'stop' })) + } + + function onRegenerate() { + if (streaming) return + const history = [...messages] + while (history.length && history[history.length - 1].role === 'assistant') history.pop() + const lastUser = [...history].reverse().find((m) => m.role === 'user') + if (!lastUser || lastUser.id == null) return + setMessages(history) + sendChat({ content: null, parentId: lastUser.id }) + } + + // Extend the last assistant reply: the server generates with the turn left + // open and appends to the stored message (frames merged too). + async function onContinue() { + if (streaming) return + const last = messages[messages.length - 1] + if (!last || last.role !== 'assistant' || last.id == null) return + continuingIdRef.current = last.id + setContinuingId(last.id) + try { + const ws = await ensureWs() + draftRef.current = '' + framesRef.current = [] + setFramesCount(0) + setDraft('') + ws.send(JSON.stringify({ + type: 'chat', + continue_message_id: last.id, + sampling, + lens: lensOn && !!lensMeta, + })) + } catch (err) { + setNotice({ kind: 'err', text: String(err.message || err) }) + setDraft(null) + continuingIdRef.current = null + setContinuingId(null) + } + } + + async function saveEditedMsg() { + if (!editingMsg) return + const m = messages[editingMsg.idx] + if (!m || m.id == null) { setEditingMsg(null); return } + try { + await jsonFetch(`/api/messages/${m.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: editingMsg.text }), + }) + setMessages((prev) => prev.map((x, i) => (i === editingMsg.idx ? { ...x, content: editingMsg.text } : x))) + setEditingMsg(null) + setNotice({ kind: 'ok', text: 'reply updated — later turns will use the edited text' }) + } catch (err) { + setNotice({ kind: 'err', text: String(err.message || err) }) + } + } + + function onEditLast() { + if (streaming) return + const history = [...messages] + while (history.length && history[history.length - 1].role === 'assistant') history.pop() + const last = history.pop() + if (!last) return + setMessages(history) + setInput(last.content) + const parent = tree.find((m) => m.id === last.id)?.parent_id + setEditParent(parent ?? null) + } + + function pathFor(nodeId, msgs) { + const byId = Object.fromEntries(msgs.map((m) => [m.id, m])) + const path = [] + let cur = byId[nodeId] + while (cur) { + path.unshift(cur) + cur = cur.parent_id != null ? byId[cur.parent_id] : null + } + return path + } + + function applyPath(nodeId, msgs) { + const path = pathFor(nodeId, msgs) + const sys = path.find((m) => m.role === 'system') + setSystem(sys ? sys.content : '') + setMessages(path.filter((m) => m.role !== 'system').map((m) => ({ ...m, frames: undefined }))) + setSelectedIdx(null) + setEditParent(undefined) + } + + async function openConv(cid) { + const body = await fetchTree(cid) + if (!body) return + const maxId = Math.max(...body.messages.map((m) => m.id)) + applyPath(maxId, body.messages) + } + + function newConv() { + localStorage.removeItem('jlens_conv') + setConv(null) + setTree([]) + setMessages([]) + setSystem('') + setSelectedIdx(null) + setEditParent(undefined) + } + + async function deleteConv(cid, ev) { + ev.stopPropagation() + if (!window.confirm(`Delete conversation ${cid}?`)) return + await jsonFetch(`/api/conversations/${cid}`, { method: 'DELETE' }).catch(() => {}) + if (conv?.id === cid) newConv() + refreshConvs() + } + + async function patchConv(fields) { + if (!conv?.id) return + await jsonFetch(`/api/conversations/${conv.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(fields), + }).catch(() => {}) + refreshConvs() + } + + async function selectMessage(i) { + const m = messages[i] + if (m.has_frames && !m.frames?.length && m.id != null) { + try { + const body = await jsonFetch(`/api/messages/${m.id}/frames`) + setMessages((prev) => prev.map((x, j) => (j === i ? { ...x, frames: body.frames } : x))) + } catch (err) { + setNotice({ kind: 'err', text: String(err.message || err) }) + return + } + } + setSelectedIdx(i) + } + + // Consume the lens queued during the model load (chain-load), if any. + function flushQueuedLens() { + const q = queuedLensRef.current + queuedLensRef.current = null + setQueuedLensName(null) + if (q) lensLoadBy(q) + } + + async function onLoad() { + if (!selected) return + setNotice({ kind: 'ok', text: `loading ${selected}...` }) + setLoadingId(selected) + try { + const meta = await jsonFetch('/api/load', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model_id: selected, dtype, quant: quant || null, device }), + }) + const note = templateNote(meta) + setNotice({ + kind: note.warn ? 'err' : 'ok', + text: `loaded in ${meta.load_seconds}s (${meta.n_layers} layers, d=${meta.d_model})` + note.suffix, + }) + flushQueuedLens() + } catch (err) { + queuedLensRef.current = null + setQueuedLensName(null) + setNotice({ kind: 'err', text: String(err.message || err) }) + } finally { + setLoadingId(null) + } + } + + async function onUnload() { + try { + const r = await jsonFetch('/api/unload', { method: 'POST' }) + let text = 'nothing to unload' + if (r.unloaded) { + // VRAM actually returned = allocated before − reserved after (the rest = CUDA context) + const before = Object.values(r.vram_allocated_before || {}).reduce((a, b) => a + b, 0) + const reserved = Object.values(r.vram_reserved_after || {}).reduce((a, b) => a + b, 0) + text = `model unloaded · ${(before / GB).toFixed(1)} GB returned` + + (reserved > 64 * 2 ** 20 ? ` (${(reserved / GB).toFixed(1)} GB still reserved)` : ' (VRAM returned to the driver)') + } + setNotice({ kind: 'ok', text }) + } catch (err) { + setNotice({ kind: 'err', text: String(err.message || err) }) + } + } + + async function lensLoadBy(payload) { + setNotice({ kind: 'ok', text: 'loading the lens...' }) + // form top-k applied to ALL load paths (registry included) + if (payload.k == null && +lensForm.k > 0) payload = { ...payload, k: +lensForm.k } + try { + const meta = await jsonFetch('/api/lens/load', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ k: +lensForm.k || 8, ...payload }), + }) + const warn = (meta.warnings || []).join(' ; ') + setNotice({ kind: warn ? 'err' : 'ok', text: warn || `lens loaded (layers ${meta.tapped_layers.join(', ')})` }) + } catch (err) { + setNotice({ kind: 'err', text: String(err.message || err) }) + } + } + + function onLensLoad() { + if (lensForm.path.trim()) { + lensClick({ path: lensForm.path.trim() }) + return + } + lensClick({ + repo_id: lensForm.repo_id || null, + revision: lensForm.revision || null, + filename: lensForm.filename, + }) + } + + // While a model is loading, clicking a lens QUEUES it (chain-load). + const modelLoading = !!loadingId && busy === 'loading' + function lensClick(payload) { + if (modelLoading) { + queuedLensRef.current = payload + setQueuedLensName(payload.path || payload.filename?.split('/').pop() || 'lens') + setNotice({ kind: 'ok', text: 'lens queued — it loads as soon as the model is ready' }) + return + } + lensLoadBy(payload) + } + + function renderHubLens(h) { + const nMatch = h.filename.match(/_n(\d+)/) + const stem = h.filename.split('/').pop().replace('_jacobian_lens', '').replace('.pt', '') + const isBase = h.via === 'base-model' || h.via === 'base-guess' + return ( +
+
+
+ {stem} Hub + {isBase && base model} +
+
+ {nMatch ? `${nMatch[1]} prompts` : 'n unspecified (repo default fit)'} + {h.base_model ? ` · base ${h.base_model}` : ''} + {h.cached ? ' · ✓ cached' : ' · to download'} +
+
{h.repo_id} @{h.revision}
+ {h.reason &&
⚠ {h.reason}
} +
+ +
+ ) + } + + async function onLensUnload() { + await jsonFetch('/api/lens/unload', { method: 'POST' }).catch(() => {}) + } + + function openEditorWith(prefill) { + setEditorOpen(true) + if (prefill) setEditorPrefill(prefill) + } + + // "Visualized" generation: the live stream, else the selected message if it has + // frames, else the last message with frames. Shared between the J-lens and the + // editor (auto-selecting the peak layer of an added token). + function currentGenView() { + const live = streaming && framesRef.current.length ? framesRef.current : null + let idx = selectedIdx + if (idx == null || !messages[idx]?.frames?.length) { + idx = -1 + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].frames?.length) { idx = i; break } + } + } + // gen_id is only known for messages generated THIS page session; after a + // reload, fall back to the id carried by the persisted frames themselves — + // the server-side residual store survives a page refresh. + const msgGen = idx >= 0 + ? messages[idx].gen_id ?? messages[idx].frames[messages[idx].frames.length - 1]?.gen ?? null + : null + const genId = live ? live[live.length - 1]?.gen ?? null : msgGen + return { live, idx, genId } + } + + async function applyCapture() { + const list = [] + for (const part of capLayers.split(',')) { + const m = part.trim().match(/^(\d+)\s*-\s*(\d+)$/) + if (m) for (let i = +m[1]; i <= +m[2]; i++) list.push(i) + else if (part.trim()) list.push(+part.trim()) + } + const layers = list.filter((x) => !isNaN(x)) + try { + const body = await jsonFetch('/api/lens/layers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + // empty layers field = keep the current layers (changing k only) + layers: layers.length ? layers : lensMeta.tapped_layers, + k: capK ? +capK : null, + }), + }) + setCapLayers('') + setCapK('') + setNotice({ kind: 'ok', text: `capture: layers ${body.tapped_layers.join(', ')} · k=${body.k}` }) + } catch (err) { + setNotice({ kind: 'err', text: String(err.message || err) }) + } + } + + async function openBrowse(path) { + try { + const body = await jsonFetch(`/api/browse${path ? `?path=${encodeURIComponent(path)}` : ''}`) + setBrowse(body) + } catch (err) { + setNotice({ kind: 'err', text: String(err.message || err) }) + } + } + + // NATIVE folder picker (server-side tkinter dialog — the server is local, and + // tkinter works on Windows/Linux/macOS alike). Returns the chosen path. + async function pickFolder() { + try { + const body = await jsonFetch('/api/pick-path', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kind: 'dir' }), + }) + return body.path || null + } catch (err) { + setNotice({ kind: 'err', text: String(err.message || err) }) + return null + } + } + + async function pickNative() { + const path = await pickFolder() + if (path) await registerPath(path) + } + + // Browse result: the folder is ADDED to the model list (nothing is loaded + // yet) — load it from the list with the dtype/quant/device of your choice. + async function registerPath(path) { + try { + const r = await jsonFetch('/api/models/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }), + }) + setSelected(r.registered) + setNotice({ kind: 'ok', text: `added to the list: ${r.registered} — pick dtype/quant/device and Load` }) + refreshModels() + } catch (err) { + setNotice({ kind: 'err', text: String(err.message || err) }) + } + } + + async function unregisterPath(m, ev) { + ev.stopPropagation() + try { + await jsonFetch('/api/models/unregister', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: m.path }), + }) + setNotice({ kind: 'ok', text: `removed from the list (files untouched): ${m.path}` }) + if (selected === m.id) setSelected(null) + refreshModels() + } catch (err) { + setNotice({ kind: 'err', text: String(err.message || err) }) + } + } + + async function deleteModel(m, ev) { + ev.stopPropagation() + const size = m.size_bytes ? ` (${(m.size_bytes / GB).toFixed(1)} GB)` : '' + if (!window.confirm(`Permanently delete ${m.id}${size}?\nThis erases the files from disk.`)) return + try { + const r = await jsonFetch('/api/models/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model_id: m.id }), + }) + const freed = r.freed_bytes ? ` · ${(r.freed_bytes / GB).toFixed(1)} GB freed` : '' + setNotice({ kind: 'ok', text: `deleted: ${m.id}${freed}` }) + if (selected === m.id) setSelected(null) + refreshModels() + } catch (err) { + setNotice({ kind: 'err', text: String(err.message || err) }) + } + } + + async function convertBf16(path) { + try { + await jsonFetch('/api/convert-bf16', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }), + }) + setNotice({ kind: 'ok', text: 'bf16 conversion started (see status below)' }) + } catch (err) { + setNotice({ kind: 'err', text: String(err.message || err) }) + } + } + + async function onDownload() { + if (!downloadRepo.trim()) return + try { + await jsonFetch('/api/download', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ repo_id: downloadRepo.trim() }), + }) + setNotice({ kind: 'ok', text: `download of ${downloadRepo} started` }) + } catch (err) { + setNotice({ kind: 'err', text: String(err.message || err) }) + } + } + + const doneDownloads = (status?.downloads || []).filter((d) => d.state === 'done').length + useEffect(() => { + if (doneDownloads > 0) refreshModels() + }, [doneDownloads]) + + useEffect(() => { + if (status?.convert?.state === 'done') refreshModels() + }, [status?.convert?.state]) + + const childs = {} + tree.forEach((m) => { (childs[m.parent_id] ??= []).push(m) }) + const activeIds = new Set(messages.map((m) => m.id).filter((x) => x != null)) + tree.filter((m) => m.role === 'system' && activeIds.size).forEach((m) => activeIds.add(m.id)) + + return ( + <> +
+

J-Wash

+ +

GPU

+
+ {(status?.gpus || []).map((g) => ( +
+
+ cuda:{g.index} · {g.name.replace('NVIDIA GeForce ', '')} + {(g.vram_used / GB).toFixed(1)} / {(g.vram_total / GB).toFixed(0)} GB +
+
+
+ ))} +
+ +
+ {[['chat', 'Chat'], ['model', 'Model'], ['lens', 'Lens'], ['fit', 'Fit'], ['options', 'Options']].map(([id, label]) => ( + + ))} +
+ + {tab === 'chat' && (<> +

Conversations

+ setConvQuery(e.target.value)} /> +
+ {convs.map((c) => ( +
openConv(c.id)}> +
+ {c.title || `conversation ${c.id}`} + +
+
+ {c.n_messages} msgs{c.tags?.length ? ` · ${c.tags.join(', ')}` : ''} + {c.snippet ?
{c.snippet}
: null} +
+
+ ))} +
+ +

Ignored tokens{hidden.size ? ` (${hidden.size})` : ''}

+ setHideInput(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { hideToken(hideInput); setHideInput('') } }} /> + {hidden.size > 0 ? ( +
+ {[...hidden].map((h) => ( + unhideToken(h)}>{fmtTok(h)} ✕ + ))} +
+ ) : ( +
tokens excluded from the Frequencies view (right-click a token there to add one)
+ )} + + )} + + {tab === 'model' && (<> +

Models

+
+ {models.map((m) => { + const isFp32 = m.dtype === 'float32' || m.dtype === 'fp32' + const isRegistered = m.source === 'registered' + const label = isRegistered ? (m.path.split(/[\\/]/).pop() || m.id) : m.id + return ( +
setSelected(m.id)} + > +
+ {label} {loadedId === m.id ? '●' : ''}{m.missing ? ' ⚠ folder missing' : ''} + {isRegistered ? ( + + ) : ( + + )} +
+
+ {m.source} · {(m.size_bytes / GB).toFixed(1)} GB + {m.n_layers != null ? ` · ${m.n_layers} layers` : ''} + {m.dtype ? ` · ${m.dtype}` : ''} +
+ {isFp32 && ( +
+ fp32 — +
+ )} +
+ ) + })} +
+ +
+ +
+
+ +
+
+ +
+
+ + +
+ )} + + {tab === 'lens' && (<> +

J-Lens

+ {lensMeta ? ( +
+
+ ▶ {lensName(lensMeta)} +
+
+ layers {lensMeta.tapped_layers[0]}–{lensMeta.tapped_layers[lensMeta.tapped_layers.length - 1]} ({lensMeta.tapped_layers.length}) · k={lensMeta.k} · n={lensMeta.n_prompts} +
+ {(lensMeta.warnings || []).map((w, i) =>
⚠ {w}
)} +
+ + +
+
+ + setCapLayers(e.target.value)} /> + + setCapK(e.target.value)} style={{ width: 52, flexShrink: 0 }} /> + +
+
captured: {lensMeta.tapped_layers.join(', ')} · k={lensMeta.k} — applies to the next generations
+
+ ) : ( + <> + {modelLoading && ( +
+ ⏳ {loadingId} is loading — pick a lens now, it will load automatically + once the model is ready{queuedLensName ? ` · queued: ${queuedLensName}` : ''}. +
+ )} + {reg && (reg.local.length + reg.hub.length > 0) && ( +
+ {reg.local.map((l) => { + const m = l.meta || {} + const sl = m.source_layers + return ( +
+
+
{l.name} local{l.compatible === false ? ' ⚠' : ''}
+
+ {m.n_prompts != null ? `${m.n_prompts} prompts` : 'n unknown'} + {sl ? ` · layers ${sl[0]}–${sl[1]}` : ''} + {m.dtype ? ` · ${m.quant || m.dtype}` : ''} + {m.d_model ? ` · d=${m.d_model}` : ''} +
+ {m.created_at &&
fit {m.created_at.slice(0, 10)}{m.fit_seconds ? ` · ${Math.round(m.fit_seconds / 60)} min` : ''}{m.corpus ? ` · ${m.corpus.split(' ')[0]}` : ''}
} + {l.reason &&
⚠ {l.reason}
} +
+ +
+ ) + })} + {reg.hub.map(renderHubLens)} + {reg.hub_error &&
{reg.hub_error}
} +
+ )} + {reg && reg.local.length + reg.hub.length === 0 && (loadedId || loadingId) && ( +
no known lens for this model — fit one (Fit tab), or + load a lens fitted for a compatible model below
+ )} + {reg && (reg.other?.length || 0) > 0 && ( +
+ + lenses fitted for other models ({reg.other.length}) + +
+ For your own finetune or merge without a matching lens: a lens fitted + on a compatible model of the SAME architecture can work (d_model and + layer count are checked at load). Readouts drift with the distance + between the weights — treat them as approximate. +
+
{reg.other.map(renderHubLens)}
+
+ )} +
+ manual load +
+ Load any Jacobian-lens .pt: either a Hugging Face repo + the + file path inside it (mirror the entries above), or the local path of a + lens you fitted (Fit tab writes lenses/<name>/lens.pt). +
+
+ setLensForm({ ...lensForm, repo_id: e.target.value })} /> +
+
+ setLensForm({ ...lensForm, filename: e.target.value })} /> +
+
+ setLensForm({ ...lensForm, revision: e.target.value })} /> +
+
+ setLensForm({ ...lensForm, path: e.target.value })} /> +
+
+ setLensForm({ ...lensForm, k: e.target.value })} /> +
+ +
+ + )} + + {lensMeta && ( + <> +

Token editing ☢

+ + {ivScale !== 1 &&
global multiplier: ×{(+ivScale).toFixed(2)}
} + + )} + )} + + {tab === 'fit' && (<> +

Fitting

+ {(() => { + const fit = status?.fit + if (fit?.state === 'running' || fit?.state === 'stopping') { + return ( +
+
+ {fit.name} · {fit.phase} · {fit.done}/{fit.total} + {fit.eta_seconds ? ` · ETA ${Math.max(1, Math.round(fit.eta_seconds / 60))} min` : ''} +
+ {(fit.workers || []).map((w, i) => ( +
+
{w.device} · {w.state}{w.done}/{w.total}
+
+
+ ))} + +
+ ) + } + return ( + <> + {fit?.state === 'done' && ( +
+ fit done: {fit.name} ({Math.round((fit.meta?.fit_seconds || 0) / 60)} min) — reload the model then{' '} + +
+ )} + {fit?.state === 'error' &&
fit error: {fit.error}
} + {fit?.state === 'stopped' &&
fit stopped (checkpoints kept, restart = resume)
} +
+ +
+ {fitContinue && ( +
resume: {fitN} prompts are added to the {localLenses.find((l) => l.path === fitContinue)?.meta?.n_prompts ?? '?'} existing ones (weighted average = fit over the union); source layers inherited
+ )} +
+ +
+
+ setFitName(e.target.value)} /> +
+
+ setFitN(e.target.value)} /> +
+
+ +
+
+ +
+ +
+ +
+ {fitAdvanced && ( +
+
+ + {(status?.gpus || []).map((g) => { + const d = `cuda:${g.index}` + return ( + + ) + })} + +
+
+ setFitDimBatch(e.target.value)} /> +
+
+ setFitMaxSeq(e.target.value)} /> +
+
+ setFitLayers(e.target.value)} /> +
+
+ max = second-to-last layer: the last one is the lens TARGET + (its readout is already exact, J = I, nothing to fit) +
+
resume: restarting with the same name picks up from the checkpoints in data/fits/<name>/
+
+ )} + + {loadedId &&
unload the model first (VRAM required)
} + + ) + })()} + )} + + {tab === 'model' && (<> +

Download

+
+ setDownloadRepo(e.target.value)} /> + +
+ {(status?.downloads || []).map((d) => { + const planTxt = d.plan + ? ` (${(d.plan.size_bytes / GB).toFixed(1)} GB${d.plan.kind === 'fallback' ? ' · full repo' : ''})` + : '' + const pr = d.progress + const pct = pr && pr.total ? Math.min(100, (100 * pr.done) / pr.total) : null + return ( +
+ {d.state === 'running' && `downloading: ${d.repo_id}${planTxt}${pct != null ? ` — ${pct.toFixed(0)} %` : '...'}`} + {d.state === 'converting' && `auto bf16 conversion: ${d.repo_id} (fp32 detected)...`} + {d.state === 'done' && `downloaded: ${d.repo_id}${d.converted ? ` → converted: ${d.converted}` : ''}`} + {d.state === 'error' && `failed ${d.repo_id}: ${d.error}`} + {(d.state === 'done' || d.state === 'error') && ( + jsonFetch(`/api/download/${encodeURIComponent(d.repo_id)}`, { method: 'DELETE' }).catch(() => {})}> ✕ + )} + {d.state === 'running' && pct != null && ( +
+ )} +
+ ) + })} + {status?.convert?.state === 'running' && ( +
bf16 conversion in progress: {status.convert.path}...
+ )} + {status?.convert?.state === 'done' && ( +
converted to bf16: {status.convert.result?.id}
+ )} + {status?.convert?.state === 'error' && ( +
conversion failed: {status.convert.error}
+ )} + +

Browse

+
+ +
+ {browseOpen && browse && ( +
+
+ {browse.parent != null && } + + {browse.path || '(drives)'} +
+ {browse.is_model && ( +
+
📦 this folder is a model
+ +
+ )} +
+ {browse.dirs.map((d) => ( +
+ openBrowse(d.path)}> + {d.is_model ? '📦' : '📁'} {d.name} + + {d.is_model && } +
+ ))} + {!browse.dirs.length &&
(empty)
} +
+
+ )} + )} + + {tab === 'options' && (<> +

Defaults

+
+ +
+
+ + patchSettings({ auto_layer_radius: Math.max(0, Math.min(8, Math.trunc(+e.target.value) || 0)) })} + style={{ width: 64, flex: 'none' }} /> +
+
+ +
+ +

Paths

+
+ + setSettings((s) => ({ ...s, hf_cache: e.target.value }))} + onBlur={(e) => patchSettings({ hf_cache: e.target.value.trim() })} /> + +
+
+ + setSettings((s) => ({ ...s, llamacpp_dir: e.target.value }))} + onBlur={(e) => patchSettings({ llamacpp_dir: e.target.value.trim() })} /> + +
+
+ HF cache applies at the next server start. llama.cpp enables the direct + GGUF export (editor → Export). +
+ )} + +
+ {busy && ({busy}) } + {/* no duplicate "busy: generating" when busy already shows the state */} + {notice && !(busy && /^busy\s*:/.test(notice.text)) && {notice.text}} +
+
+ +
+ {conv?.id && ( +
+ e.target.value !== conv.title && patchConv({ title: e.target.value })} + /> + patchConv({ tags: e.target.value.split(',').map((t) => t.trim()).filter(Boolean) })} + /> + + JSON + MD +
+ )} + + {showTree && conv?.id && ( +
+ {(childs[null] || []).map((root) => ( + applyPath(nodeId, tree)} + /> + ))} +
click a node = resume/branch from there
+
+ )} + +
+ System prompt {system.trim() ? '●' : ''} {conv?.id ? '(set at creation)' : ''} +