programasweights 0.3.2__tar.gz → 0.4.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.
Files changed (63) hide show
  1. {programasweights-0.3.2 → programasweights-0.4.0}/PKG-INFO +1 -1
  2. {programasweights-0.3.2 → programasweights-0.4.0}/docs/case-studies/site-navigation.md +21 -21
  3. {programasweights-0.3.2 → programasweights-0.4.0}/docs/guide/browser-inference.md +6 -6
  4. {programasweights-0.3.2 → programasweights-0.4.0}/docs/index.md +1 -1
  5. {programasweights-0.3.2 → programasweights-0.4.0}/programasweights/__init__.py +24 -7
  6. programasweights-0.4.0/programasweights/cache.py +323 -0
  7. {programasweights-0.3.2 → programasweights-0.4.0}/programasweights/cli.py +1 -1
  8. {programasweights-0.3.2 → programasweights-0.4.0}/programasweights/client.py +65 -2
  9. {programasweights-0.3.2 → programasweights-0.4.0}/programasweights/runtime_llamacpp.py +16 -2
  10. {programasweights-0.3.2 → programasweights-0.4.0}/pyproject.toml +1 -1
  11. programasweights-0.4.0/tests/test_runtime_registry_sdk.py +81 -0
  12. programasweights-0.3.2/programasweights/cache.py +0 -132
  13. {programasweights-0.3.2 → programasweights-0.4.0}/.gitignore +0 -0
  14. {programasweights-0.3.2 → programasweights-0.4.0}/.readthedocs.yaml +0 -0
  15. {programasweights-0.3.2 → programasweights-0.4.0}/AGENTS.md +0 -0
  16. {programasweights-0.3.2 → programasweights-0.4.0}/CHANGELOG.md +0 -0
  17. {programasweights-0.3.2 → programasweights-0.4.0}/LICENSE +0 -0
  18. {programasweights-0.3.2 → programasweights-0.4.0}/PYPI_README.md +0 -0
  19. {programasweights-0.3.2 → programasweights-0.4.0}/README.md +0 -0
  20. {programasweights-0.3.2 → programasweights-0.4.0}/docs/adr/001-llama-cpp-over-pytorch.md +0 -0
  21. {programasweights-0.3.2 → programasweights-0.4.0}/docs/adr/002-q4_0-adapter-format.md +0 -0
  22. {programasweights-0.3.2 → programasweights-0.4.0}/docs/adr/003-single-spec-field.md +0 -0
  23. {programasweights-0.3.2 → programasweights-0.4.0}/docs/adr/004-compiler-naming.md +0 -0
  24. {programasweights-0.3.2 → programasweights-0.4.0}/docs/adr/005-vllm-hidden-states.md +0 -0
  25. {programasweights-0.3.2 → programasweights-0.4.0}/docs/adr/006-email-api-key-auth.md +0 -0
  26. {programasweights-0.3.2 → programasweights-0.4.0}/docs/advanced/adrs.md +0 -0
  27. {programasweights-0.3.2 → programasweights-0.4.0}/docs/advanced/architecture.md +0 -0
  28. {programasweights-0.3.2 → programasweights-0.4.0}/docs/api-reference/cli.md +0 -0
  29. {programasweights-0.3.2 → programasweights-0.4.0}/docs/api-reference/python-sdk.md +0 -0
  30. {programasweights-0.3.2 → programasweights-0.4.0}/docs/api-reference/rest-api.md +0 -0
  31. {programasweights-0.3.2 → programasweights-0.4.0}/docs/architecture.md +0 -0
  32. {programasweights-0.3.2 → programasweights-0.4.0}/docs/case-studies/log-monitoring.md +0 -0
  33. {programasweights-0.3.2 → programasweights-0.4.0}/docs/case-studies/semantic-search.md +0 -0
  34. {programasweights-0.3.2 → programasweights-0.4.0}/docs/case-studies/tool-calling.md +0 -0
  35. {programasweights-0.3.2 → programasweights-0.4.0}/docs/getting-started/first-program.md +0 -0
  36. {programasweights-0.3.2 → programasweights-0.4.0}/docs/getting-started/installation.md +0 -0
  37. {programasweights-0.3.2 → programasweights-0.4.0}/docs/getting-started/naming-programs.md +0 -0
  38. {programasweights-0.3.2 → programasweights-0.4.0}/docs/guide/how-it-works.md +0 -0
  39. {programasweights-0.3.2 → programasweights-0.4.0}/docs/guide/local-inference.md +0 -0
  40. {programasweights-0.3.2 → programasweights-0.4.0}/docs/guide/writing-good-specs.md +0 -0
  41. {programasweights-0.3.2 → programasweights-0.4.0}/docs/hub/browsing-programs.md +0 -0
  42. {programasweights-0.3.2 → programasweights-0.4.0}/docs/hub/feedback-cases.md +0 -0
  43. {programasweights-0.3.2 → programasweights-0.4.0}/docs/hub/publishing-programs.md +0 -0
  44. {programasweights-0.3.2 → programasweights-0.4.0}/docs/requirements.txt +0 -0
  45. {programasweights-0.3.2 → programasweights-0.4.0}/examples/flask_app.py +0 -0
  46. {programasweights-0.3.2 → programasweights-0.4.0}/examples/jupyter_notebook.py +0 -0
  47. {programasweights-0.3.2 → programasweights-0.4.0}/examples/langchain_integration.py +0 -0
  48. {programasweights-0.3.2 → programasweights-0.4.0}/examples/paw_monitor.py +0 -0
  49. {programasweights-0.3.2 → programasweights-0.4.0}/examples/replace_openai.py +0 -0
  50. {programasweights-0.3.2 → programasweights-0.4.0}/mkdocs.yml +0 -0
  51. {programasweights-0.3.2 → programasweights-0.4.0}/programasweights/_output.py +0 -0
  52. {programasweights-0.3.2 → programasweights-0.4.0}/programasweights/artifacts.py +0 -0
  53. {programasweights-0.3.2 → programasweights-0.4.0}/programasweights/compiler/__init__.py +0 -0
  54. {programasweights-0.3.2 → programasweights-0.4.0}/programasweights/compiler/dummy.py +0 -0
  55. {programasweights-0.3.2 → programasweights-0.4.0}/programasweights/config.py +0 -0
  56. {programasweights-0.3.2 → programasweights-0.4.0}/programasweights/convert_peft_to_paw.py +0 -0
  57. {programasweights-0.3.2 → programasweights-0.4.0}/programasweights/paw_format.py +0 -0
  58. {programasweights-0.3.2 → programasweights-0.4.0}/programasweights/runtime/__init__.py +0 -0
  59. {programasweights-0.3.2 → programasweights-0.4.0}/programasweights/runtime/interpreter.py +0 -0
  60. {programasweights-0.3.2 → programasweights-0.4.0}/programasweights/runtime/interpreter_onnx.py +0 -0
  61. {programasweights-0.3.2 → programasweights-0.4.0}/tests/test_offline_cache.py +0 -0
  62. {programasweights-0.3.2 → programasweights-0.4.0}/tests/test_sdk.py +0 -0
  63. {programasweights-0.3.2 → programasweights-0.4.0}/tests/test_sdk.sh +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programasweights
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Compile natural language specifications into neural programs that run locally via llama.cpp.
5
5
  Project-URL: Homepage, https://programasweights.com
