programasweights 0.4.5__tar.gz → 0.4.6__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 (73) hide show
  1. {programasweights-0.4.5 → programasweights-0.4.6}/AGENTS.md +6 -0
  2. {programasweights-0.4.5 → programasweights-0.4.6}/CHANGELOG.md +8 -0
  3. {programasweights-0.4.5 → programasweights-0.4.6}/PKG-INFO +13 -1
  4. {programasweights-0.4.5 → programasweights-0.4.6}/PYPI_README.md +12 -0
  5. {programasweights-0.4.5 → programasweights-0.4.6}/README.md +12 -0
  6. {programasweights-0.4.5 → programasweights-0.4.6}/docs/api-reference/python-sdk.md +4 -1
  7. {programasweights-0.4.5 → programasweights-0.4.6}/docs/api-reference/rest-api.md +10 -1
  8. {programasweights-0.4.5 → programasweights-0.4.6}/programasweights/__init__.py +1 -1
  9. {programasweights-0.4.5 → programasweights-0.4.6}/programasweights/runtime_llamacpp.py +38 -0
  10. {programasweights-0.4.5 → programasweights-0.4.6}/pyproject.toml +1 -1
  11. {programasweights-0.4.5 → programasweights-0.4.6}/tests/test_base_interpreter.py +204 -2
  12. {programasweights-0.4.5 → programasweights-0.4.6}/.github/workflows/test.yml +0 -0
  13. {programasweights-0.4.5 → programasweights-0.4.6}/.gitignore +0 -0
  14. {programasweights-0.4.5 → programasweights-0.4.6}/.readthedocs.yaml +0 -0
  15. {programasweights-0.4.5 → programasweights-0.4.6}/LICENSE +0 -0
  16. {programasweights-0.4.5 → programasweights-0.4.6}/docs/adr/001-llama-cpp-over-pytorch.md +0 -0
  17. {programasweights-0.4.5 → programasweights-0.4.6}/docs/adr/002-q4_0-adapter-format.md +0 -0
  18. {programasweights-0.4.5 → programasweights-0.4.6}/docs/adr/003-single-spec-field.md +0 -0
  19. {programasweights-0.4.5 → programasweights-0.4.6}/docs/adr/004-compiler-naming.md +0 -0
  20. {programasweights-0.4.5 → programasweights-0.4.6}/docs/adr/005-vllm-hidden-states.md +0 -0
  21. {programasweights-0.4.5 → programasweights-0.4.6}/docs/adr/006-email-api-key-auth.md +0 -0
  22. {programasweights-0.4.5 → programasweights-0.4.6}/docs/advanced/adrs.md +0 -0
  23. {programasweights-0.4.5 → programasweights-0.4.6}/docs/advanced/architecture.md +0 -0
  24. {programasweights-0.4.5 → programasweights-0.4.6}/docs/api-reference/cli.md +0 -0
  25. {programasweights-0.4.5 → programasweights-0.4.6}/docs/architecture.md +0 -0
  26. {programasweights-0.4.5 → programasweights-0.4.6}/docs/case-studies/alien-taboo.md +0 -0
  27. {programasweights-0.4.5 → programasweights-0.4.6}/docs/case-studies/log-monitoring.md +0 -0
  28. {programasweights-0.4.5 → programasweights-0.4.6}/docs/case-studies/semantic-search.md +0 -0
  29. {programasweights-0.4.5 → programasweights-0.4.6}/docs/case-studies/site-navigation.md +0 -0
  30. {programasweights-0.4.5 → programasweights-0.4.6}/docs/case-studies/tool-calling.md +0 -0
  31. {programasweights-0.4.5 → programasweights-0.4.6}/docs/getting-started/first-program.md +0 -0
  32. {programasweights-0.4.5 → programasweights-0.4.6}/docs/getting-started/installation.md +0 -0
  33. {programasweights-0.4.5 → programasweights-0.4.6}/docs/getting-started/naming-programs.md +0 -0
  34. {programasweights-0.4.5 → programasweights-0.4.6}/docs/guide/browser-inference.md +0 -0
  35. {programasweights-0.4.5 → programasweights-0.4.6}/docs/guide/how-it-works.md +0 -0
  36. {programasweights-0.4.5 → programasweights-0.4.6}/docs/guide/local-inference.md +0 -0
  37. {programasweights-0.4.5 → programasweights-0.4.6}/docs/guide/writing-good-specs.md +0 -0
  38. {programasweights-0.4.5 → programasweights-0.4.6}/docs/hub/browsing-programs.md +0 -0
  39. {programasweights-0.4.5 → programasweights-0.4.6}/docs/hub/feedback-cases.md +0 -0
  40. {programasweights-0.4.5 → programasweights-0.4.6}/docs/hub/publishing-programs.md +0 -0
  41. {programasweights-0.4.5 → programasweights-0.4.6}/docs/index.md +0 -0
  42. {programasweights-0.4.5 → programasweights-0.4.6}/docs/requirements.txt +0 -0
  43. {programasweights-0.4.5 → programasweights-0.4.6}/examples/flask_app.py +0 -0
  44. {programasweights-0.4.5 → programasweights-0.4.6}/examples/jupyter_notebook.py +0 -0
  45. {programasweights-0.4.5 → programasweights-0.4.6}/examples/langchain_integration.py +0 -0
  46. {programasweights-0.4.5 → programasweights-0.4.6}/examples/paw_monitor.py +0 -0
  47. {programasweights-0.4.5 → programasweights-0.4.6}/examples/replace_openai.py +0 -0
  48. {programasweights-0.4.5 → programasweights-0.4.6}/mkdocs.yml +0 -0
  49. {programasweights-0.4.5 → programasweights-0.4.6}/programasweights/_output.py +0 -0
  50. {programasweights-0.4.5 → programasweights-0.4.6}/programasweights/_program_reference.py +0 -0
  51. {programasweights-0.4.5 → programasweights-0.4.6}/programasweights/artifacts.py +0 -0
  52. {programasweights-0.4.5 → programasweights-0.4.6}/programasweights/cache.py +0 -0
  53. {programasweights-0.4.5 → programasweights-0.4.6}/programasweights/cli.py +0 -0
  54. {programasweights-0.4.5 → programasweights-0.4.6}/programasweights/client.py +0 -0
  55. {programasweights-0.4.5 → programasweights-0.4.6}/programasweights/compiler/__init__.py +0 -0
  56. {programasweights-0.4.5 → programasweights-0.4.6}/programasweights/compiler/dummy.py +0 -0
  57. {programasweights-0.4.5 → programasweights-0.4.6}/programasweights/config.py +0 -0
  58. {programasweights-0.4.5 → programasweights-0.4.6}/programasweights/convert_peft_to_paw.py +0 -0
  59. {programasweights-0.4.5 → programasweights-0.4.6}/programasweights/errors.py +0 -0
  60. {programasweights-0.4.5 → programasweights-0.4.6}/programasweights/local_program.py +0 -0
  61. {programasweights-0.4.5 → programasweights-0.4.6}/programasweights/paw_format.py +0 -0
  62. {programasweights-0.4.5 → programasweights-0.4.6}/programasweights/runtime/__init__.py +0 -0
  63. {programasweights-0.4.5 → programasweights-0.4.6}/programasweights/runtime/interpreter.py +0 -0
  64. {programasweights-0.4.5 → programasweights-0.4.6}/programasweights/runtime/interpreter_onnx.py +0 -0
  65. {programasweights-0.4.5 → programasweights-0.4.6}/tests/test_api_errors.py +0 -0
  66. {programasweights-0.4.5 → programasweights-0.4.6}/tests/test_cli_auth.py +0 -0
  67. {programasweights-0.4.5 → programasweights-0.4.6}/tests/test_compile_timeouts.py +0 -0
  68. {programasweights-0.4.5 → programasweights-0.4.6}/tests/test_desktop_sdk.py +0 -0
  69. {programasweights-0.4.5 → programasweights-0.4.6}/tests/test_local_program.py +0 -0
  70. {programasweights-0.4.5 → programasweights-0.4.6}/tests/test_offline_cache.py +0 -0
  71. {programasweights-0.4.5 → programasweights-0.4.6}/tests/test_runtime_registry_sdk.py +0 -0
  72. {programasweights-0.4.5 → programasweights-0.4.6}/tests/test_sdk.py +0 -0
  73. {programasweights-0.4.5 → programasweights-0.4.6}/tests/test_sdk.sh +0 -0
