programasweights 0.3.1__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.1 → programasweights-0.4.0}/AGENTS.md +1 -1
  2. {programasweights-0.3.1 → programasweights-0.4.0}/PKG-INFO +2 -2
  3. {programasweights-0.3.1 → programasweights-0.4.0}/PYPI_README.md +1 -1
  4. {programasweights-0.3.1 → programasweights-0.4.0}/docs/adr/001-llama-cpp-over-pytorch.md +1 -1
  5. {programasweights-0.3.1 → programasweights-0.4.0}/docs/advanced/adrs.md +5 -4
  6. {programasweights-0.3.1 → programasweights-0.4.0}/docs/case-studies/site-navigation.md +21 -21
  7. {programasweights-0.3.1 → programasweights-0.4.0}/docs/guide/browser-inference.md +7 -7
  8. {programasweights-0.3.1 → programasweights-0.4.0}/docs/index.md +2 -2
  9. {programasweights-0.3.1 → programasweights-0.4.0}/programasweights/__init__.py +26 -8
  10. programasweights-0.4.0/programasweights/cache.py +323 -0
  11. {programasweights-0.3.1 → programasweights-0.4.0}/programasweights/cli.py +1 -1
  12. {programasweights-0.3.1 → programasweights-0.4.0}/programasweights/client.py +69 -3
  13. {programasweights-0.3.1 → programasweights-0.4.0}/programasweights/runtime_llamacpp.py +19 -6
  14. {programasweights-0.3.1 → programasweights-0.4.0}/pyproject.toml +1 -1
  15. programasweights-0.4.0/tests/test_runtime_registry_sdk.py +81 -0
  16. programasweights-0.3.1/programasweights/cache.py +0 -131
  17. {programasweights-0.3.1 → programasweights-0.4.0}/.gitignore +0 -0
  18. {programasweights-0.3.1 → programasweights-0.4.0}/.readthedocs.yaml +0 -0
  19. {programasweights-0.3.1 → programasweights-0.4.0}/CHANGELOG.md +0 -0
  20. {programasweights-0.3.1 → programasweights-0.4.0}/LICENSE +0 -0
  21. {programasweights-0.3.1 → programasweights-0.4.0}/README.md +0 -0
  22. {programasweights-0.3.1 → programasweights-0.4.0}/docs/adr/002-q4_0-adapter-format.md +0 -0
  23. {programasweights-0.3.1 → programasweights-0.4.0}/docs/adr/003-single-spec-field.md +0 -0
  24. {programasweights-0.3.1 → programasweights-0.4.0}/docs/adr/004-compiler-naming.md +0 -0
  25. {programasweights-0.3.1 → programasweights-0.4.0}/docs/adr/005-vllm-hidden-states.md +0 -0
  26. {programasweights-0.3.1 → programasweights-0.4.0}/docs/adr/006-email-api-key-auth.md +0 -0
  27. {programasweights-0.3.1 → programasweights-0.4.0}/docs/advanced/architecture.md +0 -0
  28. {programasweights-0.3.1 → programasweights-0.4.0}/docs/api-reference/cli.md +0 -0
  29. {programasweights-0.3.1 → programasweights-0.4.0}/docs/api-reference/python-sdk.md +0 -0
  30. {programasweights-0.3.1 → programasweights-0.4.0}/docs/api-reference/rest-api.md +0 -0
  31. {programasweights-0.3.1 → programasweights-0.4.0}/docs/architecture.md +0 -0
  32. {programasweights-0.3.1 → programasweights-0.4.0}/docs/case-studies/log-monitoring.md +0 -0
  33. {programasweights-0.3.1 → programasweights-0.4.0}/docs/case-studies/semantic-search.md +0 -0
  34. {programasweights-0.3.1 → programasweights-0.4.0}/docs/case-studies/tool-calling.md +0 -0
  35. {programasweights-0.3.1 → programasweights-0.4.0}/docs/getting-started/first-program.md +0 -0
  36. {programasweights-0.3.1 → programasweights-0.4.0}/docs/getting-started/installation.md +0 -0
  37. {programasweights-0.3.1 → programasweights-0.4.0}/docs/getting-started/naming-programs.md +0 -0
  38. {programasweights-0.3.1 → programasweights-0.4.0}/docs/guide/how-it-works.md +0 -0
  39. {programasweights-0.3.1 → programasweights-0.4.0}/docs/guide/local-inference.md +0 -0
  40. {programasweights-0.3.1 → programasweights-0.4.0}/docs/guide/writing-good-specs.md +0 -0
  41. {programasweights-0.3.1 → programasweights-0.4.0}/docs/hub/browsing-programs.md +0 -0
  42. {programasweights-0.3.1 → programasweights-0.4.0}/docs/hub/feedback-cases.md +0 -0
  43. {programasweights-0.3.1 → programasweights-0.4.0}/docs/hub/publishing-programs.md +0 -0
  44. {programasweights-0.3.1 → programasweights-0.4.0}/docs/requirements.txt +0 -0
  45. {programasweights-0.3.1 → programasweights-0.4.0}/examples/flask_app.py +0 -0
  46. {programasweights-0.3.1 → programasweights-0.4.0}/examples/jupyter_notebook.py +0 -0
  47. {programasweights-0.3.1 → programasweights-0.4.0}/examples/langchain_integration.py +0 -0
  48. {programasweights-0.3.1 → programasweights-0.4.0}/examples/paw_monitor.py +0 -0
  49. {programasweights-0.3.1 → programasweights-0.4.0}/examples/replace_openai.py +0 -0
  50. {programasweights-0.3.1 → programasweights-0.4.0}/mkdocs.yml +0 -0
  51. {programasweights-0.3.1 → programasweights-0.4.0}/programasweights/_output.py +0 -0
  52. {programasweights-0.3.1 → programasweights-0.4.0}/programasweights/artifacts.py +0 -0
  53. {programasweights-0.3.1 → programasweights-0.4.0}/programasweights/compiler/__init__.py +0 -0
  54. {programasweights-0.3.1 → programasweights-0.4.0}/programasweights/compiler/dummy.py +0 -0
  55. {programasweights-0.3.1 → programasweights-0.4.0}/programasweights/config.py +0 -0
  56. {programasweights-0.3.1 → programasweights-0.4.0}/programasweights/convert_peft_to_paw.py +0 -0
  57. {programasweights-0.3.1 → programasweights-0.4.0}/programasweights/paw_format.py +0 -0
  58. {programasweights-0.3.1 → programasweights-0.4.0}/programasweights/runtime/__init__.py +0 -0
  59. {programasweights-0.3.1 → programasweights-0.4.0}/programasweights/runtime/interpreter.py +0 -0
  60. {programasweights-0.3.1 → programasweights-0.4.0}/programasweights/runtime/interpreter_onnx.py +0 -0
  61. {programasweights-0.3.1 → programasweights-0.4.0}/tests/test_offline_cache.py +0 -0
  62. {programasweights-0.3.1 → programasweights-0.4.0}/tests/test_sdk.py +0 -0
  63. {programasweights-0.3.1 → programasweights-0.4.0}/tests/test_sdk.sh +0 -0
