logit-classifier 0.2.0__tar.gz → 0.3.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 (71) hide show
  1. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/PKG-INFO +92 -15
  2. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/README.md +90 -13
  3. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/examples/compare_models.py +2 -1
  4. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/examples/images.py +2 -1
  5. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/examples/question_types.py +2 -1
  6. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/examples/quickstart.py +2 -1
  7. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/pyproject.toml +3 -3
  8. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/_version.py +1 -1
  9. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/backends/comfy_clip.py +25 -1
  10. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/classifier.py +26 -3
  11. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/config.py +3 -3
  12. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/prompt.py +8 -2
  13. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/schema.py +14 -2
  14. logit_classifier-0.3.0/src/logit_classifier/tags.py +36 -0
  15. logit_classifier-0.3.0/src/logit_classifier/toolkit/__init__.py +5 -0
  16. logit_classifier-0.3.0/src/logit_classifier/toolkit/comfyui/__init__.py +50 -0
  17. logit_classifier-0.3.0/src/logit_classifier/toolkit/comfyui/classifier.py +39 -0
  18. logit_classifier-0.3.0/src/logit_classifier/toolkit/comfyui/generate.py +146 -0
  19. logit_classifier-0.3.0/src/logit_classifier/toolkit/comfyui/progress.py +86 -0
  20. logit_classifier-0.3.0/src/logit_classifier/toolkit/comfyui/scopes.py +77 -0
  21. logit_classifier-0.3.0/src/logit_classifier/toolkit/comfyui/tagger.py +390 -0
  22. logit_classifier-0.3.0/src/logit_classifier/toolkit/tags.py +372 -0
  23. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/vision.py +34 -11
  24. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/web/index.html +2 -2
  25. logit_classifier-0.3.0/tests/test_toolkit.py +1445 -0
  26. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests/test_unit.py +454 -177
  27. logit_classifier-0.3.0/tests-AB/comfy_env.py +167 -0
  28. logit_classifier-0.2.0/src/logit_classifier/tags.py +0 -121
  29. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/.gitignore +0 -0
  30. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/FINDINGS.md +0 -0
  31. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/LICENSE +0 -0
  32. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/examples/http_client.py +0 -0
  33. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/examples/own_backend.py +0 -0
  34. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/__init__.py +0 -0
  35. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/__main__.py +0 -0
  36. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/backends/__init__.py +0 -0
  37. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/backends/_torch_window.py +0 -0
  38. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/backends/base.py +0 -0
  39. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/backends/hf.py +0 -0
  40. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/calibrate.py +0 -0
  41. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/cli.py +0 -0
  42. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/deps.py +0 -0
  43. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/errors.py +0 -0
  44. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/labels.py +0 -0
  45. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/py.typed +0 -0
  46. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/scoring.py +0 -0
  47. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/service.py +0 -0
  48. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests/fixtures/banking77_test.json +0 -0
  49. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests/fixtures/eval_set.json +0 -0
  50. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests/fixtures/many_options_request.json +0 -0
  51. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests/fixtures/quickstart_request.json +0 -0
  52. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests/test_model.py +0 -0
  53. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/ab_branch_packing.py +0 -0
  54. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/ab_determinism_scope.py +0 -0
  55. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/ab_env.py +0 -0
  56. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/ab_math_sdp_reduction.py +0 -0
  57. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/ab_multi_label.py +0 -0
  58. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/ab_noul_wording.py +0 -0
  59. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/ab_temperature.py +0 -0
  60. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/banking77.py +0 -0
  61. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/benchmark.py +0 -0
  62. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/evaluate.py +0 -0
  63. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/inputs/_full.png +0 -0
  64. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/inputs/_sheet.png +0 -0
  65. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/inputs/low-L.png +0 -0
  66. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/inputs/low-R.png +0 -0
  67. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/inputs/mid-L.png +0 -0
  68. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/inputs/mid-R.png +0 -0
  69. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/inputs/top-L.png +0 -0
  70. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/inputs/top-R.png +0 -0
  71. {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/tune_groups.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: logit-classifier
3
- Version: 0.2.0
4
- Summary: Local zero-shot classifier for text and images. Declares options, returns a calibrated probability for each, generates no text. Accepts the TypeSafe System One request format.
3
+ Version: 0.3.0
4
+ Summary: Local zero-shot classifier for text and images. Declares options and reads a calibrated probability for each from the logits. Accepts the TypeSafe System One request format, and ships a toolkit of image tag tools and ComfyUI node tools.
5
5
  Project-URL: Repository, https://github.com/Blakeem/logit-classifier
6
6
  Project-URL: Bug Tracker, https://github.com/Blakeem/logit-classifier/issues
7
7
  Author: Blake
@@ -88,8 +88,8 @@ beside your project instead, which is what the examples do.
88
88
  Config(models_dir=Path("models"))
89
89
  ```
90
90
 
91
- `LOGIT_MODELS_DIR` sets the same folder for every process, and `HF_HOME` moves the
92
- Hugging Face cache itself.
91
+ `LOGIT_MODELS_DIR` sets the same folder for the service and for `Config.from_env()`.
92
+ `HF_HOME` moves the Hugging Face cache itself.
93
93
 
94
94
  ## Models
95
95
 
@@ -98,8 +98,9 @@ Hugging Face cache itself.
98
98
  | reads images | yes | no |
99
99
  | better at | choice and score | noul |
100
100
 
101
- `Qwen3-VL-4B-Instruct` is the default. Set `LOGIT_MODEL_ID` to use the other one. Any Qwen
102
- chat model loads, and a model with no fitted temperature gets 2.5.
101
+ `Qwen3-VL-4B-Instruct` is the default. Set `LOGIT_MODEL_ID` to use the other one in the
102
+ service or through `Config.from_env()`. Any Qwen chat model loads, and a model with no
103
+ fitted temperature gets 2.5.
103
104
 
104
105
  ## Python API
105
106
 
@@ -123,6 +124,14 @@ print(response.to_dict())
123
124
  `load_model` loads the weights through the `hf` extra and returns a `Backend`.
124
125
  `Classifier(Config())` calls it for you when you pass no backend.
125
126
 
127
+ `classifier.nouls` asks a list of statements about one state and returns one probability
128
+ per statement. It sends more than 256 statements as several requests.
129
+
130
+ ```python
131
+ probabilities = classifier.nouls(["The message is urgent", "The message is polite"],
132
+ state="the payment failed again")
133
+ ```
134
+
126
135
  Loading each model yourself is what lets one script compare several. The fitted
127
136
  temperature follows the backend, so every model is scored with its own value.
128
137
 
@@ -169,7 +178,7 @@ the model.
169
178
 
170
179
  ```json
171
180
  {
172
- "model": "logit-classifier-0.2.0",
181
+ "model": "logit-classifier-0.3.0",
173
182
  "answers": {
174
183
  "department": {
175
184
  "type": "choice",
@@ -230,8 +239,9 @@ Each script runs on its own.
230
239
 
231
240
  ## Configuration
232
241
 
233
- Every setting reads from the environment at startup. `logit-classifier config` prints what
234
- they produce.
242
+ The service and `logit-classifier config` read these variables at startup through
243
+ `Config.from_env()`. Library code gets them by calling `Config.from_env()`, since `Config()`
244
+ reads none of them. `logit-classifier config` prints what they produce.
235
245
 
236
246
  | Variable | Default | Effect |
237
247
  |---|---|---|
@@ -242,6 +252,7 @@ they produce.
242
252
  | `LOGIT_PRIOR_DEBIAS` | `1` | set to `0` to skip the label prior |
243
253
  | `LOGIT_BATCH_BRANCHES` | `1` | set to `0` for one forward pass per branch |
244
254
  | `LOGIT_SCORE_METHOD` | `joint` | set to `independent` to judge each level alone |
255
+ | `LOGIT_PERMUTATIONS` | `1` | letterings averaged per question, each one adds branches |
245
256
  | `LOGIT_ABSTAIN` | `1` | set to `0` to drop the `none of these` label below 52 options |
246
257
  | `LOGIT_CALIBRATION_PATH` | `calibration.json` | where the service stores the running prior |
247
258
 
@@ -269,10 +280,10 @@ Nothing is sampled, so the same request returns bitwise identical logits.
269
280
 
270
281
  Batch composition and padding length both change the low bits of a bfloat16 forward pass,
271
282
  and both are pure functions of the request. So the same question asked inside two
272
- different requests can differ slightly. Set `LOGIT_BATCH_BRANCHES=0` to score each branch
273
- alone, which makes a question independent of the questions sent with it and costs one
274
- forward pass per branch. `ComfyClipBackend` reads no environment variable, so it takes
275
- `batch_branches=False` as a keyword instead.
283
+ different requests can differ slightly. Scoring each branch alone makes a question
284
+ independent of the questions sent with it and costs one forward pass per branch. The service
285
+ turns it on with `LOGIT_BATCH_BRANCHES=0`. Library code passes `Config(batch_branches=False)`,
286
+ and `ComfyClipBackend` takes `batch_branches=False` as a keyword.
276
287
 
277
288
  A forward pass needs several process-global torch settings held at known values. Torch
278
289
  exposes none of them as a call argument, so the backend sets them around each pass and
@@ -339,6 +350,72 @@ shape `[1, H, W, 3]`. An empty state asks about the image alone. Questions share
339
350
  pass of up to 4,096 tokens, and the image counts toward that limit. Any text encoder other
340
351
  than Qwen3-VL raises `UnsupportedModelError`.
341
352
 
353
+ ## Toolkit
354
+
355
+ `logit_classifier.toolkit` holds tools built on the classifier with the settings our
356
+ measurements found best. A project imports them to get good results without working out the
357
+ wording, thresholds and host details itself. The toolkit uses the classifier's public API,
358
+ and the classifier never imports the toolkit.
359
+
360
+ | Package | Runs in | Holds |
361
+ |---|---|---|
362
+ | `logit_classifier.toolkit.tags` | any Python process | text tools for image tags and prompts |
363
+ | `logit_classifier.toolkit.comfyui` | a ComfyUI node | a classifier over the workflow's CLIP, text generation, a progress display and an image tagger |
364
+
365
+ ### Tag Text Tools
366
+
367
+ | Tool | Does |
368
+ |---|---|
369
+ | `parse_candidates` | splits a model's tag reply into clean tags with no duplicates |
370
+ | `split_prompt` | splits an image prompt into its fragments |
371
+ | `drop_subsets` | drops a tag whose words all appear in a longer tag |
372
+ | `prompt_word_forms`, `in_prompt` | test whether a tag's words appear in a prompt, singular or plural |
373
+ | `merge_candidates` | merges proposed tags with a prompt's tags under a cap |
374
+ | `presence_question` | writes "Is there a car in this image?" with the article the thing takes |
375
+
376
+ ```python
377
+ from logit_classifier.toolkit.tags import drop_subsets, parse_candidates
378
+
379
+ tags = drop_subsets(parse_candidates("red car, car, wooden bench, bench"))
380
+ # ['red car', 'wooden bench']
381
+ ```
382
+
383
+ `drop_subsets(tags, rule="head-noun")` drops a tag only into a longer tag with the same head
384
+ noun. The head noun is the last word before "of", "in", "on", "with" or "reading". So "cat"
385
+ stays beside "cat ear", and "signage" is dropped beside "signage in chinese".
386
+
387
+ ### ComfyUI Tools
388
+
389
+ These run inside a ComfyUI node over the CLIP the workflow loaded. Each one imports comfy and
390
+ torch only when it is called.
391
+
392
+ | Tool | Does |
393
+ |---|---|
394
+ | `comfy_classifier` | builds a classifier over the CLIP, with errors that name the node and the Load CLIP fix |
395
+ | `generate_text` | generates one greedy reply with the Qwen chat template, and stops early on a condition |
396
+ | `shared_vision_encode` | runs the vision tower once per picture for every generate and classify inside the block |
397
+ | `skip_resident_loads` | skips core's model load while the CLIP is already on the GPU |
398
+ | `routed_progress_bars`, `send_status`, `run_outcome` | show one progress bar per run and a status line under the node |
399
+ | `fit_picture` | downscales an image to a pixel cap |
400
+ | `prompt_tags` | lists the physical things a prompt names |
401
+ | `tag_picture` | tags a picture, with a prompt's things added as candidates |
402
+ | `TagSettings` | holds every wording, budget, cap and threshold of the tagger |
403
+
404
+ The tagger generates candidate tags over the CLIP and verifies each one with the classifier.
405
+
406
+ ```python
407
+ from logit_classifier.toolkit.comfyui import TagSettings, comfy_classifier, fit_picture, tag_picture
408
+
409
+ classifier = comfy_classifier(clip, node="My Tagger")
410
+ picture = fit_picture(image, 1024 * 1024)
411
+ trace = tag_picture(clip, classifier, picture, settings=TagSettings(), known={})
412
+ tags = trace.kept
413
+ ```
414
+
415
+ `known` holds the thing check's answers across a run, so each tag is asked once. The
416
+ `TagSettings` defaults are the current measured values, and a later release may change them.
417
+ Pass every value your output depends on to keep it fixed.
418
+
342
419
  ## Differences From Jev
343
420
 
344
421
  This project matches the System One request and response format and the documented limits.
@@ -348,8 +425,8 @@ Jev is trained for calibrated probabilities. This project reads them from a gene
348
425
  so the choices agree more often than the confidences do.
349
426
 
350
427
  Jev judges a score level without its number or its neighbours. This project judges all
351
- levels together by default. Set `LOGIT_SCORE_METHOD=independent` for the documented
352
- behavior.
428
+ levels together by default. The service follows the documented behavior with
429
+ `LOGIT_SCORE_METHOD=independent`, and library code with `Config(score_method="independent")`.
353
430
 
354
431
  Jev publishes status codes but no error body. The error shape here is our own.
355
432
 
@@ -55,8 +55,8 @@ beside your project instead, which is what the examples do.
55
55
  Config(models_dir=Path("models"))
56
56
  ```
57
57
 
58
- `LOGIT_MODELS_DIR` sets the same folder for every process, and `HF_HOME` moves the
59
- Hugging Face cache itself.
58
+ `LOGIT_MODELS_DIR` sets the same folder for the service and for `Config.from_env()`.
59
+ `HF_HOME` moves the Hugging Face cache itself.
60
60
 
61
61
  ## Models
62
62
 
@@ -65,8 +65,9 @@ Hugging Face cache itself.
65
65
  | reads images | yes | no |
66
66
  | better at | choice and score | noul |
67
67
 
68
- `Qwen3-VL-4B-Instruct` is the default. Set `LOGIT_MODEL_ID` to use the other one. Any Qwen
69
- chat model loads, and a model with no fitted temperature gets 2.5.
68
+ `Qwen3-VL-4B-Instruct` is the default. Set `LOGIT_MODEL_ID` to use the other one in the
69
+ service or through `Config.from_env()`. Any Qwen chat model loads, and a model with no
70
+ fitted temperature gets 2.5.
70
71
 
71
72
  ## Python API
72
73
 
@@ -90,6 +91,14 @@ print(response.to_dict())
90
91
  `load_model` loads the weights through the `hf` extra and returns a `Backend`.
91
92
  `Classifier(Config())` calls it for you when you pass no backend.
92
93
 
94
+ `classifier.nouls` asks a list of statements about one state and returns one probability
95
+ per statement. It sends more than 256 statements as several requests.
96
+
97
+ ```python
98
+ probabilities = classifier.nouls(["The message is urgent", "The message is polite"],
99
+ state="the payment failed again")
100
+ ```
101
+
93
102
  Loading each model yourself is what lets one script compare several. The fitted
94
103
  temperature follows the backend, so every model is scored with its own value.
95
104
 
@@ -136,7 +145,7 @@ the model.
136
145
 
137
146
  ```json
138
147
  {
139
- "model": "logit-classifier-0.2.0",
148
+ "model": "logit-classifier-0.3.0",
140
149
  "answers": {
141
150
  "department": {
142
151
  "type": "choice",
@@ -197,8 +206,9 @@ Each script runs on its own.
197
206
 
198
207
  ## Configuration
199
208
 
200
- Every setting reads from the environment at startup. `logit-classifier config` prints what
201
- they produce.
209
+ The service and `logit-classifier config` read these variables at startup through
210
+ `Config.from_env()`. Library code gets them by calling `Config.from_env()`, since `Config()`
211
+ reads none of them. `logit-classifier config` prints what they produce.
202
212
 
203
213
  | Variable | Default | Effect |
204
214
  |---|---|---|
@@ -209,6 +219,7 @@ they produce.
209
219
  | `LOGIT_PRIOR_DEBIAS` | `1` | set to `0` to skip the label prior |
210
220
  | `LOGIT_BATCH_BRANCHES` | `1` | set to `0` for one forward pass per branch |
211
221
  | `LOGIT_SCORE_METHOD` | `joint` | set to `independent` to judge each level alone |
222
+ | `LOGIT_PERMUTATIONS` | `1` | letterings averaged per question, each one adds branches |
212
223
  | `LOGIT_ABSTAIN` | `1` | set to `0` to drop the `none of these` label below 52 options |
213
224
  | `LOGIT_CALIBRATION_PATH` | `calibration.json` | where the service stores the running prior |
214
225
 
@@ -236,10 +247,10 @@ Nothing is sampled, so the same request returns bitwise identical logits.
236
247
 
237
248
  Batch composition and padding length both change the low bits of a bfloat16 forward pass,
238
249
  and both are pure functions of the request. So the same question asked inside two
239
- different requests can differ slightly. Set `LOGIT_BATCH_BRANCHES=0` to score each branch
240
- alone, which makes a question independent of the questions sent with it and costs one
241
- forward pass per branch. `ComfyClipBackend` reads no environment variable, so it takes
242
- `batch_branches=False` as a keyword instead.
250
+ different requests can differ slightly. Scoring each branch alone makes a question
251
+ independent of the questions sent with it and costs one forward pass per branch. The service
252
+ turns it on with `LOGIT_BATCH_BRANCHES=0`. Library code passes `Config(batch_branches=False)`,
253
+ and `ComfyClipBackend` takes `batch_branches=False` as a keyword.
243
254
 
244
255
  A forward pass needs several process-global torch settings held at known values. Torch
245
256
  exposes none of them as a call argument, so the backend sets them around each pass and
@@ -306,6 +317,72 @@ shape `[1, H, W, 3]`. An empty state asks about the image alone. Questions share
306
317
  pass of up to 4,096 tokens, and the image counts toward that limit. Any text encoder other
307
318
  than Qwen3-VL raises `UnsupportedModelError`.
308
319
 
320
+ ## Toolkit
321
+
322
+ `logit_classifier.toolkit` holds tools built on the classifier with the settings our
323
+ measurements found best. A project imports them to get good results without working out the
324
+ wording, thresholds and host details itself. The toolkit uses the classifier's public API,
325
+ and the classifier never imports the toolkit.
326
+
327
+ | Package | Runs in | Holds |
328
+ |---|---|---|
329
+ | `logit_classifier.toolkit.tags` | any Python process | text tools for image tags and prompts |
330
+ | `logit_classifier.toolkit.comfyui` | a ComfyUI node | a classifier over the workflow's CLIP, text generation, a progress display and an image tagger |
331
+
332
+ ### Tag Text Tools
333
+
334
+ | Tool | Does |
335
+ |---|---|
336
+ | `parse_candidates` | splits a model's tag reply into clean tags with no duplicates |
337
+ | `split_prompt` | splits an image prompt into its fragments |
338
+ | `drop_subsets` | drops a tag whose words all appear in a longer tag |
339
+ | `prompt_word_forms`, `in_prompt` | test whether a tag's words appear in a prompt, singular or plural |
340
+ | `merge_candidates` | merges proposed tags with a prompt's tags under a cap |
341
+ | `presence_question` | writes "Is there a car in this image?" with the article the thing takes |
342
+
343
+ ```python
344
+ from logit_classifier.toolkit.tags import drop_subsets, parse_candidates
345
+
346
+ tags = drop_subsets(parse_candidates("red car, car, wooden bench, bench"))
347
+ # ['red car', 'wooden bench']
348
+ ```
349
+
350
+ `drop_subsets(tags, rule="head-noun")` drops a tag only into a longer tag with the same head
351
+ noun. The head noun is the last word before "of", "in", "on", "with" or "reading". So "cat"
352
+ stays beside "cat ear", and "signage" is dropped beside "signage in chinese".
353
+
354
+ ### ComfyUI Tools
355
+
356
+ These run inside a ComfyUI node over the CLIP the workflow loaded. Each one imports comfy and
357
+ torch only when it is called.
358
+
359
+ | Tool | Does |
360
+ |---|---|
361
+ | `comfy_classifier` | builds a classifier over the CLIP, with errors that name the node and the Load CLIP fix |
362
+ | `generate_text` | generates one greedy reply with the Qwen chat template, and stops early on a condition |
363
+ | `shared_vision_encode` | runs the vision tower once per picture for every generate and classify inside the block |
364
+ | `skip_resident_loads` | skips core's model load while the CLIP is already on the GPU |
365
+ | `routed_progress_bars`, `send_status`, `run_outcome` | show one progress bar per run and a status line under the node |
366
+ | `fit_picture` | downscales an image to a pixel cap |
367
+ | `prompt_tags` | lists the physical things a prompt names |
368
+ | `tag_picture` | tags a picture, with a prompt's things added as candidates |
369
+ | `TagSettings` | holds every wording, budget, cap and threshold of the tagger |
370
+
371
+ The tagger generates candidate tags over the CLIP and verifies each one with the classifier.
372
+
373
+ ```python
374
+ from logit_classifier.toolkit.comfyui import TagSettings, comfy_classifier, fit_picture, tag_picture
375
+
376
+ classifier = comfy_classifier(clip, node="My Tagger")
377
+ picture = fit_picture(image, 1024 * 1024)
378
+ trace = tag_picture(clip, classifier, picture, settings=TagSettings(), known={})
379
+ tags = trace.kept
380
+ ```
381
+
382
+ `known` holds the thing check's answers across a run, so each tag is asked once. The
383
+ `TagSettings` defaults are the current measured values, and a later release may change them.
384
+ Pass every value your output depends on to keep it fixed.
385
+
309
386
  ## Differences From Jev
310
387
 
311
388
  This project matches the System One request and response format and the documented limits.
@@ -315,8 +392,8 @@ Jev is trained for calibrated probabilities. This project reads them from a gene
315
392
  so the choices agree more often than the confidences do.
316
393
 
317
394
  Jev judges a score level without its number or its neighbours. This project judges all
318
- levels together by default. Set `LOGIT_SCORE_METHOD=independent` for the documented
319
- behavior.
395
+ levels together by default. The service follows the documented behavior with
396
+ `LOGIT_SCORE_METHOD=independent`, and library code with `Config(score_method="independent")`.
320
397
 
321
398
  Jev publishes status codes but no error body. The error shape here is our own.
322
399
 
@@ -19,7 +19,8 @@ from pathlib import Path
19
19
  from logit_classifier import Classifier, Config, load_model, parse_request
20
20
 
21
21
  # Weights land beside the project instead of in the global Hugging Face cache.
22
- # Set LOGIT_MODELS_DIR, or HF_HOME, to keep them somewhere shared across projects.
22
+ # Edit MODELS_DIR to share one folder across projects.
23
+ # Neither LOGIT_MODELS_DIR nor HF_HOME reaches this script.
23
24
  MODELS_DIR = Path(__file__).resolve().parent.parent / "models"
24
25
 
25
26
  MODELS = ["Qwen/Qwen3-VL-4B-Instruct", "Qwen/Qwen3-4B-Instruct-2507"]
@@ -23,7 +23,8 @@ from logit_classifier import (
23
23
  )
24
24
 
25
25
  # Weights land beside the project instead of in the global Hugging Face cache.
26
- # Set LOGIT_MODELS_DIR, or HF_HOME, to keep them somewhere shared across projects.
26
+ # Edit MODELS_DIR to share one folder across projects.
27
+ # Neither LOGIT_MODELS_DIR nor HF_HOME reaches this script.
27
28
  MODELS_DIR = Path(__file__).resolve().parent.parent / "models"
28
29
  QUESTIONS = {
29
30
  "subject": {
@@ -14,7 +14,8 @@ from pathlib import Path
14
14
  from logit_classifier import Classifier, Config, load_model, parse_request
15
15
 
16
16
  # Weights land beside the project instead of in the global Hugging Face cache.
17
- # Set LOGIT_MODELS_DIR, or HF_HOME, to keep them somewhere shared across projects.
17
+ # Edit MODELS_DIR to share one folder across projects.
18
+ # Neither LOGIT_MODELS_DIR nor HF_HOME reaches this script.
18
19
  MODELS_DIR = Path(__file__).resolve().parent.parent / "models"
19
20
 
20
21
  REVIEW = "Shipped two days late and the box was crushed, but the product itself works fine."
@@ -16,7 +16,8 @@ from pathlib import Path
16
16
  from logit_classifier import Classifier, Config, load_model, parse_request
17
17
 
18
18
  # Weights land beside the project instead of in the global Hugging Face cache.
19
- # Set LOGIT_MODELS_DIR, or HF_HOME, to keep them somewhere shared across projects.
19
+ # Edit MODELS_DIR to share one folder across projects.
20
+ # Neither LOGIT_MODELS_DIR nor HF_HOME reaches this script.
20
21
  MODELS_DIR = Path(__file__).resolve().parent.parent / "models"
21
22
 
22
23
  SAMPLE_TEXT = "I have been trying to connect my Stripe account for 3 days and it keeps failing."
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "logit-classifier"
3
3
  dynamic = ["version"]
4
- description = "Local zero-shot classifier for text and images. Declares options, returns a calibrated probability for each, generates no text. Accepts the TypeSafe System One request format."
4
+ description = "Local zero-shot classifier for text and images. Declares options and reads a calibrated probability for each from the logits. Accepts the TypeSafe System One request format, and ships a toolkit of image tag tools and ComfyUI node tools."
5
5
  requires-python = ">=3.12"
6
6
  license = "GPL-3.0-or-later"
7
7
  license-files = ["LICENSE"]
@@ -136,8 +136,8 @@ show_error_codes = true
136
136
  [[tool.mypy.overrides]]
137
137
  # transformers publishes no complete stubs, and accelerate none at all. torch is
138
138
  # listed so the type gate still runs where the hf extra is absent, such as CI.
139
- # The ComfyUI host supplies comfy, and neither this venv nor CI has it.
140
- module = ["transformers.*", "accelerate.*", "torch.*", "comfy.*"]
139
+ # The ComfyUI host supplies comfy, server and comfy_execution, and neither this venv nor CI has them.
140
+ module = ["transformers.*", "accelerate.*", "torch.*", "comfy.*", "server", "server.*", "comfy_execution.*"]
141
141
  ignore_missing_imports = true
142
142
 
143
143
  [[tool.mypy.overrides]]
@@ -1,3 +1,3 @@
1
1
  """The package version, apart from __init__ so config.py can read it without a cycle."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -101,6 +101,29 @@ def _check_image(image: Any) -> None:
101
101
  raise ImageError(f"a ComfyUI IMAGE of shape [1, H, W, 3] is required, got shape {shape}")
102
102
 
103
103
 
104
+ def _resident(clip: Any) -> bool:
105
+ """Whether the CLIP sits where its own load_model call leaves it, so loading again is a no-op.
106
+
107
+ Core's load_models_gpu has no fast path for a loaded model and costs about 0.1 s per call.
108
+ """
109
+ try:
110
+ import comfy.model_management as model_management
111
+
112
+ patcher = clip.patcher
113
+ loaded = model_management.current_loaded_models
114
+ # Another model's load always inserts at the head, and a changed LoRA patch set or an
115
+ # offloaded CLIP must reload.
116
+ return bool(
117
+ loaded
118
+ and loaded[0].model is patcher
119
+ and patcher.model.device == patcher.load_device
120
+ and patcher.model.current_weight_patches_uuid == patcher.patches_uuid
121
+ and patcher.model.model_loaded_weight_memory > 0
122
+ )
123
+ except (ImportError, AttributeError):
124
+ return False
125
+
126
+
104
127
  def _pack_passes(prefix_length: int, suffix_lengths: list[int], batch_branches: bool) -> list[list[int]]:
105
128
  """Group suffixes in request order into passes that stay under PACKED_TOKEN_CEILING.
106
129
 
@@ -284,7 +307,8 @@ class ComfyClipBackend:
284
307
  prefix_tokens[vision["pad_index"]] = {"type": "image", "data": vision["image"], "original_type": "image"}
285
308
  with _determinism(), torch.inference_mode():
286
309
  encoder.reset_clip_options()
287
- clip.load_model({self._tokens_key: [prefix_tokens]})
310
+ if not _resident(clip):
311
+ clip.load_model({self._tokens_key: [prefix_tokens]})
288
312
  device = clip.patcher.load_device
289
313
  encoder.set_clip_options({"layer": None, "execution_device": device})
290
314
  # BaseGenerate.generate picks its execution dtype this way, at llama.py:1115-1120.
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import hashlib
6
6
  import random
7
+ from collections.abc import Sequence
7
8
  from dataclasses import dataclass, field, replace
8
9
  from typing import Any
9
10
 
@@ -11,7 +12,7 @@ import numpy as np
11
12
 
12
13
  from .backends.base import Backend, BackendContractError, BranchLogits
13
14
  from .calibrate import PriorStore
14
- from .config import ANSWER_PREFILL, Config, fitted_temperature
15
+ from .config import ANSWER_PREFILL, JEV_MAX_QUESTIONS, Config, fitted_temperature
15
16
  from .deps import MissingDependencyError
16
17
  from .prompt import (
17
18
  PROMPT_VERSION,
@@ -25,6 +26,7 @@ from .schema import (
25
26
  Answer,
26
27
  ChoiceAnswer,
27
28
  ChoiceQuestion,
29
+ JSONContent,
28
30
  NoulAnswer,
29
31
  NoulQuestion,
30
32
  Question,
@@ -180,13 +182,12 @@ class Classifier:
180
182
  rounds = [questions]
181
183
 
182
184
  for seed in range(1, max(1, self.config.permutations)):
183
- shuffler = random.Random(seed)
184
185
  reordered: dict[str, Question] = {}
185
186
  for qid, question in questions.items():
186
187
  if not isinstance(question, ChoiceQuestion):
187
188
  continue
188
189
  names = list(question.criteria)
189
- shuffler.shuffle(names)
190
+ random.Random(seed).shuffle(names)
190
191
  criteria = {name: question.criteria[name] for name in names}
191
192
  reordered[qid] = replace(question, criteria=criteria)
192
193
  if reordered:
@@ -280,6 +281,28 @@ class Classifier:
280
281
  )
281
282
  return response, diagnostics
282
283
 
284
+ def nouls(self, statements: Sequence[str], *, image: Any = None, state: JSONContent = "") -> list[float]:
285
+ """Return each statement's noul about the state, in statement order.
286
+
287
+ The statements are split into requests of JEV_MAX_QUESTIONS, one classify call each, and no
288
+ statements make no request. An empty state with an image is the image-only state.
289
+ """
290
+ keys = [f"s{index}" for index in range(len(statements))]
291
+ questions: dict[str, Question] = {}
292
+ nouls: list[float] = []
293
+
294
+ for start in range(0, len(statements), JEV_MAX_QUESTIONS):
295
+ window = slice(start, start + JEV_MAX_QUESTIONS)
296
+ questions = {key: NoulQuestion(instructions=statement)
297
+ for key, statement in zip(keys[window], statements[window], strict=True)}
298
+ response, _diagnostics = self.classify(SystemOneRequest(state=state, questions=questions), image=image)
299
+ for key in questions:
300
+ answer = response.answers[key]
301
+ if not isinstance(answer, NoulAnswer):
302
+ raise TypeError(f"question {key} got a {answer.type} answer, not a noul")
303
+ nouls.append(answer.noul)
304
+ return nouls
305
+
283
306
  def _round_answers(self, questions: dict[str, Question], branches: list[Branch],
284
307
  probabilities: list[np.ndarray]) -> dict[str, Answer]:
285
308
  answers: dict[str, Answer] = {}
@@ -141,9 +141,9 @@ class Config:
141
141
 
142
142
  # "joint" scores all levels in one branch, "independent" judges each alone.
143
143
  score_method: str = "joint"
144
- # Averaging over several letterings cancels the model's preference for a label
145
- # position. Measured on Banking77, four letterings moved accuracy from 0.554 to
146
- # 0.693. Off by default because it multiplies the branch count.
144
+ # Relettering varies which group each option lands in once a question splits above 52 options.
145
+ # On Banking77's 77 options, four letterings moved accuracy from 0.554 to 0.693. At 10 options
146
+ # in one branch, accuracy did not move. Off by default because it multiplies the branch count.
147
147
  permutations: int = 1
148
148
  # An escape label absorbs the mass the model would otherwise spread over wrong
149
149
  # options, so offering one raised accuracy from 0.881 to 0.887 as well as scoring
@@ -52,6 +52,12 @@ class Branch:
52
52
  suffix_text: str
53
53
 
54
54
 
55
+ def inert(text: str) -> str:
56
+ # The backends' tokenizers read a <|...|> spelling in plain text as a control token, so
57
+ # client text could otherwise forge a chat turn or a second image pad.
58
+ return text.replace("<|", "<\u200b|")
59
+
60
+
55
61
  def _option_lines(entries: list[tuple[str, str]]) -> str:
56
62
  lines = []
57
63
  for index, (name, description) in enumerate(entries):
@@ -61,7 +67,7 @@ def _option_lines(entries: list[tuple[str, str]]) -> str:
61
67
 
62
68
 
63
69
  def _question_block(prompt_line: str, entries: list[tuple[str, str]]) -> str:
64
- return f"{prompt_line}\nOptions:\n{_option_lines(entries)}"
70
+ return inert(f"{prompt_line}\nOptions:\n{_option_lines(entries)}")
65
71
 
66
72
 
67
73
  def _choice_branches(qid: str, question: ChoiceQuestion, plan: BranchPlan,
@@ -146,7 +152,7 @@ def build_branches(questions: dict[str, Question], score_method: str = "joint",
146
152
  def prefix_content(state: Any, has_image: bool = False) -> str:
147
153
  """Build the user-message body every branch shares, image marker included."""
148
154
  marker = f"{IMAGE_MARKER}\n" if has_image else ""
149
- return f"Context:\n{marker}{render_content(state)}\n\n"
155
+ return f"Context:\n{marker}{inert(render_content(state))}\n\n"
150
156
 
151
157
 
152
158
  def branch_content(state: Any, branch: Branch, has_image: bool = False) -> str:
@@ -25,6 +25,8 @@ _REQUEST_FIELDS = frozenset({"state", "model", "questions"})
25
25
  _QUESTION_FIELDS = frozenset({"type", "instructions", "criteria"})
26
26
  _NOUL_CRITERIA_FIELDS = frozenset({"true", "false"})
27
27
  _QUESTION_TYPES = ("choice", "score", "noul")
28
+ # Keeps render_content's recursive json.dumps(indent=2) far below the interpreter recursion limit.
29
+ MAX_CONTENT_DEPTH = 64
28
30
 
29
31
 
30
32
  class SchemaError(LogitClassifierError, ValueError):
@@ -178,9 +180,19 @@ def _reject_unknown(mapping: dict[str, Any], allowed: frozenset[str], where: str
178
180
 
179
181
 
180
182
  def _content(value: Any, where: str) -> JSONContent:
181
- if isinstance(value, str | dict | list):
183
+ pending: list[tuple[Any, int]] = [(value, 1)]
184
+
185
+ if isinstance(value, str):
182
186
  return value
183
- raise SchemaError(f"expected a string, object or array, got {type(value).__name__}", where)
187
+ if not isinstance(value, dict | list):
188
+ raise SchemaError(f"expected a string, object or array, got {type(value).__name__}", where)
189
+ while pending:
190
+ node, depth = pending.pop()
191
+ if depth > MAX_CONTENT_DEPTH:
192
+ raise SchemaError(f"content nests deeper than {MAX_CONTENT_DEPTH} levels", where)
193
+ children = node.values() if isinstance(node, dict) else node
194
+ pending.extend((child, depth + 1) for child in children if isinstance(child, dict | list))
195
+ return value
184
196
 
185
197
 
186
198
  def _optional_content(value: Any, where: str) -> JSONContent | None:
@@ -0,0 +1,36 @@
1
+ """The tag text tools, which moved to logit_classifier.toolkit.tags in 0.3.0.
2
+
3
+ This path stays for code built on 0.2.x, which PyPI published. It raises no warning, so that code keeps a quiet log.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from .toolkit.tags import (
9
+ MAX_CANDIDATES,
10
+ MAX_REPEAT_BLOCK,
11
+ MAX_TAG_CHARS,
12
+ MAX_TAG_WORDS,
13
+ clean_item,
14
+ complete_tags,
15
+ drop_subsets,
16
+ drop_unfinished_tag,
17
+ normalize_item,
18
+ parse_candidates,
19
+ repeated_block,
20
+ split_prompt,
21
+ )
22
+
23
+ __all__ = [
24
+ "MAX_CANDIDATES",
25
+ "MAX_REPEAT_BLOCK",
26
+ "MAX_TAG_CHARS",
27
+ "MAX_TAG_WORDS",
28
+ "clean_item",
29
+ "complete_tags",
30
+ "drop_subsets",
31
+ "drop_unfinished_tag",
32
+ "normalize_item",
33
+ "parse_candidates",
34
+ "repeated_block",
35
+ "split_prompt",
36
+ ]
@@ -0,0 +1,5 @@
1
+ """Tools built on the classifier core that follow its measured best practice.
2
+
3
+ A caller gets good results from these tools without working the details out from the base API.
4
+ The tools use the core and never mix into it, so the core never imports this package.
5
+ """