proprioceptive-lens 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Proprioceptive AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: proprioceptive-lens
3
+ Version: 0.1.0
4
+ Summary: See inside your local model: live confidence, hesitations, long-term memory and internal-state behaviour for local LLMs.
5
+ License-Expression: MIT
6
+ Keywords: llm,ollama,interpretability,local-ai,memory,hidden-states
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Provides-Extra: deep
14
+ Requires-Dist: torch>=2.1; extra == "deep"
15
+ Requires-Dist: transformers>=4.43; extra == "deep"
16
+ Dynamic: license-file
17
+
18
+ # Proprioceptive Lens
19
+
20
+ **See inside your local model.** A free app for anyone running local AI with [Ollama](https://ollama.com) or Hugging Face models.
21
+
22
+ ![Lens showing a confidently wrong answer: every word at near-100% certainty](docs/demo-confidence.gif)
23
+
24
+ *qwen3:4b says the 17th US president was Garfield, with 100% certainty. (It was Andrew Johnson.) Token confidence shows the surface; Lens also lets you look underneath.*
25
+
26
+ - **Confidence you can see.** Every word is tinted by how sure the model was. Hover any word to see what it almost said.
27
+ - **Where it hesitated.** A live certainty trace, plus the exact words where the model weighed alternatives.
28
+ - **It remembers you.** Private memory of your conversations, on your computer, for any model.
29
+ - **Inside the model** (deep mode). A live layer-by-layer activity map of the network while it writes.
30
+ - **Behaviour** (optional). Internal-state probes score what the model is doing: writing code or prose, formal or casual
31
+ register, following your instructions, creative or literal, assertive or hedged, repeating itself, asking or stating.
32
+ These probes were validated on the original model, then transferred to each supported model. There they were
33
+ re-tested against length and random-direction controls, and with a live test on the model's own answers.
34
+ - **Terminal mode and an API** for wiring Lens into your own stack.
35
+ - **Private.** Everything runs locally. Behaviour is opt-in and sends only 16 scrambled numbers per word, never your text.
36
+
37
+ ![Deep mode: layer activity map and live Behaviour scores](docs/demo-deep-behaviour.gif)
38
+
39
+ ## Install and run
40
+
41
+ ```bash
42
+ pip install proprioceptive-lens # no dependencies; Python 3.9+
43
+ ollama pull qwen3:4b # any chat model works
44
+ lens # opens the app in your browser
45
+ ```
46
+
47
+ Options: `lens --port 7777 --ollama http://127.0.0.1:11434 --hindsight http://localhost:8888`.
48
+
49
+ ## Deep mode and Behaviour
50
+
51
+ ```bash
52
+ pip install "proprioceptive-lens[deep]"
53
+ lens --deep Qwen/Qwen2.5-3B-Instruct
54
+ ```
55
+
56
+ Deep mode runs a Hugging Face model locally and reads its hidden states. It works with Llama, Qwen, Mistral, Gemma,
57
+ Phi, Falcon, Granite, OLMo, SmolLM, DeepSeek and other families. Lens tells Ollama users which command matches the
58
+ model they already run. Switch on **Behaviour** in the app to see internal-state probe scores. The app lists which
59
+ models have a Behaviour pack; so does [docs/MODELS.md](docs/MODELS.md).
60
+
61
+ ## Proprioceptive Terminal Mode
62
+
63
+ ```bash
64
+ lens term # interactive: confidence-coloured output, /why, /memory, /behaviour
65
+ lens term -m qwen3:4b "your question" # one-shot
66
+ echo "your question" | lens term --json # JSON-lines events for scripts and pipelines
67
+ ```
68
+
69
+ Events: `memory`, `token` (`text`, `p`, `alternatives`, plus `layers` in deep mode), `behaviour` (score per probe),
70
+ `done`. Any OpenAI-compatible client pointed at `http://127.0.0.1:7777/v1` also gets Lens memory.
71
+
72
+ ## Share a prompt
73
+
74
+ `http://127.0.0.1:7777/?ask=Why%20is%20the%20sky%20blue` opens Lens with the prompt ready to send.
75
+
76
+ ## Privacy
77
+
78
+ Lens listens only on `127.0.0.1` and rejects requests from other websites. Memories live in
79
+ `~/.proprioceptive-lens/memory.sqlite3`. You can view or forget them in the app, or delete the file. Behaviour is off
80
+ until you turn it on.
81
+
82
+ ## License
83
+
84
+ MIT for this client. The Behaviour probes and the scoring service are proprietary to Proprioceptive AI, are not part
85
+ of this package, and no licence to them (or to any Proprioceptive AI patent) is granted by this licence.
@@ -0,0 +1,68 @@
1
+ # Proprioceptive Lens
2
+
3
+ **See inside your local model.** A free app for anyone running local AI with [Ollama](https://ollama.com) or Hugging Face models.
4
+
5
+ ![Lens showing a confidently wrong answer: every word at near-100% certainty](docs/demo-confidence.gif)
6
+
7
+ *qwen3:4b says the 17th US president was Garfield, with 100% certainty. (It was Andrew Johnson.) Token confidence shows the surface; Lens also lets you look underneath.*
8
+
9
+ - **Confidence you can see.** Every word is tinted by how sure the model was. Hover any word to see what it almost said.
10
+ - **Where it hesitated.** A live certainty trace, plus the exact words where the model weighed alternatives.
11
+ - **It remembers you.** Private memory of your conversations, on your computer, for any model.
12
+ - **Inside the model** (deep mode). A live layer-by-layer activity map of the network while it writes.
13
+ - **Behaviour** (optional). Internal-state probes score what the model is doing: writing code or prose, formal or casual
14
+ register, following your instructions, creative or literal, assertive or hedged, repeating itself, asking or stating.
15
+ These probes were validated on the original model, then transferred to each supported model. There they were
16
+ re-tested against length and random-direction controls, and with a live test on the model's own answers.
17
+ - **Terminal mode and an API** for wiring Lens into your own stack.
18
+ - **Private.** Everything runs locally. Behaviour is opt-in and sends only 16 scrambled numbers per word, never your text.
19
+
20
+ ![Deep mode: layer activity map and live Behaviour scores](docs/demo-deep-behaviour.gif)
21
+
22
+ ## Install and run
23
+
24
+ ```bash
25
+ pip install proprioceptive-lens # no dependencies; Python 3.9+
26
+ ollama pull qwen3:4b # any chat model works
27
+ lens # opens the app in your browser
28
+ ```
29
+
30
+ Options: `lens --port 7777 --ollama http://127.0.0.1:11434 --hindsight http://localhost:8888`.
31
+
32
+ ## Deep mode and Behaviour
33
+
34
+ ```bash
35
+ pip install "proprioceptive-lens[deep]"
36
+ lens --deep Qwen/Qwen2.5-3B-Instruct
37
+ ```
38
+
39
+ Deep mode runs a Hugging Face model locally and reads its hidden states. It works with Llama, Qwen, Mistral, Gemma,
40
+ Phi, Falcon, Granite, OLMo, SmolLM, DeepSeek and other families. Lens tells Ollama users which command matches the
41
+ model they already run. Switch on **Behaviour** in the app to see internal-state probe scores. The app lists which
42
+ models have a Behaviour pack; so does [docs/MODELS.md](docs/MODELS.md).
43
+
44
+ ## Proprioceptive Terminal Mode
45
+
46
+ ```bash
47
+ lens term # interactive: confidence-coloured output, /why, /memory, /behaviour
48
+ lens term -m qwen3:4b "your question" # one-shot
49
+ echo "your question" | lens term --json # JSON-lines events for scripts and pipelines
50
+ ```
51
+
52
+ Events: `memory`, `token` (`text`, `p`, `alternatives`, plus `layers` in deep mode), `behaviour` (score per probe),
53
+ `done`. Any OpenAI-compatible client pointed at `http://127.0.0.1:7777/v1` also gets Lens memory.
54
+
55
+ ## Share a prompt
56
+
57
+ `http://127.0.0.1:7777/?ask=Why%20is%20the%20sky%20blue` opens Lens with the prompt ready to send.
58
+
59
+ ## Privacy
60
+
61
+ Lens listens only on `127.0.0.1` and rejects requests from other websites. Memories live in
62
+ `~/.proprioceptive-lens/memory.sqlite3`. You can view or forget them in the app, or delete the file. Behaviour is off
63
+ until you turn it on.
64
+
65
+ ## License
66
+
67
+ MIT for this client. The Behaviour probes and the scoring service are proprietary to Proprioceptive AI, are not part
68
+ of this package, and no licence to them (or to any Proprioceptive AI patent) is granted by this licence.
@@ -0,0 +1,2 @@
1
+ """Proprioceptive Lens — see inside your local model."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,63 @@
1
+ """`lens` command: start the local app and open it in the browser."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import os
6
+ import sys
7
+ import time
8
+ import webbrowser
9
+ from pathlib import Path
10
+
11
+ from . import __version__
12
+ from .server import LensApp, serve
13
+
14
+
15
+ def main(argv=None) -> int:
16
+ argv = sys.argv[1:] if argv is None else argv
17
+ if argv and argv[0] in ("term", "terminal"):
18
+ from .terminal import main as terminal_main
19
+ return terminal_main(argv[1:])
20
+ parser = argparse.ArgumentParser(prog="lens", description="Proprioceptive Lens — see inside your local model. "
21
+ "Run `lens term` for Proprioceptive Terminal Mode.")
22
+ parser.add_argument("--port", type=int, default=int(os.environ.get("LENS_PORT", 7777)))
23
+ parser.add_argument("--ollama", default=os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434"),
24
+ help="Ollama URL (default http://127.0.0.1:11434)")
25
+ parser.add_argument("--hindsight", default=os.environ.get("LENS_HINDSIGHT_URL"),
26
+ help="optional Hindsight server URL to mirror memories into")
27
+ parser.add_argument("--data", default=os.environ.get("LENS_DATA", str(Path.home() / ".proprioceptive-lens")))
28
+ parser.add_argument("--deep", default=os.environ.get("LENS_DEEP_MODEL"),
29
+ help="Hugging Face model to run in deep mode, e.g. Qwen/Qwen2.5-3B-Instruct "
30
+ "(needs: pip install proprioceptive-lens[deep])")
31
+ parser.add_argument("--cloud", default=os.environ.get("LENS_CLOUD_URL"), help=argparse.SUPPRESS)
32
+ parser.add_argument("--no-browser", action="store_true")
33
+ parser.add_argument("--version", action="version", version=f"lens {__version__}")
34
+ args = parser.parse_args(argv)
35
+ ollama = args.ollama if args.ollama.startswith("http") else "http://" + args.ollama
36
+ if args.deep:
37
+ print(f"Loading deep model {args.deep} (first run downloads it)…")
38
+ app = LensApp(Path(args.data), ollama, args.hindsight, deep_model=args.deep, cloud_url=args.cloud)
39
+ if app.deep_error:
40
+ print(f" Deep mode unavailable: {app.deep_error}")
41
+ try:
42
+ serve(app, args.port)
43
+ except OSError as exc:
44
+ print(f"Could not start on port {args.port}: {exc}", file=sys.stderr)
45
+ return 1
46
+ url = f"http://127.0.0.1:{args.port}/"
47
+ status = app.status()
48
+ print(f"Proprioceptive Lens {__version__} running at {url}")
49
+ print(f" Ollama: {'connected' if status['backend_ok'] else 'NOT FOUND at ' + ollama} · "
50
+ f"{len(status['models'])} models · memory: {status['memory_count']} saved")
51
+ print(f" Use it from other apps: OpenAI-compatible endpoint {url}v1 (adds memory to any chat app)")
52
+ if not args.no_browser:
53
+ webbrowser.open(url)
54
+ try:
55
+ while True:
56
+ time.sleep(3600)
57
+ except KeyboardInterrupt:
58
+ print("\nStopped.")
59
+ return 0
60
+
61
+
62
+ if __name__ == "__main__":
63
+ raise SystemExit(main())
@@ -0,0 +1,69 @@
1
+ """Map the Ollama model names people use to their Hugging Face equivalents (for deep mode).
2
+
3
+ Ollama does not expose hidden states, so deep view and Behaviour run the same model's Hugging Face weights.
4
+ This table lets Lens tell an Ollama user the exact command for the model they already use.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ OLLAMA_TO_HF = {
9
+ # Qwen
10
+ "qwen2.5:0.5b": "Qwen/Qwen2.5-0.5B-Instruct", "qwen2.5:1.5b": "Qwen/Qwen2.5-1.5B-Instruct",
11
+ "qwen2.5:3b": "Qwen/Qwen2.5-3B-Instruct", "qwen2.5:7b": "Qwen/Qwen2.5-7B-Instruct",
12
+ "qwen2.5-coder:0.5b": "Qwen/Qwen2.5-Coder-0.5B-Instruct", "qwen2.5-coder:1.5b": "Qwen/Qwen2.5-Coder-1.5B-Instruct",
13
+ "qwen2.5-coder:3b": "Qwen/Qwen2.5-Coder-3B-Instruct", "qwen2.5-coder:7b": "Qwen/Qwen2.5-Coder-7B-Instruct",
14
+ "qwen2:0.5b": "Qwen/Qwen2-0.5B-Instruct", "qwen2:7b": "Qwen/Qwen2-7B-Instruct",
15
+ "qwen3:0.6b": "Qwen/Qwen3-0.6B", "qwen3:1.7b": "Qwen/Qwen3-1.7B", "qwen3:4b": "Qwen/Qwen3-4B", "qwen3:8b": "Qwen/Qwen3-8B",
16
+ # Llama (ungated copies of Meta's weights, so no Hugging Face account is needed)
17
+ "llama3.2:1b": "unsloth/Llama-3.2-1B-Instruct", "llama3.2:3b": "unsloth/Llama-3.2-3B-Instruct",
18
+ "llama3.1:8b": "unsloth/Llama-3.1-8B-Instruct", "llama3:8b": "NousResearch/Meta-Llama-3-8B-Instruct",
19
+ "hermes3:8b": "NousResearch/Hermes-3-Llama-3.1-8B", "tinyllama:1.1b": "TinyLlama/TinyLlama-1.1B-Chat-v1.0",
20
+ # Mistral, Gemma
21
+ "mistral:7b": "mistralai/Mistral-7B-Instruct-v0.3", "mistral-nemo:12b": "m8than/Mistral-Nemo-Instruct-2407-lenient-chatfix",
22
+ "gemma2:2b": "Efficient-Large-Model/gemma-2-2b-it", "gemma3:1b": "unsloth/gemma-3-1b-it",
23
+ # Microsoft, DeepSeek
24
+ "phi3:3.8b": "microsoft/Phi-3-mini-4k-instruct", "phi3.5:3.8b": "microsoft/Phi-3.5-mini-instruct",
25
+ "phi4-mini:3.8b": "microsoft/Phi-4-mini-instruct", "phi4-mini-reasoning:3.8b": "microsoft/Phi-4-mini-reasoning",
26
+ "deepseek-r1:1.5b": "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B", "deepseek-r1:7b": "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B",
27
+ "deepseek-r1:8b": "deepseek-ai/DeepSeek-R1-0528-Qwen3-8B", "deepseek-coder:1.3b": "deepseek-ai/deepseek-coder-1.3b-instruct",
28
+ "deepseek-coder:6.7b": "deepseek-ai/deepseek-coder-6.7b-instruct",
29
+ # Others
30
+ "smollm2:1.7b": "HuggingFaceTB/SmolLM2-1.7B-Instruct", "smollm2:360m": "HuggingFaceTB/SmolLM2-360M-Instruct",
31
+ "smollm3:3b": "HuggingFaceTB/SmolLM3-3B", "granite3.3:2b": "ibm-granite/granite-3.3-2b-instruct",
32
+ "granite3.3:8b": "ibm-granite/granite-3.3-8b-instruct", "granite3.2:8b": "ibm-granite/granite-3.2-8b-instruct",
33
+ "granite3.1-dense:2b": "ibm-granite/granite-3.1-2b-instruct", "granite3.1-dense:8b": "ibm-granite/granite-3.1-8b-instruct",
34
+ "falcon3:3b": "tiiuae/Falcon3-3B-Instruct", "falcon3:7b": "tiiuae/Falcon3-7B-Instruct",
35
+ "olmo2:1b": "allenai/OLMo-2-0425-1B-Instruct", "olmo2:7b": "allenai/OLMo-2-1124-7B-Instruct",
36
+ "exaone3.5:2.4b": "LGAI-EXAONE/EXAONE-3.5-2.4B-Instruct", "exaone3.5:7.8b": "LGAI-EXAONE/EXAONE-3.5-7.8B-Instruct",
37
+ "internlm2:7b": "internlm/internlm2-chat-7b", "stablelm-zephyr:3b": "stabilityai/stablelm-zephyr-3b",
38
+ "qwen2:1.5b": "Qwen/Qwen2-1.5B-Instruct", "qwen:0.5b": "Qwen/Qwen1.5-0.5B-Chat", "qwen:1.8b": "Qwen/Qwen1.5-1.8B-Chat",
39
+ "zephyr:7b": "HuggingFaceH4/zephyr-7b-beta", "openchat:7b": "openchat/openchat-3.5-1210",
40
+ "solar:10.7b": "upstage/SOLAR-10.7B-Instruct-v1.0", "llama2:7b": "unsloth/llama-2-7b-chat",
41
+ "llama2:13b": "Featherless-Chat-Models/llama2-13b-chat-hf", "falcon:7b": "tiiuae/falcon-7b-instruct",
42
+ "foundation-sec:8b": "fdtn-ai/Foundation-Sec-8B-Instruct", "danube3:500m": "h2oai/h2o-danube3-500m-chat",
43
+ }
44
+ # More specific Ollama tags that point at a different checkpoint than the base name.
45
+ TAGGED = {
46
+ "qwen3:4b-instruct-2507": "Qwen/Qwen3-4B-Instruct-2507", "qwen3:4b-thinking-2507": "Qwen/Qwen3-4B-Thinking-2507",
47
+ "deepseek-r1:8b-llama-distill": "deepseek-ai/DeepSeek-R1-Distill-Llama-8B",
48
+ "phi3:3.8b-mini-128k-instruct": "microsoft/Phi-3-mini-128k-instruct",
49
+ "mistral:7b-instruct-v0.3": "mistralai/Mistral-7B-Instruct-v0.3", "mistral:7b-instruct-v0.1": "mistralai/Mistral-7B-Instruct-v0.1",
50
+ "deepseek-coder:6.7b-instruct": "deepseek-ai/deepseek-coder-6.7b-instruct",
51
+ }
52
+ # What Ollama pulls when no tag (or ":latest") is given.
53
+ DEFAULT_SIZE = {
54
+ "qwen3": "8b", "qwen2.5": "7b", "qwen2.5-coder": "7b", "qwen2": "7b", "llama3.2": "3b", "llama3.1": "8b", "llama3": "8b",
55
+ "llama2": "7b", "mistral": "7b", "phi3": "3.8b", "phi3.5": "3.8b", "phi4-mini": "3.8b", "deepseek-r1": "8b",
56
+ "smollm2": "1.7b", "granite3.3": "8b", "falcon3": "7b", "olmo2": "7b", "tinyllama": "1.1b", "zephyr": "7b",
57
+ "openchat": "7b", "solar": "10.7b", "hermes3": "8b", "exaone3.5": "7.8b",
58
+ }
59
+
60
+
61
+ def hf_for(ollama_name: str) -> str | None:
62
+ """Best Hugging Face match for an Ollama model name ('qwen2.5:3b-instruct-q4_K_M' -> Qwen/Qwen2.5-3B-Instruct)."""
63
+ name = ollama_name.lower().split("/")[-1]
64
+ base, _, tag = name.partition(":")
65
+ for full, hf in TAGGED.items():
66
+ if name == full or name.startswith(full + "-"):
67
+ return hf
68
+ size = tag.split("-")[0] if tag and tag != "latest" else DEFAULT_SIZE.get(base, "")
69
+ return OLLAMA_TO_HF.get(f"{base}:{size}")
@@ -0,0 +1,98 @@
1
+ """Proprioceptive Cloud client (opt-in): behaviour scores from internal state.
2
+
3
+ What is sent: for each generated token, 16 numbers per probe layer (the hidden
4
+ state multiplied by this device's scrambled matrix) and the hidden state's
5
+ length. No text, no prompts and no raw hidden states leave the machine.
6
+ What comes back: behaviour scores. The probes themselves stay in the cloud.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import json
12
+ import struct
13
+ import urllib.error
14
+ import urllib.parse
15
+ import urllib.request
16
+ from pathlib import Path
17
+
18
+ from . import __version__
19
+
20
+ DEFAULT_URL = "https://api.proprioceptiveai.com"
21
+
22
+
23
+ class CloudError(RuntimeError):
24
+ pass
25
+
26
+
27
+ class Cloud:
28
+ def __init__(self, data_dir: Path, url: str | None = None):
29
+ self.url = (url or DEFAULT_URL).rstrip("/")
30
+ self.creds_path = Path(data_dir) / "cloud.json"
31
+ self.creds = json.loads(self.creds_path.read_text()) if self.creds_path.exists() else {}
32
+ self.config: dict | None = None
33
+
34
+ @property
35
+ def enabled(self) -> bool:
36
+ return bool(self.creds.get("token")) and self.creds.get("url") == self.url
37
+
38
+ def _call(self, method: str, path: str, body: dict | None = None, timeout: float = 15.0) -> dict:
39
+ headers = {"Content-Type": "application/json", "User-Agent": f"proprioceptive-lens/{__version__}"}
40
+ if self.creds.get("token"):
41
+ headers["Authorization"] = "Bearer " + self.creds["token"]
42
+ req = urllib.request.Request(self.url + path, method=method, headers=headers,
43
+ data=json.dumps(body).encode() if body is not None else None)
44
+ try:
45
+ with urllib.request.urlopen(req, timeout=timeout) as response:
46
+ return json.load(response)
47
+ except urllib.error.HTTPError as exc:
48
+ try:
49
+ message = json.load(exc).get("error", str(exc))
50
+ except (ValueError, AttributeError):
51
+ message = str(exc)
52
+ raise CloudError(message) from None
53
+ except (urllib.error.URLError, OSError) as exc:
54
+ raise CloudError(f"Proprioceptive Cloud unreachable ({exc})") from None
55
+
56
+ def enable(self) -> dict:
57
+ data = self._call("POST", "/v1/lens/devices", {"client": f"lens/{__version__}"})
58
+ self.creds = {"url": self.url, "device_id": data["device_id"], "token": data["token"]}
59
+ self.creds_path.parent.mkdir(parents=True, exist_ok=True)
60
+ self.creds_path.write_text(json.dumps(self.creds))
61
+ self.creds_path.chmod(0o600)
62
+ return data
63
+
64
+ def disable(self) -> None:
65
+ self.creds = {}
66
+ self.config = None
67
+ self.creds_path.unlink(missing_ok=True)
68
+
69
+ def load_config(self, model_id: str) -> dict:
70
+ """Fetch this device's scrambled matrices for a model. Returns {layer: rows (list of lists)}."""
71
+ data = self._call("GET", "/v1/lens/packs/" + urllib.parse.quote(model_id, safe="") + "/config")
72
+ matrices = {}
73
+ for layer in data["layers"]:
74
+ raw = base64.b64decode(layer["matrix_b64"])
75
+ values = struct.unpack(f"<{layer['rows'] * layer['cols']}f", raw)
76
+ cols = layer["cols"]
77
+ matrices[int(layer["layer"])] = [list(values[r * cols:(r + 1) * cols]) for r in range(layer["rows"])]
78
+ self.config = data
79
+ return matrices
80
+
81
+ def supported_models(self) -> list[str]:
82
+ """Models with a Behaviour pack (public list, cached for 10 minutes)."""
83
+ import time
84
+ cached = getattr(self, "_supported", None)
85
+ if cached and time.time() - cached[0] < 600:
86
+ return cached[1]
87
+ try:
88
+ models = self._call("GET", "/v1/lens/health", timeout=5).get("packs", [])
89
+ except CloudError:
90
+ models = cached[1] if cached else []
91
+ self._supported = (time.time(), models)
92
+ return models
93
+
94
+ def score(self, steps: list[dict]) -> dict:
95
+ if not self.config:
96
+ raise CloudError("no behaviour pack loaded")
97
+ return self._call("POST", "/v1/lens/score", {"pack_id": self.config["pack_id"],
98
+ "key_version": self.config["key_version"], "steps": steps})
@@ -0,0 +1,141 @@
1
+ """Deep mode: run a Hugging Face model locally and read its internal state.
2
+
3
+ Needs `pip install proprioceptive-lens[deep]` (torch + transformers).
4
+ Per generated token this yields the same events as the Ollama backend plus
5
+ layers: activity (RMS of the hidden state) for every layer, for the deep view
6
+ sigma/norm: for opted-in cloud scoring, M_L @ h and |h| at the layers the
7
+ cloud requested, where M_L is the device's scrambled matrix.
8
+ This module contains no proprietary components: it multiplies hidden states by
9
+ whatever matrix the cloud sent and nothing else.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import threading
14
+ from typing import Iterator
15
+
16
+
17
+ # Where decoder blocks live, by architecture family. Covers Llama, Qwen 1-3, Mistral, Mixtral, Gemma 1-3, Phi 1-4,
18
+ # Granite, OLMo, DeepSeek, InternLM, StableLM, Falcon, GPT-2/J/NeoX, BLOOM, MPT, OPT, Mamba/Falcon-Mamba, RWKV and more.
19
+ LAYER_PATHS = ("model.layers", "model.language_model.layers", "language_model.model.layers", "transformer.h",
20
+ "gpt_neox.layers", "model.decoder.layers", "transformer.blocks", "backbone.layers", "rwkv.blocks",
21
+ "transformer.layers", "model.transformer.h")
22
+
23
+
24
+ def find_layers(model):
25
+ """Return the model's list of decoder blocks, or raise if the architecture is unknown."""
26
+ for path in LAYER_PATHS:
27
+ obj = model
28
+ for part in path.split("."):
29
+ obj = getattr(obj, part, None)
30
+ if obj is None:
31
+ break
32
+ if obj is not None and hasattr(obj, "__len__") and len(obj) > 0:
33
+ return obj
34
+ raise ValueError(f"deep mode does not recognise the layer layout of {type(model).__name__}")
35
+
36
+
37
+ class DeepModel:
38
+ def __init__(self, model_id: str, device: str | None = None):
39
+ import torch
40
+ from transformers import AutoModelForCausalLM, AutoTokenizer
41
+ self.torch = torch
42
+ # "Org/Name@/local/path" loads local weights while reporting the public model ID.
43
+ model_id, _, local_path = model_id.partition("@")
44
+ source = local_path or model_id
45
+ self.model_id = model_id
46
+ self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
47
+ dtype = torch.bfloat16 if self.device == "cuda" else torch.float32
48
+ self.tokenizer = AutoTokenizer.from_pretrained(source)
49
+ try:
50
+ model = AutoModelForCausalLM.from_pretrained(source, dtype=dtype)
51
+ except TypeError: # transformers < 4.56 uses torch_dtype
52
+ model = AutoModelForCausalLM.from_pretrained(source, torch_dtype=dtype)
53
+ self.model = model.to(self.device).eval()
54
+ self.layers = find_layers(self.model)
55
+ self.n_layers = len(self.layers)
56
+ self.lock = threading.Lock()
57
+ self.projections: dict[int, "torch.Tensor"] = {} # layer -> (16, d) scrambled matrix from the cloud
58
+
59
+ def set_projections(self, matrices: dict[int, list]) -> None:
60
+ torch = self.torch
61
+ self.projections = {int(layer): torch.tensor(m, dtype=torch.float32, device=self.device)
62
+ for layer, m in matrices.items()}
63
+
64
+ def chat_stream(self, messages: list[dict], max_new_tokens: int = 512, temperature: float = 0.6,
65
+ top_alternatives: int = 3) -> Iterator[dict]:
66
+ torch = self.torch
67
+ captured: dict[int, "torch.Tensor"] = {}
68
+
69
+ def make_hook(index):
70
+ def hook(_module, _inputs, output):
71
+ hidden = output[0] if isinstance(output, tuple) else output
72
+ captured[index] = hidden[0, -1, :].detach().float() # last token, like the validated board
73
+ return hook
74
+
75
+ # Running mean of each projected layer's hidden state over the answer so far. The probes were
76
+ # validated on mean-pooled states, so Behaviour scores the mean (generic arithmetic, no IP).
77
+ self._sums = {}
78
+ self._count = 0
79
+ with self.lock:
80
+ handles = [layer.register_forward_hook(make_hook(i)) for i, layer in enumerate(self.layers)]
81
+ try:
82
+ prompt = self.tokenizer.apply_chat_template(messages, add_generation_prompt=True,
83
+ return_tensors="pt", return_dict=True)
84
+ input_ids = prompt["input_ids"].to(self.device) # dict form works on transformers 4.4x and 5.x
85
+ past = None
86
+ generated = 0
87
+ with torch.no_grad():
88
+ while generated < max_new_tokens:
89
+ out = self.model(input_ids=input_ids, past_key_values=past, use_cache=True)
90
+ past = out.past_key_values
91
+ logits = out.logits[0, -1].float()
92
+ probs = torch.softmax(logits, dim=-1)
93
+ if temperature > 0:
94
+ choice = int(torch.multinomial(torch.softmax(logits / temperature, -1), 1))
95
+ else:
96
+ choice = int(torch.argmax(probs))
97
+ if generated == 0:
98
+ captured.clear() # the prompt pass is not a generated token
99
+ else:
100
+ yield self._step_event(previous_token, previous_probs, captured, top_alternatives)
101
+ if choice in (self.tokenizer.eos_token_id, *self._stop_ids()):
102
+ break
103
+ previous_token, previous_probs = choice, probs
104
+ input_ids = torch.tensor([[choice]], device=self.device)
105
+ generated += 1
106
+ else:
107
+ out = self.model(input_ids=input_ids, past_key_values=past, use_cache=True)
108
+ yield self._step_event(previous_token, previous_probs, captured, top_alternatives)
109
+ finally:
110
+ for handle in handles:
111
+ handle.remove()
112
+ yield {"type": "done", "tokens": generated, "seconds": None}
113
+
114
+ def _stop_ids(self) -> tuple:
115
+ ids = []
116
+ for token in ("<|im_end|>", "<|endoftext|>", "<|eot_id|>"):
117
+ tid = self.tokenizer.convert_tokens_to_ids(token)
118
+ if isinstance(tid, int) and tid >= 0 and tid != self.tokenizer.unk_token_id:
119
+ ids.append(tid)
120
+ return tuple(ids)
121
+
122
+ def _step_event(self, token_id: int, probs, captured: dict, top_k: int) -> dict:
123
+ """Event for one generated token; hidden states are those produced while reading that token."""
124
+ torch = self.torch
125
+ top = torch.topk(probs, top_k + 1)
126
+ alternatives = [{"text": self.tokenizer.decode([int(i)]), "p": float(p)}
127
+ for p, i in zip(top.values, top.indices) if int(i) != token_id][:top_k]
128
+ alternatives = [a for a in alternatives if a["text"].strip()]
129
+ event = {"type": "token", "text": self.tokenizer.decode([token_id]), "p": float(probs[token_id]),
130
+ "alternatives": alternatives,
131
+ "layers": [round(float(captured[i].pow(2).mean().sqrt()), 4) for i in range(self.n_layers)
132
+ if i in captured]}
133
+ if self.projections:
134
+ self._count += 1
135
+ for L in self.projections:
136
+ if L in captured:
137
+ self._sums[L] = self._sums.get(L, 0) + captured[L]
138
+ means = {L: self._sums[L] / self._count for L in self._sums}
139
+ event["sigma"] = {str(L): (M @ means[L]).tolist() for L, M in self.projections.items() if L in means}
140
+ event["norm"] = {str(L): float(means[L].norm()) for L in self.projections if L in means}
141
+ return event