6
6
  Project-URL: Repository, https://github.com/programasweights/programasweights-python
@@ -21,20 +21,20 @@ Pages: /playground, /hub, /browser, /docs, /settings, /about
21
21
 
22
22
  ### Attempt 2: Reframe as classification
23
23
 
24
- Instead of generating URLs, output a digit: 1 = Playground, 2 = Hub, 3 = Browser, etc. The frontend maps digits to actual routes.
24
+ Instead of generating URLs, output a short semantic label such as `playground`, `docs`, or `feedback`. The frontend maps those labels to actual destinations.
25
25
 
26
26
  ```
27
- Classify the user's intent. Return ONLY a single digit.
28
- 1 = Create or compile a new program
29
- 2 = Browse or search existing programs
30
- 3 = Run a program in the browser
27
+ Classify the user's intent. Return ONLY a single label.
28
+ playground = Create or compile a new program
29
+ hub = Browse or search existing programs
30
+ browser = Run a program in the browser
31
31
  ...
32
- 0 = None of the above
32
+ none = None of the above
33
33
  ```
34
34
 
35
35
  **Result:** Accuracy jumped from ~67% to ~88%. Classification is far more reliable than generation for routing.
36
36
 
37
- **Lesson:** Reframe generation tasks as classification whenever possible. Output a label or digit, map it to the real value in your code.
37
+ **Lesson:** Reframe generation tasks as classification whenever possible. Output a short label, map it to the real value in your code.
38
38
 
39
39
  ### Attempt 3: Add Q&A support
40
40
 
@@ -71,8 +71,8 @@ validator = paw.function("my-answer-validator")
71
71
 
72
72
  def handle_query(user_query: str):
73
73
  destination = router(user_query)
74
- if destination != "0":
75
- return {"action": "navigate", "page": PAGES[int(destination)]}
74
+ if destination != "none":
75
+ return {"action": "navigate", "page": PAGE_MAP[destination]}
76
76
 
77
77
  category = q_type(user_query)
78
78
  if category == "yes_no":
@@ -92,36 +92,36 @@ Each program has a focused spec. For example, the page classifier:
92
92
 
93
93
  ```python
94
94
  router = paw.compile("""
95
- Classify the user's intent. Return ONLY a single digit.
96
- 1 = Create or compile something new
97
- 2 = Browse or search existing items
98
- 3 = Run something in the browser
99
- 4 = Read documentation
100
- 5 = Manage account or API keys
101
- 0 = None of the above (likely a question)
95
+ Classify the user's intent. Return ONLY a single label.
96
+ playground = Create or compile something new
97
+ hub = Browse or search existing items
98
+ browser = Run something in the browser
99
+ docs = Read documentation
100
+ settings = Manage account or API keys
101
+ none = None of the above (likely a question)
102
102
 
103
103
  Input: how do I get started
104
- Output: 4
104
+ Output: docs
105
105
 
106
106
  Input: browse community programs
107
- Output: 2
107
+ Output: hub
108
108
 
109
109
  Input: is it free?
110
- Output: 0
110
+ Output: none
111
111
  """)
112
112
  ```
113
113
 
114
114
  ## Adapting this for your site
115
115
 
116
116
  1. **List your pages** with short descriptions of what users do there
117
- 2. **Compile a classifier** that maps intents to page numbers
117
+ 2. **Compile a classifier** that maps intents to short labels
118
118
  3. **Test with 20-30 real queries** your users would type — iterate on the spec wording
119
119
  4. If users also ask questions, add Q&A programs and a validator
120
120
  5. Each program compiles once and is cached forever — the pipeline runs locally with no API calls
121
121
 
122
122
  ## Takeaways
123
123
 
124
- - **Classification beats generation** for routing. Output a label, map it in code.
124
+ - **Classification beats generation** for routing. Output a short label, map it in code.
125
125
  - **Multiple small programs beat one complex program.** When accuracy drops, split.
126
126
  - **A validator catches failures** the other programs miss — cheap insurance.
127
127
  - **Iterate with real queries.** Build a small test set, measure, adjust wording, repeat.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Run PAW programs directly in the browser via WebAssembly. No server, no API key, no setup.
4
4
 
5
- Programs compiled with the compact interpreter (GPT-2 124M) run entirely client-side. The base model (105 MB) downloads once and is cached; each program adds only ~5 MB.
5
+ Programs compiled with the compact interpreter (GPT-2 124M) run entirely client-side. The base model (134 MB) downloads once and is cached; each program adds ~12 MB total (~5 MB adapter + ~7 MB prefix cache).
6
6
 
7
7
  ## Quick Start
8
8
 
@@ -12,7 +12,7 @@ Programs compiled with the compact interpreter (GPT-2 124M) run entirely client-
12
12
  <script type="module">
13
13
  import paw from 'https://cdn.jsdelivr.net/npm/@programasweights/web';
14
14
 
