logit-classifier 0.2.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 (71) hide show
  1. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/FINDINGS.md +26 -1
  2. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/PKG-INFO +90 -6
  3. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/README.md +88 -4
  4. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/pyproject.toml +3 -3
  5. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/_version.py +1 -1
  6. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/backends/comfy_clip.py +25 -1
  7. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/classifier.py +25 -1
  8. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/prompt.py +3 -3
  9. logit_classifier-0.4.0/src/logit_classifier/tags.py +36 -0
  10. logit_classifier-0.4.0/src/logit_classifier/toolkit/__init__.py +5 -0
  11. logit_classifier-0.4.0/src/logit_classifier/toolkit/comfyui/__init__.py +50 -0
  12. logit_classifier-0.4.0/src/logit_classifier/toolkit/comfyui/classifier.py +39 -0
  13. logit_classifier-0.4.0/src/logit_classifier/toolkit/comfyui/generate.py +146 -0
  14. logit_classifier-0.4.0/src/logit_classifier/toolkit/comfyui/progress.py +86 -0
  15. logit_classifier-0.4.0/src/logit_classifier/toolkit/comfyui/scopes.py +77 -0
  16. logit_classifier-0.4.0/src/logit_classifier/toolkit/comfyui/tagger.py +395 -0
  17. logit_classifier-0.4.0/src/logit_classifier/toolkit/tags.py +389 -0
  18. logit_classifier-0.4.0/tests/test_toolkit.py +1477 -0
  19. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests/test_unit.py +294 -218
  20. logit_classifier-0.4.0/tests-AB/comfy_env.py +167 -0
  21. logit_classifier-0.2.1/src/logit_classifier/tags.py +0 -171
  22. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/.gitignore +0 -0
  23. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/LICENSE +0 -0
  24. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/examples/compare_models.py +0 -0
  25. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/examples/http_client.py +0 -0
  26. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/examples/images.py +0 -0
  27. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/examples/own_backend.py +0 -0
  28. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/examples/question_types.py +0 -0
  29. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/examples/quickstart.py +0 -0
  30. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/__init__.py +0 -0
  31. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/__main__.py +0 -0
  32. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/backends/__init__.py +0 -0
  33. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/backends/_torch_window.py +0 -0
  34. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/backends/base.py +0 -0
  35. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/backends/hf.py +0 -0
  36. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/calibrate.py +0 -0
  37. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/cli.py +0 -0
  38. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/config.py +0 -0
  39. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/deps.py +0 -0
  40. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/errors.py +0 -0
  41. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/labels.py +0 -0
  42. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/py.typed +0 -0
  43. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/schema.py +0 -0
  44. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/scoring.py +0 -0
  45. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/service.py +0 -0
  46. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/vision.py +0 -0
  47. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/web/index.html +0 -0
  48. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests/fixtures/banking77_test.json +0 -0
  49. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests/fixtures/eval_set.json +0 -0
  50. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests/fixtures/many_options_request.json +0 -0
  51. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests/fixtures/quickstart_request.json +0 -0
  52. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests/test_model.py +0 -0
  53. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/ab_branch_packing.py +0 -0
  54. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/ab_determinism_scope.py +0 -0
  55. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/ab_env.py +0 -0
  56. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/ab_math_sdp_reduction.py +0 -0
  57. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/ab_multi_label.py +0 -0
  58. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/ab_noul_wording.py +0 -0
  59. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/ab_temperature.py +0 -0
  60. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/banking77.py +0 -0
  61. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/benchmark.py +0 -0
  62. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/evaluate.py +0 -0
  63. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/inputs/_full.png +0 -0
  64. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/inputs/_sheet.png +0 -0
  65. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/inputs/low-L.png +0 -0
  66. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/inputs/low-R.png +0 -0
  67. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/inputs/mid-L.png +0 -0
  68. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/inputs/mid-R.png +0 -0
  69. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/inputs/top-L.png +0 -0
  70. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/inputs/top-R.png +0 -0
  71. {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/tune_groups.py +0 -0
@@ -295,6 +295,31 @@ dispatcher reaches the math backend, which builds the full attention matrix. Mea
295
295
  `HFBackend` probes the backends at startup, at the model's own dtype, and pins the working
296
296
  ones. `benchmark.py`.
297
297
 
298
+ ## One presence wording, two threshold pairs
299
+
300
+ Correct tags per tile at a pooled precision, over 905 judged tile candidates on 23 tiles,
301
+ from the Logit Tagger's `tests-AB/ab_presence_wording.py` (round 4).
302
+
303
+ | Wording | P 0.95 | P 0.97 | P 0.98 | P 0.99 |
304
+ |---|---|---|---|---|
305
+ | "This image visibly contains {TAG}" | 17.43 | 12.78 | 9.87 | 6.04 |
306
+ | "Is there a {TAG} in this image?" (0.3.0, article by rule) | 17.35 | 11.30 | 2.39 | 2.09 |
307
+ | "Present in this image: {TAG}" | 17.61 | 8.74 | 8.13 | 6.74 |
308
+
309
+ The shipped fp8 tile pass cut offline, per tile and prompt over 69 runs, from
310
+ ContextAnchoredTileRefine's `tests-AB/ab_tags_paired.py`.
311
+
312
+ | Thresholds | Correct | Wrong or vague | Precision |
313
+ |---|---|---|---|
314
+ | broad (0.999, 0.9999) | 12.32 | 0.54 | 0.958 |
315
+ | strict (0.99998, 0.99998) | 7.33 | 0.17 | 0.977 |
316
+
317
+ Every paired interval against "This image visibly contains {TAG}" at 0.95 and 0.98 spans zero.
318
+ It needs no grammar, and the tile pass already asked it, so its thresholds carry over. On
319
+ declared COCO tag lists "Is there a {TAG} in this image?" (0.3.0, article by rule) kept more
320
+ correct tags at precision 0.98 (3.79 against 1.90 per image), which is the cost of one
321
+ wording.
322
+
298
323
  ## Bugs found and fixed
299
324
 
300
325
  | Bug | Effect | Fix |
@@ -329,6 +354,6 @@ and 23.66 GB against 15.12 GB, because a cropped cache is a non-contiguous view.
329
354
 
330
355
  ## Layout
331
356
 
332
- `tests/` holds the pytest suite, 146 unit tests and 14 that need the GPU. `tests-AB/` holds
357
+ `tests/` holds the pytest suite, the unit tests and 14 tests that need the GPU. `tests-AB/` holds
333
358
  the measurement harnesses, matching ComfyUI-ContextAnchoredTileRefine. Fixtures live in
334
359
  `tests/fixtures/` and `tests-AB/ab_env.py` points at them.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: logit-classifier
3
- Version: 0.2.1
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.4.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
@@ -124,6 +124,14 @@ print(response.to_dict())
124
124
  `load_model` loads the weights through the `hf` extra and returns a `Backend`.
125
125
  `Classifier(Config())` calls it for you when you pass no backend.
126
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
+
127
135
  Loading each model yourself is what lets one script compare several. The fitted
128
136
  temperature follows the backend, so every model is scored with its own value.
129
137
 
@@ -170,7 +178,7 @@ the model.
170
178
 
171
179
  ```json
172
180
  {
173
- "model": "logit-classifier-0.2.1",
181
+ "model": "logit-classifier-0.4.0",
174
182
  "answers": {
175
183
  "department": {
176
184
  "type": "choice",
@@ -342,6 +350,82 @@ shape `[1, H, W, 3]`. An empty state asks about the image alone. Questions share
342
350
  pass of up to 4,096 tokens, and the image counts toward that limit. Any text encoder other
343
351
  than Qwen3-VL raises `UnsupportedModelError`.
344
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_statement` | writes "This image visibly contains car" with the tag as given |
375
+ | `BROAD_THRESHOLDS`, `STRICT_THRESHOLDS` | the scores a tag must reach, broad for more tags and strict for fewer wrong ones |
376
+
377
+ ```python
378
+ from logit_classifier.toolkit.tags import drop_subsets, parse_candidates
379
+
380
+ tags = drop_subsets(parse_candidates("red car, car, wooden bench, bench"))
381
+ # ['red car', 'wooden bench']
382
+ ```
383
+
384
+ `drop_subsets(tags, rule="head-noun")` drops a tag only into a longer tag with the same head
385
+ noun. The head noun is the last word before "of", "in", "on", "with" or "reading". So "cat"
386
+ stays beside "cat ear", and "signage" is dropped beside "signage in chinese".
387
+
388
+ ### ComfyUI Tools
389
+
390
+ These run inside a ComfyUI node over the CLIP the workflow loaded. Each one imports comfy and
391
+ torch only when it is called. `logit_classifier.toolkit.comfyui` exports every tool. The table
392
+ names the module of each tool it lists.
393
+
394
+ | Module | Tool | Does |
395
+ |---|---|---|
396
+ | `classifier` | `comfy_classifier` | builds a classifier over the CLIP, with errors that name the node and the Load CLIP fix |
397
+ | `generate` | `generate_text` | generates one greedy reply with the Qwen chat template, and stops early on a condition |
398
+ | `generate` | `clip_generate` | runs core's generate, then clears the CUDA graphs it captured |
399
+ | `scopes` | `shared_vision_encode` | runs the vision tower once per image for every generate and classify inside the block |
400
+ | `scopes` | `skip_resident_loads` | skips core's model load while the CLIP is already on the GPU |
401
+ | `progress` | `routed_progress_bars`, `send_status`, `run_outcome` | show one progress bar per run and a status line under the node |
402
+ | `tagger` | `fit_image` | downscales an image to a pixel cap |
403
+ | `tagger` | `tag_prompt` | lists the physical things a prompt names |
404
+ | `tagger` | `tag_image` | tags an image, with a prompt's things added as candidates |
405
+ | `tagger` | `TagSettings` | holds every wording, budget, cap and threshold of the tagger |
406
+
407
+ The tagger generates candidate tags over the CLIP and verifies each one with the classifier.
408
+
409
+ ```python
410
+ from logit_classifier.toolkit.comfyui import TagSettings, comfy_classifier, fit_image, tag_image
411
+
412
+ classifier = comfy_classifier(clip, node="My Tagger")
413
+ trace = tag_image(clip, classifier, fit_image(image, 1024 * 1024), settings=TagSettings(), thing_cache={})
414
+ tags = trace.kept
415
+ ```
416
+
417
+ `thing_cache` holds the thing check's answers across a run, so each tag is asked once. The
418
+ `TagSettings` defaults are the current measured values, and a later release may change them.
419
+ Pass every value your output depends on to keep it fixed.
420
+
421
+ For fewer wrong tags, pass `STRICT_THRESHOLDS` as `verify_thresholds`.
422
+
423
+ ```python
424
+ from logit_classifier.toolkit.tags import STRICT_THRESHOLDS
425
+
426
+ settings = TagSettings(verify_thresholds=STRICT_THRESHOLDS)
427
+ ```
428
+
345
429
  ## Differences From Jev
346
430
 
347
431
  This project matches the System One request and response format and the documented limits.
@@ -360,13 +444,13 @@ Jev publishes status codes but no error body. The error shape here is our own.
360
444
 
361
445
  ```
362
446
  uv sync --all-extras
363
- uv run pytest tests/test_unit.py
364
- uv run pytest tests/test_model.py -m model
447
+ uv run python -m pytest tests -m "not model"
448
+ uv run python -m pytest tests/test_model.py -m model
365
449
  uv run ruff check .
366
450
  uv run mypy
367
451
  ```
368
452
 
369
- The unit tests need no weights. The model tests load the GPU and take about 15 seconds.
453
+ The unit tests need no weights. The model tests load the GPU and take 24 to 126 seconds, the longer run being the first of a session.
370
454
 
371
455
  `FINDINGS.md` is the measurement record. Every number there names the script in
372
456
  `tests-AB/` that produced it.
@@ -91,6 +91,14 @@ print(response.to_dict())
91
91
  `load_model` loads the weights through the `hf` extra and returns a `Backend`.
92
92
  `Classifier(Config())` calls it for you when you pass no backend.
93
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
+
94
102
  Loading each model yourself is what lets one script compare several. The fitted
95
103
  temperature follows the backend, so every model is scored with its own value.
96
104
 
@@ -137,7 +145,7 @@ the model.
137
145
 
138
146
  ```json
139
147
  {
140
- "model": "logit-classifier-0.2.1",
148
+ "model": "logit-classifier-0.4.0",
141
149
  "answers": {
142
150
  "department": {
143
151
  "type": "choice",
@@ -309,6 +317,82 @@ shape `[1, H, W, 3]`. An empty state asks about the image alone. Questions share
309
317
  pass of up to 4,096 tokens, and the image counts toward that limit. Any text encoder other
310
318
  than Qwen3-VL raises `UnsupportedModelError`.
311
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_statement` | writes "This image visibly contains car" with the tag as given |
342
+ | `BROAD_THRESHOLDS`, `STRICT_THRESHOLDS` | the scores a tag must reach, broad for more tags and strict for fewer wrong ones |
343
+
344
+ ```python
345
+ from logit_classifier.toolkit.tags import drop_subsets, parse_candidates
346
+
347
+ tags = drop_subsets(parse_candidates("red car, car, wooden bench, bench"))
348
+ # ['red car', 'wooden bench']
349
+ ```
350
+
351
+ `drop_subsets(tags, rule="head-noun")` drops a tag only into a longer tag with the same head
352
+ noun. The head noun is the last word before "of", "in", "on", "with" or "reading". So "cat"
353
+ stays beside "cat ear", and "signage" is dropped beside "signage in chinese".
354
+
355
+ ### ComfyUI Tools
356
+
357
+ These run inside a ComfyUI node over the CLIP the workflow loaded. Each one imports comfy and
358
+ torch only when it is called. `logit_classifier.toolkit.comfyui` exports every tool. The table
359
+ names the module of each tool it lists.
360
+
361
+ | Module | Tool | Does |
362
+ |---|---|---|
363
+ | `classifier` | `comfy_classifier` | builds a classifier over the CLIP, with errors that name the node and the Load CLIP fix |
364
+ | `generate` | `generate_text` | generates one greedy reply with the Qwen chat template, and stops early on a condition |
365
+ | `generate` | `clip_generate` | runs core's generate, then clears the CUDA graphs it captured |
366
+ | `scopes` | `shared_vision_encode` | runs the vision tower once per image for every generate and classify inside the block |
367
+ | `scopes` | `skip_resident_loads` | skips core's model load while the CLIP is already on the GPU |
368
+ | `progress` | `routed_progress_bars`, `send_status`, `run_outcome` | show one progress bar per run and a status line under the node |
369
+ | `tagger` | `fit_image` | downscales an image to a pixel cap |
370
+ | `tagger` | `tag_prompt` | lists the physical things a prompt names |
371
+ | `tagger` | `tag_image` | tags an image, with a prompt's things added as candidates |
372
+ | `tagger` | `TagSettings` | holds every wording, budget, cap and threshold of the tagger |
373
+
374
+ The tagger generates candidate tags over the CLIP and verifies each one with the classifier.
375
+
376
+ ```python
377
+ from logit_classifier.toolkit.comfyui import TagSettings, comfy_classifier, fit_image, tag_image
378
+
379
+ classifier = comfy_classifier(clip, node="My Tagger")
380
+ trace = tag_image(clip, classifier, fit_image(image, 1024 * 1024), settings=TagSettings(), thing_cache={})
381
+ tags = trace.kept
382
+ ```
383
+
384
+ `thing_cache` holds the thing check's answers across a run, so each tag is asked once. The
385
+ `TagSettings` defaults are the current measured values, and a later release may change them.
386
+ Pass every value your output depends on to keep it fixed.
387
+
388
+ For fewer wrong tags, pass `STRICT_THRESHOLDS` as `verify_thresholds`.
389
+
390
+ ```python
391
+ from logit_classifier.toolkit.tags import STRICT_THRESHOLDS
392
+
393
+ settings = TagSettings(verify_thresholds=STRICT_THRESHOLDS)
394
+ ```
395
+
312
396
  ## Differences From Jev
313
397
 
314
398
  This project matches the System One request and response format and the documented limits.
@@ -327,13 +411,13 @@ Jev publishes status codes but no error body. The error shape here is our own.
327
411
 
328
412
  ```
329
413
  uv sync --all-extras
330
- uv run pytest tests/test_unit.py
331
- uv run pytest tests/test_model.py -m model
414
+ uv run python -m pytest tests -m "not model"
415
+ uv run python -m pytest tests/test_model.py -m model
332
416
  uv run ruff check .
333
417
  uv run mypy
334
418
  ```
335
419
 
336
- The unit tests need no weights. The model tests load the GPU and take about 15 seconds.
420
+ The unit tests need no weights. The model tests load the GPU and take 24 to 126 seconds, the longer run being the first of a session.
337
421
 
338
422
  `FINDINGS.md` is the measurement record. Every number there names the script in
339
423
  `tests-AB/` that produced it.
@@ -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.1"
3
+ __version__ = "0.4.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,
@@ -279,6 +281,28 @@ class Classifier:
279
281
  )