@@ -38,7 +38,7 @@ fn("I love this!") # "positive"
38
38
  ## Two Compilers
39
39
 
40
40
  - **Standard** (`paw-4b-qwen3-0.6b`) — higher accuracy, 594 MB base + ~22 MB/program. Default.
41
- - **Compact** (`paw-4b-gpt2`) — smaller (105 MB base + ~5 MB/program), runs in browser via WebAssembly.
41
+ - **Compact** (`paw-4b-gpt2`) — smaller (134 MB base + ~5 MB/program), runs in browser via WebAssembly.
42
42
 
43
43
  ## When to Use PAW
44
44
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programasweights
3
- Version: 0.3.1
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
@@ -71,7 +71,7 @@ fn("I love this!") # "positive"
71
71
  | --------------- | --------------------- | -------------------- |
72
72
  | Compiler name | `paw-4b-qwen3-0.6b` | `paw-4b-gpt2` |
73
73
  | Accuracy | Higher | Lower |
74
- | Base model size | 594 MB | 105 MB |
74
+ | Base model size | 594 MB | 134 MB |
75
75
  | Program size | ~22 MB | ~5 MB |
76
76
  | Local inference | ~0.05-0.5s per call | ~0.03-0.3s per call |
77
77
  | Runs in browser | No | Yes (WebAssembly) |
