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.
Files changed (36) hide show
  1. decision_toolkit/__init__.py +102 -0
  2. decision_toolkit/__main__.py +8 -0
  3. decision_toolkit/adapters/__init__.py +165 -0
  4. decision_toolkit/adapters/base.py +134 -0
  5. decision_toolkit/adapters/gliner2.py +144 -0
  6. decision_toolkit/adapters/gliner2_decide.py +117 -0
  7. decision_toolkit/adapters/julia.py +168 -0
  8. decision_toolkit/adapters/laya.py +225 -0
  9. decision_toolkit/adapters/opendecider.py +144 -0
  10. decision_toolkit/calibration.py +79 -0
  11. decision_toolkit/catalog.py +472 -0
  12. decision_toolkit/cli.py +1277 -0
  13. decision_toolkit/config.py +90 -0
  14. decision_toolkit/contracts/__init__.py +58 -0
  15. decision_toolkit/contracts/common.py +61 -0
  16. decision_toolkit/contracts/mapping.py +165 -0
  17. decision_toolkit/contracts/openai_decisions_beta.py +133 -0
  18. decision_toolkit/contracts/systemone_v1.py +140 -0
  19. decision_toolkit/decision_model.py +692 -0
  20. decision_toolkit/engine/__init__.py +111 -0
  21. decision_toolkit/errors.py +138 -0
  22. decision_toolkit/limits.py +114 -0
  23. decision_toolkit/metrics.py +198 -0
  24. decision_toolkit/models.py +588 -0
  25. decision_toolkit/observability.py +145 -0
  26. decision_toolkit/py.typed +0 -0
  27. decision_toolkit/registry.py +283 -0
  28. decision_toolkit/results.py +355 -0
  29. decision_toolkit/schema.py +516 -0
  30. decision_toolkit/server.py +930 -0
  31. decision_toolkit/tracing.py +44 -0
  32. decision_toolkit-0.1.0.dist-info/METADATA +306 -0
  33. decision_toolkit-0.1.0.dist-info/RECORD +36 -0
  34. decision_toolkit-0.1.0.dist-info/WHEEL +4 -0
  35. decision_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  36. 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,8 @@
1
+ """``python -m decision_toolkit`` is the same entry point as the ``dtk`` script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -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"]