15
- const fn = await paw.function('programasweights/email-triage');
15
+ const fn = await paw.function('email-triage');
16
16
  const result = await fn('Urgent: server is down!');
17
17
  console.log(result); // "immediate"
18
18
  </script>
@@ -27,7 +27,7 @@ npm install @programasweights/web
27
27
  ```javascript
28
28
  import paw from '@programasweights/web';
29
29
 
30
- const fn = await paw.function('programasweights/email-triage', {
30
+ const fn = await paw.function('email-triage', {
31
31
  onProgress: ({ loaded, total, stage }) => {
32
32
  console.log(`${stage}: ${Math.round(loaded/total*100)}%`);
33
33
  },
@@ -42,7 +42,7 @@ await fn.free();
42
42
 
43
43
  ## How It Works
44
44
 
45
- 1. **Base model** — Compact interpreter (GPT-2 124M, 105 MB) downloads from HuggingFace CDN and is cached in the browser after first load.
45
+ 1. **Base model** — Compact interpreter (GPT-2 124M, 134 MB) downloads from HuggingFace CDN and is cached in the browser after first load.
46
46
  2. **LoRA adapter** — Each program is a ~5 MB Q4_0 GGUF LoRA adapter that specializes the base model for a specific task.
47
47
  3. **Prefix cache** — A precomputed KV cache (~7 MB) eliminates the prompt prefill step, making the first inference call fast.
48
48
  4. **Inference** — Runs via WebAssembly (llama.cpp compiled to WASM with SIMD). ~200ms per call on Chrome.
@@ -87,7 +87,7 @@ paw.configure({
87
87
  | Base model (GPT-2 Q8_0) | 134 MB | First program load (cached) |
88
88
  | LoRA adapter | ~5 MB | Per program |
89
89
  | Prefix cache | ~7 MB | Per program |
90
- | **First load total** | **~117 MB** | |
90
+ | **First load total** | **~146 MB** | |
91
91
  | **Switching programs** | **~12 MB** | |
92
92
 
93
93
  ## Browser Compatibility
@@ -135,5 +135,5 @@ Without these headers, the SDK falls back to single-threaded WASM (still functio
135
135
  ## Limitations
136
136
 
137
137
  - Only programs compiled with the **compact** interpreter (GPT-2 124M) are supported. Programs compiled with the standard interpreter (Qwen3 0.6B) are too large for browser inference (~594 MB base model).
138
- - The 105 MB base model download may be slow on mobile connections.
138
+ - The 134 MB base model download may be slow on mobile connections.
139
139
  - Performance varies by browser (Chrome is fastest).
@@ -65,7 +65,7 @@ Programs compiled with the compact interpreter (GPT-2 124M) also run directly in
65
65
  ```html
66
66
  <script type="module">
67
67
  import paw from '@programasweights/web';
68
- const fn = await paw.function('programasweights/email-triage');
68
+ const fn = await paw.function('email-triage');
69
69
  const result = await fn('Urgent: server is down!');
70
70
  </script>
