spinf-client 0.3.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,27 @@
1
+ """spinf-client: the spinf scoring API from Python (scored queries, extraction slots, images, video frames, prompt
2
+ packs and content moderation)."""
3
+
4
+ from . import calibration, media, moderation, packs
5
+ from .client import SpinfClient, SpinfError, answers, billed_tokens, pack, pack_result, query, text
6
+ from .media import image, video, video_frames
7
+ from .moderation import Category, Moderation
8
+
9
+ __all__ = [
10
+ "Category",
11
+ "Moderation",
12
+ "SpinfClient",
13
+ "SpinfError",
14
+ "answers",
15
+ "billed_tokens",
16
+ "calibration",
17
+ "image",
18
+ "media",
19
+ "moderation",
20
+ "pack",
21
+ "pack_result",
22
+ "packs",
23
+ "query",
24
+ "text",
25
+ "video",
26
+ "video_frames",
27
+ ]
@@ -0,0 +1,234 @@
1
+ """A pack's answers -> reads -> category scores -> decision (standard library only, no GPU). The one evaluator: the hosted
2
+ API, spinf-client and the pack workbench import this module, so every evaluator gives the same answer, and the golden fixtures of
3
+ spinf-prompts (packs/<name>/fixtures/<version>.json) pin it within float rounding. Format: spinf-prompts docs/pack-format.md.
4
+
5
+ Per content:
6
+ answers the option probabilities per (query id, combination value F), renormalised over the query's options; from the
7
+ result's `p` or, when the pack says "answers": "logprob", from the full-precision logprobs
8
+ reads pair: sign * logit(p[0]); option: ln p[j] - mu; any: logsumexp(a[1:]) - a[0];
9
+ set: logsumexp(a[unsafe]) - logsumexp(a[rest]), with a = ln p - mu; then z = (value - mean) / sd
10
+ categories macro = the mean z of the category's reads ("views": the mean of the per-view means);
11
+ score = sigmoid(a * macro + b); percentile = the score in the normal-traffic quantiles; flagged = score >= threshold
12
+ (threshold null = reported only); a category with `requires` is flagged only while the categories it lists are
13
+ decision max: the highest score of the deciding categories >= one threshold; per_category: any deciding category flagged;
14
+ micro_layer: w . x + b >= threshold over category macros ("c:") and read z's ("r:"), only while the lock matches,
15
+ else the fallback mode; a deciding category has a threshold and not `decides: false`; then, in every mode, a
16
+ flagged category listed in decision.critical forces unsafe (not part of the lock: editable like the thresholds)
17
+ """
18
+
19
+ import base64
20
+ import bisect
21
+ import hashlib
22
+ import json
23
+ import math
24
+ import struct
25
+ from collections.abc import Callable
26
+ from typing import Any
27
+
28
+ CLIP = 1e-9
29
+ PAIR_CLIP = 1e-6
30
+ MODES = ("max", "per_category", "micro_layer")
31
+ LOCK_WARNING = "micro-layer lock mismatch: the pack was edited; using the fallback mode"
32
+
33
+ Answers = dict[tuple[str, str | None], list[float]]
34
+ Layer = tuple[Callable[[list[float]], float], float]
35
+
36
+
37
+ def answers_from_result(result: dict[str, Any] | list[dict[str, Any]], query_ids: set[str] | None = None, source: str = "p") -> Answers:
38
+ """One input's scoring result (or its `queries` list) -> {(query id, F value or None): [p per option]} for the pack's queries
39
+ (the caller's own queries can share the result). source = the pack's "answers": "p" (the API's p, rounded to 6 decimals, so a
40
+ rare option's p sits on a 1e-6 grid) or "logprob" (exp(logprob - logsumexp over the query's options), full precision)."""
41
+ out: Answers = {}
42
+ for q in result["queries"] if isinstance(result, dict) else result:
43
+ if query_ids is not None and q["id"] not in query_ids:
44
+ continue
45
+ for c in q["combinations"]:
46
+ if source == "logprob":
47
+ lp = [o["logprob"] for o in c["options"]]
48
+ lse = _logsumexp(lp)
49
+ ps = [math.exp(x - lse) for x in lp]
50
+ else:
51
+ ps = [o["p"] for o in c["options"]]
52
+ out[(q["id"], (c.get("values") or {}).get("F"))] = ps
53
+ return out
54
+
55
+
56
+ def _logsumexp(xs: list[float]) -> float:
57
+ m = max(xs)
58
+ return m + math.log(sum(math.exp(x - m) for x in xs))
59
+
60
+
61
+ def read_value(read: dict[str, Any], answers: Answers) -> float:
62
+ p = [max(x, CLIP) for x in answers[(read["query"], read.get("F"))]]
63
+ kind = read["kind"]
64
+ if kind == "pair":
65
+ pa = min(max(p[0] / (p[0] + p[1]), PAIR_CLIP), 1 - PAIR_CLIP)
66
+ return read.get("sign", 1) * math.log(pa / (1 - pa))
67
+ total = sum(p)
68
+ logs = [math.log(x / total) for x in p]
69
+ if kind == "option":
70
+ return logs[read["option"]] - read["mu"]
71
+ a = [x - mu for x, mu in zip(logs, read["mu"], strict=True)]
72
+ if kind == "any":
73
+ return _logsumexp(a[1:]) - a[0]
74
+ if kind == "set":
75
+ unsafe = set(read["unsafe"])
76
+ return _logsumexp([a[i] for i in sorted(unsafe)]) - _logsumexp([x for i, x in enumerate(a) if i not in unsafe])
77
+ raise ValueError(f"unknown read kind {kind!r}")
78
+
79
+
80
+ def read_z(pack: dict[str, Any], answers: Answers) -> dict[str, float]:
81
+ return {r["id"]: (read_value(r, answers) - r["mean"]) / r["sd"] for r in pack["reads"]}
82
+
83
+
84
+ def _mean(xs: list[float]) -> float:
85
+ return sum(xs) / len(xs)
86
+
87
+
88
+ def category_macros(pack: dict[str, Any], z: dict[str, float]) -> dict[str, float]:
89
+ by: dict[str, list[float]] = {}
90
+ views: dict[str, dict[str, list[float]]] = {}
91
+ for r in pack["reads"]:
92
+ by.setdefault(r["category"], []).append(z[r["id"]])
93
+ if r.get("view"):
94
+ views.setdefault(r["category"], {}).setdefault(r["view"], []).append(z[r["id"]])
95
+ return {
96
+ c: _mean([_mean(v) for v in views[c].values()]) if spec.get("aggregate") == "views" else _mean(by[c])
97
+ for c, spec in pack["categories"].items()
98
+ }
99
+
100
+
101
+ def _sigmoid(x: float) -> float:
102
+ return 1.0 / (1.0 + math.exp(-x)) if x >= 0 else math.exp(x) / (1.0 + math.exp(x))
103
+
104
+
105
+ def interp(x: float, xp: list[float], fp: list[float]) -> float:
106
+ """numpy.interp: piecewise linear, clamped at both ends (xp increasing)."""
107
+ if x <= xp[0]:
108
+ return fp[0]
109
+ if x >= xp[-1]:
110
+ return fp[-1]
111
+ i = bisect.bisect_right(xp, x) - 1
112
+ return fp[i] + (x - xp[i]) * (fp[i + 1] - fp[i]) / (xp[i + 1] - xp[i])
113
+
114
+
115
+ def percentile(spec: dict[str, Any], score: float) -> float | None:
116
+ q = spec.get("normal_quantiles")
117
+ return interp(score, q["values"], q["levels"]) if q else None
118
+
119
+
120
+ def lock_of(pack: dict[str, Any]) -> str:
121
+ """sha256 of the canonical JSON of what the micro-layer depends on (queries, reads, Platt maps, inputs)."""
122
+ ml = pack["decision"]["micro_layer"]
123
+ obj = {
124
+ "queries": pack["scoring"]["queries"],
125
+ "reads": pack["reads"],
126
+ "platt": {c: s["platt"] for c, s in pack["categories"].items()},
127
+ "inputs": ml["inputs"],
128
+ }
129
+ return "sha256:" + hashlib.sha256(json.dumps(obj, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
130
+
131
+
132
+ def micro_layer(pack: dict[str, Any]) -> Layer:
133
+ """The decoded layer: (x -> value, threshold). Weights (float32, base64): linear [w..., b, threshold]; with hidden units
134
+ [W1 (inputs x hidden), b1, w2, b2, threshold], tanh."""
135
+ ml = pack["decision"]["micro_layer"]
136
+ if ml.get("dtype", "float32") != "float32":
137
+ raise ValueError(f"micro-layer dtype {ml['dtype']!r}: only float32")
138
+ raw = base64.b64decode(ml["weights"])
139
+ w = list(struct.unpack(f"<{len(raw) // 4}f", raw))
140
+ n, h = len(ml["inputs"]), ml.get("hidden", 0)
141
+ if len(w) != micro_layer_size(n, h):
142
+ raise ValueError(f"micro-layer weights: {len(w)} floats for {n} inputs and {h} hidden units ({micro_layer_size(n, h)} expected)")
143
+ if h:
144
+ w1 = [w[i * h : (i + 1) * h] for i in range(n)]
145
+ b1, w2 = w[n * h : n * h + h], w[n * h + h : n * h + 2 * h]
146
+ b2, t = w[n * h + 2 * h], w[n * h + 2 * h + 1]
147
+
148
+ def hidden(x: list[float]) -> float:
149
+ units = [math.tanh(sum(x[i] * w1[i][k] for i in range(n)) + b1[k]) for k in range(h)]
150
+ return sum(u * v for u, v in zip(units, w2, strict=True)) + b2
151
+
152
+ return hidden, t
153
+ ww, b, t = w[:n], w[n], w[n + 1]
154
+ return (lambda x: sum(a * c for a, c in zip(x, ww, strict=True)) + b), t
155
+
156
+
157
+ def micro_layer_size(n_inputs: int, hidden: int) -> int:
158
+ """how many float32 the weights of a layer hold, the threshold included"""
159
+ return n_inputs * hidden + 2 * hidden + 2 if hidden else n_inputs + 2
160
+
161
+
162
+ def deciding(pack: dict[str, Any], limits: dict[str, float | None]) -> list[str]:
163
+ """the categories that can decide: a threshold (the pack's or the call's) and not `decides: false`"""
164
+ return [c for c, spec in pack["categories"].items() if limits[c] is not None and spec.get("decides", True)]
165
+
166
+
167
+ def evaluate(
168
+ pack: dict[str, Any],
169
+ answers: Answers,
170
+ mode: str | None = None,
171
+ threshold: float | None = None,
172
+ thresholds: dict[str, float | None] | None = None,
173
+ critical: list[str] | None = None,
174
+ lock_ok: bool | None = None,
175
+ layer: Layer | None = None,
176
+ ) -> dict[str, Any]:
177
+ """One content's answers -> {"categories", "decision", "macros"}.
178
+
179
+ mode: the decision mode (default: the pack's). threshold: the one threshold of max or micro_layer (default: the pack's).
180
+ thresholds: per-category overrides (None = reported only). critical: the categories that force unsafe when flagged (default:
181
+ the pack's decision.critical; [] = none). lock_ok / layer: precomputed by a loaded Pack, else computed here."""
182
+ z = read_z(pack, answers)
183
+ macros = category_macros(pack, z)
184
+ limits = {c: spec.get("threshold") for c, spec in pack["categories"].items()}
185
+ for c, t in (thresholds or {}).items():
186
+ if c not in limits:
187
+ raise KeyError(f"no category {c!r}")
188
+ limits[c] = t
189
+ cats: dict[str, dict[str, Any]] = {}
190
+ for c, spec in pack["categories"].items():
191
+ a, b = spec["platt"]
192
+ s = _sigmoid(a * macros[c] + b)
193
+ t = limits[c]
194
+ cats[c] = {"score": s, "percentile": percentile(spec, s), "flagged": None if t is None else s >= t}
195
+ for c, spec in pack["categories"].items(): # requires: flagged only while the required categories are
196
+ if cats[c]["flagged"] and spec.get("requires"):
197
+ cats[c]["flagged"] = all(cats[r]["flagged"] for r in spec["requires"])
198
+
199
+ dec = pack["decision"]
200
+ mode = mode or dec["mode"]
201
+ if mode not in MODES:
202
+ raise ValueError(f"mode must be one of {', '.join(MODES)}")
203
+ used = mode
204
+ if mode == "micro_layer":
205
+ ml = dec.get("micro_layer")
206
+ if lock_ok is None:
207
+ lock_ok = bool(ml) and ml.get("lock") == lock_of(pack)
208
+ if not lock_ok:
209
+ used = dec.get("fallback", "per_category")
210
+ decide = deciding(pack, limits)
211
+ out: dict[str, Any]
212
+ if used == "micro_layer":
213
+ f, t = layer or micro_layer(pack)
214
+ t = t if threshold is None else threshold
215
+ x = [macros[i[2:]] if i.startswith("c:") else z[i[2:]] for i in dec["micro_layer"]["inputs"]]
216
+ v = f(x)
217
+ out = {"unsafe": v >= t, "mode": used, "score": v, "threshold": t}
218
+ elif used == "max":
219
+ t = dec["max"]["threshold"] if threshold is None or used != mode else threshold
220
+ top = max(decide, key=lambda c: cats[c]["score"]) if decide else None
221
+ v = cats[top]["score"] if top else 0.0
222
+ out = {"unsafe": top is not None and v >= t, "mode": used, "score": v, "threshold": t, "category": top}
223
+ else:
224
+ hit = [c for c in decide if cats[c]["flagged"]]
225
+ out = {"unsafe": bool(hit), "mode": "per_category", "categories": hit}
226
+ crit = [c for c in (dec.get("critical", []) if critical is None else critical) if cats.get(c, {}).get("flagged")]
227
+ if crit:
228
+ out["critical"] = crit
229
+ if not out["unsafe"]:
230
+ out["unsafe"] = True
231
+ out["by"] = "critical"
232
+ if used != mode:
233
+ out["warning"] = LOCK_WARNING
234
+ return {"categories": cats, "decision": out, "macros": macros}
@@ -0,0 +1,70 @@
1
+ """Calibrating an extraction slot's probability on your own labelled examples.
2
+
3
+ The raw probability of an extracted answer ranks right answers above wrong ones, but it is under-confident by an amount
4
+ that depends on the field (part of the probability goes to other spellings and formats of the same answer). A two-number
5
+ correction fitted on ~50 labelled examples of the field makes it calibrated (spinf-serve's `docs/calibration.md`):
6
+
7
+ P(correct) = sigmoid(a * logit(p) + b)
8
+
9
+ a, b = fit(ps, rights) # ps: top-answer p per example; rights: was the top answer right?
10
+ query("amount", "\\nQ: ...\\nA:{$}", calibration=(a, b)) # the API then returns p_calibrated
11
+
12
+ With 50 labels the calibration error was 0.02-0.10 on every task tested. Without labels, DEFAULT_MAPS (fitted pooled
13
+ over tasks) is right on average but can be off by ~0.2 on one field, and over-corrects answers the model gives from
14
+ memory rather than from the content.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import math
20
+ from collections.abc import Sequence
21
+
22
+ # label-free fallback maps (a, b), pooled over brand / breed / SQuAD / NQ (research, 2026-09-28): the correction depends
23
+ # on the task, hardly on the model
24
+ DEFAULT_MAPS = {"spinf-12b": (0.76, 1.42), "spinf-31b": (0.71, 1.34)}
25
+
26
+
27
+ def _logit(p: float) -> float:
28
+ p = min(max(p, 1e-9), 1 - 1e-9)
29
+ return math.log(p / (1 - p))
30
+
31
+
32
+ def _sigmoid(z: float) -> float:
33
+ return 1 / (1 + math.exp(-z)) if z > -700 else 0.0
34
+
35
+
36
+ def apply(p: float, a: float, b: float) -> float:
37
+ return _sigmoid(a * _logit(p) + b)
38
+
39
+
40
+ def fit(
41
+ ps: Sequence[float], rights: Sequence[bool], l2: float = 1e-3, iters: int = 5000, lr: float = 0.2
42
+ ) -> tuple[float, float]:
43
+ """Logistic regression of rightness on logit(p): -> (a, b). A small pull toward a = 1 keeps tiny samples sane."""
44
+ if len(ps) != len(rights) or not ps:
45
+ raise ValueError("ps and rights must be non-empty and the same length")
46
+ x = [_logit(p) for p in ps]
47
+ y = [1.0 if r else 0.0 for r in rights]
48
+ n = len(x)
49
+ a, b = 1.0, 0.0
50
+ for _ in range(iters):
51
+ ga = gb = 0.0
52
+ for xi, yi in zip(x, y):
53
+ d = _sigmoid(a * xi + b) - yi
54
+ ga += d * xi
55
+ gb += d
56
+ a -= lr * (ga / n + l2 * (a - 1.0))
57
+ b -= lr * gb / n
58
+ return a, b
59
+
60
+
61
+ def ece(ps: Sequence[float], rights: Sequence[bool], bins: int = 10) -> float:
62
+ """Expected calibration error: the mean |confidence - accuracy| over equal-width bins, weighted by bin size."""
63
+ total = 0.0
64
+ for k in range(bins):
65
+ idx = [i for i, p in enumerate(ps) if min(int(p * bins), bins - 1) == k]
66
+ if idx:
67
+ conf = sum(ps[i] for i in idx) / len(idx)
68
+ acc = sum(1.0 if rights[i] else 0.0 for i in idx) / len(idx)
69
+ total += abs(conf - acc) * len(idx) / len(ps)
70
+ return total