ductus 0.0.2__tar.gz

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,24 @@
1
+ # wads CI — calls the reusable workflow hosted in i2mint/wads.
2
+ #
3
+ # All configuration comes from this repo's pyproject.toml [tool.wads.ci.*].
4
+ # To customize the workflow itself (rare), replace this file with the
5
+ # full inline template `wads/data/github_ci_uv.yml` from i2mint/wads.
6
+ #
7
+ # Permissions: the reusable workflow needs `contents: write` (version-bump
8
+ # push-back, gh-pages branch push) and `pages: write` (Pages REST config).
9
+ # Both default to read-only on personal-account callers, so they are granted
10
+ # explicitly here.
11
+ name: Continuous Integration
12
+ on: [push, pull_request]
13
+ jobs:
14
+ ci:
15
+ uses: i2mint/wads/.github/workflows/uv-ci.yml@master
16
+ permissions:
17
+ contents: write
18
+ pages: write
19
+ # Explicit pass-through (not `secrets: inherit`, which does not reliably
20
+ # propagate caller-repo secrets to a reusable workflow owned by a different
21
+ # account). ductus reads only the text it is given and makes no network calls, so
22
+ # PYPI_PASSWORD is the only secret it needs.
23
+ secrets:
24
+ PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ *.egg-info/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ .DS_Store
10
+ .claude/handoffs/
ductus-0.0.2/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thor Whalen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ductus-0.0.2/PKG-INFO ADDED
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.5
2
+ Name: ductus
3
+ Version: 0.0.2
4
+ Summary: Gauge which parts of a text read as machine-written, and why, with the evidence anchored to exact characters
5
+ Project-URL: Homepage, https://github.com/thorwhalen/ductus
6
+ Project-URL: Repository, https://github.com/thorwhalen/ductus
7
+ Project-URL: Issues, https://github.com/thorwhalen/ductus/issues
8
+ Author: Thor Whalen
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agent-skills,ai-detection,annotation,authorship,llm,machine-generated-text,mgt,provenance,stylometry,writing
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Text Processing :: Linguistic
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: cw<0.2,>=0.1.1
23
+ Requires-Dist: pyyaml>=6.0
24
+ Provides-Extra: api
25
+ Requires-Dist: httpx; extra == 'api'
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7.0; extra == 'dev'
28
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
29
+ Provides-Extra: local
30
+ Requires-Dist: torch; extra == 'local'
31
+ Requires-Dist: transformers; extra == 'local'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # ductus
35
+
36
+ Gauge which parts of a text read as machine-written, and say why — with every finding anchored to the exact characters that carry it.
37
+
38
+ ```python
39
+ from ductus import gauge
40
+
41
+ report = gauge(open("draft.md").read())
42
+ report.document.label # 'mixed-signals'
43
+ report.segments[3].lean # +0.62 (-1 human … +1 machine)
44
+ report.segments[3].signals[0].note
45
+ # "a colon introducing a three-part parallel enumeration -- a textbook assistant construction"
46
+ ```
47
+
48
+ ```bash
49
+ pip install ductus
50
+ ductus gauge draft.md # a readable diagnosis
51
+ ductus gauge draft.md --format html --out report.html # shaded, hover for the reason
52
+ ductus gauge draft.md --format json # for a program
53
+ ```
54
+
55
+ In palaeography the *ductus* is the characteristic manner and sequence of strokes by which a scribe's hand is recognised. This package looks for the equivalent in prose.
56
+
57
+ ## There is no percentage in this package, and that is the point
58
+
59
+ Every detector on the market emits a number like "87% AI". That number reads as a calibrated probability, is not one, and is how people get falsely accused. What you get here instead is three things you can argue with: a **lean** in [-1, +1], an **evidence strength**, and a coarse **label**. Behind each of them is a list of signals, each with a direction, a weight, the detector that produced it, a plain-language reason, and the exact span it came from.
60
+
61
+ Four things are true and belong in any report you make from this:
62
+
63
+ - **Heavily-edited human writing and model-assisted writing look the same.** Re-reading a hard message ten times sands off exactly the irregularity that marks it as human.
64
+ - **Detectors over-flag non-native English writers** — 61.3% false positives across seven commercial detectors on non-native TOEFL essays, against near-zero on native-speaker controls [1].
65
+ - **Register contamination is real**: people who read model output all day start writing like it, unassisted.
66
+ - **No detector survives a motivated adversary**, and that is a proven result rather than a gap in current tooling [2].
67
+
68
+ So this package describes *text*. It does not make claims about *people*, and it should not be used to.
69
+
70
+ ## What it looks at
71
+
72
+ Four detectors ship, all deterministic, all free, none needing a model or a key.
73
+
74
+ | Detector | Finds | Example |
75
+ |---|---|---|
76
+ | `tells` | Catalogue phrases models overuse, tiered by confidence | "delve", "it's important to note", "In conclusion," |
77
+ | `forensic` | Mechanical traces of how the text was produced | a hard line break mid-sentence, mixed straight-and-curly apostrophes, trailing whitespace, em-dash density |
78
+ | `rhetoric` | Sentence *shapes* a phrase list cannot see | "not X but Y", a colon introducing a three-part parallel enumeration, concession-then-pivot |
79
+ | `rhythm` | Burstiness — how much sentence length varies | metronomic paragraphs |
80
+
81
+ Note that several of these argue *for* a human. A detector that can only ever accuse is not a measuring instrument. In practice the mechanical signals are often the most decisive thing in a file, in either direction.
82
+
83
+ The deterministic pass finds phrases, artifacts and a few shapes. **It cannot find prose that is machine-written and bland** — for that a model has to read it, which is what the shipped agent skills are for.
84
+
85
+ ## Agent skills
86
+
87
+ The primary surface. Two skills and a subagent ship inside the package and install with one command:
88
+
89
+ ```bash
90
+ ductus install-skills --write # links them into ~/.claude/skills
91
+ ```
92
+
93
+ - **`ductus`** — what may and may not be claimed from a text, and which task is which.
94
+ - **`ductus-gauge`** — the full reading: the deterministic scan, then a judgment pass with rubrics for the nine machine-leaning and six human-leaning *shapes* that regular expressions miss, then the write-up.
95
+ - **`ductus-reader`** (subagent) — does the whole reading in its own context and returns a finished diagnosis.
96
+
97
+ An agent's own reading folds back in beside the deterministic signals, anchored by quote:
98
+
99
+ ```bash
100
+ ductus gauge draft.md --judgments judgments.json --format html --out report.html
101
+ ```
102
+
103
+ A quote that no longer occurs is dropped rather than mis-anchored, so re-running after an edit is safe.
104
+
105
+ ## Seams
106
+
107
+ Three, each one keyword argument, each defaulting to something that genuinely works:
108
+
109
+ ```python
110
+ gauge(text, segmenter="sentence") # or "paragraph", "document", or a callable
111
+ gauge(
112
+ text, detectors=["forensic", "rhetoric"]
113
+ ) # or your own (text, span) -> Iterator[Signal]
114
+ gauge(text, aggregate=my_calibrated_scorer) # replace the scoring model wholesale
115
+ ```
116
+
117
+ A detector is a plain function `(text, span) -> Iterator[Signal]`. There is no base class and nothing to register. Adding Fast-DetectGPT, Binoculars or a vendor API means writing one more function of that shape — see [the roadmap](misc/docs/roadmap.md).
118
+
119
+ For long documents, `iter_segments` is the streaming core and `gauge` is the batch facade over it.
120
+
121
+ ## Spans survive editing
122
+
123
+ Every `Span` carries character offsets *and* the W3C Web Annotation redundant selectors (`quote`, `prefix`, `suffix`), because plain offsets do not survive an edit to the text. That is what lets a viewer re-find a finding after the document changed, and what a future edit-and-re-score UI is built on.
124
+
125
+ ## Relationship to `deslop`
126
+
127
+ `ductus` is the read side: *where does this text read as machine-written, and why*. [`acquaint`](https://github.com/thorwhalen/acquaint)'s `deslop` is the write side: *make my draft not read that way, in my voice, calibrated to this reader*. They share this package's tells catalogue — `acquaint` imports it — and compose naturally: gauge, deslop the flagged spans, gauge again.
128
+
129
+ They are separate packages because they have different inputs. `deslop` needs a model of the reader; `ductus` must work on a stranger's text with nothing but the text.
130
+
131
+ ## Install
132
+
133
+ ```bash
134
+ pip install ductus # the core: pyyaml and cw, nothing else
135
+ pip install "ductus[local]" # + model-based detectors, offline, no API key
136
+ pip install "ductus[api]" # + vendor detector adapters
137
+ ```
138
+
139
+ ## References
140
+
141
+ [1] [Liang W, Yuksekgonul M, Mao Y, Wu E, Zou J. GPT detectors are biased against non-native English writers. *Patterns* 2023.](https://arxiv.org/abs/2304.02819)
142
+
143
+ [2] [Sadasivan VS, Kumar A, Balasubramanian S, Wang W, Feizi S. Can AI-Generated Text be Reliably Detected?](https://arxiv.org/abs/2303.11156)
144
+
145
+ [3] [Sanderson R, Ciccarese P, Young B. Web Annotation Data Model. W3C Recommendation, 2017.](https://www.w3.org/TR/annotation-model/)
146
+
147
+ [4] [Tolstykh I, et al. LLMTrace: A Corpus for Classification and Fine-Grained Localization of AI-Written Text. arXiv:2509.21269.](https://arxiv.org/abs/2509.21269) — the ground-truth fixture in `tests/fixtures/` is a slice of this, Apache-2.0.
ductus-0.0.2/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # ductus
2
+
3
+ Gauge which parts of a text read as machine-written, and say why — with every finding anchored to the exact characters that carry it.
4
+
5
+ ```python
6
+ from ductus import gauge
7
+
8
+ report = gauge(open("draft.md").read())
9
+ report.document.label # 'mixed-signals'
10
+ report.segments[3].lean # +0.62 (-1 human … +1 machine)
11
+ report.segments[3].signals[0].note
12
+ # "a colon introducing a three-part parallel enumeration -- a textbook assistant construction"
13
+ ```
14
+
15
+ ```bash
16
+ pip install ductus
17
+ ductus gauge draft.md # a readable diagnosis
18
+ ductus gauge draft.md --format html --out report.html # shaded, hover for the reason
19
+ ductus gauge draft.md --format json # for a program
20
+ ```
21
+
22
+ In palaeography the *ductus* is the characteristic manner and sequence of strokes by which a scribe's hand is recognised. This package looks for the equivalent in prose.
23
+
24
+ ## There is no percentage in this package, and that is the point
25
+
26
+ Every detector on the market emits a number like "87% AI". That number reads as a calibrated probability, is not one, and is how people get falsely accused. What you get here instead is three things you can argue with: a **lean** in [-1, +1], an **evidence strength**, and a coarse **label**. Behind each of them is a list of signals, each with a direction, a weight, the detector that produced it, a plain-language reason, and the exact span it came from.
27
+
28
+ Four things are true and belong in any report you make from this:
29
+
30
+ - **Heavily-edited human writing and model-assisted writing look the same.** Re-reading a hard message ten times sands off exactly the irregularity that marks it as human.
31
+ - **Detectors over-flag non-native English writers** — 61.3% false positives across seven commercial detectors on non-native TOEFL essays, against near-zero on native-speaker controls [1].
32
+ - **Register contamination is real**: people who read model output all day start writing like it, unassisted.
33
+ - **No detector survives a motivated adversary**, and that is a proven result rather than a gap in current tooling [2].
34
+
35
+ So this package describes *text*. It does not make claims about *people*, and it should not be used to.
36
+
37
+ ## What it looks at
38
+
39
+ Four detectors ship, all deterministic, all free, none needing a model or a key.
40
+
41
+ | Detector | Finds | Example |
42
+ |---|---|---|
43
+ | `tells` | Catalogue phrases models overuse, tiered by confidence | "delve", "it's important to note", "In conclusion," |
44
+ | `forensic` | Mechanical traces of how the text was produced | a hard line break mid-sentence, mixed straight-and-curly apostrophes, trailing whitespace, em-dash density |
45
+ | `rhetoric` | Sentence *shapes* a phrase list cannot see | "not X but Y", a colon introducing a three-part parallel enumeration, concession-then-pivot |
46
+ | `rhythm` | Burstiness — how much sentence length varies | metronomic paragraphs |
47
+
48
+ Note that several of these argue *for* a human. A detector that can only ever accuse is not a measuring instrument. In practice the mechanical signals are often the most decisive thing in a file, in either direction.
49
+
50
+ The deterministic pass finds phrases, artifacts and a few shapes. **It cannot find prose that is machine-written and bland** — for that a model has to read it, which is what the shipped agent skills are for.
51
+
52
+ ## Agent skills
53
+
54
+ The primary surface. Two skills and a subagent ship inside the package and install with one command:
55
+
56
+ ```bash
57
+ ductus install-skills --write # links them into ~/.claude/skills
58
+ ```
59
+
60
+ - **`ductus`** — what may and may not be claimed from a text, and which task is which.
61
+ - **`ductus-gauge`** — the full reading: the deterministic scan, then a judgment pass with rubrics for the nine machine-leaning and six human-leaning *shapes* that regular expressions miss, then the write-up.
62
+ - **`ductus-reader`** (subagent) — does the whole reading in its own context and returns a finished diagnosis.
63
+
64
+ An agent's own reading folds back in beside the deterministic signals, anchored by quote:
65
+
66
+ ```bash
67
+ ductus gauge draft.md --judgments judgments.json --format html --out report.html
68
+ ```
69
+
70
+ A quote that no longer occurs is dropped rather than mis-anchored, so re-running after an edit is safe.
71
+
72
+ ## Seams
73
+
74
+ Three, each one keyword argument, each defaulting to something that genuinely works:
75
+
76
+ ```python
77
+ gauge(text, segmenter="sentence") # or "paragraph", "document", or a callable
78
+ gauge(
79
+ text, detectors=["forensic", "rhetoric"]
80
+ ) # or your own (text, span) -> Iterator[Signal]
81
+ gauge(text, aggregate=my_calibrated_scorer) # replace the scoring model wholesale
82
+ ```
83
+
84
+ A detector is a plain function `(text, span) -> Iterator[Signal]`. There is no base class and nothing to register. Adding Fast-DetectGPT, Binoculars or a vendor API means writing one more function of that shape — see [the roadmap](misc/docs/roadmap.md).
85
+
86
+ For long documents, `iter_segments` is the streaming core and `gauge` is the batch facade over it.
87
+
88
+ ## Spans survive editing
89
+
90
+ Every `Span` carries character offsets *and* the W3C Web Annotation redundant selectors (`quote`, `prefix`, `suffix`), because plain offsets do not survive an edit to the text. That is what lets a viewer re-find a finding after the document changed, and what a future edit-and-re-score UI is built on.
91
+
92
+ ## Relationship to `deslop`
93
+
94
+ `ductus` is the read side: *where does this text read as machine-written, and why*. [`acquaint`](https://github.com/thorwhalen/acquaint)'s `deslop` is the write side: *make my draft not read that way, in my voice, calibrated to this reader*. They share this package's tells catalogue — `acquaint` imports it — and compose naturally: gauge, deslop the flagged spans, gauge again.
95
+
96
+ They are separate packages because they have different inputs. `deslop` needs a model of the reader; `ductus` must work on a stranger's text with nothing but the text.
97
+
98
+ ## Install
99
+
100
+ ```bash
101
+ pip install ductus # the core: pyyaml and cw, nothing else
102
+ pip install "ductus[local]" # + model-based detectors, offline, no API key
103
+ pip install "ductus[api]" # + vendor detector adapters
104
+ ```
105
+
106
+ ## References
107
+
108
+ [1] [Liang W, Yuksekgonul M, Mao Y, Wu E, Zou J. GPT detectors are biased against non-native English writers. *Patterns* 2023.](https://arxiv.org/abs/2304.02819)
109
+
110
+ [2] [Sadasivan VS, Kumar A, Balasubramanian S, Wang W, Feizi S. Can AI-Generated Text be Reliably Detected?](https://arxiv.org/abs/2303.11156)
111
+
112
+ [3] [Sanderson R, Ciccarese P, Young B. Web Annotation Data Model. W3C Recommendation, 2017.](https://www.w3.org/TR/annotation-model/)
113
+
114
+ [4] [Tolstykh I, et al. LLMTrace: A Corpus for Classification and Fine-Grained Localization of AI-Written Text. arXiv:2509.21269.](https://arxiv.org/abs/2509.21269) — the ground-truth fixture in `tests/fixtures/` is a slice of this, Apache-2.0.
@@ -0,0 +1,67 @@
1
+ """ductus -- gauge which parts of a text read as machine-written, and why.
2
+
3
+ In palaeography the *ductus* is the characteristic manner and sequence of strokes
4
+ by which a scribe's hand is recognised. This package looks for the equivalent in
5
+ prose: not a verdict about who wrote something, but evidence about how it reads,
6
+ attached to the exact characters that carry it.
7
+
8
+ >>> from ductus import gauge
9
+ >>> report = gauge("Great question! Let's delve into this robust tapestry.")
10
+ >>> report.document.label
11
+ 'leans-machine'
12
+ >>> report.segments[0].signals[0].name
13
+ 'chat-leftover'
14
+
15
+ Every finding is a :class:`Signal` with a direction, a weight, the detector that
16
+ produced it, a reason, and a :class:`Span` carrying both character offsets and
17
+ W3C-style quote/prefix/suffix selectors, so highlights survive an edit.
18
+
19
+ **There is no percentage anywhere in this package, and that is deliberate.** A
20
+ number like "87% AI" reads as a calibrated probability, is not one, and is how
21
+ people get falsely accused. What you get instead is a lean in [-1, +1], an
22
+ evidence strength, and a coarse label you can argue with. See
23
+ :mod:`ductus.score`.
24
+
25
+ Three seams, each one keyword argument with a working default:
26
+ ``segmenter=`` (how the text is cut up), ``detectors=`` (what produces evidence),
27
+ ``aggregate=`` (how evidence becomes a lean).
28
+ """
29
+
30
+ from ductus.base import (
31
+ LABELS,
32
+ SCHEMA_VERSION,
33
+ Report,
34
+ Segment,
35
+ Signal,
36
+ Span,
37
+ )
38
+ from ductus.core import gauge, iter_segments
39
+ from ductus.detect import DETECTORS
40
+ from ductus.render import to_html, to_json, to_markdown
41
+ from ductus.score import aggregate
42
+ from ductus.segment import SEGMENTERS
43
+ from ductus.tells import TellMatch, TellRule, iter_tell_matches, load_rules
44
+
45
+ __version__ = "0.0.1"
46
+
47
+ __all__ = [
48
+ "DETECTORS",
49
+ "LABELS",
50
+ "SCHEMA_VERSION",
51
+ "SEGMENTERS",
52
+ "Report",
53
+ "Segment",
54
+ "Signal",
55
+ "Span",
56
+ "TellMatch",
57
+ "TellRule",
58
+ "__version__",
59
+ "aggregate",
60
+ "gauge",
61
+ "iter_segments",
62
+ "iter_tell_matches",
63
+ "load_rules",
64
+ "to_html",
65
+ "to_json",
66
+ "to_markdown",
67
+ ]
@@ -0,0 +1,20 @@
1
+ """``python -m ductus`` -- the CLI, built from the same functions the library exposes.
2
+
3
+ ductus gauge draft.md # a markdown diagnosis on stdout
4
+ ductus gauge draft.md --format html --out report.html
5
+ ductus gauge - --format json < draft.md # from stdin, machine-readable
6
+ ductus tells --tier E # what the catalogue enforces
7
+ ductus install-skills --write # link the skills into ~/.claude
8
+ """
9
+
10
+ import cw
11
+
12
+ from ductus.tools import _dispatch_funcs
13
+
14
+
15
+ def main() -> int:
16
+ return cw.dispatch(_dispatch_funcs)
17
+
18
+
19
+ if __name__ == "__main__":
20
+ raise SystemExit(main())
@@ -0,0 +1,168 @@
1
+ """The data model: where a finding lives, what it claims, and how much it weighs.
2
+
3
+ Four types, and they are the whole contract every other module speaks in.
4
+
5
+ A :class:`Span` says *where*. It carries character offsets **and** the W3C Web
6
+ Annotation redundant selectors (quote, prefix, suffix) so a finding can be
7
+ re-anchored after the text is edited, which plain offsets cannot survive.
8
+
9
+ A :class:`Signal` is one piece of evidence: a direction, a weight, who produced
10
+ it, and a human-readable reason. Signals are never merged or averaged away --
11
+ the reason a passage scored the way it did is always recoverable.
12
+
13
+ A :class:`Segment` is a unit of text plus the signals that landed on it and the
14
+ lean derived from them. A :class:`Report` is the document-level roll-up.
15
+
16
+ >>> text = "The cat sat. It was a fine evening."
17
+ >>> span = Span.of(text, 4, 7)
18
+ >>> span.quote, span.prefix
19
+ ('cat', 'The ')
20
+ >>> Signal("test", "machine", 0.5, "demo", note="an example").direction
21
+ 'machine'
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass, field
27
+ from typing import Any
28
+
29
+ __all__ = [
30
+ "DIRECTIONS",
31
+ "LABELS",
32
+ "SCHEMA_VERSION",
33
+ "Report",
34
+ "Segment",
35
+ "Signal",
36
+ "Span",
37
+ ]
38
+
39
+ #: Bumped when the serialized shape of a Report changes incompatibly.
40
+ SCHEMA_VERSION = "1"
41
+
42
+ #: What a signal can argue for. ``neutral`` records evidence that is real but
43
+ #: does not discriminate -- it is kept because hiding it would be dishonest.
44
+ DIRECTIONS = ("machine", "human", "neutral")
45
+
46
+ #: The coarse vocabulary a segment is labelled with. Deliberately not a
47
+ #: percentage: see ``docs/why-no-percentage.md``.
48
+ LABELS = ("leans-machine", "leans-human", "mixed-signals", "uncertain", "no-evidence")
49
+
50
+ #: How much text on each side of a span is kept for re-anchoring.
51
+ CONTEXT_CHARS = 40
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Span:
56
+ """A character range, with redundant selectors so it survives an edit.
57
+
58
+ ``start``/``end`` are a ``TextPositionSelector``; ``quote`` with ``prefix``
59
+ and ``suffix`` is a ``TextQuoteSelector``. Keeping both is what lets a
60
+ viewer re-find a finding after the text around it changed.
61
+
62
+ >>> s = Span.of("one two three", 4, 7, level="sentence")
63
+ >>> (s.start, s.end, s.quote, s.level)
64
+ (4, 7, 'two', 'sentence')
65
+ >>> s.length
66
+ 3
67
+ """
68
+
69
+ start: int
70
+ end: int
71
+ quote: str
72
+ prefix: str = ""
73
+ suffix: str = ""
74
+ level: str = "segment"
75
+
76
+ @classmethod
77
+ def of(cls, text: str, start: int, end: int, *, level: str = "segment") -> Span:
78
+ """Build a span over ``text``, capturing its re-anchoring context.
79
+
80
+ >>> Span.of("abcdef", 2, 4).suffix
81
+ 'ef'
82
+ """
83
+ return cls(
84
+ start=start,
85
+ end=end,
86
+ quote=text[start:end],
87
+ prefix=text[max(0, start - CONTEXT_CHARS) : start],
88
+ suffix=text[end : end + CONTEXT_CHARS],
89
+ level=level,
90
+ )
91
+
92
+ @property
93
+ def length(self) -> int:
94
+ return self.end - self.start
95
+
96
+ def contains(self, other: Span) -> bool:
97
+ """Whether ``other`` falls entirely inside this span.
98
+
99
+ >>> a, b = Span.of("abcdef", 0, 6), Span.of("abcdef", 2, 4)
100
+ >>> a.contains(b), b.contains(a)
101
+ (True, False)
102
+ """
103
+ return self.start <= other.start and other.end <= self.end
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class Signal:
108
+ """One piece of evidence about one span.
109
+
110
+ ``weight`` is how much this moves the needle, in ``0..1``. It is a weight,
111
+ not a probability: two 0.5 signals pointing the same way are stronger than
112
+ one, and nothing here claims to be calibrated.
113
+
114
+ >>> Signal("em-dash", "machine", 0.2, "forensic", value=3).name
115
+ 'em-dash'
116
+ """
117
+
118
+ name: str
119
+ direction: str
120
+ weight: float
121
+ detector: str
122
+ value: Any = None
123
+ note: str = ""
124
+ span: Span | None = None
125
+
126
+ def __post_init__(self) -> None:
127
+ if self.direction not in DIRECTIONS:
128
+ raise ValueError(
129
+ f"direction must be one of {DIRECTIONS}, got {self.direction!r}"
130
+ )
131
+ if not 0.0 <= self.weight <= 1.0:
132
+ raise ValueError(f"weight must be in [0, 1], got {self.weight!r}")
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class Segment:
137
+ """A unit of text, the signals on it, and the lean they add up to.
138
+
139
+ ``lean`` runs from -1 (every signal argues human) to +1 (every signal argues
140
+ machine). ``strength`` is how much evidence there is at all -- a lean of
141
+ +1.0 from a single weak signal is not the same claim as +1.0 from six.
142
+ """
143
+
144
+ span: Span
145
+ signals: tuple[Signal, ...] = ()
146
+ lean: float = 0.0
147
+ strength: float = 0.0
148
+ label: str = "no-evidence"
149
+
150
+
151
+ @dataclass(frozen=True)
152
+ class Report:
153
+ """Everything a downstream consumer needs, and nothing it has to guess at."""
154
+
155
+ text_sha256: str
156
+ n_chars: int
157
+ document: Segment
158
+ segments: tuple[Segment, ...]
159
+ detectors: tuple[str, ...]
160
+ segmenter: str
161
+ schema_version: str = SCHEMA_VERSION
162
+ calibration: str = "uncalibrated"
163
+ meta: dict[str, Any] = field(default_factory=dict)
164
+
165
+ @property
166
+ def signals(self) -> tuple[Signal, ...]:
167
+ """Every signal in the report, in document order."""
168
+ return tuple(s for seg in self.segments for s in seg.signals)