decision-toolkit 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.
- decision_toolkit/__init__.py +102 -0
- decision_toolkit/__main__.py +8 -0
- decision_toolkit/adapters/__init__.py +165 -0
- decision_toolkit/adapters/base.py +134 -0
- decision_toolkit/adapters/gliner2.py +144 -0
- decision_toolkit/adapters/gliner2_decide.py +117 -0
- decision_toolkit/adapters/julia.py +168 -0
- decision_toolkit/adapters/laya.py +225 -0
- decision_toolkit/adapters/opendecider.py +144 -0
- decision_toolkit/calibration.py +79 -0
- decision_toolkit/catalog.py +472 -0
- decision_toolkit/cli.py +1277 -0
- decision_toolkit/config.py +90 -0
- decision_toolkit/contracts/__init__.py +58 -0
- decision_toolkit/contracts/common.py +61 -0
- decision_toolkit/contracts/mapping.py +165 -0
- decision_toolkit/contracts/openai_decisions_beta.py +133 -0
- decision_toolkit/contracts/systemone_v1.py +140 -0
- decision_toolkit/decision_model.py +692 -0
- decision_toolkit/engine/__init__.py +111 -0
- decision_toolkit/errors.py +138 -0
- decision_toolkit/limits.py +114 -0
- decision_toolkit/metrics.py +198 -0
- decision_toolkit/models.py +588 -0
- decision_toolkit/observability.py +145 -0
- decision_toolkit/py.typed +0 -0
- decision_toolkit/registry.py +283 -0
- decision_toolkit/results.py +355 -0
- decision_toolkit/schema.py +516 -0
- decision_toolkit/server.py +930 -0
- decision_toolkit/tracing.py +44 -0
- decision_toolkit-0.1.0.dist-info/METADATA +306 -0
- decision_toolkit-0.1.0.dist-info/RECORD +36 -0
- decision_toolkit-0.1.0.dist-info/WHEEL +4 -0
- decision_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- decision_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""decision-toolkit — local-first toolkit for open-weights decision models.
|
|
2
|
+
|
|
3
|
+
One ONNX engine, one adapter per model contract, three surfaces (library, CLI,
|
|
4
|
+
REST). Decisions, not text: typed ``choice`` / yes-no (``noul``) / ``score``
|
|
5
|
+
questions with a calibrated probability for every option, on CPU.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
from .adapters import register_adapter
|
|
13
|
+
from .calibration import brier_score, expected_calibration_error, threshold_sweep
|
|
14
|
+
from .catalog import Capabilities, ModelInfo
|
|
15
|
+
from .decision_model import DecisionModel
|
|
16
|
+
from .errors import (
|
|
17
|
+
AdapterError,
|
|
18
|
+
AdapterNotAvailableError,
|
|
19
|
+
AdapterNotImplementedError,
|
|
20
|
+
BackendFailureError,
|
|
21
|
+
DecisionToolkitError,
|
|
22
|
+
InvalidQuestionError,
|
|
23
|
+
InvalidRequestError,
|
|
24
|
+
ModelNotDownloadedError,
|
|
25
|
+
ModelNotFoundError,
|
|
26
|
+
MultimodalNotSupportedError,
|
|
27
|
+
ServerBusyError,
|
|
28
|
+
TruncationError,
|
|
29
|
+
UnsupportedCapabilityError,
|
|
30
|
+
VerificationError,
|
|
31
|
+
)
|
|
32
|
+
from .models import ModelStore
|
|
33
|
+
from .observability import (
|
|
34
|
+
configure_logging,
|
|
35
|
+
ensure_request_id,
|
|
36
|
+
get_request_id,
|
|
37
|
+
request_context,
|
|
38
|
+
)
|
|
39
|
+
from .results import Answer, Classification, Decision, Usage
|
|
40
|
+
from .schema import (
|
|
41
|
+
Box,
|
|
42
|
+
Choice,
|
|
43
|
+
Noul,
|
|
44
|
+
Score,
|
|
45
|
+
State,
|
|
46
|
+
box,
|
|
47
|
+
choice,
|
|
48
|
+
normalize_state,
|
|
49
|
+
noul,
|
|
50
|
+
score,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
# errors
|
|
55
|
+
"AdapterError",
|
|
56
|
+
"AdapterNotAvailableError",
|
|
57
|
+
"AdapterNotImplementedError",
|
|
58
|
+
# results
|
|
59
|
+
"Answer",
|
|
60
|
+
"BackendFailureError",
|
|
61
|
+
# schema
|
|
62
|
+
"Box",
|
|
63
|
+
# catalog
|
|
64
|
+
"Capabilities",
|
|
65
|
+
"Choice",
|
|
66
|
+
"Classification",
|
|
67
|
+
"Decision",
|
|
68
|
+
# core
|
|
69
|
+
"DecisionModel",
|
|
70
|
+
"DecisionToolkitError",
|
|
71
|
+
"InvalidQuestionError",
|
|
72
|
+
"InvalidRequestError",
|
|
73
|
+
"ModelInfo",
|
|
74
|
+
"ModelNotDownloadedError",
|
|
75
|
+
"ModelNotFoundError",
|
|
76
|
+
"ModelStore",
|
|
77
|
+
"MultimodalNotSupportedError",
|
|
78
|
+
"Noul",
|
|
79
|
+
"Score",
|
|
80
|
+
"ServerBusyError",
|
|
81
|
+
"State",
|
|
82
|
+
"TruncationError",
|
|
83
|
+
"UnsupportedCapabilityError",
|
|
84
|
+
"Usage",
|
|
85
|
+
"VerificationError",
|
|
86
|
+
"__version__",
|
|
87
|
+
"box",
|
|
88
|
+
# calibration
|
|
89
|
+
"brier_score",
|
|
90
|
+
"choice",
|
|
91
|
+
# observability
|
|
92
|
+
"configure_logging",
|
|
93
|
+
"ensure_request_id",
|
|
94
|
+
"expected_calibration_error",
|
|
95
|
+
"get_request_id",
|
|
96
|
+
"normalize_state",
|
|
97
|
+
"noul",
|
|
98
|
+
"register_adapter",
|
|
99
|
+
"request_context",
|
|
100
|
+
"score",
|
|
101
|
+
"threshold_sweep",
|
|
102
|
+
]
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""`decision_toolkit.adapters` — the adapter registry.
|
|
2
|
+
|
|
3
|
+
An adapter is written **only** when a real `.onnx` export demands it, and it is
|
|
4
|
+
named after the model contract it implements (not after a brand or a runtime).
|
|
5
|
+
Adding a model with an existing contract means adding a catalog variant, not an
|
|
6
|
+
adapter.
|
|
7
|
+
|
|
8
|
+
Third-party adapters can be registered in-process with
|
|
9
|
+
:func:`register_adapter` or discovered through the ``decision_toolkit.adapters``
|
|
10
|
+
entry-point group. A custom adapter may declare ``required_roles`` (an
|
|
11
|
+
iterable of file roles) so the core can validate a local/custom model before
|
|
12
|
+
loading it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from ..errors import AdapterNotAvailableError, AdapterNotImplementedError
|
|
21
|
+
from .base import Adapter
|
|
22
|
+
from .gliner2 import Gliner2Adapter
|
|
23
|
+
from .gliner2_decide import Gliner2DecideAdapter
|
|
24
|
+
from .julia import JuliaAdapter
|
|
25
|
+
from .laya import LayaAdapter
|
|
26
|
+
from .opendecider import OpendeciderAdapter
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
ADAPTERS: dict[str, Any] = {
|
|
31
|
+
"gliner2": Gliner2Adapter,
|
|
32
|
+
"gliner2-decide": Gliner2DecideAdapter,
|
|
33
|
+
"julia": JuliaAdapter,
|
|
34
|
+
"laya": LayaAdapter,
|
|
35
|
+
"opendecider": OpendeciderAdapter,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#: Fixed role requirements for the adapters that ship with the toolkit.
|
|
39
|
+
#: ``gliner2`` has an alternative layout (one fused ``model`` or split
|
|
40
|
+
#: ``encoder`` + ``classifier``), handled separately below.
|
|
41
|
+
_REQUIRED_ROLES: dict[str, frozenset[str]] = {
|
|
42
|
+
"gliner2-decide": frozenset({"model", "tokenizer"}),
|
|
43
|
+
"julia": frozenset({"model", "tokenizer"}),
|
|
44
|
+
"laya": frozenset({"encoder", "head", "tokenizer", "config"}),
|
|
45
|
+
"opendecider": frozenset({"model", "tokenizer", "config"}),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_ENTRY_POINTS_LOADED = False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def register_adapter(name: str, factory: Any, *, replace: bool = False) -> None:
|
|
52
|
+
"""Register an adapter factory under ``name``.
|
|
53
|
+
|
|
54
|
+
``factory`` is called with the adapter options as keyword arguments and must
|
|
55
|
+
return an object implementing :class:`~decision_toolkit.adapters.base.Adapter`.
|
|
56
|
+
"""
|
|
57
|
+
if not isinstance(name, str) or not name.strip():
|
|
58
|
+
raise ValueError("adapter name must be a non-empty string")
|
|
59
|
+
if not callable(factory):
|
|
60
|
+
raise TypeError(f"adapter factory for {name!r} must be callable")
|
|
61
|
+
if name in ADAPTERS and not replace:
|
|
62
|
+
raise AdapterNotAvailableError(
|
|
63
|
+
f"adapter {name!r} is already registered; pass replace=True to override it"
|
|
64
|
+
)
|
|
65
|
+
ADAPTERS[name] = factory
|
|
66
|
+
logger.debug("registered adapter %s -> %r", name, factory)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def unregister_adapter(name: str) -> None:
|
|
70
|
+
"""Remove a registered adapter (mostly useful in tests)."""
|
|
71
|
+
ADAPTERS.pop(name, None)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _load_entry_points() -> None:
|
|
75
|
+
"""Discover third-party adapters once, lazily, without breaking imports."""
|
|
76
|
+
global _ENTRY_POINTS_LOADED
|
|
77
|
+
if _ENTRY_POINTS_LOADED:
|
|
78
|
+
return
|
|
79
|
+
_ENTRY_POINTS_LOADED = True
|
|
80
|
+
try:
|
|
81
|
+
from importlib.metadata import entry_points
|
|
82
|
+
except ImportError: # pragma: no cover - Python < 3.10
|
|
83
|
+
return
|
|
84
|
+
try:
|
|
85
|
+
discovered = entry_points(group="decision_toolkit.adapters")
|
|
86
|
+
except Exception: # noqa: BLE001 - defensive for odd metadata backends # pragma: no cover
|
|
87
|
+
return
|
|
88
|
+
for entry_point in discovered:
|
|
89
|
+
try:
|
|
90
|
+
register_adapter(entry_point.name, entry_point.load())
|
|
91
|
+
except Exception as error: # noqa: BLE001 - a bad plugin must not break the toolkit
|
|
92
|
+
logger.warning(
|
|
93
|
+
"could not load adapter entry point %s: %s", entry_point.name, error
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def create_adapter(name: str, **options: Any) -> Adapter:
|
|
98
|
+
"""Instantiate the adapter ``name``.
|
|
99
|
+
|
|
100
|
+
Raises :class:`AdapterNotImplementedError` when the adapter is catalogued but
|
|
101
|
+
not written yet (no real export has demanded it).
|
|
102
|
+
"""
|
|
103
|
+
_load_entry_points()
|
|
104
|
+
adapter_type = ADAPTERS.get(name)
|
|
105
|
+
if adapter_type is None:
|
|
106
|
+
raise AdapterNotImplementedError(
|
|
107
|
+
f"adapter {name!r} is not implemented yet; it is written when its real ONNX export "
|
|
108
|
+
"is inspected (no speculative adapters)"
|
|
109
|
+
)
|
|
110
|
+
return adapter_type(**options)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def required_roles(name: str, files: Any = None) -> frozenset[str]:
|
|
114
|
+
"""The file roles an adapter needs, for early custom-model validation."""
|
|
115
|
+
factory = ADAPTERS.get(name)
|
|
116
|
+
declared = getattr(factory, "required_roles", None)
|
|
117
|
+
if declared is not None:
|
|
118
|
+
return frozenset(str(role) for role in declared)
|
|
119
|
+
return _REQUIRED_ROLES.get(name, frozenset())
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def validate_adapter_files(name: str, files: Any) -> None:
|
|
123
|
+
"""Validate a role -> path mapping against the adapter's contract.
|
|
124
|
+
|
|
125
|
+
Only role presence and basic shape are checked here; the engine later checks
|
|
126
|
+
that each path exists and opens. For ``gliner2`` either a fused ``model``
|
|
127
|
+
file or the split ``encoder`` + ``classifier`` pair is accepted.
|
|
128
|
+
"""
|
|
129
|
+
if not isinstance(files, dict) or not files:
|
|
130
|
+
raise AdapterNotAvailableError(
|
|
131
|
+
f"adapter {name!r} needs a role -> path mapping (files=...)"
|
|
132
|
+
)
|
|
133
|
+
for role, path in files.items():
|
|
134
|
+
if not isinstance(role, str) or not role.strip():
|
|
135
|
+
raise AdapterNotAvailableError(f"adapter {name!r}: file roles must be non-empty strings")
|
|
136
|
+
if not isinstance(path, str) or not path.strip():
|
|
137
|
+
raise AdapterNotAvailableError(f"adapter {name!r}: file path for {role!r} is empty")
|
|
138
|
+
|
|
139
|
+
roles = set(files)
|
|
140
|
+
if name == "gliner2":
|
|
141
|
+
if "tokenizer" not in roles:
|
|
142
|
+
raise AdapterNotAvailableError("adapter 'gliner2' needs a 'tokenizer' file")
|
|
143
|
+
if "model" not in roles and not {"encoder", "classifier"} <= roles:
|
|
144
|
+
raise AdapterNotAvailableError(
|
|
145
|
+
"adapter 'gliner2' needs either a fused 'model' file or 'encoder' + "
|
|
146
|
+
"'classifier' files"
|
|
147
|
+
)
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
missing = required_roles(name, files) - roles
|
|
151
|
+
if missing:
|
|
152
|
+
raise AdapterNotAvailableError(
|
|
153
|
+
f"adapter {name!r} needs file roles {sorted(missing)}; got {sorted(roles)}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
__all__ = [
|
|
158
|
+
"ADAPTERS",
|
|
159
|
+
"Adapter",
|
|
160
|
+
"create_adapter",
|
|
161
|
+
"register_adapter",
|
|
162
|
+
"required_roles",
|
|
163
|
+
"unregister_adapter",
|
|
164
|
+
"validate_adapter_files",
|
|
165
|
+
]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""`decision_toolkit.adapters.base` — the adapter contract and shared helpers.
|
|
2
|
+
|
|
3
|
+
An adapter is the only place that knows a model's **usage contract**: how to
|
|
4
|
+
build its input from a System One state/questions and how to read its output
|
|
5
|
+
back into probabilities. Several models can share an adapter when they share
|
|
6
|
+
that contract (e.g. the GLiNER2.5 family); a model with a different layout
|
|
7
|
+
gets its own adapter. No adapter is written without a real `.onnx` behind it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
from collections.abc import Mapping, Sequence
|
|
15
|
+
from typing import Any, Protocol, runtime_checkable
|
|
16
|
+
|
|
17
|
+
from ..engine import OnnxEngine
|
|
18
|
+
from ..errors import AdapterError, InvalidQuestionError
|
|
19
|
+
from ..schema import State
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@runtime_checkable
|
|
23
|
+
class Adapter(Protocol):
|
|
24
|
+
"""What :class:`~decision_toolkit.decision_model.DecisionModel` needs."""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
|
|
28
|
+
def system_one(
|
|
29
|
+
self, engine: OnnxEngine, state: Any, questions: Mapping[str, Mapping[str, Any]]
|
|
30
|
+
) -> Mapping[str, Mapping[str, Any]]: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def state_to_text(state: Any) -> str:
|
|
34
|
+
"""Render a System One ``state`` as text for text-based model families."""
|
|
35
|
+
if isinstance(state, State):
|
|
36
|
+
return state.text
|
|
37
|
+
if isinstance(state, str):
|
|
38
|
+
return state
|
|
39
|
+
if isinstance(state, (Mapping, list, tuple)):
|
|
40
|
+
return json.dumps(state, ensure_ascii=False, sort_keys=True)
|
|
41
|
+
if state is None:
|
|
42
|
+
raise AdapterError("state cannot be None")
|
|
43
|
+
return str(state)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def softmax(scores: Sequence[float]) -> list[float]:
|
|
47
|
+
"""Numerically stable softmax over raw scores."""
|
|
48
|
+
if not scores:
|
|
49
|
+
raise AdapterError("softmax needs at least one score")
|
|
50
|
+
top = max(float(score) for score in scores)
|
|
51
|
+
exponentials = [math.exp(float(score) - top) for score in scores]
|
|
52
|
+
total = sum(exponentials)
|
|
53
|
+
return [value / total for value in exponentials]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def question_options(question: Mapping[str, Any]) -> tuple[str, list[str], list[str | None]]:
|
|
57
|
+
"""Extract ``(kind, labels, descriptions)`` from a System One question.
|
|
58
|
+
|
|
59
|
+
* ``choice``: labels and descriptions come from the question's ``criteria``.
|
|
60
|
+
* ``noul``: labels are ``["yes", "no"]``; ``criteria.true``/``criteria.false``
|
|
61
|
+
become their descriptions.
|
|
62
|
+
* ``score``: labels are the ordered levels; no descriptions.
|
|
63
|
+
"""
|
|
64
|
+
kind = str(question.get("type"))
|
|
65
|
+
if kind == "choice":
|
|
66
|
+
criteria = question.get("criteria")
|
|
67
|
+
if not isinstance(criteria, Mapping) or not criteria:
|
|
68
|
+
raise InvalidQuestionError("a choice needs criteria (option labels) for this adapter")
|
|
69
|
+
labels = [str(label) for label in criteria]
|
|
70
|
+
descriptions = [None if value is None else str(value) for value in criteria.values()]
|
|
71
|
+
return kind, labels, descriptions
|
|
72
|
+
if kind == "noul":
|
|
73
|
+
criteria = question.get("criteria") or {}
|
|
74
|
+
if not isinstance(criteria, Mapping):
|
|
75
|
+
criteria = {}
|
|
76
|
+
true_description = criteria.get("true")
|
|
77
|
+
false_description = criteria.get("false")
|
|
78
|
+
return kind, ["yes", "no"], [
|
|
79
|
+
None if true_description is None else str(true_description),
|
|
80
|
+
None if false_description is None else str(false_description),
|
|
81
|
+
]
|
|
82
|
+
if kind == "score":
|
|
83
|
+
levels = question.get("criteria")
|
|
84
|
+
if not isinstance(levels, Sequence) or isinstance(levels, (str, bytes)) or len(levels) < 2:
|
|
85
|
+
raise InvalidQuestionError("a score needs its ordered levels in 'criteria'")
|
|
86
|
+
labels = [str(level) for level in levels]
|
|
87
|
+
return kind, labels, [None] * len(labels)
|
|
88
|
+
raise InvalidQuestionError(f"unsupported question type {kind!r}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def build_choice_answer(labels: Sequence[str], probabilities: Sequence[float]) -> dict[str, Any]:
|
|
92
|
+
best = max(range(len(labels)), key=lambda index: probabilities[index])
|
|
93
|
+
return {
|
|
94
|
+
"type": "choice",
|
|
95
|
+
"choice": labels[best],
|
|
96
|
+
"probabilities": {label: float(p) for label, p in zip(labels, probabilities)},
|
|
97
|
+
"confidence": float(probabilities[best]),
|
|
98
|
+
"confidence_kind": "probability",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_noul_answer(probability_yes: float, probability_no: float) -> dict[str, Any]:
|
|
103
|
+
return {
|
|
104
|
+
"type": "noul",
|
|
105
|
+
"noul": float(probability_yes),
|
|
106
|
+
"probabilities": {"true": float(probability_yes), "false": float(probability_no)},
|
|
107
|
+
"confidence": float(max(probability_yes, probability_no)),
|
|
108
|
+
"confidence_kind": "probability",
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_score_answer(labels: Sequence[str], probabilities: Sequence[float]) -> dict[str, Any]:
|
|
113
|
+
expected = sum(index * probability for index, probability in enumerate(probabilities))
|
|
114
|
+
best = max(range(len(labels)), key=lambda index: probabilities[index])
|
|
115
|
+
return {
|
|
116
|
+
"type": "score",
|
|
117
|
+
"score": float(expected),
|
|
118
|
+
"level": int(best),
|
|
119
|
+
"legend": {str(index): label for index, label in enumerate(labels)},
|
|
120
|
+
"probabilities": {str(index): float(p) for index, p in enumerate(probabilities)},
|
|
121
|
+
"confidence": float(probabilities[best]),
|
|
122
|
+
"confidence_kind": "probability",
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
__all__ = [
|
|
127
|
+
"Adapter",
|
|
128
|
+
"build_choice_answer",
|
|
129
|
+
"build_noul_answer",
|
|
130
|
+
"build_score_answer",
|
|
131
|
+
"question_options",
|
|
132
|
+
"softmax",
|
|
133
|
+
"state_to_text",
|
|
134
|
+
]
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""`decision_toolkit.adapters.gliner2` — the GLiNER2.5 classifier layout.
|
|
2
|
+
|
|
3
|
+
Contract implemented by the GLiNER2.5 exports (split encoder + classifier):
|
|
4
|
+
|
|
5
|
+
schema = ( [P] <task> [DESCRIPTION] label: … ( [C] label … ) ) [SEP_TEXT] <words>
|
|
6
|
+
encoder(input_ids, attention_mask) -> hidden states
|
|
7
|
+
classifier(hidden_state at each [C]) -> one logit per option
|
|
8
|
+
|
|
9
|
+
Validated against the local mininvox export (15 routing cases, 9/15, 215 ms
|
|
10
|
+
on CPU). This adapter only runs models whose files follow that layout.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from ..engine import OnnxEngine, encode
|
|
21
|
+
from ..errors import AdapterError
|
|
22
|
+
from .base import (
|
|
23
|
+
build_choice_answer,
|
|
24
|
+
build_noul_answer,
|
|
25
|
+
build_score_answer,
|
|
26
|
+
question_options,
|
|
27
|
+
softmax,
|
|
28
|
+
state_to_text,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
#: Reference word pattern of the GLiNER2 schema (urls, emails, handles, words, punctuation).
|
|
34
|
+
WORD_PATTERN = re.compile(
|
|
35
|
+
r"(?:https?://[^\s]+|www\.[^\s]+)"
|
|
36
|
+
r"|[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}"
|
|
37
|
+
r"|@[a-z0-9_]+"
|
|
38
|
+
r"|[\w]+(?:[-_][\w]+)*"
|
|
39
|
+
r"|\S",
|
|
40
|
+
re.IGNORECASE | re.UNICODE,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Gliner2Adapter:
|
|
45
|
+
"""System One over a GLiNER2.5 classifier export (encoder + classifier)."""
|
|
46
|
+
|
|
47
|
+
name = "gliner2"
|
|
48
|
+
|
|
49
|
+
def __init__(self, *, task: str = "classify", use_descriptions: bool = True) -> None:
|
|
50
|
+
self._task = task
|
|
51
|
+
self._use_descriptions = bool(use_descriptions)
|
|
52
|
+
self._cache: dict[str, list[int]] = {}
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------- tokenizing
|
|
55
|
+
def _tokenize(self, tokenizer: Any, token: str) -> list[int]:
|
|
56
|
+
ids = self._cache.get(token)
|
|
57
|
+
if ids is None:
|
|
58
|
+
ids = encode(tokenizer, token)
|
|
59
|
+
self._cache[token] = ids
|
|
60
|
+
return ids
|
|
61
|
+
|
|
62
|
+
# --------------------------------------------------------------- inference
|
|
63
|
+
def _scores(
|
|
64
|
+
self,
|
|
65
|
+
engine: OnnxEngine,
|
|
66
|
+
text: str,
|
|
67
|
+
options: list[tuple[str, str | None]],
|
|
68
|
+
task: str,
|
|
69
|
+
) -> list[float]:
|
|
70
|
+
import numpy as np # only the real ONNX path needs arrays
|
|
71
|
+
|
|
72
|
+
text = text if text.endswith((".", "!", "?")) else text + "."
|
|
73
|
+
words = [match.group(0).lower() for match in WORD_PATTERN.finditer(text)]
|
|
74
|
+
tokenizer = engine.tokenizer
|
|
75
|
+
|
|
76
|
+
parent = task
|
|
77
|
+
if self._use_descriptions:
|
|
78
|
+
for label, description in options:
|
|
79
|
+
if description:
|
|
80
|
+
parent += f" [DESCRIPTION] {label}: {description}"
|
|
81
|
+
|
|
82
|
+
schema = ["(", "[P]", parent, "("]
|
|
83
|
+
for label, _ in options:
|
|
84
|
+
schema += ["[C]", label]
|
|
85
|
+
schema += [")", ")"]
|
|
86
|
+
|
|
87
|
+
input_ids: list[int] = []
|
|
88
|
+
positions: list[int] = []
|
|
89
|
+
for token in schema:
|
|
90
|
+
if token == "[C]":
|
|
91
|
+
positions.append(len(input_ids))
|
|
92
|
+
input_ids.extend(self._tokenize(tokenizer, token))
|
|
93
|
+
input_ids.extend(self._tokenize(tokenizer, "[SEP_TEXT]"))
|
|
94
|
+
for word in words:
|
|
95
|
+
input_ids.extend(self._tokenize(tokenizer, word))
|
|
96
|
+
|
|
97
|
+
ids = np.asarray([input_ids], dtype=np.int64)
|
|
98
|
+
attention = np.ones_like(ids)
|
|
99
|
+
|
|
100
|
+
# Fused export (e.g. onnx-community/GLiNER2.5-Decide-ONNX): one graph with
|
|
101
|
+
# marker_positions and one logit per marker. Split export (e.g. the multi-v1
|
|
102
|
+
# repo): encoder + classifier, with the classifier input name read from the graph.
|
|
103
|
+
if "model" in engine.files:
|
|
104
|
+
session = engine.session("model")
|
|
105
|
+
input_names = {item.name for item in session.get_inputs()}
|
|
106
|
+
feeds = {"input_ids": ids, "attention_mask": attention}
|
|
107
|
+
if "marker_positions" in input_names:
|
|
108
|
+
feeds["marker_positions"] = np.asarray([positions], dtype=np.int64)
|
|
109
|
+
logits = session.run(None, feeds)[0]
|
|
110
|
+
return [float(value) for value in np.asarray(logits)[0][: len(options)]]
|
|
111
|
+
|
|
112
|
+
hidden = engine.session("encoder").run(
|
|
113
|
+
None, {"input_ids": ids, "attention_mask": attention}
|
|
114
|
+
)[0]
|
|
115
|
+
queries = hidden[0][positions].astype(np.float32)
|
|
116
|
+
classifier = engine.session("classifier")
|
|
117
|
+
input_name = classifier.get_inputs()[0].name
|
|
118
|
+
logits = classifier.run(None, {input_name: queries})[0].reshape(-1)
|
|
119
|
+
return [float(value) for value in logits]
|
|
120
|
+
|
|
121
|
+
def system_one(
|
|
122
|
+
self, engine: OnnxEngine, state: Any, questions: Mapping[str, Mapping[str, Any]]
|
|
123
|
+
) -> Mapping[str, Mapping[str, Any]]:
|
|
124
|
+
state_text = state_to_text(state)
|
|
125
|
+
answers: dict[str, dict[str, Any]] = {}
|
|
126
|
+
for name, question in questions.items():
|
|
127
|
+
kind, labels, descriptions = question_options(question)
|
|
128
|
+
task = str(question.get("task") or self._task)
|
|
129
|
+
scores = self._scores(engine, state_text, list(zip(labels, descriptions)), task)
|
|
130
|
+
if len(scores) != len(labels):
|
|
131
|
+
raise AdapterError(
|
|
132
|
+
f"the classifier returned {len(scores)} scores for {len(labels)} options"
|
|
133
|
+
)
|
|
134
|
+
probabilities = softmax(scores)
|
|
135
|
+
if kind == "choice":
|
|
136
|
+
answers[name] = build_choice_answer(labels, probabilities)
|
|
137
|
+
elif kind == "noul":
|
|
138
|
+
answers[name] = build_noul_answer(probabilities[0], probabilities[1])
|
|
139
|
+
else:
|
|
140
|
+
answers[name] = build_score_answer(labels, probabilities)
|
|
141
|
+
return answers
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
__all__ = ["Gliner2Adapter"]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""`decision_toolkit.adapters.gliner2_decide` — the fused GLiNER2.5-Decide export.
|
|
2
|
+
|
|
3
|
+
The fused ONNX export (`onnx-community/GLiNER2.5-Decide-ONNX`) differs from the
|
|
4
|
+
split multi-v1 layout: it expects the GLiNER2 classification sequence with
|
|
5
|
+
``[L]`` label markers and a ``[SEP_STRUCT]`` structural separator, and returns
|
|
6
|
+
one logit per marker in a single graph:
|
|
7
|
+
|
|
8
|
+
( [P] prompt [DESCRIPTION] … ( [L] label … ) ) [SEP_STRUCT] [SEP_TEXT] words
|
|
9
|
+
inputs: input_ids, attention_mask, marker_positions
|
|
10
|
+
output: logits [batch, markers] → softmax per question
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ..engine import OnnxEngine, encode
|
|
20
|
+
from ..errors import AdapterError
|
|
21
|
+
from .base import (
|
|
22
|
+
build_choice_answer,
|
|
23
|
+
build_noul_answer,
|
|
24
|
+
build_score_answer,
|
|
25
|
+
question_options,
|
|
26
|
+
softmax,
|
|
27
|
+
state_to_text,
|
|
28
|
+
)
|
|
29
|
+
from .gliner2 import WORD_PATTERN
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Gliner2DecideAdapter:
|
|
35
|
+
"""System One over the fused GLiNER2.5-Decide ONNX export."""
|
|
36
|
+
|
|
37
|
+
name = "gliner2-decide"
|
|
38
|
+
|
|
39
|
+
def __init__(self, *, use_descriptions: bool = True) -> None:
|
|
40
|
+
self._use_descriptions = bool(use_descriptions)
|
|
41
|
+
self._cache: dict[str, list[int]] = {}
|
|
42
|
+
|
|
43
|
+
def _tokenize(self, tokenizer: Any, token: str) -> list[int]:
|
|
44
|
+
ids = self._cache.get(token)
|
|
45
|
+
if ids is None:
|
|
46
|
+
ids = encode(tokenizer, token)
|
|
47
|
+
self._cache[token] = ids
|
|
48
|
+
return ids
|
|
49
|
+
|
|
50
|
+
def _scores(
|
|
51
|
+
self,
|
|
52
|
+
engine: OnnxEngine,
|
|
53
|
+
text: str,
|
|
54
|
+
prompt: str,
|
|
55
|
+
options: list[tuple[str, str | None]],
|
|
56
|
+
) -> list[float]:
|
|
57
|
+
import numpy as np
|
|
58
|
+
|
|
59
|
+
tokenizer = engine.tokenizer
|
|
60
|
+
text = text if text.endswith((".", "!", "?")) else text + "."
|
|
61
|
+
words = [match.group(0).lower() for match in WORD_PATTERN.finditer(text)]
|
|
62
|
+
|
|
63
|
+
parent = prompt
|
|
64
|
+
if self._use_descriptions:
|
|
65
|
+
for label, description in options:
|
|
66
|
+
if description:
|
|
67
|
+
parent += f" [DESCRIPTION] {label}: {description}"
|
|
68
|
+
|
|
69
|
+
schema = ["(", "[P]", parent, "("]
|
|
70
|
+
for label, _ in options:
|
|
71
|
+
schema += ["[L]", label]
|
|
72
|
+
schema += [")", ")", "[SEP_STRUCT]", "[SEP_TEXT]"]
|
|
73
|
+
|
|
74
|
+
input_ids: list[int] = []
|
|
75
|
+
positions: list[int] = []
|
|
76
|
+
for token in schema:
|
|
77
|
+
if token == "[L]":
|
|
78
|
+
positions.append(len(input_ids))
|
|
79
|
+
input_ids.extend(self._tokenize(tokenizer, token))
|
|
80
|
+
for word in words:
|
|
81
|
+
input_ids.extend(self._tokenize(tokenizer, word))
|
|
82
|
+
|
|
83
|
+
ids = np.asarray([input_ids], dtype=np.int64)
|
|
84
|
+
logits = engine.session("model").run(None, {
|
|
85
|
+
"input_ids": ids,
|
|
86
|
+
"attention_mask": np.ones_like(ids),
|
|
87
|
+
"marker_positions": np.asarray([positions], dtype=np.int64),
|
|
88
|
+
})[0][0]
|
|
89
|
+
return [float(value) for value in np.asarray(logits)[: len(positions)]]
|
|
90
|
+
|
|
91
|
+
def system_one(
|
|
92
|
+
self, engine: OnnxEngine, state: Any, questions: Mapping[str, Mapping[str, Any]]
|
|
93
|
+
) -> Mapping[str, Mapping[str, Any]]:
|
|
94
|
+
state_text = state_to_text(state)
|
|
95
|
+
answers: dict[str, dict[str, Any]] = {}
|
|
96
|
+
for name, question in questions.items():
|
|
97
|
+
kind, labels, descriptions = question_options(question)
|
|
98
|
+
prompt = str(question.get("instructions") or "classify")
|
|
99
|
+
scores = self._scores(
|
|
100
|
+
engine, state_text, prompt, list(zip(labels, descriptions))
|
|
101
|
+
)
|
|
102
|
+
if len(scores) != len(labels):
|
|
103
|
+
raise AdapterError(
|
|
104
|
+
f"{name!r}: the graph returned {len(scores)} logits for {len(labels)} labels"
|
|
105
|
+
)
|
|
106
|
+
probabilities = softmax(scores)
|
|
107
|
+
if kind == "choice":
|
|
108
|
+
answers[name] = build_choice_answer(labels, probabilities)
|
|
109
|
+
elif kind == "noul":
|
|
110
|
+
# question_options maps noul to labels ["yes", "no"]
|
|
111
|
+
answers[name] = build_noul_answer(probabilities[0], probabilities[1])
|
|
112
|
+
else:
|
|
113
|
+
answers[name] = build_score_answer(labels, probabilities)
|
|
114
|
+
return answers
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
__all__ = ["Gliner2DecideAdapter"]
|