71
71
  ```
@@ -27,14 +27,14 @@ try:
27
27
  from importlib.metadata import version as _meta_version
28
28
  __version__ = _meta_version("programasweights")
29
29
  except Exception:
30
- __version__ = "0.3.1"
30
+ __version__ = "0.4.0"
31
31
 
32
32
  from .config import get_api_url, get_api_key, set_api_key
33
33
 
34
34
 
35
35
  def compile(
36
36
  spec: str,
37
- compiler: str = "paw-4b-qwen3-0.6b",
37
+ compiler: str | None = None,
38
38
  name: str | None = None,
39
39
  tags: list[str] | None = None,
40
40
  public: bool = True,
@@ -49,7 +49,8 @@ def compile(
49
49
 
50
50
  Args:
51
51
  spec: Full specification text. Include examples in the text if desired.
52
- compiler: Compiler model (alias or snapshot name).
52
+ compiler: Compiler model (alias or snapshot name). If omitted, the
53
+ server chooses the current default compiler.
53
54
  name: Human-readable program name (display title for the hub).
54
55
  tags: Tags for hub discovery.
55
56
  public: Whether to list on the public hub.
@@ -126,6 +127,7 @@ def function(
126
127
  from .cache import is_program_cached, get_program_dir, get_cached_slug, save_slug_mapping
127
128
  from .runtime_llamacpp import PawFunction
128
129
 
130
+ offline = offline or os.environ.get("PAW_OFFLINE", "").strip() in ("1", "true", "yes")
129
131
  if n_gpu_layers is None:
130
132
  n_gpu_layers = int(os.environ.get("PAW_GPU_LAYERS", "-1"))
131
133
 
@@ -134,7 +136,7 @@ def function(
134
136
  resolved_id = program_id
135
137
  if not re.fullmatch(r"[a-f0-9]{16,64}", program_id):
136
138
  is_pinned = bool(re.search(r"@v\d+$", program_id))
137
- use_offline = offline or os.environ.get("PAW_OFFLINE", "").strip() in ("1", "true", "yes")
139
+ use_offline = offline
138
140
 
139
141
  if is_pinned:
140
142
  cached = get_cached_slug(program_id)
@@ -177,7 +179,13 @@ def function(
177
179
 
178
180
  program_dir = get_program_dir(resolved_id)
179
181
  return PawFunction(
180
- program_dir, n_ctx=n_ctx, n_gpu_layers=n_gpu_layers, verbose=verbose,
182
+ program_dir,
183
+ n_ctx=n_ctx,
184
+ n_gpu_layers=n_gpu_layers,
185
+ verbose=verbose,
186
+ api_url=get_api_url(),
187
+ api_key=get_api_key(),
188
+ offline=offline,
181
189
  )
182
190
 
183
191
 
@@ -228,7 +236,7 @@ def login(key: str | None = None):
228
236
 
229
237
  def compile_and_load(
230
238
  spec: str,
231
- compiler: str = "paw-4b-qwen3-0.6b",
239
+ compiler: str | None = None,
232
240
  n_ctx: int = 2048,
233
241
  n_gpu_layers: int | None = None,
234
242
  verbose: bool = False,
@@ -241,7 +249,8 @@ def compile_and_load(
241
249
 
242
250
  Args:
243
251
  spec: Natural language specification.
244
- compiler: Compiler model name.
252
+ compiler: Compiler model name. If omitted, the server chooses the
253
+ current default compiler.
245
254
  n_ctx: Context window size for llama.cpp.
246
255
  n_gpu_layers: GPU layers (-1 = all, 0 = CPU only).
247
256
  verbose: Print llama.cpp debug output.
@@ -294,10 +303,18 @@ def list_programs(sort: str = "recent", per_page: int = 20, page: int = 1) -> di
294
303
  return client.list_programs(sort=sort, per_page=per_page, page=page)
295
304
 
296
305
 
306
+ def list_compilers() -> list[dict]:
307
+ """List available compilers from the server."""
308
+ from .client import PAWClient
309
+ client = PAWClient(api_url=get_api_url(), api_key=get_api_key())
310
+ return client.list_compilers()
311
+
312
+
297
313
  __all__ = [
298
314
  "compile",
299
315
  "compile_and_load",
300
316
  "function",
317
+ "list_compilers",
301
318
  "list_programs",
302
319
  "list_versions",
303
320
  "login",
@@ -0,0 +1,323 @@
1
+ """
2
+ Local cache management for base models and compiled programs.
3
+
4
+ Cache structure:
5
+ ~/.cache/programasweights/
6
+ base_models/
7
+ qwen3-0.6b-q6_k.gguf # ~594 MB, downloaded once
8
+ gpt2-q8_0.gguf # ~134 MB, downloaded once
9
+ programs/
10
+ <program_id>/
11
+ adapter.gguf # ~23 MB, Q4_0 LoRA
12
+ prompt_template.txt
13
+ meta.json
14
+ slug_cache.json # slug -> program_id mapping
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ from pathlib import Path
22
+
23
+ import httpx
24
+
25
+ from . import config
26
+
27
+ BASE_MODEL_URLS = {
28
+ "qwen3-0.6b-q6_k": "https://huggingface.co/programasweights/Qwen3-0.6B-GGUF-Q6_K/resolve/main/qwen3-0.6b-q6_k.gguf",
29
+ "gpt2-q8_0": "https://huggingface.co/programasweights/GPT2-GGUF-Q8_0/resolve/main/gpt2-q8_0.gguf",
30
+ }
31
+
32
+ INTERPRETER_TO_GGUF = {
33
+ "Qwen/Qwen3-0.6B": "qwen3-0.6b-q6_k",
34
+ "gpt2": "gpt2-q8_0",
35
+ }
36
+
37
+
38
+ LEGACY_RUNTIME_MANIFESTS = {
39
+ "qwen3-0.6b-q6_k": {
40
+ "runtime_id": "qwen3-0.6b-q6_k",
41
+ "manifest_version": 1,
42
+ "display_name": "Qwen3 0.6B (Q6_K)",
43
+ "interpreter": "Qwen/Qwen3-0.6B",
44
+ "adapter_format": "gguf_lora",
45
+ "local_sdk": {
46
+ "supported": True,
47
+ "base_model": {
48
+ "provider": "huggingface",
49
+ "repo": "programasweights/Qwen3-0.6B-GGUF-Q6_K",
50
+ "file": "qwen3-0.6b-q6_k.gguf",
51
+ "url": BASE_MODEL_URLS["qwen3-0.6b-q6_k"],
52
+ "sha256": None,
53
+ },
54
+ "n_ctx": 2048,
55
+ },
56
+ "js_sdk": {
57
+ "supported": False,
58
+ "base_model": None,
59
+ "prefix_cache_supported": False,
60
+ },
61
+ },
62
+ "gpt2-q8_0": {
63
+ "runtime_id": "gpt2-q8_0",
64
+ "manifest_version": 1,
65
+ "display_name": "GPT-2 124M (Q8_0)",
66
+ "interpreter": "gpt2",
67
+ "adapter_format": "gguf_lora",
68
+ "local_sdk": {
69
+ "supported": True,
70
+ "base_model": {
71
+ "provider": "huggingface",
72
+ "repo": "programasweights/GPT2-GGUF-Q8_0",
73
+ "file": "gpt2-q8_0.gguf",
74
+ "url": BASE_MODEL_URLS["gpt2-q8_0"],
75
+ "sha256": None,
76
+ },
77
+ "n_ctx": 2048,
78
+ },
79
+ "js_sdk": {
80
+ "supported": True,
81
+ "base_model": {
82
+ "provider": "huggingface",
83
+ "repo": "programasweights/GPT2-GGUF-Q8_0",
84
+ "file": "gpt2-q8_0.gguf",
85
+ "url": BASE_MODEL_URLS["gpt2-q8_0"],
86
+ "sha256": None,
87
+ },
88
+ "prefix_cache_supported": True,
89
+ },
90
+ },
91
+ }
92
+
93
+
94
+ def _runtime_cache_dir() -> Path:
95
+ d = config.get_cache_dir() / "runtimes"
96
+ d.mkdir(parents=True, exist_ok=True)
97
+ return d
98
+
99
+
100
+ def _runtime_manifest_path(runtime_id: str) -> Path:
101
+ return _runtime_cache_dir() / f"{runtime_id}.json"
102
+
103
+
104
+ def get_cached_runtime_manifest(runtime_id: str) -> dict | None:
105
+ path = _runtime_manifest_path(runtime_id)
106
+ if not path.exists():
107
+ return None
108
+ try:
109
+ return json.loads(path.read_text())
110
+ except (json.JSONDecodeError, OSError):
111
+ return None
112
+
113
+
114
+ def save_runtime_manifest(runtime_manifest: dict) -> None:
115
+ runtime_id = runtime_manifest.get("runtime_id")
116
+ if not runtime_id:
117
+ return
118
+ path = _runtime_manifest_path(runtime_id)
119
+ path.write_text(json.dumps(runtime_manifest, indent=2))
120
+
121
+
122
+ def _legacy_runtime_manifest(interpreter: str | None) -> dict | None:
123
+ if not interpreter:
124
+ return None
125
+ runtime_id = INTERPRETER_TO_GGUF.get(interpreter)
126
+ if not runtime_id:
127
+ return None
128
+ manifest = LEGACY_RUNTIME_MANIFESTS.get(runtime_id)
129
+ return json.loads(json.dumps(manifest)) if manifest else None
130
+
131
+
132
+ def _is_runtime_manifest_complete(runtime_manifest: dict | None) -> bool:
133
+ if not runtime_manifest:
134
+ return False
135
+ base_model = runtime_manifest.get("local_sdk", {}).get("base_model")
136
+ return bool(runtime_manifest.get("runtime_id") and base_model and base_model.get("file"))
137
+
138
+
139
+ def fetch_runtime_manifest(
140
+ runtime_id: str,
141
+ api_url: str | None = None,
142
+ api_key: str | None = None,
143
+ ) -> dict:
144
+ base_url = (api_url or config.get_api_url()).rstrip("/")
145
+ headers = {}
146
+ if api_key:
147
+ headers["X-API-Key"] = api_key
148
+ resp = httpx.get(
149
+ f"{base_url}/api/v1/models/runtimes/{runtime_id}",
150
+ headers=headers,
151
+ timeout=10.0,
152
+ )
153
+ resp.raise_for_status()
154
+ runtime_manifest = resp.json()
155
+ save_runtime_manifest(runtime_manifest)
156
+ return runtime_manifest
157
+
158
+
159
+ def resolve_runtime_manifest(
160
+ program_meta: dict,
161
+ api_url: str | None = None,
162
+ api_key: str | None = None,
163
+ offline: bool = False,
164
+ ) -> dict | None:
165
+ embedded = program_meta.get("runtime")
166
+ if isinstance(embedded, dict) and _is_runtime_manifest_complete(embedded):
167
+ save_runtime_manifest(embedded)
168
+ return embedded
169
+
170
+ runtime_id = program_meta.get("runtime_id")
171
+ if runtime_id:
172
+ cached = get_cached_runtime_manifest(runtime_id)
173
+ if _is_runtime_manifest_complete(cached):
174
+ return cached
175
+ if not offline:
176
+ try:
177
+ return fetch_runtime_manifest(runtime_id, api_url=api_url, api_key=api_key)
178
+ except Exception:
179
+ pass
180
+
181
+ return _legacy_runtime_manifest(program_meta.get("interpreter"))
182
+
183
+
184
+ def _base_model_info_from_runtime(runtime_manifest: dict) -> dict | None:
185
+ return runtime_manifest.get("local_sdk", {}).get("base_model")
186
+
187
+
188
+ def _build_hf_url(repo: str, file_name: str) -> str:
189
+ return f"https://huggingface.co/{repo}/resolve/main/{file_name}"
190
+
191
+
192
+ def get_base_model_path(
193
+ interpreter: str = "Qwen/Qwen3-0.6B",
194
+ runtime_manifest: dict | None = None,
195
+ ) -> Path:
196
+ """Get the path to the base model GGUF, downloading if needed."""
197
+ if runtime_manifest:
198
+ local_sdk = runtime_manifest.get("local_sdk", {})
199
+ if not local_sdk.get("supported", False):
200
+ raise ValueError(
201
+ f"Runtime '{runtime_manifest.get('runtime_id', interpreter)}' is not supported by the local SDK."
202
+ )
203
+
204
+ base_model = _base_model_info_from_runtime(runtime_manifest)
205
+ if base_model:
206
+ file_name = base_model.get("file")
207
+ if not file_name:
208
+ raise ValueError(
209
+ f"Runtime '{runtime_manifest.get('runtime_id', interpreter)}' is missing a base model file."
210
+ )
211
+
212
+ gguf_path = config.get_base_models_dir() / file_name
213
+ if gguf_path.exists():
214
+ return gguf_path
215
+
216
+ url = base_model.get("url")
217
+ if not url and base_model.get("provider") == "huggingface":
218
+ url = _build_hf_url(base_model["repo"], file_name)
219
+ if not url:
220
+ raise ValueError(
221
+ f"Runtime '{runtime_manifest.get('runtime_id', interpreter)}' is missing a downloadable base model URL."
222
+ )
223
+
224
+ from ._output import status
225
+ label = runtime_manifest.get("display_name") or runtime_manifest.get("runtime_id") or interpreter
226
+ status(f"Downloading interpreter {label} (one-time download)...")
227
+ _download_file(url, gguf_path)
228
+ status(f"Saved to {gguf_path}")
229
+ return gguf_path
230
+
231
+ gguf_name = INTERPRETER_TO_GGUF.get(interpreter)
232
+ if not gguf_name:
233
+ raise ValueError(
234
+ f"Unknown interpreter: '{interpreter}'. "
235
+ f"Supported: {list(INTERPRETER_TO_GGUF.keys())}"
236
+ )
237
+ gguf_path = config.get_base_models_dir() / f"{gguf_name}.gguf"
238
+
239
+ if gguf_path.exists():
240
+ return gguf_path
241
+
242
+ url = BASE_MODEL_URLS.get(gguf_name)
243
+ if not url:
244
+ raise ValueError(
245
+ f"Unknown interpreter model: {interpreter}. "
246
+ f"Available: {list(INTERPRETER_TO_GGUF.keys())}"
247
+ )
248
+
249
+ from ._output import status
250
+ status(f"Downloading interpreter {gguf_name} (one-time download)...")
251
+ _download_file(url, gguf_path)
252
+ status(f"Saved to {gguf_path}")
253
+ return gguf_path
254
+
255
+
256
+ def get_program_dir(program_id: str) -> Path:
257
+ """Get the local cache directory for a program."""
258
+ return config.get_programs_dir() / program_id
259
+
260
+
261
+ def is_program_cached(program_id: str) -> bool:
262
+ """Check if a program's artifacts are already cached locally."""
263
+ d = get_program_dir(program_id)
264
+ return (d / "adapter.gguf").exists() and (d / "prompt_template.txt").exists()
265
+
266
+
267
+ def _download_file(url: str, dest: Path):
268
+ """Download a file atomically with progress indication."""
269
+ dest.parent.mkdir(parents=True, exist_ok=True)
270
+ tmp = dest.with_suffix(dest.suffix + ".tmp")
271
+ try:
272
+ with httpx.stream("GET", url, follow_redirects=True, timeout=300.0) as resp:
273
+ resp.raise_for_status()
274
+ total = int(resp.headers.get("content-length", 0))
275
+ downloaded = 0
276
+ with open(tmp, "wb") as f:
277
+ for chunk in resp.iter_bytes(chunk_size=8192):
278
+ f.write(chunk)
279
+ downloaded += len(chunk)
280
+ if total > 0:
281
+ pct = downloaded / total * 100
282
+ mb = downloaded / 1024 / 1024
283
+ from ._output import status_inline, status_end
284
+ status_inline(f" {mb:.1f} MB ({pct:.0f}%)")
285
+ from ._output import status_end
286
+ status_end()
287
+ os.replace(str(tmp), str(dest))
288
+ except BaseException:
289
+ tmp.unlink(missing_ok=True)
290
+ raise
291
+
292
+
293
+ def _slug_cache_path() -> Path:
294
+ return config.get_cache_dir() / "slug_cache.json"
295
+
296
+
297
+ def get_cached_slug(slug: str) -> str | None:
298
+ """Look up a slug in the local cache. Returns program_id or None."""
299
+ path = _slug_cache_path()
300
+ if not path.exists():
301
+ return None
302
+ try:
303
+ data = json.loads(path.read_text())
304
+ program_id = data.get(slug)
305
+ if program_id and is_program_cached(program_id):
306
+ return program_id
307
+ except (json.JSONDecodeError, OSError):
308
+ pass
309
+ return None
310
+
311
+
312
+ def save_slug_mapping(slug: str, program_id: str) -> None:
313
+ """Save a slug -> program_id mapping to the local cache."""
314
+ path = _slug_cache_path()
315
+ data: dict = {}
316
+ if path.exists():
317
+ try:
318
+ data = json.loads(path.read_text())
319
+ except (json.JSONDecodeError, OSError):
320
+ pass
321
+ data[slug] = program_id
322
+ path.parent.mkdir(parents=True, exist_ok=True)
323
+ path.write_text(json.dumps(data))
@@ -169,7 +169,7 @@ def main():
169
169
 
170
170
  p = sub.add_parser("compile", help="Compile a spec on the server")
171
171
  p.add_argument("--spec", required=True, help="Natural language specification")
172
- p.add_argument("--compiler", default="paw-4b-qwen3-0.6b", help="Compiler model")
172
+ p.add_argument("--compiler", default=None, help="Compiler model (omit to use the server default)")
173
173
  p.add_argument("--slug", default=None, help="URL-safe handle (e.g. 'message-classifier')")
174
174
  p.add_argument("--private", action="store_true", help="Make program private (not listed on hub)")
175
175
  p.add_argument("--json", action="store_true", help="JSON output")
@@ -6,6 +6,7 @@ Handles compilation, program download, and authentication.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import json
9
10
  import os
10
11
  import time
11
12
  import zipfile
@@ -25,6 +26,10 @@ class Program:
25
26
  status: str
26
27
  slug: Optional[str] = None
27
28
  compiler_snapshot: Optional[str] = None
29
+ compiler_kind: Optional[str] = None
30
+ pseudo_program_strategy: Optional[str] = None
31
+ runtime_id: Optional[str] = None
32
+ runtime_manifest_version: Optional[int] = None
28
33
  timings: Optional[dict] = None
29
34
  error: Optional[str] = None
30
35
  version: Optional[int] = None
@@ -47,7 +52,7 @@ class PAWClient:
47
52
  def compile(
48
53
  self,
49
54
  spec: str,
50
- compiler: str = "paw-4b-qwen3-0.6b",
55
+ compiler: str | None = None,
51
56
  name: str | None = None,
52
57
  tags: list[str] | None = None,
53
58
  public: bool = True,
@@ -71,7 +76,9 @@ class PAWClient:
71
76
  Raises:
72
77
  httpx.HTTPStatusError: On API errors (422 for validation, 429 for rate limit).
73
78
  """
74
- body: dict = {"spec": spec, "compiler": compiler, "public": public}
79
+ body: dict = {"spec": spec, "public": public}
80
+ if compiler:
81
+ body["compiler"] = compiler
75
82
  if ephemeral:
76
83
  body["ephemeral"] = True
77
84
  if name:
@@ -95,6 +102,10 @@ class PAWClient:
95
102
  status=data.get("status", "unknown"),
96
103
  slug=data.get("slug"),
97
104
  compiler_snapshot=data.get("compiler_snapshot"),
105
+ compiler_kind=data.get("compiler_kind"),
106
+ pseudo_program_strategy=data.get("pseudo_program_strategy"),
107
+ runtime_id=data.get("runtime_id"),
108
+ runtime_manifest_version=data.get("runtime_manifest_version"),
98
109
  timings=data.get("timings"),
99
110
  error=data.get("error"),
100
111
  version=data.get("version"),
@@ -177,8 +188,36 @@ class PAWClient:
177
188
  raise ValueError(f"Unsafe path in .paw archive: {member}")
178
189
  zf.extractall(program_dir)
179
190
 
191
+ self._hydrate_runtime_manifest(program_dir)
192
+
180
193
  return program_dir
181
194
 
195
+ def _hydrate_runtime_manifest(self, program_dir: Path) -> None:
196
+ meta_path = program_dir / "meta.json"
197
+ if not meta_path.exists():
198
+ return
199
+
200
+ try:
201
+ meta = json.loads(meta_path.read_text())
202
+ except (json.JSONDecodeError, OSError):
203
+ return
204
+
205
+ runtime_id = meta.get("runtime_id")
206
+ runtime = meta.get("runtime")
207
+ if isinstance(runtime, dict) and runtime.get("runtime_id") and runtime.get("local_sdk", {}).get("base_model"):
208
+ return
209
+ if not runtime_id:
210
+ return
211
+
212
+ try:
213
+ runtime_manifest = self.get_runtime_manifest(runtime_id)
214
+ except Exception:
215
+ return
216
+
217
+ meta["runtime"] = runtime_manifest
218
+ meta.setdefault("runtime_manifest_version", runtime_manifest.get("manifest_version"))
219
+ meta_path.write_text(json.dumps(meta, indent=2))
220
+
182
221
  def get_program_meta(self, program_id: str) -> dict:
183
222
  """Get program metadata from the server."""
184
223
  resp = httpx.get(
@@ -189,6 +228,30 @@ class PAWClient:
189
228
  resp.raise_for_status()
190
229
  return resp.json()
191
230
 
231
+ def get_runtime_manifest(self, runtime_id: str) -> dict:
232
+ """Fetch a runtime manifest from the server and cache it locally."""
233
+ from . import cache
234
+
235
+ cached = cache.get_cached_runtime_manifest(runtime_id)
236
+ if cached:
237
+ return cached
238
+
239
+ return cache.fetch_runtime_manifest(
240
+ runtime_id,
241
+ api_url=self._api_url,
242
+ api_key=self._api_key,
243
+ )
244
+
245
+ def list_compilers(self) -> list[dict]:
246
+ """List available compilers from the server."""
247
+ resp = httpx.get(
248
+ f"{self._api_url}/api/v1/models/compilers",
249
+ headers=self._headers(),
250
+ timeout=10.0,
251
+ )
252
+ resp.raise_for_status()
253
+ return resp.json()["compilers"]
254
+
192
255
  def list_slug_versions(self, slug: str) -> dict:
193
256
  """List all versions of a slug. Slug format: 'username/slug-name' or bare 'slug-name'."""
194
257
  resp = httpx.get(
@@ -1,7 +1,8 @@
1
1
  """
2
2
  llama.cpp runtime for local inference with LoRA adapters.
3
3
 
4
- Loads a base GGUF model (Q6_K) and applies a Q4_0 LoRA adapter per-program.
4
+ Loads a base GGUF model (Q6_K for Qwen3, Q8_0 for GPT-2) and applies a
5
+ Q4_0 LoRA adapter per-program.
5
6
  Uses the pre-rendered prompt template from the .paw bundle.
6
7
 
7
8
  Prefix KV cache is saved to disk after the first call and reloaded on
@@ -33,6 +34,9 @@ class PawFunction:
33
34
  n_ctx: int = 2048,
34
35
  n_gpu_layers: int = 0,
35
36
  verbose: bool = False,
37
+ api_url: str | None = None,
38
+ api_key: str | None = None,
39
+ offline: bool = False,
36
40
  ):
37
41
  program_dir = Path(program_dir)
38
42
  self._program_dir = program_dir
@@ -53,7 +57,17 @@ class PawFunction:
53
57
 
54
58
  from ._output import status
55
59
  interpreter = self._meta.get("interpreter", "Qwen/Qwen3-0.6B")
56
- base_model_path = cache.get_base_model_path(interpreter)
60
+ runtime_manifest = cache.resolve_runtime_manifest(
61
+ self._meta,
62
+ api_url=api_url,
63
+ api_key=api_key,
64
+ offline=offline,
65
+ )
66
+ if runtime_manifest:
67
+ self._meta["runtime"] = runtime_manifest
68
+ self._meta.setdefault("runtime_id", runtime_manifest.get("runtime_id"))
69
+ self._meta.setdefault("runtime_manifest_version", runtime_manifest.get("manifest_version"))
70
+ base_model_path = cache.get_base_model_path(interpreter, runtime_manifest=runtime_manifest)
57
71
 
58
72
  status("Loading interpreter...")
59
73
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "programasweights"
7
- version = "0.3.2"
7
+ version = "0.4.0"
8
8
  description = "Compile natural language specifications into neural programs that run locally via llama.cpp."
9
9
  readme = "PYPI_README.md"
10
10
  requires-python = ">=3.9"
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from programasweights import cache
9
+
10
+
11
+ RUNTIME = {
12
+ "runtime_id": "gpt2-q8_0",
13
+ "manifest_version": 1,
14
+ "display_name": "GPT-2 124M (Q8_0)",
15
+ "interpreter": "gpt2",
16
+ "adapter_format": "gguf_lora",
17
+ "local_sdk": {
18
+ "supported": True,
19
+ "base_model": {
20
+ "provider": "huggingface",
21
+ "repo": "programasweights/GPT2-GGUF-Q8_0",
22
+ "file": "gpt2-q8_0.gguf",
23
+ "url": "https://example.com/gpt2-q8_0.gguf",
24
+ "sha256": None,
25
+ },
26
+ "n_ctx": 2048,
27
+ },
28
+ "js_sdk": {
29
+ "supported": True,
30
+ "base_model": {
31
+ "provider": "huggingface",
32
+ "repo": "programasweights/GPT2-GGUF-Q8_0",
33
+ "file": "gpt2-q8_0.gguf",
34
+ "url": "https://example.com/gpt2-q8_0.gguf",
35
+ "sha256": None,
36
+ },
37
+ "prefix_cache_supported": True,
38
+ },
39
+ }
40
+
41
+
42
+ @pytest.fixture(autouse=True)
43
+ def _cache_dir(tmp_path, monkeypatch):
44
+ monkeypatch.setenv("PAW_CACHE_DIR", str(tmp_path / "cache"))
45
+
46
+
47
+ def test_resolve_runtime_manifest_prefers_embedded_manifest():
48
+ meta = {
49
+ "interpreter": "gpt2",
50
+ "runtime_id": "gpt2-q8_0",
51
+ "runtime": RUNTIME,
52
+ }
53
+ resolved = cache.resolve_runtime_manifest(meta, offline=True)
54
+ assert resolved["runtime_id"] == "gpt2-q8_0"
55
+ cached = cache.get_cached_runtime_manifest("gpt2-q8_0")
56
+ assert cached["runtime_id"] == "gpt2-q8_0"
57
+
58
+
59
+ def test_resolve_runtime_manifest_uses_legacy_fallback_when_offline():
60
+ meta = {
61
+ "interpreter": "gpt2",
62
+ "runtime_id": "gpt2-q8_0",
63
+ }
64
+ resolved = cache.resolve_runtime_manifest(meta, offline=True)
65
+ assert resolved is not None
66
+ assert resolved["runtime_id"] == "gpt2-q8_0"
67
+ assert resolved["local_sdk"]["base_model"]["file"] == "gpt2-q8_0.gguf"
68
+
69
+
70
+ def test_get_base_model_path_uses_runtime_manifest(tmp_path, monkeypatch):
71
+ target = tmp_path / "cache" / "base_models" / "gpt2-q8_0.gguf"
72
+
73
+ def fake_download(url: str, dest: Path):
74
+ assert url == "https://example.com/gpt2-q8_0.gguf"
75
+ dest.parent.mkdir(parents=True, exist_ok=True)
76
+ dest.write_bytes(b"gguf")
77
+
78
+ monkeypatch.setattr(cache, "_download_file", fake_download)
79
+ path = cache.get_base_model_path("gpt2", runtime_manifest=RUNTIME)
80
+ assert path == target
81
+ assert path.read_bytes() == b"gguf"
@@ -1,132 +0,0 @@
1
- """
2
- Local cache management for base models and compiled programs.
3
-
4
- Cache structure:
5
- ~/.cache/programasweights/
6
- base_models/
7
- qwen3-0.6b-q6_k.gguf # ~594 MB, downloaded once
8
- gpt2-q8_0.gguf # ~134 MB, downloaded once
9
- programs/
10
- <program_id>/
11
- adapter.gguf # ~23 MB, Q4_0 LoRA
12
- prompt_template.txt
13
- meta.json
14
- slug_cache.json # slug -> program_id mapping
15
- """
16
-
17
- from __future__ import annotations
18
-
19
- import json
20
- import os
21
- from pathlib import Path
22
-
23
- import httpx
24
-
25
- from . import config
26
-
27
- BASE_MODEL_URLS = {
28
- "qwen3-0.6b-q6_k": "https://huggingface.co/programasweights/Qwen3-0.6B-GGUF-Q6_K/resolve/main/qwen3-0.6b-q6_k.gguf",
29
- "gpt2-q8_0": "https://huggingface.co/programasweights/GPT2-GGUF-Q8_0/resolve/main/gpt2-q8_0.gguf",
30
- }
31
-
32
- INTERPRETER_TO_GGUF = {
33
- "Qwen/Qwen3-0.6B": "qwen3-0.6b-q6_k",
34
- "gpt2": "gpt2-q8_0",
35
- }
36
-
37
-
38
- def get_base_model_path(interpreter: str = "Qwen/Qwen3-0.6B") -> Path:
39
- """Get the path to the base model GGUF, downloading if needed."""
40
- gguf_name = INTERPRETER_TO_GGUF.get(interpreter)
41
- if not gguf_name:
42
- raise ValueError(
43
- f"Unknown interpreter: '{interpreter}'. "
44
- f"Supported: {list(INTERPRETER_TO_GGUF.keys())}"
45
- )
46
- gguf_path = config.get_base_models_dir() / f"{gguf_name}.gguf"
47
-
48
- if gguf_path.exists():
49
- return gguf_path
50
-
51
- url = BASE_MODEL_URLS.get(gguf_name)
52
- if not url:
53
- raise ValueError(
54
- f"Unknown interpreter model: {interpreter}. "
55
- f"Available: {list(INTERPRETER_TO_GGUF.keys())}"
56
- )
57
-
58
- from ._output import status
59
- status(f"Downloading interpreter {gguf_name} (one-time download)...")
60
- _download_file(url, gguf_path)
61
- status(f"Saved to {gguf_path}")
62
- return gguf_path
63
-
64
-
65
- def get_program_dir(program_id: str) -> Path:
66
- """Get the local cache directory for a program."""
67
- return config.get_programs_dir() / program_id
68
-
69
-
70
- def is_program_cached(program_id: str) -> bool:
71
- """Check if a program's artifacts are already cached locally."""
72
- d = get_program_dir(program_id)
73
- return (d / "adapter.gguf").exists() and (d / "prompt_template.txt").exists()
74
-
75
-
76
- def _download_file(url: str, dest: Path):
77
- """Download a file atomically with progress indication."""
78
- dest.parent.mkdir(parents=True, exist_ok=True)
79
- tmp = dest.with_suffix(dest.suffix + ".tmp")
80
- try:
81
- with httpx.stream("GET", url, follow_redirects=True, timeout=300.0) as resp:
82
- resp.raise_for_status()
83
- total = int(resp.headers.get("content-length", 0))
84
- downloaded = 0
85
- with open(tmp, "wb") as f:
86
- for chunk in resp.iter_bytes(chunk_size=8192):
87
- f.write(chunk)
88
- downloaded += len(chunk)
89
- if total > 0:
90
- pct = downloaded / total * 100
91
- mb = downloaded / 1024 / 1024
92
- from ._output import status_inline, status_end
93
- status_inline(f" {mb:.1f} MB ({pct:.0f}%)")
94
- from ._output import status_end
95
- status_end()
96
- os.replace(str(tmp), str(dest))
97
- except BaseException:
98
- tmp.unlink(missing_ok=True)
99
- raise
100
-
101
-
102
- def _slug_cache_path() -> Path:
103
- return config.get_cache_dir() / "slug_cache.json"
104
-
105
-
106
- def get_cached_slug(slug: str) -> str | None:
107
- """Look up a slug in the local cache. Returns program_id or None."""
108
- path = _slug_cache_path()
109
- if not path.exists():
110
- return None
111
- try:
112
- data = json.loads(path.read_text())
113
- program_id = data.get(slug)
114
- if program_id and is_program_cached(program_id):
115
- return program_id
116
- except (json.JSONDecodeError, OSError):
117
- pass
118
- return None
119
-
120
-
121
- def save_slug_mapping(slug: str, program_id: str) -> None:
122
- """Save a slug -> program_id mapping to the local cache."""
123
- path = _slug_cache_path()
124
- data: dict = {}
125
- if path.exists():
126
- try:
127
- data = json.loads(path.read_text())
128
- except (json.JSONDecodeError, OSError):
129
- pass
130
- data[slug] = program_id
131
- path.parent.mkdir(parents=True, exist_ok=True)
132
- path.write_text(json.dumps(data))