humanus 0.33.1__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 (46) hide show
  1. humanus/__init__.py +45 -0
  2. humanus/__main__.py +8 -0
  3. humanus/_util.py +18 -0
  4. humanus/builtin_checks.py +303 -0
  5. humanus/calibration.py +131 -0
  6. humanus/check.py +209 -0
  7. humanus/classifier_lane.py +390 -0
  8. humanus/cli.py +80 -0
  9. humanus/contract_commands.py +127 -0
  10. humanus/contracts.py +84 -0
  11. humanus/data/SKILL.md +57 -0
  12. humanus/data/__init__.py +1 -0
  13. humanus/emoji.py +11 -0
  14. humanus/finding_ids.py +38 -0
  15. humanus/openai_responses.py +94 -0
  16. humanus/packs/NOTICE.md +56 -0
  17. humanus/packs/__init__.py +27 -0
  18. humanus/packs/ai-writing-tells.yml +1119 -0
  19. humanus/packs/asd-ste100.yml +179 -0
  20. humanus/packs/plain-language.yml +322 -0
  21. humanus/policy_pack.py +267 -0
  22. humanus/rules_lane.py +92 -0
  23. humanus/schemas/calibration-v1.json +46 -0
  24. humanus/schemas/check-request-v1.json +126 -0
  25. humanus/schemas/check-result-v1.json +273 -0
  26. humanus/schemas/draft-questions-request-v1.json +57 -0
  27. humanus/schemas/draft-questions-result-v1.json +33 -0
  28. humanus/schemas/feedback-labels-v1.json +47 -0
  29. humanus/schemas/finding-v1.json +105 -0
  30. humanus/schemas/policy-pack-v1.json +443 -0
  31. humanus/schemas/suggest-request-v1.json +79 -0
  32. humanus/schemas/suggest-result-v1.json +104 -0
  33. humanus/segmentation.py +390 -0
  34. humanus/skill.py +13 -0
  35. humanus/standard_checks.py +262 -0
  36. humanus/style_profile_import.py +175 -0
  37. humanus/suggest.py +421 -0
  38. humanus/usability_commands.py +38 -0
  39. humanus/usability_profile.py +256 -0
  40. humanus/usability_scan.py +1024 -0
  41. humanus-0.33.1.dist-info/METADATA +182 -0
  42. humanus-0.33.1.dist-info/RECORD +46 -0
  43. humanus-0.33.1.dist-info/WHEEL +5 -0
  44. humanus-0.33.1.dist-info/entry_points.txt +2 -0
  45. humanus-0.33.1.dist-info/licenses/LICENSE +21 -0
  46. humanus-0.33.1.dist-info/top_level.txt +1 -0
