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.
- proprioceptive_lens-0.1.0/LICENSE +21 -0
- proprioceptive_lens-0.1.0/PKG-INFO +85 -0
- proprioceptive_lens-0.1.0/README.md +68 -0
- proprioceptive_lens-0.1.0/lens/__init__.py +2 -0
- proprioceptive_lens-0.1.0/lens/__main__.py +63 -0
- proprioceptive_lens-0.1.0/lens/catalog.py +69 -0
- proprioceptive_lens-0.1.0/lens/cloud.py +98 -0
- proprioceptive_lens-0.1.0/lens/hf.py +141 -0
- proprioceptive_lens-0.1.0/lens/memory.py +151 -0
- proprioceptive_lens-0.1.0/lens/ollama.py +78 -0
- proprioceptive_lens-0.1.0/lens/server.py +302 -0
- proprioceptive_lens-0.1.0/lens/static/index.html +450 -0
- proprioceptive_lens-0.1.0/lens/terminal.py +245 -0
- proprioceptive_lens-0.1.0/proprioceptive_lens.egg-info/PKG-INFO +85 -0
- proprioceptive_lens-0.1.0/proprioceptive_lens.egg-info/SOURCES.txt +20 -0
- proprioceptive_lens-0.1.0/proprioceptive_lens.egg-info/dependency_links.txt +1 -0
- proprioceptive_lens-0.1.0/proprioceptive_lens.egg-info/entry_points.txt +2 -0
- proprioceptive_lens-0.1.0/proprioceptive_lens.egg-info/requires.txt +4 -0
- proprioceptive_lens-0.1.0/proprioceptive_lens.egg-info/top_level.txt +1 -0
- proprioceptive_lens-0.1.0/pyproject.toml +31 -0
- proprioceptive_lens-0.1.0/setup.cfg +4 -0
- proprioceptive_lens-0.1.0/tests/test_lens.py +200 -0
|
@@ -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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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,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
|