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.
- logit_classifier/__init__.py +90 -0
- logit_classifier/__main__.py +10 -0
- logit_classifier/backends/__init__.py +5 -0
- logit_classifier/backends/base.py +125 -0
- logit_classifier/backends/hf.py +412 -0
- logit_classifier/calibrate.py +141 -0
- logit_classifier/classifier.py +329 -0
- logit_classifier/cli.py +51 -0
- logit_classifier/config.py +187 -0
- logit_classifier/deps.py +30 -0
- logit_classifier/errors.py +16 -0
- logit_classifier/labels.py +91 -0
- logit_classifier/prompt.py +154 -0
- logit_classifier/py.typed +0 -0
- logit_classifier/schema.py +285 -0
- logit_classifier/scoring.py +102 -0
- logit_classifier/service.py +174 -0
- logit_classifier/vision.py +95 -0
- logit_classifier/web/index.html +411 -0
- logit_classifier-0.1.0.dist-info/METADATA +354 -0
- logit_classifier-0.1.0.dist-info/RECORD +24 -0
- logit_classifier-0.1.0.dist-info/WHEEL +4 -0
- logit_classifier-0.1.0.dist-info/entry_points.txt +2 -0
- logit_classifier-0.1.0.dist-info/licenses/LICENSE +674 -0
|
@@ -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
|
+
)
|
logit_classifier/deps.py
ADDED
|
@@ -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
|