humanus/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """Humanus: a configurable editorial compliance engine.
2
+
3
+ A caller sends content and a policy pack; ``check`` returns where the content
4
+ breaks the pack's policies, ``suggest`` offers rewrites for a finding or any
5
+ selection, ``draft_questions`` helps write classifier detectors, and
6
+ ``calibrate`` learns from editors' decisions. The JSON Schemas under
7
+ ``humanus/schemas`` are the contract (``load_schema``).
8
+ """
9
+
10
+ __version__ = "0.33.1"
11
+
12
+ from .calibration import apply_calibration, calibrate
13
+ from .check import CheckRequestValidationError, check
14
+ from .contracts import ContractValidationError, load_schema, validate_payload
15
+ from .finding_ids import finding_id
16
+ from .packs import builtin_pack, list_builtin_packs
17
+ from .policy_pack import PolicyPack, PolicyPackValidationError, load_policy_pack
18
+ from .segmentation import SegmentedDocument, segment_document
19
+ from .style_profile_import import StyleProfileImportError, policy_pack_from_style_profile
20
+ from .suggest import SuggestRequestValidationError, SuggestUnavailableError, draft_questions, suggest
21
+
22
+ __all__ = [
23
+ "CheckRequestValidationError",
24
+ "ContractValidationError",
25
+ "PolicyPack",
26
+ "PolicyPackValidationError",
27
+ "SegmentedDocument",
28
+ "StyleProfileImportError",
29
+ "SuggestRequestValidationError",
30
+ "SuggestUnavailableError",
31
+ "__version__",
32
+ "apply_calibration",
33
+ "builtin_pack",
34
+ "calibrate",
35
+ "check",
36
+ "draft_questions",
37
+ "finding_id",
38
+ "list_builtin_packs",
39
+ "load_policy_pack",
40
+ "load_schema",
41
+ "policy_pack_from_style_profile",
42
+ "segment_document",
43
+ "suggest",
44
+ "validate_payload",
45
+ ]
humanus/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Run ``python -m humanus`` the same way as the ``humanus`` console script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
humanus/_util.py ADDED
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from typing import Any
6
+
7
+
8
+ def hash_stable(value: Any) -> str:
9
+ if isinstance(value, str):
10
+ payload = value
11
+ else:
12
+ payload = json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
13
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
14
+
15
+
16
+ def hash_short(value: Any) -> str:
17
+ return hash_stable(value)[:16]
18
+
@@ -0,0 +1,303 @@
1
+ """Built-in prose checks for the rules lane.
2
+
3
+ Each check reads the segmented document (prose only: code, quotes, directives
4
+ and the other protected regions never reach it) and yields matches as
5
+ (start, end, message, replacement) in document offsets.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import gzip
10
+ import re
11
+ from collections import defaultdict
12
+ from typing import Callable, Iterable
13
+
14
+ from .emoji import EMOJI_PATTERN
15
+ from .segmentation import Segment, SegmentedDocument, mask_inline, normalize_text
16
+ from .standard_checks import STANDARD_CHECKS
17
+
18
+ Match = tuple[int, int, "str | None", "str | None"]
19
+ BuiltinCheck = Callable[[SegmentedDocument, list[Segment], list[Segment], dict], Iterable[Match]]
20
+
21
+ EMPTY_LEADIN_PATTERNS = (
22
+ "^In today's\\b",
23
+ '^It is important to note\\b',
24
+ '^When it comes to\\b',
25
+ '^At the end of the day\\b',
26
+ '^In conclusion\\b',
27
+ )
28
+ CRUTCH_WORDS = ('actually', 'really', 'very', 'basically', 'essentially', 'literally', 'simply', 'clearly', 'obviously')
29
+ CONTRACTION_PAIRS = (
30
+ ('that is', "that's"),
31
+ ('it is', "it's"),
32
+ ('there is', "there's"),
33
+ ('who is', "who's"),
34
+ ('what is', "what's"),
35
+ ('do not', "don't"),
36
+ ('does not', "doesn't"),
37
+ ('did not', "didn't"),
38
+ ('cannot', "can't"),
39
+ ('can not', "can't"),
40
+ ('will not', "won't"),
41
+ ('would not', "wouldn't"),
42
+ ('should not', "shouldn't"),
43
+ ('could not', "couldn't"),
44
+ ('is not', "isn't"),
45
+ ('are not', "aren't"),
46
+ ('was not', "wasn't"),
47
+ ('were not', "weren't"),
48
+ ('have not', "haven't"),
49
+ ('has not', "hasn't"),
50
+ ('had not', "hadn't"),
51
+ ('they are', "they're"),
52
+ ('we are', "we're"),
53
+ ('you are', "you're"),
54
+ ('i am', "I'm"),
55
+ ('let us', "let's"),
56
+ )
57
+ PHRASE_CERTAINTY = re.compile('\\b(everyone knows|undeniably|proven)\\b', re.IGNORECASE)
58
+ ALWAYS_NEVER = re.compile('\\b(always|never)\\b', re.IGNORECASE)
59
+ HYPHENATED_ALWAYS_NEVER = re.compile('\\b(always|never)-\\w+', re.IGNORECASE)
60
+ NEVER_AUXILIARY = re.compile('\\bnever\\s+(had|would|could|should|might|may|will|can)\\b', re.IGNORECASE)
61
+ CLEARLY_ASSERTIVE = re.compile('\\bclearly\\s+(shows|demonstrates|proves|will|is)\\b', re.IGNORECASE)
62
+ CITATION = re.compile('(https?://|\\[[0-9]+\\]|according to|\\([12][0-9]{3}\\)|\\b20[0-9]{2}\\b)', re.IGNORECASE)
63
+ LIST_SHAPED = re.compile('\\bFirst,\\s+.+\\bSecond,\\s+.+\\bThird,\\s+', re.IGNORECASE)
64
+ INTENSIFIER_VAGUE = re.compile('\\b(very|significantly|dramatically|truly|really)\\b.+\\b(transform|revolutionize|leverage)\\b', re.IGNORECASE)
65
+ IRREGULAR_PARTICIPLES = (
66
+ 'made', 'done', 'given', 'taken', 'held', 'kept', 'put', 'set', 'sent', 'shown', 'known', 'seen', 'written',
67
+ 'built', 'found', 'paid', 'told', 'brought', 'bought', 'caught', 'taught', 'thought', 'lost', 'begun', 'chosen',
68
+ 'driven', 'eaten', 'forgotten', 'gotten', 'hidden', 'broken', 'spoken', 'stolen', 'worn', 'torn', 'drawn',
69
+ 'grown', 'thrown', 'sold', 'shut', 'led', 'fed', 'laid', 'said', 'heard', 'meant', 'understood', 'struck',
70
+ 'hung', 'won', 'spent', 'bound',
71
+ )
72
+ PASSIVE = re.compile(
73
+ rf"\b(is|are|was|were|be|been|being)\s+(\w+ed|{'|'.join(IRREGULAR_PARTICIPLES)})\b", re.IGNORECASE
74
+ )
75
+ STAT_PERCENT = re.compile('\\d+(?:\\.\\d+)?%')
76
+ STAT_MULTIPLIER = re.compile('\\d+\\s*[×x]\\b|\\d+\\s+times\\b', re.IGNORECASE)
77
+ STAT_COUNT_UNIT = re.compile('\\b\\d{3,}\\s+(users|ms|seconds|minutes|hours|days|requests|tokens)\\b', re.IGNORECASE)
78
+ STICKER_NUMBER = re.compile('#\\d+\\b')
79
+ REFRAIN_MAX_WORDS = 12
80
+ TOKEN = re.compile(r"[A-Za-z0-9']+")
81
+
82
+ # Function-word inventory aligned with Ure (1971) / Halliday lexical-density practice.
83
+ FUNCTION_WORDS = frozenset(
84
+ """
85
+ a about above after again against all am an and any are as at be because been before being
86
+ below between both but by can could did do does doing down during each few for from further
87
+ had has have having he her here hers herself him himself his how i if in into is it its
88
+ itself just me more most my myself no nor not now of off on once only or other our ours
89
+ ourselves out over own same she should so some such than that the their theirs them
90
+ themselves then there these they this those through to too under until up very was we were
91
+ what when where which while who whom why will with would you your yours yourself yourselves
92
+ """.split()
93
+ )
94
+
95
+
96
+ # --- sentence predicates ----------------------------------------------------------
97
+
98
+
99
+ def has_unsupported_certainty(sentence: str) -> bool:
100
+ if PHRASE_CERTAINTY.search(sentence) or CLEARLY_ASSERTIVE.search(sentence):
101
+ return True
102
+ if not ALWAYS_NEVER.search(HYPHENATED_ALWAYS_NEVER.sub(" ", sentence)):
103
+ return False
104
+ if NEVER_AUXILIARY.search(sentence) or re.match(r"^Never\s+\w", sentence.strip()):
105
+ return False
106
+ return True
107
+
108
+
109
+ def has_statistical_claim(sentence: str) -> bool:
110
+ return bool(STAT_PERCENT.search(sentence) or STAT_MULTIPLIER.search(sentence) or STAT_COUNT_UNIT.search(sentence))
111
+
112
+
113
+ def _sentence_check(predicate: Callable[[Segment], bool], message: str) -> BuiltinCheck:
114
+ def run(document, paragraphs, sentences, params):
115
+ for sentence in sentences:
116
+ if predicate(sentence):
117
+ yield sentence.start, sentence.end, message, None
118
+
119
+ return run
120
+
121
+
122
+ def _empty_leadin(sentence: Segment) -> bool:
123
+ return any(re.search(pattern, sentence.text, re.IGNORECASE) for pattern in EMPTY_LEADIN_PATTERNS)
124
+
125
+
126
+ def _unsupported_certainty(sentence: Segment) -> bool:
127
+ return has_unsupported_certainty(sentence.text) and not CITATION.search(sentence.source)
128
+
129
+
130
+ def _missing_attribution(sentence: Segment) -> bool:
131
+ if not has_statistical_claim(sentence.text) or CITATION.search(sentence.source):
132
+ return False
133
+ return not (STICKER_NUMBER.search(sentence.text) and not STAT_PERCENT.search(sentence.text))
134
+
135
+
136
+ # --- paragraph and document checks --------------------------------------------------
137
+
138
+
139
+ def _list_shaped_prose(document, paragraphs, sentences, params):
140
+ for paragraph in paragraphs:
141
+ if LIST_SHAPED.search(paragraph.text):
142
+ yield paragraph.start, paragraph.end, "Paragraph reads like an inline list without substantive development.", None
143
+
144
+
145
+ def _uniform_cadence(document, paragraphs, sentences, params):
146
+ prose = [s for s in sentences if s.block == "paragraph"]
147
+ run_start = 0
148
+ while len(prose) >= 5 and run_start < len(prose):
149
+ anchor = len(prose[run_start].text.split())
150
+ run_end = run_start + 1
151
+ while run_end < len(prose) and abs(len(prose[run_end].text.split()) - anchor) <= 3:
152
+ run_end += 1
153
+ if run_end - run_start >= 5:
154
+ yield prose[run_start].start, prose[run_end - 1].end, "Five or more consecutive sentences share nearly identical length.", None
155
+ run_start = run_end
156
+ else:
157
+ run_start += 1
158
+
159
+
160
+ def _overused_words(document, paragraphs, sentences, params):
161
+ words = " ".join(p.text for p in paragraphs)
162
+ total = max(len(words.split()), 1)
163
+ threshold = max(3, round(total / 250))
164
+ for word in CRUTCH_WORDS:
165
+ pattern = re.compile(rf"\b{re.escape(word)}\b", re.IGNORECASE)
166
+ uses = len(pattern.findall(words))
167
+ if uses <= threshold:
168
+ continue
169
+ for paragraph in paragraphs:
170
+ first = pattern.search(mask_inline(paragraph.source))
171
+ if first:
172
+ yield (
173
+ paragraph.start + first.start(),
174
+ paragraph.start + first.end(),
175
+ f"'{word}' appears {uses} times across {total} words; cut most of them.",
176
+ None,
177
+ )
178
+ break
179
+
180
+
181
+ def _uncontracted_forms(document, paragraphs, sentences, params):
182
+ for paragraph in paragraphs:
183
+ masked = mask_inline(paragraph.source)
184
+ taken: list[tuple[int, int]] = []
185
+ for formal, contracted in CONTRACTION_PAIRS:
186
+ for match in re.finditer(rf"\b{re.escape(formal)}\b", masked, re.IGNORECASE):
187
+ start, end = match.span()
188
+ if any(start < b and end > a for a, b in taken):
189
+ continue
190
+ if formal == "that is" and masked[end : end + 1] == ",":
191
+ continue # the "i.e." sense; contracting it changes the meaning
192
+ matched = paragraph.source[start:end]
193
+ replacement = contracted[0].upper() + contracted[1:] if matched[0].isupper() else contracted
194
+ taken.append((start, end))
195
+ yield paragraph.start + start, paragraph.start + end, f"'{matched}' could contract to '{replacement}'.", replacement
196
+
197
+
198
+ def _emphasis_spans(source: str) -> list[tuple[int, int]]:
199
+ """Spans inside single *...* or _..._ emphasis (not ** bold): italic quotations and formulas."""
200
+ return [m.span(2) for m in re.finditer(r"(?<![*_\w])([*_])(?!\1)(.+?)(?<!\1)\1(?![*_\w])", source, re.S)]
201
+
202
+
203
+ def _redundancy(document, paragraphs, sentences, params):
204
+ """Sentences in different places that share a four-word phrase.
205
+
206
+ Italic passages, prose inside container directives (pull quotes repeat the
207
+ body on purpose) and short identical refrains are not redundancy.
208
+ """
209
+ shingles: dict[str, list[Segment]] = defaultdict(list)
210
+ for sentence in sentences:
211
+ if sentence.block == "heading" or sentence.container is not None:
212
+ continue
213
+ masked = mask_inline(sentence.source)
214
+ italic = _emphasis_spans(sentence.source)
215
+ tokens = [
216
+ (match.group().lower(), match.start(), match.end())
217
+ for match in TOKEN.finditer(masked)
218
+ if sentence.source[match.start() : match.end()] == match.group()
219
+ ]
220
+ seen: set[str] = set()
221
+ for index in range(len(tokens) - 3):
222
+ start, end = tokens[index][1], tokens[index + 3][2]
223
+ if any(a <= start and end <= b for a, b in italic):
224
+ continue
225
+ shingle = " ".join(token for token, _, _ in tokens[index : index + 4])
226
+ if shingle not in seen:
227
+ seen.add(shingle)
228
+ shingles[shingle].append(sentence)
229
+ flagged: set[str] = set()
230
+ for members in shingles.values():
231
+ unique = list({s.id: s for s in members}.values())
232
+ if len(unique) < 2:
233
+ continue
234
+ texts = {normalize_text(s.text).lower() for s in unique}
235
+ if len(texts) == 1 and len(next(iter(texts)).split()) <= REFRAIN_MAX_WORDS:
236
+ continue
237
+ for sentence in unique:
238
+ if sentence.id not in flagged:
239
+ flagged.add(sentence.id)
240
+ yield sentence.start, sentence.end, "Repeats a four-word phrase used elsewhere in the document.", None
241
+
242
+
243
+ def lexical_density(tokens: list[str]) -> float:
244
+ return sum(1 for token in tokens if token not in FUNCTION_WORDS) / len(tokens) if tokens else 0.0
245
+
246
+
247
+ def gzip_ratio(text: str) -> float:
248
+ encoded = text.encode("utf-8")
249
+ return len(gzip.compress(encoded)) / len(encoded) if encoded else 1.0
250
+
251
+
252
+ def _information_density(document, paragraphs, sentences, params):
253
+ prose = "\n\n".join(p.text for p in paragraphs)
254
+ tokens = [token.lower() for token in TOKEN.findall(prose)]
255
+ if len(tokens) < params.get("minWords", 400):
256
+ return
257
+ density = lexical_density(tokens)
258
+ minimum = params.get("minLexicalDensity", 0.45)
259
+ if density < minimum:
260
+ yield 0, len(document.text), f"Lexical density {density:.3f} is below {minimum:.3f}: many words, little content.", None
261
+ ratio = gzip_ratio(prose)
262
+ maximum = params.get("maxGzipRatio", 0.35)
263
+ if ratio <= maximum:
264
+ yield 0, len(document.text), f"The text compresses unusually well (gzip ratio {ratio:.3f} <= {maximum:.3f}): it repeats itself.", None
265
+
266
+
267
+ def _contrast_cap(document, paragraphs, sentences, params):
268
+ cap = params["max"]
269
+ matches = [
270
+ (p.start + m.start(), p.start + m.end())
271
+ for p in paragraphs
272
+ for m in re.finditer(r",\s*not\s+\w+", mask_inline(p.source), re.IGNORECASE)
273
+ ]
274
+ if len(matches) > cap:
275
+ for start, end in matches:
276
+ yield start, end, f"{len(matches)} 'X, not Y' contrasts in this document; the cap is {cap}.", None
277
+
278
+
279
+ def _no_emojis(document, paragraphs, sentences, params):
280
+ for paragraph in paragraphs:
281
+ for match in EMOJI_PATTERN.finditer(paragraph.source):
282
+ yield paragraph.start + match.start(), paragraph.start + match.end(), "Contains an emoji.", ""
283
+
284
+
285
+ BUILTIN_CHECKS: dict[str, BuiltinCheck] = {
286
+ "empty_leadin": _sentence_check(_empty_leadin, "Sentence opens with empty boilerplate instead of a concrete stake."),
287
+ "list_shaped_prose": _list_shaped_prose,
288
+ "vague_claim": _sentence_check(
289
+ lambda s: bool(INTENSIFIER_VAGUE.search(s.text)),
290
+ "Sentence pairs empty intensifiers with vague transformation language.",
291
+ ),
292
+ "unsupported_certainty": _sentence_check(_unsupported_certainty, "Sentence states certainty without attributable evidence."),
293
+ "uniform_cadence": _uniform_cadence,
294
+ "redundancy": _redundancy,
295
+ "passive_voice": _sentence_check(lambda s: bool(PASSIVE.search(s.text)), "Sentence uses passive voice."),
296
+ "missing_attribution": _sentence_check(_missing_attribution, "Numeric or statistical claim lacks attributable evidence."),
297
+ "information_density": _information_density,
298
+ "overused_words": _overused_words,
299
+ "uncontracted_forms": _uncontracted_forms,
300
+ "contrast_cap": _contrast_cap,
301
+ "no_emojis": _no_emojis,
302
+ **STANDARD_CHECKS,
303
+ }
humanus/calibration.py ADDED
@@ -0,0 +1,131 @@
1
+ """Turn editor decisions into per-detector probability calibration.
2
+
3
+ Raw decision-model probabilities are overconfident. Each accepted, fixed or
4
+ dismissed classifier finding is a label: the model's probability and whether
5
+ the editor agreed it was a violation. ``calibrate`` fits a monotone map per
6
+ detector, which the caller stores on the detector (``calibration``) so that
7
+ ``check`` thresholds on what editors actually mean.
8
+
9
+ Isotonic regression needs more labels than Platt scaling; with very few
10
+ labels no calibration is returned at all.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ from collections import defaultdict
16
+ from typing import Any, Iterable, Mapping, Sequence
17
+
18
+ from .contracts import ContractValidationError, validate_payload
19
+
20
+ MIN_LABELS = 10
21
+ MIN_ISOTONIC_LABELS = 50
22
+
23
+
24
+ class FeedbackLabelsValidationError(ContractValidationError):
25
+ """The payload is not a valid ``feedback-labels-v1`` document."""
26
+
27
+
28
+ def calibrate(payload: Mapping[str, Any]) -> dict[str, Any]:
29
+ """Fit a calibration for every detector that has labels (``calibration-v1``)."""
30
+ validate_payload("feedback-labels-v1", payload, FeedbackLabelsValidationError)
31
+ grouped: dict[tuple[str, str], list[Mapping[str, Any]]] = defaultdict(list)
32
+ for label in payload["labels"]:
33
+ grouped[(label["policyId"], label["detectorId"])].append(label)
34
+
35
+ detectors = []
36
+ for (policy_id, detector_id), labels in sorted(grouped.items()):
37
+ probabilities = [float(label["probability"]) for label in labels]
38
+ outcomes = [bool(label["violates"]) for label in labels]
39
+ entry: dict[str, Any] = {"policyId": policy_id, "detectorId": detector_id, "labels": len(labels)}
40
+ if len(labels) < MIN_LABELS or len(set(outcomes)) < 2:
41
+ entry["note"] = f"not enough labels to calibrate (need {MIN_LABELS}, with both outcomes)"
42
+ elif len(labels) < MIN_ISOTONIC_LABELS:
43
+ a, b = fit_platt(probabilities, outcomes)
44
+ entry["calibration"] = {"method": "platt", "a": round(a, 6), "b": round(b, 6), "labels": len(labels)}
45
+ else:
46
+ points = fit_isotonic(probabilities, outcomes)
47
+ entry["calibration"] = {"method": "isotonic", "points": points, "labels": len(labels)}
48
+ detectors.append(entry)
49
+ return {"schemaVersion": 1, "detectors": detectors}
50
+
51
+
52
+ def apply_calibration(calibration: Mapping[str, Any] | None, probability: float) -> float:
53
+ """Map a raw probability through a stored calibration (identity when there is none)."""
54
+ if not calibration:
55
+ return probability
56
+ if calibration["method"] == "platt":
57
+ return _sigmoid(calibration["a"] * _logit(probability) + calibration["b"])
58
+ points = calibration["points"]
59
+ if probability <= points[0][0]:
60
+ return float(points[0][1])
61
+ for (x0, y0), (x1, y1) in zip(points, points[1:]):
62
+ if probability <= x1:
63
+ return float(y0 if x1 == x0 else y0 + (y1 - y0) * (probability - x0) / (x1 - x0))
64
+ return float(points[-1][1])
65
+
66
+
67
+ def fit_isotonic(probabilities: Sequence[float], outcomes: Sequence[bool]) -> list[list[float]]:
68
+ """Pool-adjacent-violators; returns increasing (raw, calibrated) knots."""
69
+ pairs = sorted(zip(probabilities, (1.0 if y else 0.0 for y in outcomes)))
70
+ blocks: list[list[float]] = [] # [sum_x, sum_y, count]
71
+ for x, y in pairs:
72
+ blocks.append([x, y, 1.0])
73
+ while len(blocks) > 1 and blocks[-2][1] / blocks[-2][2] >= blocks[-1][1] / blocks[-1][2]:
74
+ x2, y2, n2 = blocks.pop()
75
+ blocks[-1][0] += x2
76
+ blocks[-1][1] += y2
77
+ blocks[-1][2] += n2
78
+ return [[round(sx / n, 6), round(sy / n, 6)] for sx, sy, n in blocks]
79
+
80
+
81
+ def fit_platt(probabilities: Sequence[float], outcomes: Sequence[bool], iterations: int = 100) -> tuple[float, float]:
82
+ """Logistic regression of the outcome on logit(p), by Newton's method."""
83
+ xs = [_logit(p) for p in probabilities]
84
+ ys = [1.0 if y else 0.0 for y in outcomes]
85
+ a, b = 1.0, 0.0
86
+ for _ in range(iterations):
87
+ grad_a = grad_b = h_aa = h_ab = h_bb = 0.0
88
+ for x, y in zip(xs, ys):
89
+ p = _sigmoid(a * x + b)
90
+ w = p * (1 - p) + 1e-9
91
+ grad_a += (p - y) * x
92
+ grad_b += p - y
93
+ h_aa += w * x * x
94
+ h_ab += w * x
95
+ h_bb += w
96
+ det = h_aa * h_bb - h_ab * h_ab
97
+ if abs(det) < 1e-12:
98
+ break
99
+ step_a = (h_bb * grad_a - h_ab * grad_b) / det
100
+ step_b = (h_aa * grad_b - h_ab * grad_a) / det
101
+ a, b = a - step_a, b - step_b
102
+ if abs(step_a) + abs(step_b) < 1e-9:
103
+ break
104
+ return a, b
105
+
106
+
107
+ def expected_calibration_error(probabilities: Iterable[float], outcomes: Iterable[bool], bins: int = 10) -> float:
108
+ """Average |confidence - accuracy| over equal-width probability bins, weighted by size."""
109
+ buckets: list[list[tuple[float, float]]] = [[] for _ in range(bins)]
110
+ total = 0
111
+ for p, y in zip(probabilities, outcomes):
112
+ buckets[min(int(p * bins), bins - 1)].append((p, 1.0 if y else 0.0))
113
+ total += 1
114
+ if not total:
115
+ return 0.0
116
+ error = 0.0
117
+ for bucket in buckets:
118
+ if bucket:
119
+ mean_p = sum(p for p, _ in bucket) / len(bucket)
120
+ mean_y = sum(y for _, y in bucket) / len(bucket)
121
+ error += len(bucket) / total * abs(mean_p - mean_y)
122
+ return error
123
+
124
+
125
+ def _logit(p: float) -> float:
126
+ p = min(max(p, 1e-6), 1 - 1e-6)
127
+ return math.log(p / (1 - p))
128
+
129
+
130
+ def _sigmoid(x: float) -> float:
131
+ return 1 / (1 + math.exp(-x)) if x >= 0 else math.exp(x) / (1 + math.exp(x))