@@ -43,6 +43,8 @@ fn = paw.compile_and_load("Classify sentiment as positive or negative")
43
43
  fn("I love this!") # "positive"
44
44
  ```
45
45
 
46
+ Load a local `.paw` file with `paw.function("./classifier.paw")` (SDK 0.4.5+).
47
+
46
48
  If you want the smaller browser-compatible runtime explicitly, pass `compiler="paw-4b-gpt2"`. Otherwise, omit `compiler` and let the server default decide.
47
49
 
48
50
  ## Current Public Compilers
@@ -89,6 +91,8 @@ Output: delete
89
91
  - Spec + input + output share a ~2048 token context window. Inputs that exceed it will error.
90
92
  - `max_tokens` defaults to `None`: generation runs until EOS or the context limit.
91
93
  - Compile runs on the hosted PAW API. Inference should usually run locally through the SDK.
94
+ - Synchronous compile requests use a 40-minute read timeout.
95
+ - **Run local inference sequentially by default.** With PAW’s current llama.cpp backend, simultaneous inference calls often perform worse. Reuse loaded functions and process inputs one at a time; never call the same function instance concurrently.
92
96
  - **GPU acceleration** is enabled by default (`n_gpu_layers=-1`). Uses Metal on Mac, CUDA on Linux, and falls back to CPU automatically. If GPU causes issues, set `PAW_GPU_LAYERS=0` or pass `n_gpu_layers=0`.
93
97
  - **First call** is usually ~1-5s because it loads the base model. Subsequent calls are typically ~0.05-0.5s depending on input length and GPU availability.
94
98
  - **Base model files are shared** across programs on disk. Each Standard LoRA adapter is ~22 MB; each Compact LoRA adapter is ~5 MB.
@@ -102,6 +106,8 @@ Output: delete
102
106
 
103
107
  ## Common Errors
104
108
 
109
+ Compile API HTTP errors raise `paw.APIError`. Check `error.code` and `error.message` for details. The SDK does not retry automatically.
110
+
105
111
  | Error | Cause | Fix |
106
112
  |-------|-------|-----|
107
113
  | `RuntimeError: assets not ready` on download | Program is still generating after compile | The SDK polls automatically for up to 60s. If it still fails, retry shortly or recompile. |
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.6 (2026-09-13)
4
+
5
+ - Add an optional `logits_processor` argument to a compiled or base program
6
+ call for caller-supplied token constraints in llama.cpp's sampler.
7
+ Defaults to `None`; sampling is unchanged when it is unset.
8
+ - Propagate processor failures to the caller instead of letting native
9
+ callback errors silently continue with unconstrained output.
10
+
3
11
  ## 0.4.5 (2026-09-10)
4
12
 
5
13
  - Expose structured compile API failures as `paw.APIError`, compatible with
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: programasweights
3
- Version: 0.4.5
3
+ Version: 0.4.6
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
@@ -84,6 +84,18 @@ If you need to inspect available compiler aliases programmatically, use `paw.lis
84
84
 
