logit-classifier 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,187 @@
1
+ """Runtime configuration for the classifier service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import os
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from .errors import ConfigError
12
+
13
+ # Jev caps a Choice at 255 options and a Score at 10 levels. Matching those caps
14
+ # keeps a Jev-shaped request valid here without translation.
15
+ JEV_MAX_OPTIONS = 255
16
+ JEV_MAX_SCORE_LEVELS = 10
17
+ JEV_MAX_QUESTIONS = 256
18
+
19
+ # The tokenizer merges a letter into a preceding space, so the prefill must end
20
+ # on a non-space. Verified against Qwen: "Answer: (" ends on the token "Ġ(" and
21
+ # "Ġ(A" is absent from the vocabulary, leaving each letter its own token.
22
+ ANSWER_PREFILL = "Answer: ("
23
+
24
+ # Temperature belongs to the model, not to the method. Each of these was fitted on the
25
+ # held out half of a 924 row Banking77 sample and chosen on calibration error. Accuracy
26
+ # also climbs with temperature above 52 options, but that is the split path artefact
27
+ # rather than a gain, so the fit does not follow it.
28
+ FITTED_TEMPERATURES = {
29
+ "Qwen/Qwen3-VL-4B-Instruct": 1.25,
30
+ "Qwen/Qwen3-4B-Instruct-2507": 6.0,
31
+ }
32
+
33
+ # What an unfitted model gets. Published fitted temperatures for this readout cluster
34
+ # near 1 in domain and near 3 out of domain, so the midpoint is the least wrong guess.
35
+ DEFAULT_TEMPERATURE = 2.5
36
+
37
+
38
+ def fitted_temperature(model_id: str) -> float:
39
+ return FITTED_TEMPERATURES.get(model_id, DEFAULT_TEMPERATURE)
40
+
41
+
42
+ def canonical_model_id(model_id: str) -> str:
43
+ """Map a local directory holding shipped weights onto its Hugging Face repo id.
44
+
45
+ A local path would otherwise miss FITTED_TEMPERATURES and fall back to the default.
46
+ """
47
+ if model_id in FITTED_TEMPERATURES:
48
+ return model_id
49
+ folder = model_id.replace("\\", "/").rstrip("/").rsplit("/", 1)[-1]
50
+ for known in FITTED_TEMPERATURES:
51
+ if known.rsplit("/", 1)[-1] == folder:
52
+ return known
53
+ return model_id
54
+
55
+
56
+ # Offering an escape label costs nothing and scores how badly the model wants to decline.
57
+ ABSTAIN = True
58
+
59
+ # The two branch layouts _score_branches implements. Any other spelling would reach
60
+ # the independent path without a word.
61
+ SCORE_METHODS = frozenset({"joint", "independent"})
62
+
63
+ # Below this many observations the running prior is noise, so it stays unused.
64
+ PRIOR_MIN_OBSERVATIONS = 32
65
+
66
+ # A label the model never picks drives its mean toward zero, and subtracting the log of a
67
+ # near-zero prior would hand that label an unbounded boost. Blending toward uniform bounds
68
+ # the correction. Measured on Banking77, an unsmoothed prior reached a 40 million to one
69
+ # spread across 40 labels.
70
+ PRIOR_SMOOTHING = 0.1
71
+
72
+
73
+ def _reject_score_method(where: str, value: str) -> None:
74
+ if value not in SCORE_METHODS:
75
+ raise ConfigError(
76
+ f"{where}={value!r} is not a score method. "
77
+ f"Accepted values are {', '.join(sorted(SCORE_METHODS))}."
78
+ )
79
+
80
+
81
+ def _reject_temperature(where: str, value: float | None) -> None:
82
+ if value is not None and not (math.isfinite(value) and value > 0):
83
+ raise ConfigError(
84
+ f"{where}={value!r} is not a temperature. "
85
+ "Accepted values are None or a finite number above 0."
86
+ )
87
+
88
+
89
+ def _reject_below_one(where: str, value: int) -> None:
90
+ if value < 1:
91
+ raise ConfigError(
92
+ f"{where}={value!r} is below 1. Accepted values are whole numbers of 1 or more."
93
+ )
94
+
95
+
96
+ def _env_switch(name: str, default: bool) -> bool:
97
+ raw = os.environ.get(name)
98
+
99
+ if raw is None:
100
+ return default
101
+ if raw not in ("0", "1"):
102
+ raise ConfigError(f"{name}={raw!r} is not a switch. Accepted values are 0 and 1.")
103
+ return raw == "1"
104
+
105
+
106
+ def _env_number[N: (int, float)](name: str, parse: Callable[[str], N]) -> N | None:
107
+ raw = os.environ.get(name)
108
+
109
+ if not raw:
110
+ return None
111
+ try:
112
+ return parse(raw)
113
+ except ValueError as error:
114
+ raise ConfigError(
115
+ f"{name}={raw!r} is not a number. Accepted values are {parse.__name__} literals."
116
+ ) from error
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class Config:
121
+ # Reads images as well as text, and scored 0.626 against 0.554 for the text only
122
+ # Qwen3-4B-Instruct-2507 on the same held out Banking77 rows at the same latency.
123
+ model_id: str = "Qwen/Qwen3-VL-4B-Instruct"
124
+ dtype: str = "bfloat16"
125
+ device: str = "cuda"
126
+ # None leaves the download location to HF_HOME, so installing this package puts
127
+ # nothing in whatever directory the caller happened to start in. A path keeps the
128
+ # weights beside the project instead, which is what the examples do.
129
+ models_dir: Path | None = None
130
+
131
+ served_model_id: str = "logit-classifier-0.1.0"
132
+ model_aliases: tuple[str, ...] = ("logit-latest", "logit-preview", "jev-latest", "jev-preview")
133
+
134
+ # None asks for the backend model's fitted value, resolved by Classifier.
135
+ temperature: float | None = None
136
+ use_prior_debias: bool = True
137
+ # None keeps the running prior in memory for the life of the process, so importing
138
+ # this library writes no file the host never asked for. from_env turns it back on.
139
+ calibration_path: Path | None = None
140
+
141
+ # "joint" scores all levels in one branch, "independent" judges each alone.
142
+ score_method: str = "joint"
143
+ # Averaging over several letterings cancels the model's preference for a label
144
+ # position. Measured on Banking77, four letterings moved accuracy from 0.554 to
145
+ # 0.693. Off by default because it multiplies the branch count.
146
+ permutations: int = 1
147
+ # An escape label absorbs the mass the model would otherwise spread over wrong
148
+ # options, so offering one raised accuracy from 0.881 to 0.887 as well as scoring
149
+ # how badly the model wants to decline.
150
+ abstain: bool = ABSTAIN
151
+
152
+ # False scores each branch in its own forward pass, which makes a question's
153
+ # logits independent of the sibling questions batched with it.
154
+ batch_branches: bool = True
155
+ max_batch_rows: int = 32
156
+
157
+ def __post_init__(self) -> None:
158
+ # A ComfyUI pack builds Config directly, so from_env cannot be the only check.
159
+ _reject_score_method("Config.score_method", self.score_method)
160
+ _reject_temperature("Config.temperature", self.temperature)
161
+ _reject_below_one("Config.permutations", self.permutations)
162
+ _reject_below_one("Config.max_batch_rows", self.max_batch_rows)
163
+
164
+ @classmethod
165
+ def from_env(cls) -> Config:
166
+ models_dir = os.environ.get("LOGIT_MODELS_DIR")
167
+ score_method = os.environ.get("LOGIT_SCORE_METHOD", cls.score_method)
168
+ permutations = _env_number("LOGIT_PERMUTATIONS", int)
169
+ temperature = _env_number("LOGIT_TEMPERATURE", float)
170
+
171
+ # Checked here as well as in __post_init__ so the message names the variable set.
172
+ _reject_score_method("LOGIT_SCORE_METHOD", score_method)
173
+ _reject_temperature("LOGIT_TEMPERATURE", temperature)
174
+ if permutations is not None:
175
+ _reject_below_one("LOGIT_PERMUTATIONS", permutations)
176
+ return cls(
177
+ model_id=os.environ.get("LOGIT_MODEL_ID", cls.model_id),
178
+ models_dir=Path(models_dir) if models_dir else None,
179
+ device=os.environ.get("LOGIT_DEVICE", cls.device),
180
+ temperature=temperature,
181
+ use_prior_debias=_env_switch("LOGIT_PRIOR_DEBIAS", cls.use_prior_debias),
182
+ batch_branches=_env_switch("LOGIT_BATCH_BRANCHES", cls.batch_branches),
183
+ score_method=score_method,
184
+ permutations=cls.permutations if permutations is None else permutations,
185
+ abstain=_env_switch("LOGIT_ABSTAIN", cls.abstain),
186
+ calibration_path=Path(os.environ.get("LOGIT_CALIBRATION_PATH", "calibration.json")),
187
+ )
@@ -0,0 +1,30 @@
1
+ """Loading the packages that live behind an extra.
2
+
3
+ The core install names numpy alone so a ComfyUI pack never asks pip to resolve
4
+ torch beside ComfyUI's pinned CUDA build. Everything outside that core is
5
+ imported here, on first use, and a missing one fails with its install line.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ from types import ModuleType
12
+
13
+ from .errors import LogitClassifierError
14
+
15
+ DISTRIBUTION = "logit-classifier"
16
+
17
+
18
+ class MissingDependencyError(LogitClassifierError, ImportError):
19
+ """A path needs an optional extra that is not installed."""
20
+
21
+
22
+ def require(module: str, extra: str) -> ModuleType:
23
+ """Import a module from an extra, or fail naming the command that installs it."""
24
+ try:
25
+ return importlib.import_module(module)
26
+ except ImportError as error:
27
+ raise MissingDependencyError(
28
+ f"{module} is needed here and is not installed. "
29
+ f'Install it with: pip install "{DISTRIBUTION}[{extra}]"'
30
+ ) from error
@@ -0,0 +1,16 @@
1
+ """The one base every error this library raises inherits.
2
+
3
+ A host catching one name covers the library. An error the caller can fix by
4
+ changing its input also inherits ValueError. An internal invariant failure also
5
+ inherits RuntimeError. A missing optional dependency also inherits ImportError.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class LogitClassifierError(Exception):
12
+ """Any failure this library raises on purpose."""
13
+
14
+
15
+ class ConfigError(LogitClassifierError, ValueError):
16
+ """A Config field or a LOGIT_ environment variable holds a value it cannot take."""
@@ -0,0 +1,91 @@
1
+ """Option label alphabet, boundary verification and the >52-option split."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import string
6
+ from dataclasses import dataclass
7
+ from typing import Protocol
8
+
9
+ from .errors import LogitClassifierError
10
+
11
+ # Bare single letters are one token each in the Qwen vocabulary when the prefill
12
+ # ends on "Ġ(", which gives 52 usable labels. Verified per tokenizer at startup.
13
+ LABEL_ALPHABET = string.ascii_uppercase + string.ascii_lowercase
14
+ MAX_LABELS_PER_BRANCH = len(LABEL_ALPHABET)
15
+
16
+
17
+ class LabelBoundaryError(LogitClassifierError, RuntimeError):
18
+ """A label does not occupy exactly one token after the prefill."""
19
+
20
+
21
+ class Encoder(Protocol):
22
+ """The one method the boundary proof needs, so it binds to no host."""
23
+
24
+ def encode(self, text: str) -> list[int]: ...
25
+
26
+
27
+ def verify_label_ids(tokenizer: Encoder, prefill_text: str, count: int) -> list[int]:
28
+ """Return one token id per label, proving each adds exactly one token.
29
+
30
+ The check must run against the rendered prefill, not the label alone. A
31
+ prefill ending in a space silently merges the space into the letter, which
32
+ an isolated encode of "A" cannot detect.
33
+ """
34
+ base_ids: list[int] = []
35
+ label_ids: list[int] = []
36
+
37
+ if count > MAX_LABELS_PER_BRANCH:
38
+ raise LabelBoundaryError(f"{count} labels exceeds the {MAX_LABELS_PER_BRANCH} available")
39
+
40
+ base_ids = tokenizer.encode(prefill_text)
41
+ for label in LABEL_ALPHABET[:count]:
42
+ full = tokenizer.encode(prefill_text + label)
43
+ if len(full) != len(base_ids) + 1 or full[: len(base_ids)] != base_ids:
44
+ raise LabelBoundaryError(
45
+ f"label {label!r} does not add exactly one token after {prefill_text!r}"
46
+ )
47
+ label_ids.append(full[-1])
48
+
49
+ if len(set(label_ids)) != len(label_ids):
50
+ raise LabelBoundaryError("labels collide onto the same token id")
51
+ return label_ids
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class BranchPlan:
56
+ """How one choice question's options map onto scored branches.
57
+
58
+ A flat plan scores every option in a single branch. A split plan scores each
59
+ option group in its own branch with an escape label, and combine_escape weighs the
60
+ groups by the mass each keeps off that label.
61
+ """
62
+
63
+ groups: tuple[tuple[int, ...], ...]
64
+ split: bool
65
+
66
+
67
+ def plan_branches(option_count: int, *, abstain: bool = False) -> BranchPlan:
68
+ """Split options into groups only when they exceed one branch's labels.
69
+
70
+ A split group holds one label back for the escape label that lets it say the
71
+ answer is not among its members. abstain means a flat plan carries that label
72
+ as well, so it has room for one option fewer.
73
+ """
74
+ group_count = 0
75
+ group_size = 0
76
+ capacity = MAX_LABELS_PER_BRANCH - 1
77
+ groups: list[tuple[int, ...]] = []
78
+
79
+ if option_count + (1 if abstain else 0) <= MAX_LABELS_PER_BRANCH:
80
+ return BranchPlan(groups=(tuple(range(option_count)),), split=False)
81
+
82
+ group_count = -(-option_count // capacity)
83
+ if group_count > capacity:
84
+ raise LabelBoundaryError(
85
+ f"{option_count} options needs {group_count} groups, over the "
86
+ f"{capacity} label limit"
87
+ )
88
+ group_size = -(-option_count // group_count)
89
+ for start in range(0, option_count, group_size):
90
+ groups.append(tuple(range(start, min(start + group_size, option_count))))
91
+ return BranchPlan(groups=tuple(groups), split=True)
@@ -0,0 +1,154 @@
1
+ """Prompt assembly: one shared prefix, one suffix per scored branch.
2
+
3
+ Everything here is text. Applying the host's chat template around it is the
4
+ backend's job, since the HF tokenizer and a ComfyUI CLIP render it differently.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Any, Literal
11
+
12
+ from .labels import LABEL_ALPHABET, BranchPlan, plan_branches
13
+ from .schema import (
14
+ ChoiceQuestion,
15
+ NoulQuestion,
16
+ Question,
17
+ ScoreQuestion,
18
+ render_content,
19
+ )
20
+
21
+ # Bumped whenever a change to branch layout or prior bucketing makes an older learned
22
+ # prior invalid.
23
+ PROMPT_VERSION = 2
24
+
25
+ SYSTEM_PROMPT = (
26
+ "You are a classification engine. Read the context, then answer the single "
27
+ "question about it.\n"
28
+ "Reply with exactly one option label from the list. Labels are case-sensitive."
29
+ )
30
+
31
+ # The processor replaces this single token with one per patch, so the marker costs
32
+ # nothing to write and expands to the right length on its own.
33
+ IMAGE_MARKER = "<|vision_start|><|image_pad|><|vision_end|>"
34
+
35
+ # A split group carries this extra label so it can decline, which is what lets
36
+ # groups be weighed against each other without a separate group branch.
37
+ ESCAPE_LABEL = "none of these"
38
+ ESCAPE_DESCRIPTION = "the answer is not in this list"
39
+
40
+ BranchKind = Literal["choice", "member", "score_level", "score_joint", "noul"]
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Branch:
45
+ """One prompt whose final token position carries a distribution."""
46
+
47
+ question_id: str
48
+ kind: BranchKind
49
+ label_count: int
50
+ # Which option or level indices this branch's labels map back to.
51
+ targets: tuple[int, ...]
52
+ suffix_text: str
53
+
54
+
55
+ def _option_lines(entries: list[tuple[str, str]]) -> str:
56
+ lines = []
57
+ for index, (name, description) in enumerate(entries):
58
+ label = LABEL_ALPHABET[index]
59
+ lines.append(f"({label}) {name}: {description}" if description else f"({label}) {name}")
60
+ return "\n".join(lines)
61
+
62
+
63
+ def _question_block(prompt_line: str, entries: list[tuple[str, str]]) -> str:
64
+ return f"{prompt_line}\nOptions:\n{_option_lines(entries)}"
65
+
66
+
67
+ def _choice_branches(qid: str, question: ChoiceQuestion, plan: BranchPlan,
68
+ abstain: bool = False) -> list[Branch]:
69
+ names: list[str] = list(question.criteria)
70
+ prompt_line = f"Question: {render_content(question.instructions)}".rstrip()
71
+ branches: list[Branch] = []
72
+
73
+ if not plan.split:
74
+ entries = [(n, render_content(question.criteria[n])) for n in names]
75
+ if not abstain:
76
+ return [Branch(qid, "choice", len(names), tuple(range(len(names))),
77
+ _question_block(prompt_line, entries))]
78
+ # One group carrying the escape label, so the split path reads it the same way.
79
+ entries.append((ESCAPE_LABEL, ESCAPE_DESCRIPTION))
80
+ return [Branch(qid, "member", len(entries), tuple(range(len(names))),
81
+ _question_block(prompt_line, entries))]
82
+
83
+ for group in plan.groups:
84
+ entries = [(names[i], render_content(question.criteria[names[i]])) for i in group]
85
+ entries.append((ESCAPE_LABEL, ESCAPE_DESCRIPTION))
86
+ branches.append(Branch(qid, "member", len(entries), tuple(group),
87
+ _question_block(prompt_line, entries)))
88
+ return branches
89
+
90
+
91
+ def _score_branches(qid: str, question: ScoreQuestion, method: str) -> list[Branch]:
92
+ """Expand a score question into branches.
93
+
94
+ "independent" follows Jev's stated semantics, judging each level on its own.
95
+ "joint" offers the levels as one labelled list, which forces the model to
96
+ discriminate between adjacent levels instead of accepting several at once.
97
+ Levels carry letters rather than indices either way, so no level number
98
+ reaches the model.
99
+ """
100
+ instructions = render_content(question.instructions)
101
+ branches: list[Branch] = []
102
+
103
+ if method == "joint":
104
+ entries = [(render_content(level), "") for level in question.criteria]
105
+ prompt_line = (
106
+ f"Question: {instructions}\n" if instructions else ""
107
+ ) + "Which description best matches the context?"
108
+ return [Branch(qid, "score_joint", len(question.criteria),
109
+ tuple(range(len(question.criteria))),
110
+ _question_block(prompt_line, entries))]
111
+
112
+ for index, level in enumerate(question.criteria):
113
+ prompt_line = (
114
+ f"Question: {instructions}\n" if instructions else ""
115
+ ) + f"Statement: the context matches this description: {render_content(level)}"
116
+ branches.append(Branch(qid, "score_level", 2, (index,),
117
+ _question_block(prompt_line, [("yes", ""), ("no", "")])))
118
+ return branches
119
+
120
+
121
+ def _noul_branches(qid: str, question: NoulQuestion) -> list[Branch]:
122
+ prompt_line = f"Statement: {render_content(question.instructions)}".rstrip()
123
+ yes_text = render_content(question.criteria.true_) if question.criteria else ""
124
+ no_text = render_content(question.criteria.false_) if question.criteria else ""
125
+ return [Branch(qid, "noul", 2, (0,),
126
+ _question_block(prompt_line, [("yes", yes_text), ("no", no_text)]))]
127
+
128
+
129
+ def build_branches(questions: dict[str, Question], score_method: str = "joint",
130
+ abstain: bool = False) -> list[Branch]:
131
+ """Expand every question into the branches that must be scored."""
132
+ branches: list[Branch] = []
133
+
134
+ for qid, question in questions.items():
135
+ if isinstance(question, ChoiceQuestion):
136
+ branches.extend(_choice_branches(
137
+ qid, question, plan_branches(len(question.criteria), abstain=abstain), abstain
138
+ ))
139
+ elif isinstance(question, ScoreQuestion):
140
+ branches.extend(_score_branches(qid, question, score_method))
141
+ else:
142
+ branches.extend(_noul_branches(qid, question))
143
+ return branches
144
+
145
+
146
+ def prefix_content(state: Any, has_image: bool = False) -> str:
147
+ """Build the user-message body every branch shares, image marker included."""
148
+ marker = f"{IMAGE_MARKER}\n" if has_image else ""
149
+ return f"Context:\n{marker}{render_content(state)}\n\n"
150
+
151
+
152
+ def branch_content(state: Any, branch: Branch, has_image: bool = False) -> str:
153
+ """One branch's full user-message body, opening with the shared prefix."""
154
+ return prefix_content(state, has_image) + branch.suffix_text
File without changes