@@ -41,7 +41,7 @@ fn("I love this!") # "positive"
41
41
  | --------------- | --------------------- | -------------------- |
42
42
  | Compiler name | `paw-4b-qwen3-0.6b` | `paw-4b-gpt2` |
43
43
  | Accuracy | Higher | Lower |
44
- | Base model size | 594 MB | 105 MB |
44
+ | Base model size | 594 MB | 134 MB |
45
45
  | Program size | ~22 MB | ~5 MB |
46
46
  | Local inference | ~0.05-0.5s per call | ~0.03-0.3s per call |
47
47
  | Runs in browser | No | Yes (WebAssembly) |
@@ -17,5 +17,5 @@ Replace the PyTorch runtime with llama-cpp-python (~80MB install). Use GGUF mode
17
17
  - Inference uses Metal (Mac), CPU (Linux/Windows) — no GPU required
18
18
  - Must pre-render chat templates server-side (no transformers tokenizer on client)
19
19
  - .paw format must change from v2 (safetensors) to v3 (GGUF adapter)
20
- - Base model is downloaded once (~594 MB for Q6_K) and shared across all functions
20
+ - Base model is downloaded once (~594 MB Q6_K for Qwen3, ~134 MB Q8_0 for GPT-2) and shared across all functions
21
21
  - Per-function adapter download is ~23MB (Q4_0, confirmed lossless at 4096-scale eval)
@@ -14,16 +14,17 @@ Concise records of major technical choices. Full ADR files may live elsewhere in
14
14
 
15
15
  ---
16
16
 
17
- ## ADR 002: Q4_0 adapter and Q6_K base quantization
17
+ ## ADR 002: Quantization levels for base models and adapters
18
18
 
19
- **Decision:** Use **Q4_0** for adapters and **Q6_K** for the base model in shipped bundles.
19
+ **Decision:** Use **Q4_0** for adapters. For base models, use **Q6_K** for Qwen3 0.6B and **Q8_0** for GPT-2.
20
20
 
21
21
  **Context:** Empirical evaluation on **4096** held-out examples across quantization settings informed the trade-off.
22
22
 
23
23
  **Consequence:**
24
24
 
25
- - **Q6_K base** — quality is preserved while the footprint is roughly **60% smaller** than fp16.
26
- - **Q4_0 adapter** — quality loss is negligible; adapter size drops to about **23 MB** versus **78 MB** for a heavier format at comparable settings in prior experiments.
25
+ - **Q6_K base (Qwen3 0.6B)** — quality is preserved while the footprint is roughly **60% smaller** than fp16. 4096-sample eval shows no accuracy loss vs fp16.
26
+ - **Q8_0 base (GPT-2)** — Q6_K caused ~3.5% accuracy loss on GPT-2 (4096-sample eval). Q8_0 closes the gap at only ~29 MB additional cost (134 MB vs 105 MB).
27
+ - **Q4_0 adapter** — quality loss is negligible for both models; adapter size drops to about **23 MB** (Qwen3) / **5 MB** (GPT-2) versus **78 MB** / **19 MB** at fp16.
27
28
 
28
29
  ---
29
30
 
@@ -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.
@@ -84,10 +84,10 @@ paw.configure({
84
84
 
85
85
  | Component | Size | When |
86
86
  |-----------|------|------|
87
- | Base model (GPT-2 Q6_K) | 105 MB | First program load (cached) |
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).
@@ -56,7 +56,7 @@ Each compiled program has two parts:
56
56
  1. **Discrete pseudo-program** -- text instructions generated by the neural compiler
57
57
  2. **Continuous neural adapter** -- LoRA weights (~23 MB) that tune the interpreter model
58
58
 
59
- At runtime, the SDK loads a Q6_K base model (~594 MB, downloaded once) and applies the LoRA adapter. Inference runs entirely locally via llama.cpp.
59
+ At runtime, the SDK loads a quantized base model (Q6_K ~594 MB for Qwen3, Q8_0 ~134 MB for GPT-2, downloaded once) and applies the LoRA adapter. Inference runs entirely locally via llama.cpp.
60
60
 
61
61
  ## Browser Inference
62
62
 
@@ -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,18 +27,19 @@ 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,
41
41
  slug: str | None = None,
42
+ ephemeral: bool = False,
42
43
  ):