280
282
  return response, diagnostics
281
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
+
282
306
  def _round_answers(self, questions: dict[str, Question], branches: list[Branch],
283
307
  probabilities: list[np.ndarray]) -> dict[str, Answer]:
284
308
  answers: dict[str, Answer] = {}
@@ -52,7 +52,7 @@ class Branch:
52
52
  suffix_text: str
53
53
 
54
54
 
55
- def _inert(text: str) -> str:
55
+ def inert(text: str) -> str:
56
56
  # The backends' tokenizers read a <|...|> spelling in plain text as a control token, so
57
57
  # client text could otherwise forge a chat turn or a second image pad.
58
58
  return text.replace("<|", "<\u200b|")
@@ -67,7 +67,7 @@ def _option_lines(entries: list[tuple[str, str]]) -> str:
67
67
 
68
68
 
69
69
  def _question_block(prompt_line: str, entries: list[tuple[str, str]]) -> str:
70
- return _inert(f"{prompt_line}\nOptions:\n{_option_lines(entries)}")
70
+ return inert(f"{prompt_line}\nOptions:\n{_option_lines(entries)}")
71
71
 
72
72
 
73
73
  def _choice_branches(qid: str, question: ChoiceQuestion, plan: BranchPlan,
@@ -152,7 +152,7 @@ def build_branches(questions: dict[str, Question], score_method: str = "joint",
152
152
  def prefix_content(state: Any, has_image: bool = False) -> str:
153
153
  """Build the user-message body every branch shares, image marker included."""
154
154
  marker = f"{IMAGE_MARKER}\n" if has_image else ""
155
- return f"Context:\n{marker}{_inert(render_content(state))}\n\n"
155
+ return f"Context:\n{marker}{inert(render_content(state))}\n\n"
156
156
 
157
157
 
158
158
  def branch_content(state: Any, branch: Branch, has_image: bool = False) -> str:
@@ -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
+ """
@@ -0,0 +1,50 @@
1
+ """The tools for a ComfyUI node that holds a loaded CLIP.
2
+
3
+ Every module here imports comfy, server, torch and comfy_execution inside the functions that need them, so importing
4
+ this package in a plain Python process loads none of them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ... import COMFY_SOCKET_TYPE
10
+ from ...backends.comfy_clip import PACKED_TOKEN_CEILING, ComfyClipBackend
11
+ from .classifier import comfy_classifier
12
+ from .generate import IMAGE_SLOT, chat, clip_generate, generate_text, stop_when
13
+ from .progress import routed_progress_bars, run_outcome, send_status, token_fill
14
+ from .scopes import shared_vision_encode, skip_resident_loads
15
+ from .tagger import (
16
+ ImageTagTrace,
17
+ PromptTagTrace,
18
+ TagSettings,
19
+ fit_image,
20
+ tag_image,
21
+ tag_prompt,
22
+ thing_scores,
23
+ to_english,
24
+ )
25
+
26
+ __all__ = [
27
+ "COMFY_SOCKET_TYPE",
28
+ "IMAGE_SLOT",
29
+ "PACKED_TOKEN_CEILING",
30
+ "ComfyClipBackend",
31
+ "ImageTagTrace",
32
+ "PromptTagTrace",
33
+ "TagSettings",
34
+ "chat",
35
+ "clip_generate",
36
+ "comfy_classifier",
37
+ "fit_image",
38
+ "generate_text",
39
+ "routed_progress_bars",
40
+ "run_outcome",
41
+ "send_status",
42
+ "shared_vision_encode",
43
+ "skip_resident_loads",
44
+ "stop_when",
45
+ "tag_image",
46
+ "tag_prompt",
47
+ "thing_scores",
48
+ "to_english",
49
+ "token_fill",
50
+ ]
@@ -0,0 +1,39 @@
1
+ """One Classifier over the CLIP a ComfyUI workflow already loaded."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ...backends.base import UnsupportedModelError, VisionUnsupportedError
8
+ from ...backends.comfy_clip import ComfyClipBackend
9
+ from ...classifier import Classifier
10
+ from ...config import Config
11
+
12
+
13
+ def comfy_classifier(
14
+ clip: Any, *, node: str | None = None, needs_images: bool = True, batch_branches: bool = True
15
+ ) -> Classifier:
16
+ """One Classifier over the workflow's CLIP, with no running prior and no calibration file.
17
+
18
+ Each error starts with the node's name when one is given, and names the Load CLIP fix.
19
+ """
20
+ prefix = f"{node}: " if node else ""
21
+ backend: ComfyClipBackend | None = None
22
+
23
+ try:
24
+ backend = ComfyClipBackend(clip, batch_branches=batch_branches)
25
+ except UnsupportedModelError as error:
26
+ raise UnsupportedModelError(
27
+ f"{prefix}this CLIP is not a Qwen3-VL text encoder, so the node cannot read it. Load a Qwen3-VL text "
28
+ "encoder, such as qwen3vl_4b_fp8_scaled.safetensors, with a Load CLIP type that keeps its vision tower, "
29
+ "such as krea2."
30
+ ) from error
31
+ if needs_images and not backend.sees_images:
32
+ raise VisionUnsupportedError(
33
+ f"{prefix}this CLIP was loaded without its vision tower, so the node cannot read images. Load the "
34
+ "Qwen3-VL text encoder with a Load CLIP type that keeps it, such as krea2. The flux and flux2 types "
35
+ "drop it."
36
+ )
37
+ # A node reuses one classifier across a batch, and the running prior would make an item's answer depend on the
38
+ # items before it.
39
+ return Classifier(Config(use_prior_debias=False, calibration_path=None), backend)