85
85
  GPU acceleration is enabled by default (Metal on Mac, CUDA on Linux, falls back to CPU). Set `PAW_GPU_LAYERS=0` to force CPU if GPU causes issues.
86
86
 
87
+ ## Constrained Decoding
88
+
89
+ In SDK 0.4.6+, a call accepts an optional `logits_processor`: an advanced hook for caller-supplied llama.cpp-compatible token constraints, not built-in regex or JSON-schema validation. It runs at every generation step. The default, `None`, keeps sampling unchanged.
90
+
91
+ ```python
92
+ import llama_cpp
93
+ # my_processor is your compatible callable: (input_ids, scores) -> scores.
94
+ fn("Office line: +1-555-666-7777", logits_processor=llama_cpp.LogitsProcessorList([my_processor]))
95
+ ```
96
+
97
+ Processors see the full prompt and generated-token history, not just the output. Create or reset stateful processors for each call. Token limits and output whitespace trimming still apply, so validate the returned result.
98
+
87
99
  ## Desktop and Offline Workflows
88
100
 
89
101
  Prepare and inspect validated local assets without keeping a model loaded:
@@ -53,6 +53,18 @@ If you need to inspect available compiler aliases programmatically, use `paw.lis
53
53
 
54
54
  GPU acceleration is enabled by default (Metal on Mac, CUDA on Linux, falls back to CPU). Set `PAW_GPU_LAYERS=0` to force CPU if GPU causes issues.
55
55
 
56
+ ## Constrained Decoding
57
+
58
+ In SDK 0.4.6+, a call accepts an optional `logits_processor`: an advanced hook for caller-supplied llama.cpp-compatible token constraints, not built-in regex or JSON-schema validation. It runs at every generation step. The default, `None`, keeps sampling unchanged.
59
+
60
+ ```python
61
+ import llama_cpp
62
+ # my_processor is your compatible callable: (input_ids, scores) -> scores.
63
+ fn("Office line: +1-555-666-7777", logits_processor=llama_cpp.LogitsProcessorList([my_processor]))
64
+ ```
65
+
66
+ Processors see the full prompt and generated-token history, not just the output. Create or reset stateful processors for each call. Token limits and output whitespace trimming still apply, so validate the returned result.
67
+
56
68
  ## Desktop and Offline Workflows
57
69
 
58
70
  Prepare and inspect validated local assets without keeping a model loaded:
@@ -53,6 +53,18 @@ If you need to inspect available compiler aliases programmatically, use `paw.lis
53
53
 
