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.
- ductus-0.0.2/.github/workflows/ci.yml +24 -0
- ductus-0.0.2/.gitignore +10 -0
- ductus-0.0.2/LICENSE +21 -0
- ductus-0.0.2/PKG-INFO +147 -0
- ductus-0.0.2/README.md +114 -0
- ductus-0.0.2/ductus/__init__.py +67 -0
- ductus-0.0.2/ductus/__main__.py +20 -0
- ductus-0.0.2/ductus/base.py +168 -0
- ductus-0.0.2/ductus/core.py +125 -0
- ductus-0.0.2/ductus/data/__init__.py +0 -0
- ductus-0.0.2/ductus/data/agents/ductus-reader.md +11 -0
- ductus-0.0.2/ductus/data/skills/ductus/SKILL.md +60 -0
- ductus-0.0.2/ductus/data/skills/ductus-gauge/SKILL.md +90 -0
- ductus-0.0.2/ductus/data/tells.yaml +161 -0
- ductus-0.0.2/ductus/detect.py +388 -0
- ductus-0.0.2/ductus/render.py +314 -0
- ductus-0.0.2/ductus/score.py +87 -0
- ductus-0.0.2/ductus/segment.py +84 -0
- ductus-0.0.2/ductus/tells.py +178 -0
- ductus-0.0.2/ductus/tools.py +223 -0
- ductus-0.0.2/pyproject.toml +71 -0
- ductus-0.0.2/tests/fixtures/mixed_authorship.json +228 -0
- ductus-0.0.2/tests/test_anchoring.py +73 -0
- ductus-0.0.2/tests/test_ground_truth.py +91 -0
- ductus-0.0.2/tests/test_smoke.py +66 -0
- ductus-0.0.2/tests/test_tells_contract.py +94 -0
|
@@ -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 }}
|
ductus-0.0.2/.gitignore
ADDED
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)
|