43
44
  """Compile a natural language specification into a neural program.
44
45
 
@@ -48,7 +49,8 @@ def compile(
48
49
 
49
50
  Args:
50
51
  spec: Full specification text. Include examples in the text if desired.
51
- 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.
52
54
  name: Human-readable program name (display title for the hub).
53
55
  tags: Tags for hub discovery.
54
56
  public: Whether to list on the public hub.
@@ -71,7 +73,7 @@ def compile(
71
73
  from ._output import status
72
74
  status("Compiling...")
73
75
  client = PAWClient(api_url=get_api_url(), api_key=get_api_key())
74
- result = client.compile(spec, compiler=compiler, name=name, tags=tags, public=public, slug=slug)
76
+ result = client.compile(spec, compiler=compiler, name=name, tags=tags, public=public, slug=slug, ephemeral=ephemeral)
75
77
  label = f"{result.id}"
76
78
  if result.slug:
77
79
  ver_str = f" v{result.version}" if result.version and result.version > 1 else ""
@@ -125,6 +127,7 @@ def function(
125
127
  from .cache import is_program_cached, get_program_dir, get_cached_slug, save_slug_mapping
126
128
  from .runtime_llamacpp import PawFunction
127
129
 
130
+ offline = offline or os.environ.get("PAW_OFFLINE", "").strip() in ("1", "true", "yes")
128
131
  if n_gpu_layers is None:
129
132
  n_gpu_layers = int(os.environ.get("PAW_GPU_LAYERS", "-1"))
130
133
 
@@ -133,7 +136,7 @@ def function(
133
136
  resolved_id = program_id
134
137
  if not re.fullmatch(r"[a-f0-9]{16,64}", program_id):
135
138
  is_pinned = bool(re.search(r"@v\d+$", program_id))
136
- use_offline = offline or os.environ.get("PAW_OFFLINE", "").strip() in ("1", "true", "yes")
139
+ use_offline = offline
137
140
 
138
141
  if is_pinned:
139
142
  cached = get_cached_slug(program_id)
@@ -176,7 +179,13 @@ def function(
176
179
 
177
180
  program_dir = get_program_dir(resolved_id)
178
181
  return PawFunction(
179
- 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,
180
189
  )
181
190
 
182
191
 
@@ -227,7 +236,7 @@ def login(key: str | None = None):
227
236
 
228
237
  def compile_and_load(
229
238
  spec: str,
230
- compiler: str = "paw-4b-qwen3-0.6b",
239
+ compiler: str | None = None,
231
240
  n_ctx: int = 2048,
232
241
  n_gpu_layers: int | None = None,
233
242
  verbose: bool = False,
@@ -240,7 +249,8 @@ def compile_and_load(
240
249
 
241
250
  Args:
242
251
  spec: Natural language specification.
243
- compiler: Compiler model name.
252
+ compiler: Compiler model name. If omitted, the server chooses the
253
+ current default compiler.
244
254
  n_ctx: Context window size for llama.cpp.
245
255
  n_gpu_layers: GPU layers (-1 = all, 0 = CPU only).
246
256
  verbose: Print llama.cpp debug output.
@@ -293,10 +303,18 @@ def list_programs(sort: str = "recent", per_page: int = 20, page: int = 1) -> di
293
303
  return client.list_programs(sort=sort, per_page=per_page, page=page)
294
304
 
295
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
+
296
313
  __all__ = [
297
314
  "compile",
298
315
  "compile_and_load",
299
316
  "function",
317
+ "list_compilers",
300
318
  "list_programs",
301
319
  "list_versions",
302
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,11 +52,12 @@ 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,
54
59
  slug: str | None = None,
60
+ ephemeral: bool = False,
55
61
  ) -> Program:
56
62
  """Compile a spec into a neural program on the server.
