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.
- detectorproof/__init__.py +101 -0
- detectorproof/cli.py +153 -0
- detectorproof/domain.py +314 -0
- detectorproof/panel.py +434 -0
- detectorproof/report.py +102 -0
- detectorproof/stats.py +92 -0
- detectorproof-0.1.0.dist-info/METADATA +192 -0
- detectorproof-0.1.0.dist-info/RECORD +12 -0
- detectorproof-0.1.0.dist-info/WHEEL +5 -0
- detectorproof-0.1.0.dist-info/entry_points.txt +2 -0
- detectorproof-0.1.0.dist-info/licenses/LICENSE +21 -0
- detectorproof-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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())
|
detectorproof/domain.py
ADDED
|
@@ -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
|
+
]
|