54
54
  GPU acceleration is enabled by default (Metal on Mac, CUDA on Linux, falls back to CPU). Set `PAW_GPU_LAYERS=0` to force CPU if GPU causes issues.
55
55
 
56
+ ## Constrained Decoding
57
+
58
+ In SDK 0.4.6+, a call accepts an optional `logits_processor`: an advanced hook for caller-supplied llama.cpp-compatible token constraints, not built-in regex or JSON-schema validation. It runs at every generation step. The default, `None`, keeps sampling unchanged.
59
+
60
+ ```python
61
+ import llama_cpp
62
+ # my_processor is your compatible callable: (input_ids, scores) -> scores.
63
+ fn("Office line: +1-555-666-7777", logits_processor=llama_cpp.LogitsProcessorList([my_processor]))
64
+ ```
65
+
66
+ Processors see the full prompt and generated-token history, not just the output. Create or reset stateful processors for each call. Token limits and output whitespace trimming still apply, so validate the returned result.
67
+
56
68
  ## Desktop and Offline Workflows
57
69
 
58
70
  Prepare and inspect validated local assets without keeping a model loaded:
@@ -44,7 +44,7 @@ bundle directly. Required runtime metadata and base models are cached for reuse.
44
44
  The returned callable:
45
45
 
46
46
  ```python
47
- output: str = fn(input_text, max_tokens=None, temperature=0.0)
47
+ output: str = fn(input_text, max_tokens=None, temperature=0.0, logits_processor=None)
48
48
  ```
49
49
 
50
50
  | Parameter | Description |
@@ -52,6 +52,9 @@ output: str = fn(input_text, max_tokens=None, temperature=0.0)
52
52
  | `input_text` | Input string for the program. |
53
53
  | `max_tokens` | Maximum tokens to generate. `None` (default) = use all remaining context window. |
54
54
  | `temperature` | Sampling temperature (default `0.0`). |