57
63
 
@@ -70,7 +76,11 @@ class PAWClient:
70
76
  Raises:
71
77
  httpx.HTTPStatusError: On API errors (422 for validation, 429 for rate limit).
72
78
  """
73
- body: dict = {"spec": spec, "compiler": compiler, "public": public}
79
+ body: dict = {"spec": spec, "public": public}
80
+ if compiler:
81
+ body["compiler"] = compiler
82
+ if ephemeral:
83
+ body["ephemeral"] = True
74
84
  if name:
75
85
  body["name"] = name
76
86
  if tags:
@@ -92,6 +102,10 @@ class PAWClient:
92
102
  status=data.get("status", "unknown"),
93
103
  slug=data.get("slug"),
94
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"),
95
109
  timings=data.get("timings"),
96
110
  error=data.get("error"),
97
111
  version=data.get("version"),
@@ -121,7 +135,7 @@ class PAWClient:
121
135
  from ._output import status
122
136
 
123
137
  status(f"Downloading program {program_id[:12]}...")
124
- max_wait = 30
138
+ max_wait = 60
125
139
  elapsed = 0
126
140
  resp = None
127
141
  waiting_logged = False
@@ -174,8 +188,36 @@ class PAWClient:
174
188
  raise ValueError(f"Unsafe path in .paw archive: {member}")
175
189
  zf.extractall(program_dir)
176
190
 
191
+ self._hydrate_runtime_manifest(program_dir)
192
+
177
193
  return program_dir
178
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
+
179
221
  def get_program_meta(self, program_id: str) -> dict:
180
222
  """Get program metadata from the server."""
181
223
  resp = httpx.get(
@@ -186,6 +228,30 @@ class PAWClient:
186
228
  resp.raise_for_status()
187
229
  return resp.json()
188
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
+
189
255
  def list_slug_versions(self, slug: str) -> dict:
190
256
  """List all versions of a slug. Slug format: 'username/slug-name' or bare 'slug-name'."""
191
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
 
@@ -88,7 +102,6 @@ class PawFunction:
88
102
  _os.close(_old_stderr)
89
103
 
90
104
  placeholder = "{INPUT_PLACEHOLDER}"
91
- self._use_special = interpreter not in ("gpt2",)
92
105
 
93
106
  if placeholder in self._template:
94
107
  prefix_text = self._template.split(placeholder)[0]
@@ -99,8 +112,8 @@ class PawFunction:
99
112
 
100
113
  self._prefix_tokens = self._llm.tokenize(
101
114
  prefix_text.encode("utf-8"),
102
- add_bos=not self._use_special,
103
- special=self._use_special,
115
+ add_bos=False,
116
+ special=True,
104
117
  )
105
118
  self._suffix_text = suffix_text
106
119
  self._n_prefix = len(self._prefix_tokens)
@@ -183,7 +196,7 @@ class PawFunction:
183
196
  input_tokens = self._llm.tokenize(
184
197
  input_with_suffix.encode("utf-8"),
185
198
  add_bos=False,
186
- special=self._use_special,
199
+ special=True,
187
200
  )
188
201
 
189
202
  tokens_used = self._n_prefix + len(input_tokens)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "programasweights"
7
- version = "0.3.1"
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,131 +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
- programs/
9
- <program_id>/
10
- adapter.gguf # ~23 MB, Q4_0 LoRA
11
- prompt_template.txt
12
- meta.json
13
- slug_cache.json # slug -> program_id mapping
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- import json
19
- import os
20
- from pathlib import Path
21
-
22
- import httpx
23
-
24
- from . import config
25
-
26
- BASE_MODEL_URLS = {
27
- "qwen3-0.6b-q6_k": "https://huggingface.co/programasweights/Qwen3-0.6B-GGUF-Q6_K/resolve/main/qwen3-0.6b-q6_k.gguf",
28
- "gpt2-q6_k": "https://huggingface.co/programasweights/GPT2-GGUF-Q6_K/resolve/main/gpt2-q6_k.gguf",
29
- }
30
-
31
- INTERPRETER_TO_GGUF = {
32
- "Qwen/Qwen3-0.6B": "qwen3-0.6b-q6_k",
33
- "gpt2": "gpt2-q6_k",
34
- }
35
-
36
-
37
- def get_base_model_path(interpreter: str = "Qwen/Qwen3-0.6B") -> Path:
38
- """Get the path to the base model GGUF, downloading if needed."""
39
- gguf_name = INTERPRETER_TO_GGUF.get(interpreter)
40
- if not gguf_name:
41
- raise ValueError(
42
- f"Unknown interpreter: '{interpreter}'. "
43
- f"Supported: {list(INTERPRETER_TO_GGUF.keys())}"
44
- )
45
- gguf_path = config.get_base_models_dir() / f"{gguf_name}.gguf"
46
-
47
- if gguf_path.exists():
48
- return gguf_path
49
-
50
- url = BASE_MODEL_URLS.get(gguf_name)
51
- if not url:
52
- raise ValueError(
53
- f"Unknown interpreter model: {interpreter}. "
54
- f"Available: {list(INTERPRETER_TO_GGUF.keys())}"
55
- )
56
-
57
- from ._output import status
58
- status(f"Downloading interpreter {gguf_name} (one-time download)...")
59
- _download_file(url, gguf_path)
60
- status(f"Saved to {gguf_path}")
61
- return gguf_path
62
-
63
-
64
- def get_program_dir(program_id: str) -> Path:
65
- """Get the local cache directory for a program."""
66
- return config.get_programs_dir() / program_id
67
-
68
-
69
- def is_program_cached(program_id: str) -> bool:
70
- """Check if a program's artifacts are already cached locally."""
71
- d = get_program_dir(program_id)
72
- return (d / "adapter.gguf").exists() and (d / "prompt_template.txt").exists()
73
-
74
-
75
- def _download_file(url: str, dest: Path):
76
- """Download a file atomically with progress indication."""
77
- dest.parent.mkdir(parents=True, exist_ok=True)
78
- tmp = dest.with_suffix(dest.suffix + ".tmp")
79
- try:
80
- with httpx.stream("GET", url, follow_redirects=True, timeout=300.0) as resp:
81
- resp.raise_for_status()
82
- total = int(resp.headers.get("content-length", 0))
83
- downloaded = 0
84
- with open(tmp, "wb") as f:
85
- for chunk in resp.iter_bytes(chunk_size=8192):
86
- f.write(chunk)
87
- downloaded += len(chunk)
88
- if total > 0:
89
- pct = downloaded / total * 100
90
- mb = downloaded / 1024 / 1024
91
- from ._output import status_inline, status_end
92
- status_inline(f" {mb:.1f} MB ({pct:.0f}%)")
93
- from ._output import status_end
94
- status_end()
95
- os.replace(str(tmp), str(dest))
96
- except BaseException:
97
- tmp.unlink(missing_ok=True)
98
- raise
99
-
100
-
101
- def _slug_cache_path() -> Path:
102
- return config.get_cache_dir() / "slug_cache.json"
103
-
104
-
105
- def get_cached_slug(slug: str) -> str | None:
106
- """Look up a slug in the local cache. Returns program_id or None."""
107
- path = _slug_cache_path()
108
- if not path.exists():
109
- return None
110
- try:
111
- data = json.loads(path.read_text())
112
- program_id = data.get(slug)
113
- if program_id and is_program_cached(program_id):
114
- return program_id
115
- except (json.JSONDecodeError, OSError):
116
- pass
117
- return None
118
-
119
-
120
- def save_slug_mapping(slug: str, program_id: str) -> None:
121
- """Save a slug -> program_id mapping to the local cache."""
122
- path = _slug_cache_path()
123
- data: dict = {}
124
- if path.exists():
125
- try:
126
- data = json.loads(path.read_text())
127
- except (json.JSONDecodeError, OSError):
128
- pass
129
- data[slug] = program_id
130
- path.parent.mkdir(parents=True, exist_ok=True)
131
- path.write_text(json.dumps(data))