detectorproof 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.
@@ -0,0 +1,101 @@
1
+ """detectorproof - what is a synthetic-speech detector's score reacting to?
2
+
3
+ This is **not** a synthetic-speech detector, and it must not become one. It is an
4
+ instrument for measuring detectors: what a declared panel of frozen checkpoints
5
+ can and cannot do on your material, and how stable those judgments are under
6
+ ordinary benign processing.
7
+
8
+ `panel` is what is built. It scores a declared set of frozen detectors over a
9
+ labelled control set, reports the area under the ROC curve for each, and refuses
10
+ to let an incompetent one contribute to anything downstream. It is the gate the
11
+ rest of the instrument depends on, which is why it exists first and alone.
12
+
13
+ Five refusals are enforced in code rather than described in documentation. Each
14
+ was bought by a specific measured failure:
15
+
16
+ a detector at or near chance is NOT COMPETENT ON THIS MATERIAL, reported in
17
+ full and excluded from every aggregate
18
+
19
+ the panel is declared before the run - an undeclared detector in the scores
20
+ is a fault, and a declared detector with no scores stays in the report as
21
+ NOT MEASURED
22
+
23
+ two scores for one detector and item are a fault, not something to average;
24
+ overlapping analysis windows are not independent samples
25
+
26
+ NOT MEASURED is a third state, never zero and never an absence of effect
27
+
28
+ a near miss at the edge of the chance band is recorded as a near miss, not
29
+ resolved by widening the band after seeing the number
30
+
31
+ There is deliberately no function that recommends a transformation to move a
32
+ score. In the work this package came out of, an intervention tuned against a
33
+ chosen detector statistic made the audio *more* separable, not less - one
34
+ detector's AUC moved from 0.576 to 0.763. The instrument reports; it does not
35
+ advise, and that refusal is the point rather than an omission.
36
+
37
+ No audio is read and no model is loaded. Everything here works on a score table
38
+ a study already has, with no dependencies, so it runs on a machine that will
39
+ never hold the corpus.
40
+ """
41
+ from .domain import (
42
+ BONAFIDE,
43
+ HIGHER_BONAFIDE,
44
+ HIGHER_SYNTHETIC,
45
+ NOT_MEASURED,
46
+ ORIENTATION_UNRESOLVED,
47
+ SYNTHETIC,
48
+ DomainError,
49
+ ScoreRow,
50
+ score_is_measured,
51
+ validate_row,
52
+ validate_rows,
53
+ )
54
+ from .panel import (
55
+ BAND_EDGE_MARGIN,
56
+ CHANCE_BAND,
57
+ COMPETENT,
58
+ NOT_COMPETENT,
59
+ NOT_MEASURED_VERDICT,
60
+ UNRESOLVED_ORIENTATION,
61
+ Detector,
62
+ DetectorResult,
63
+ PanelFault,
64
+ PanelReport,
65
+ run_panel,
66
+ )
67
+ from .report import render
68
+ from .stats import auc, holm, holm_adjusted, midranks
69
+
70
+ __version__ = "0.1.0"
71
+
72
+ __all__ = [
73
+ "BAND_EDGE_MARGIN",
74
+ "BONAFIDE",
75
+ "CHANCE_BAND",
76
+ "COMPETENT",
77
+ "Detector",
78
+ "DetectorResult",
79
+ "DomainError",
80
+ "HIGHER_BONAFIDE",
81
+ "HIGHER_SYNTHETIC",
82
+ "NOT_COMPETENT",
83
+ "NOT_MEASURED",
84
+ "NOT_MEASURED_VERDICT",
85
+ "ORIENTATION_UNRESOLVED",
86
+ "PanelFault",
87
+ "PanelReport",
88
+ "SYNTHETIC",
89
+ "ScoreRow",
90
+ "UNRESOLVED_ORIENTATION",
91
+ "__version__",
92
+ "auc",
93
+ "holm",
94
+ "holm_adjusted",
95
+ "midranks",
96
+ "render",
97
+ "run_panel",
98
+ "score_is_measured",
99
+ "validate_row",
100
+ "validate_rows",
101
+ ]
detectorproof/cli.py ADDED
@@ -0,0 +1,153 @@
1
+ """Command line: `detectorproof panel`.
2
+
3
+ One subcommand, because one thing is built. The instrument's own build order
4
+ puts `panel` first and says not to build all four at once: `panel` is the gate
5
+ the other three depend on, and it has to refuse correctly on a detector already
6
+ known to be incompetent before anything is built on top of it.
7
+
8
+ Input is three files a study already has:
9
+
10
+ --panel JSON: the declared detectors, fixed before the run
11
+ --scores CSV: detector,item,condition,score
12
+ --labels CSV: item,label (label is bonafide or synthetic)
13
+
14
+ No audio is read and no model is loaded. This runs on a machine that will never
15
+ hold the corpus.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import csv
21
+ import json
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ from .domain import DomainError
26
+ from .panel import CHANCE_BAND, Detector, PanelFault, run_panel
27
+ from .report import render
28
+
29
+
30
+ def _read_csv(path: Path) -> list[dict[str, str]]:
31
+ with path.open(newline="", encoding="utf-8-sig") as handle:
32
+ return list(csv.DictReader(handle))
33
+
34
+
35
+ def label_rows_of(path: Path) -> list[dict[str, str]]:
36
+ """Every label row, in file order, with duplicates preserved.
37
+
38
+ Deliberately returns rows rather than a mapping. A dict built here loses
39
+ duplicate items before anything can object to them, which is exactly how a
40
+ conflicting label became a silent last-write-wins.
41
+ """
42
+ return _read_csv(path)
43
+
44
+
45
+ def _load_panel(path: Path) -> list[Detector]:
46
+ text = path.read_text(encoding="utf-8")
47
+ try:
48
+ raw = json.loads(text)
49
+ except json.JSONDecodeError as exc:
50
+ # A malformed declaration is bad user input, not a bug in this package,
51
+ # so it takes the documented refusal path and exits 2 rather than
52
+ # escaping as a traceback and exiting 1. The position is reported
53
+ # because "invalid JSON" in a hand-written file is unactionable without
54
+ # it.
55
+ raise PanelFault(
56
+ f"the panel declaration at {path} is not valid JSON: {exc.msg} "
57
+ f"(line {exc.lineno}, column {exc.colno})."
58
+ ) from None
59
+ if isinstance(raw, dict):
60
+ raw = raw.get("detectors", raw)
61
+ if not isinstance(raw, list):
62
+ raise PanelFault(
63
+ "the panel declaration must be a JSON list of detectors, or an object with "
64
+ "a 'detectors' list. The declaration is the pre-registration of this run."
65
+ )
66
+ out = []
67
+ for entry in raw:
68
+ if not isinstance(entry, dict):
69
+ raise PanelFault("each declared detector must be a JSON object.")
70
+ out.append(
71
+ Detector(
72
+ name=entry.get("name", ""),
73
+ orientation=entry.get("orientation", ""),
74
+ identity=entry.get("identity", ""),
75
+ source=entry.get("source", ""),
76
+ )
77
+ )
78
+ return out
79
+
80
+
81
+ def main(argv: list[str] | None = None) -> int:
82
+ parser = argparse.ArgumentParser(
83
+ prog="detectorproof",
84
+ description=(
85
+ "Report what a declared panel of frozen detectors can and cannot do on "
86
+ "your control set. This is not a detector and it never recommends a "
87
+ "transformation."
88
+ ),
89
+ )
90
+ sub = parser.add_subparsers(dest="command", required=True)
91
+
92
+ p = sub.add_parser("panel", help="measure the detectors before trusting any of them")
93
+ p.add_argument("--panel", required=True, type=Path, help="JSON declaration of the panel")
94
+ p.add_argument("--scores", required=True, type=Path, help="CSV of detector scores")
95
+ p.add_argument("--labels", required=True, type=Path, help="CSV of item,label")
96
+ p.add_argument(
97
+ "--condition",
98
+ required=True,
99
+ help="which condition in the score table is the labelled control set",
100
+ )
101
+ p.add_argument(
102
+ "--chance-band",
103
+ nargs=2,
104
+ type=float,
105
+ metavar=("LOW", "HIGH"),
106
+ default=list(CHANCE_BAND),
107
+ help=(
108
+ "the AUC band inside which a detector is not competent on this material. "
109
+ "Declared before the run; not adjusted afterwards."
110
+ ),
111
+ )
112
+
113
+ args = parser.parse_args(argv)
114
+
115
+ try:
116
+ detectors = _load_panel(args.panel)
117
+ scores = _read_csv(args.scores)
118
+ # Passed as PAIRS, not collapsed into a dict. Building a dict here was
119
+ # half of the blocking defect: a duplicate item silently kept whichever
120
+ # row was read last, so reversing two conflicting rows could flip the
121
+ # verdict. `run_panel` canonicalises and refuses collisions itself.
122
+ label_pairs = [(row["item"], row["label"]) for row in label_rows_of(args.labels)]
123
+ report = run_panel(
124
+ detectors,
125
+ scores,
126
+ label_pairs,
127
+ condition=args.condition,
128
+ chance_band=(args.chance_band[0], args.chance_band[1]),
129
+ )
130
+ except (DomainError, PanelFault) as exc:
131
+ print(f"detectorproof: {exc}", file=sys.stderr)
132
+ return 2
133
+ except KeyError as exc:
134
+ print(
135
+ f"detectorproof: the labels file needs an 'item' and a 'label' column; "
136
+ f"missing {exc}",
137
+ file=sys.stderr,
138
+ )
139
+ return 2
140
+ except OSError as exc:
141
+ # A missing or unreadable input file is user input, not a defect here.
142
+ print(f"detectorproof: cannot read {exc.filename or 'an input file'}: {exc.strerror}",
143
+ file=sys.stderr)
144
+ return 2
145
+
146
+ print(render(report))
147
+ # A run that produced no competent detector is not a crash, but it is not a
148
+ # success either: nothing downstream may run on an empty competent set.
149
+ return 0 if report.competent_detectors() else 1
150
+
151
+
152
+ if __name__ == "__main__": # pragma: no cover
153
+ raise SystemExit(main())
@@ -0,0 +1,314 @@
1
+ """Declared domains for every field this package reads.
2
+
3
+ R19, and the reason this module exists at all:
4
+
5
+ A checker that decides whether input is acceptable must **declare what each
6
+ field may be** and test membership of that declaration. It must not carry a
7
+ list of the bad values someone has seen. The two look identical on the day
8
+ they are written and diverge immediately afterwards, because the list of bad
9
+ values is unbounded and the domain is not. A field with no declared domain
10
+ turns "is this present?" into the only question that can be asked of it, and
11
+ then `""`, `0`, `-1`, `[]`, `"NaN"` and `None` all arrive in turn, each one a
12
+ separate repair, none of them the cause.
13
+
14
+ So every field below names its type, its admissible values, and what it means
15
+ when it is absent. `validate_row` asks one question of each field - is this
16
+ value inside the declared domain - and every rejection carries the domain it
17
+ failed, not a description of the value.
18
+
19
+ The third state matters as much as the domain. A score is one of:
20
+
21
+ a finite real number the detector produced a value
22
+ NOT_MEASURED the detector was not run on this item
23
+ (absent) a fault - the row does not describe what it claims
24
+
25
+ `NOT_MEASURED` is never zero, never an empty string, and never silently
26
+ dropped. It propagates to the report as itself, because a panel that reports a
27
+ missing measurement as an absence of effect is worse than one that reports
28
+ nothing.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ from dataclasses import dataclass
33
+ from typing import Any, Iterable
34
+
35
+
36
+ # The sentinel. It is a singleton object rather than a string so that no CSV
37
+ # cell, however malformed, can ever be mistaken for it, and so that arithmetic
38
+ # on it raises instead of silently producing a number.
39
+ class _NotMeasured:
40
+ __slots__ = ()
41
+
42
+ def __repr__(self) -> str: # pragma: no cover - trivial
43
+ return "NOT_MEASURED"
44
+
45
+ def __bool__(self) -> bool:
46
+ raise TypeError(
47
+ "NOT_MEASURED has no truth value. A missing measurement is not "
48
+ "False, not zero and not absent - handle it explicitly."
49
+ )
50
+
51
+
52
+ NOT_MEASURED = _NotMeasured()
53
+
54
+ # The literal spellings accepted in a table for the sentinel. This is a parsing
55
+ # convenience, not a domain: the domain of `score` is "finite real, or the
56
+ # sentinel", and these strings are how the sentinel is written in a CSV.
57
+ NOT_MEASURED_SPELLINGS = frozenset({"not_measured", "not measured", "n/a", "na", "nm"})
58
+
59
+ # Identifiers are the join keys of the whole package: detector, item, condition
60
+ # and speaker. An identifier that differs only by surrounding whitespace or by
61
+ # case is the classic silent-mismatch fault, so the domain is deliberately
62
+ # narrow and normalisation happens once, here, at the boundary.
63
+ _ID_MAX = 200
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class Identifier:
68
+ """A non-empty label used as a join key.
69
+
70
+ Admissible: a string of 1..200 characters after stripping, containing no
71
+ control characters. Case and surrounding whitespace are normalised, because
72
+ `Pellav2 ` and `pellav2` naming the same detector in two rows is a fault
73
+ that produces two detectors and no error.
74
+ """
75
+
76
+ field: str
77
+
78
+ description = (
79
+ "non-empty text, at most 200 characters, no control characters; "
80
+ "compared case-insensitively after stripping"
81
+ )
82
+
83
+ def parse(self, value: Any) -> str:
84
+ if value is None:
85
+ raise DomainError(self.field, value, self.description, "absent")
86
+ if not isinstance(value, str):
87
+ raise DomainError(self.field, value, self.description, "not text")
88
+ text = value.strip()
89
+ if not text:
90
+ raise DomainError(self.field, value, self.description, "empty after stripping")
91
+ if len(text) > _ID_MAX:
92
+ raise DomainError(self.field, value, self.description, f"longer than {_ID_MAX}")
93
+ if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in text):
94
+ raise DomainError(self.field, value, self.description, "contains a control character")
95
+ return text
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class Score:
100
+ """A detector's native output for one item, or the explicit sentinel.
101
+
102
+ Admissible: any finite real number, on the detector's own scale, or
103
+ NOT_MEASURED. Deliberately unbounded - these are native units from thirteen
104
+ models on mutually incomparable scales, and a package that imposed a range
105
+ would be inventing one. Non-finite values are rejected rather than coerced:
106
+ an inf or a NaN in a score column is a fault upstream, and replacing it with
107
+ zero would put a fabricated measurement into an average.
108
+ """
109
+
110
+ field: str = "score"
111
+
112
+ description = (
113
+ "a finite real number in ASCII decimal notation, in the detector's native "
114
+ "units, or NOT_MEASURED"
115
+ )
116
+
117
+ def parse(self, value: Any) -> float | _NotMeasured:
118
+ if value is NOT_MEASURED:
119
+ return NOT_MEASURED
120
+ if value is None:
121
+ raise DomainError(self.field, value, self.description, "absent")
122
+ if isinstance(value, str):
123
+ text = value.strip()
124
+ if text.lower() in NOT_MEASURED_SPELLINGS:
125
+ return NOT_MEASURED
126
+ if not text:
127
+ raise DomainError(self.field, value, self.description, "empty")
128
+ # The domain is narrowed to ASCII decimal deliberately. Python's
129
+ # float() accepts any Unicode decimal digit, so "٤" (Arabic-Indic
130
+ # four) becomes 4.0 without complaint. A score column carrying digits
131
+ # from another numeral system is far more likely to be a corrupted
132
+ # export than a deliberate measurement, and a package that silently
133
+ # converts it has invented a number nobody wrote. Found by the
134
+ # property fuzz in tests/test_fuzz.py, not by an auditor.
135
+ if not text.isascii():
136
+ raise DomainError(
137
+ self.field, value, self.description, "not ASCII decimal notation"
138
+ )
139
+ try:
140
+ number = float(text)
141
+ except ValueError:
142
+ raise DomainError(self.field, value, self.description, "not a number") from None
143
+ elif isinstance(value, bool):
144
+ # bool is a subclass of int. A boolean in a score column means the
145
+ # upstream table has a label where a measurement should be.
146
+ raise DomainError(self.field, value, self.description, "boolean, not a measurement")
147
+ elif isinstance(value, (int, float)):
148
+ number = float(value)
149
+ else:
150
+ raise DomainError(self.field, value, self.description, "not a number")
151
+ if number != number: # NaN
152
+ raise DomainError(self.field, value, self.description, "NaN")
153
+ if number in (float("inf"), float("-inf")):
154
+ raise DomainError(self.field, value, self.description, "not finite")
155
+ return number
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class Enum:
160
+ """One of a declared, closed set of spellings."""
161
+
162
+ field: str
163
+ allowed: frozenset[str]
164
+
165
+ @property
166
+ def description(self) -> str:
167
+ return "one of: " + ", ".join(sorted(self.allowed))
168
+
169
+ def parse(self, value: Any) -> str:
170
+ if value is None:
171
+ raise DomainError(self.field, value, self.description, "absent")
172
+ if not isinstance(value, str):
173
+ raise DomainError(self.field, value, self.description, "not text")
174
+ text = value.strip().lower()
175
+ if text not in self.allowed:
176
+ raise DomainError(self.field, value, self.description, "outside the declared set")
177
+ return text
178
+
179
+
180
+ class DomainError(ValueError):
181
+ """A value outside its field's declared domain.
182
+
183
+ The message names the domain that was violated, never a catalogue of values
184
+ that have been seen before. That is the whole point of R19: the next bad
185
+ value is one nobody has written down yet, and it has to fail for the same
186
+ reason as the last one.
187
+ """
188
+
189
+ def __init__(self, field: str, value: Any, description: str, why: str) -> None:
190
+ self.field = field
191
+ self.value = value
192
+ self.description = description
193
+ self.why = why
194
+ shown = repr(value)
195
+ if len(shown) > 80:
196
+ shown = shown[:77] + "..."
197
+ super().__init__(
198
+ f"{field}={shown} is outside its declared domain ({why}). "
199
+ f"{field} must be: {description}."
200
+ )
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # Orientation: which direction of a detector's native score means "synthetic".
205
+ #
206
+ # This is the field the estate's own protocol is most emphatic about. It is
207
+ # resolved from a model card or a training-label path and never from whether it
208
+ # makes a benchmark look right, so the package treats it as declared input, not
209
+ # as something to infer. "unresolved" is a first-class value: a detector whose
210
+ # documentation does not settle the direction is scored, reported, and its SIGN
211
+ # is not interpreted.
212
+ # ---------------------------------------------------------------------------
213
+ HIGHER_BONAFIDE = "higher_bonafide"
214
+ HIGHER_SYNTHETIC = "higher_synthetic"
215
+ ORIENTATION_UNRESOLVED = "unresolved"
216
+
217
+ ORIENTATION = Enum(
218
+ "orientation",
219
+ frozenset({HIGHER_BONAFIDE, HIGHER_SYNTHETIC, ORIENTATION_UNRESOLVED}),
220
+ )
221
+
222
+ # Labels for the control set. `bonafide` is genuine human speech; `synthetic`
223
+ # is generated. Anything else in a label column is a fault: a control set with
224
+ # a third class is not the two-class problem AUC describes.
225
+ BONAFIDE = "bonafide"
226
+ SYNTHETIC = "synthetic"
227
+ LABEL = Enum("label", frozenset({BONAFIDE, SYNTHETIC}))
228
+
229
+ DETECTOR = Identifier("detector")
230
+ ITEM = Identifier("item")
231
+ CONDITION = Identifier("condition")
232
+ SCORE = Score()
233
+
234
+
235
+ @dataclass(frozen=True)
236
+ class ScoreRow:
237
+ """One detector's measurement of one item under one condition."""
238
+
239
+ detector: str
240
+ item: str
241
+ condition: str
242
+ score: float | _NotMeasured
243
+
244
+ @property
245
+ def measured(self) -> bool:
246
+ return score_is_measured(self.score)
247
+
248
+
249
+ def score_is_measured(value: float | _NotMeasured) -> bool:
250
+ """True when a score is a real measurement rather than the sentinel.
251
+
252
+ Written as a function rather than a truth test because `NOT_MEASURED`
253
+ deliberately raises on `bool()`, so `if row.score:` fails loudly instead of
254
+ quietly treating a missing measurement as zero.
255
+ """
256
+ return not isinstance(value, _NotMeasured)
257
+
258
+
259
+ def validate_row(row: dict[str, Any]) -> ScoreRow:
260
+ """Parse one score row against the declared domains.
261
+
262
+ Every field is checked against its own domain and the first violation is
263
+ raised with that domain attached. Unknown extra keys are ignored: a study's
264
+ table usually carries columns this package has no opinion about, and
265
+ rejecting them would make the package harder to adopt without making any
266
+ measurement safer.
267
+ """
268
+ return ScoreRow(
269
+ detector=DETECTOR.parse(row.get("detector")),
270
+ item=ITEM.parse(row.get("item")),
271
+ condition=CONDITION.parse(row.get("condition")),
272
+ score=SCORE.parse(row.get("score", None)),
273
+ )
274
+
275
+
276
+ def validate_rows(rows: Iterable[dict[str, Any]]) -> list[ScoreRow]:
277
+ """Parse a table, reporting the row index of the first fault.
278
+
279
+ Row numbers are 1-based and count the data rows a caller passed, not file
280
+ lines, because the caller may have read the table from anywhere.
281
+ """
282
+ out: list[ScoreRow] = []
283
+ for index, raw in enumerate(rows, start=1):
284
+ try:
285
+ out.append(validate_row(raw))
286
+ except DomainError as exc:
287
+ raise DomainError(
288
+ exc.field, exc.value, exc.description, f"row {index}: {exc.why}"
289
+ ) from None
290
+ return out
291
+
292
+
293
+ __all__ = [
294
+ "BONAFIDE",
295
+ "CONDITION",
296
+ "DETECTOR",
297
+ "DomainError",
298
+ "Enum",
299
+ "HIGHER_BONAFIDE",
300
+ "HIGHER_SYNTHETIC",
301
+ "ITEM",
302
+ "Identifier",
303
+ "LABEL",
304
+ "NOT_MEASURED",
305
+ "ORIENTATION",
306
+ "ORIENTATION_UNRESOLVED",
307
+ "SCORE",
308
+ "SYNTHETIC",
309
+ "Score",
310
+ "ScoreRow",
311
+ "score_is_measured",
312
+ "validate_row",
313
+ "validate_rows",
314
+ ]