55
+ | `logits_processor` | SDK 0.4.6+. Optional `llama_cpp.LogitsProcessorList` of caller-supplied processors, applied at every generation step. `None` (default) keeps sampling unchanged. |
56
+
57
+ This advanced hook is not built-in regex or JSON-schema validation. Each processor takes `(input_ids, scores)` and returns modified scores. Its token history includes the full prompt (including any compiled prefix and suffix or base-model template) plus generated tokens. Create or reset stateful processors for each call; processor exceptions propagate to the caller. Token limits and the usual output whitespace trimming still apply, so validate the returned result.
55
58
 
56
59
  **Context limits:** Spec + input + output share a ~2048 token window. Inputs that exceed it will error. `max_tokens` defaults to `None`: generation runs until EOS or the context limit.
57
60
 
@@ -136,7 +136,16 @@ List available compiler models and identifiers for use with compile requests.
136
136
 
137
137
  ### `GET /health`
138
138
 
139
- Liveness or readiness style health check for the API service.
139
+ Returns HTTP 200 for API liveness. The JSON `status` is `healthy` only when
140
+ all enabled public compilers pass their provider checks; otherwise it is
141
+ `degraded`, with details in `warnings` and `gpu_services`, keyed by compiler
142
+ name. Finetune checks include its base compiler, durable Redis, and at least
143
+ one healthy worker. A healthy worker remains available while busy.
144
+
145
+ Checks run concurrently with a three-second timeout and share a five-second
146
+ cache. `queue_depth` counts waiting finetune jobs across distinct dispatchers,
147
+ not running jobs; it is `null` when a queue cannot be verified. This is a
148
+ readiness observation, not a guarantee that a new compilation will succeed.
140
149
 
141
150
  ## Errors
142
151
 
@@ -27,7 +27,7 @@ try:
27
27
  from importlib.metadata import version as _meta_version
28
28
  __version__ = _meta_version("programasweights")
29
29
  except Exception:
30
- __version__ = "0.4.5"
30
+ __version__ = "0.4.6"
31
31
 
32
32
  from ._output import ProgressCallback, ProgressEvent, report_progress
33
33
  from .cache import CachedProgram
@@ -397,6 +397,7 @@ class PawFunction:
397
397
  input_text: str,
398
398
  max_tokens: int | None = None,
399
399
  temperature: float = 0.0,
400
+ logits_processor: llama_cpp.LogitsProcessorList | None = None,
400
401
  ) -> str:
401
402
  """Run the program on an input.
402
403
 
@@ -404,6 +405,11 @@ class PawFunction:
404
405
  input_text: The input to process.
405
406
  max_tokens: Maximum output tokens. None = use all remaining context.
406
407
  temperature: Sampling temperature (0 = greedy).
408
+ logits_processor: Optional llama.cpp logits processors for
409
+ caller-supplied token constraints, applied at each generation
410
+ step with the full prompt and generated-token history.
411
+ Processor errors propagate to the caller. None = unchanged
412
+ sampling; callers must validate any structured output.
407
413
 
408
414
  Returns:
409
415
  The program's output as a string.
@@ -444,6 +450,7 @@ class PawFunction:
444
450
  prompt_tokens,
445
451
  max_tokens=max_tokens,
446
452
  temperature=temperature,
453
+ logits_processor=logits_processor,
447
454
  token_description="prompt",
448
455
  )
449
456
 
@@ -459,6 +466,7 @@ class PawFunction:
459
466
  input_tokens,
460
467
  max_tokens=max_tokens,
461
468
  temperature=temperature,
469
+ logits_processor=logits_processor,
462
470
  prior_tokens=self._n_prefix,
463
471
  token_description="input",
464
472
  )
@@ -469,6 +477,7 @@ class PawFunction:
469
477
  *,
470
478
  max_tokens: int | None,
471
479
  temperature: float,
480
+ logits_processor: llama_cpp.LogitsProcessorList | None = None,
472
481
  prior_tokens: int = 0,
473
482
  token_description: str,
474
483
  ) -> str:
@@ -487,11 +496,40 @@ class PawFunction:
487
496
  if prompt_tokens:
488
497
  self._llm.eval(prompt_tokens)
489
498
 
499
+ # Leave sample() unchanged when no processors were supplied. Native
500
+ # llama.cpp invokes processors through ctypes, which swallows callback
501
+ # exceptions, so defer those errors until control returns to Python.
502
+ sample_kwargs = {}
503
+ processor_error: BaseException | None = None
504
+ if logits_processor is not None:
505
+ def guarded_processor(input_ids, scores):
506
+ nonlocal processor_error
507
+ score_count = len(scores)
508
+ try:
509
+ for processor in logits_processor:
510
+ # Match native in-place conversion, including errors
511
+ # for invalid processor return shapes or values.
512
+ scores[:] = processor(input_ids, scores)
513
+ return scores
514
+ except BaseException as exc:
515
+ processor_error = exc
516
+ # The processor may have corrupted or made scores
517
+ # read-only. Return fresh, finite logits so native sampling
518
+ # can finish; its token will be discarded below.
519
+ return [0.0] * score_count
520
+
521
+ sample_kwargs["logits_processor"] = llama_cpp.LogitsProcessorList(
522
+ [guarded_processor]
523
+ )
524
+
490
525
  output_tokens = []
491
526
  for _ in range(gen_limit):
492
527
  token = self._llm.sample(
493
528
  temp=temperature if temperature > 0 else 0,
529
+ **sample_kwargs,
494
530
  )
531
+ if processor_error is not None:
532
+ raise processor_error
495
533
 
496
534
  if token == self._llm.token_eos():
497
535
  break
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "programasweights"
7
- version = "0.4.5"
7
+ version = "0.4.6"
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"
@@ -4,6 +4,7 @@ import ctypes
4
4
  import hashlib
5
5
  import importlib
6
6
  import json
7
+ import math
7
8
  import sys
8
9
  import threading
9
10
  import types
@@ -74,6 +75,7 @@ def fake_runtime(monkeypatch):
74
75
  self.input_ids = [0] * 4096
75
76
  self.tokenize_calls = []
76
77
  self.eval_calls = []
78
+ self.sample_calls = []
77
79
  self.reset_calls = 0
78
80
  self.closed = False
79
81
  self._sample_index = 0
@@ -94,12 +96,25 @@ def fake_runtime(monkeypatch):
94
96
  def eval(self, tokens):
95
97
  copied = list(tokens)
96
98
  self.eval_calls.append(copied)
99
+ self.input_ids[self.n_tokens:self.n_tokens + len(copied)] = copied
97
100
  self.n_tokens += len(copied)
98
101
 
99
- def sample(self, *, temp):
102
+ def sample(self, *, temp, logits_processor=None):
103
+ self.sample_calls.append(
104
+ {"temp": temp, "logits_processor": logits_processor}
105
+ )
100
106
  token = ord("A") if self._sample_index == 0 else 0
101
107
  self._sample_index += 1
102
- return token
108
+ if logits_processor is None:
109
+ return token
110
+ # Mirror llama.cpp: every processor sees the full token
111
+ # sequence so far and returns the scores to sample from.
112
+ scores = [0.0] * 256
113
+ scores[token] = 1.0
114
+ input_ids = list(self.input_ids[:self.n_tokens])
115
+ for processor in logits_processor:
116
+ scores = processor(input_ids, scores)
117
+ return max(range(len(scores)), key=scores.__getitem__)
103
118
 
104
119
  def token_eos(self):
105
120
  return 0
@@ -111,6 +126,7 @@ def fake_runtime(monkeypatch):
111
126
  self.reset_calls += 1
112
127
  self.n_tokens = 0
113
128
  self._sample_index = 0
129
+ self.sample_calls = []
114
130
 
115
131
  def close(self):
116
132
  self.closed = True
@@ -144,7 +160,11 @@ def fake_runtime(monkeypatch):
144
160
  decoded_path.write_bytes(state.prefix_save_bytes)
145
161
  return state.prefix_save_result
146
162
 
163
+ class FakeLogitsProcessorList(list):
164
+ pass
165
+
147
166
  module.Llama = FakeLlama
167
+ module.LogitsProcessorList = FakeLogitsProcessorList
148
168
  module.llama_adapter_lora_init = adapter_init
149
169
  module.llama_set_adapter_lora = adapter_apply
150
170
  module.llama_adapter_lora_free = adapter_free
@@ -641,3 +661,185 @@ def test_native_stderr_redirection_is_serialized(
641
661
 
642
662
  assert max_active == 1
643
663
  assert operations == ["open", "open"]
664
+
665
+
666
+ def _force_token(token: int):
667
+ """A llama.cpp logits processor that always selects one token."""
668
+ seen = []
669
+
670
+ def processor(input_ids, scores):
671
+ seen.append(list(input_ids))
672
+ forced = list(scores)
673
+ forced[token] = max(scores) + 1.0
674
+ return forced
675
+
676
+ processor.seen = seen
677
+ return processor
678
+
679
+
680
+ def test_generation_passes_no_logits_processor_by_default(
681
+ fake_runtime,
682
+ tmp_path,
683
+ monkeypatch,
684
+ ):
685
+ runtime, state = fake_runtime
686
+ program_dir = _write_compiled_program(tmp_path)
687
+
688
+ fn = runtime.PawFunction(program_dir, offline=True)
689
+ instance = state.instances[-1]
690
+ temperatures = []
691
+
692
+ def sample_without_processor_keyword(*, temp):
693
+ temperatures.append(temp)
694
+ return ord("A")
695
+
696
+ monkeypatch.setattr(instance, "sample", sample_without_processor_keyword)
697
+
698
+ assert fn("payload", max_tokens=1) == "A"
699
+ assert temperatures == [0]
700
+ fn.close()
701
+
702
+
703
+ def test_compiled_logits_processor_runs_on_every_generated_token(
704
+ fake_runtime,
705
+ tmp_path,
706
+ ):
707
+ import llama_cpp
708
+
709
+ runtime, state = fake_runtime
710
+ program_dir = _write_compiled_program(tmp_path)
711
+
712
+ fn = runtime.PawFunction(program_dir, offline=True)
713
+ instance = state.instances[-1]
714
+ processor = _force_token(ord("B"))
715
+ processors = llama_cpp.LogitsProcessorList([processor])
716
+
717
+ output = fn("payload", max_tokens=3, logits_processor=processors)
718
+
719
+ assert output == "BBB"
720
+ assert len(instance.sample_calls) == 3
721
+ assert processor.seen == [
722
+ list(b"Prefix:payload:Suffix"),
723
+ list(b"Prefix:payload:SuffixB"),
724
+ list(b"Prefix:payload:SuffixBB"),
725
+ ]
726
+ fn.close()
727
+
728
+
729
+ @pytest.mark.parametrize("interpreter", ["gpt2", "Qwen/Qwen3-0.6B"])
730
+ def test_base_logits_processor_is_passed_through(fake_runtime, interpreter):
731
+ import llama_cpp
732
+
733
+ _, state = fake_runtime
734
+ _write_base_model(interpreter)
735
+
736
+ fn = paw.function(None, interpreter=interpreter)
737
+ instance = state.instances[-1]
738
+ processor = _force_token(ord("C"))
739
+ processors = llama_cpp.LogitsProcessorList([processor])
740
+
741
+ assert fn("hello", max_tokens=2, logits_processor=processors) == "CC"
742
+ assert len(instance.sample_calls) == 2
743
+ prompt = list(instance.tokenize_calls[0]["data"])
744
+ assert processor.seen == [prompt, prompt + [ord("C")]]
745
+ fn.close()
746
+
747
+
748
+ def test_logits_processor_can_select_eos(fake_runtime, tmp_path):
749
+ import llama_cpp
750
+
751
+ runtime, state = fake_runtime
752
+ fn = runtime.PawFunction(_write_compiled_program(tmp_path), offline=True)
753
+ instance = state.instances[-1]
754
+ processor = _force_token(instance.token_eos())
755
+ eval_count = len(instance.eval_calls)
756
+
757
+ assert fn(
758
+ "payload",
759
+ max_tokens=3,
760
+ logits_processor=llama_cpp.LogitsProcessorList([processor]),
761
+ ) == ""
762
+ assert processor.seen == [list(b"Prefix:payload:Suffix")]
763
+ assert instance.eval_calls[eval_count:] == [list(b"payload:Suffix")]
764
+ fn.close()
765
+
766
+
767
+ @pytest.mark.parametrize("mode", ["compiled", "base"])
768
+ @pytest.mark.parametrize(
769
+ "exception_type,invalid_result",
770
+ [(ValueError, False), (KeyboardInterrupt, False), (SystemExit, False),
771
+ (TypeError, True)],
772
+ )
773
+ def test_logits_processor_errors_cross_native_callback_safely(
774
+ fake_runtime, tmp_path, monkeypatch, mode, exception_type, invalid_result,
775
+ ):
776
+ import llama_cpp
777
+
778
+ runtime, state = fake_runtime
779
+ if mode == "compiled":
780
+ fn = runtime.PawFunction(_write_compiled_program(tmp_path), offline=True)
781
+ expected_prompt = list(b"payload:Suffix")
782
+ else:
783
+ _write_base_model("gpt2")
784
+ fn = paw.function(None, interpreter="gpt2")
785
+ expected_prompt = list(b"payload")
786
+ instance = state.instances[-1]
787
+ error = exception_type("processor failed")
788
+ later_calls = []
789
+ ignored_errors = []
790
+ native_scores = []
791
+ decoded_outputs = []
792
+ monkeypatch.setattr(sys, "unraisablehook", ignored_errors.append)
793
+
794
+ def failing_processor(input_ids, scores):
795
+ scores[:] = [float("-inf")] * len(scores)
796
+ if invalid_result:
797
+ # The native scores assignment must also be inside the guard.
798
+ return object()
799
+ raise error
800
+
801
+ def later_processor(input_ids, scores):
802
+ later_calls.append(list(input_ids))
803
+ return scores
804
+
805
+ def native_sample(*, temp, logits_processor=None):
806
+ if logits_processor is None:
807
+ return ord("A")
808
+ scores = [0.0] * 256
809
+ input_ids = list(instance.input_ids[:instance.n_tokens])
810
+
811
+ @ctypes.CFUNCTYPE(None)
812
+ def apply_processors():
813
+ nonlocal scores
814
+ for processor in logits_processor:
815
+ scores = processor(input_ids, scores)
816
+
817
+ apply_processors()
818
+ native_scores.append(list(scores))
819
+ # The guarded callback must leave native sampling usable, even when
820
+ # the failed processor has already invalidated every candidate.
821
+ assert len(scores) == 256 and all(math.isfinite(score) for score in scores)
822
+ return instance.token_eos()
823
+
824
+ def detokenize(tokens):
825
+ decoded_outputs.append(list(tokens))
826
+ return bytes(tokens)
827
+
828
+ monkeypatch.setattr(instance, "sample", native_sample)
829
+ monkeypatch.setattr(instance, "detokenize", detokenize)
830
+ eval_count = len(instance.eval_calls)
831
+ processors = llama_cpp.LogitsProcessorList([failing_processor, later_processor])
832
+ with pytest.raises(exception_type) as caught:
833
+ fn("payload", max_tokens=3, logits_processor=processors)
834
+
835
+ if not invalid_result:
836
+ assert caught.value is error
837
+ assert ignored_errors == []
838
+ assert later_calls == []
839
+ assert len(native_scores) == 1
840
+ assert instance.eval_calls[eval_count:] == [expected_prompt]
841
+ assert decoded_outputs == []
842
+ assert fn("payload", max_tokens=1) == "A"
843
+ assert len(native_scores) == 1
844
+ assert later_calls == ignored_errors == []
845
+ fn.close()