ductus 0.0.2__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.
- ductus/__init__.py +67 -0
- ductus/__main__.py +20 -0
- ductus/base.py +168 -0
- ductus/core.py +125 -0
- ductus/data/__init__.py +0 -0
- ductus/data/agents/ductus-reader.md +11 -0
- ductus/data/skills/ductus/SKILL.md +60 -0
- ductus/data/skills/ductus-gauge/SKILL.md +90 -0
- ductus/data/tells.yaml +161 -0
- ductus/detect.py +388 -0
- ductus/render.py +314 -0
- ductus/score.py +87 -0
- ductus/segment.py +84 -0
- ductus/tells.py +178 -0
- ductus/tools.py +223 -0
- ductus-0.0.2.dist-info/METADATA +147 -0
- ductus-0.0.2.dist-info/RECORD +20 -0
- ductus-0.0.2.dist-info/WHEEL +4 -0
- ductus-0.0.2.dist-info/entry_points.txt +2 -0
- ductus-0.0.2.dist-info/licenses/LICENSE +21 -0
ductus/__init__.py
ADDED
|
@@ -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
|
+
]
|
ductus/__main__.py
ADDED
|
@@ -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())
|
ductus/base.py
ADDED
|
@@ -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)
|
ductus/core.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""The core: stream segments, or take the whole report. Everything else is a surface.
|
|
2
|
+
|
|
3
|
+
Two entry points, and the second is a facade over the first:
|
|
4
|
+
|
|
5
|
+
:func:`iter_segments` yields one :class:`~ductus.base.Segment` at a time as it
|
|
6
|
+
scores them -- the streaming surface, for long documents and for a UI that wants
|
|
7
|
+
to paint as results arrive.
|
|
8
|
+
|
|
9
|
+
:func:`gauge` collects them into a :class:`~ductus.base.Report`.
|
|
10
|
+
|
|
11
|
+
The three seams are keyword arguments, each defaulting to something that
|
|
12
|
+
genuinely works rather than to a stub:
|
|
13
|
+
|
|
14
|
+
=============== ========================================== ==========================
|
|
15
|
+
seam v1 default swap in
|
|
16
|
+
=============== ========================================== ==========================
|
|
17
|
+
``segmenter=`` ``"paragraph"`` ``"sentence"``, a callable
|
|
18
|
+
``detectors=`` all four deterministic detectors a model-based detector
|
|
19
|
+
``aggregate=`` :func:`ductus.score.aggregate` a calibrated scorer
|
|
20
|
+
=============== ========================================== ==========================
|
|
21
|
+
|
|
22
|
+
``extra_signals=`` is not a seam but an input: evidence produced elsewhere --
|
|
23
|
+
by an agent reading the text, by a vendor API -- attached to the segment that
|
|
24
|
+
contains it. It is how the shipped skills feed a model's reading back in.
|
|
25
|
+
|
|
26
|
+
>>> report = gauge("Great question! Let's delve into this robust tapestry.")
|
|
27
|
+
>>> report.document.label
|
|
28
|
+
'leans-machine'
|
|
29
|
+
>>> report = gauge("Sent the export Friday. Two sites, not five. Call if it breaks.")
|
|
30
|
+
>>> report.document.label
|
|
31
|
+
'no-evidence'
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import hashlib
|
|
37
|
+
from collections.abc import Callable, Iterable, Iterator, Sequence
|
|
38
|
+
|
|
39
|
+
from ductus.base import Report, Segment, Signal, Span
|
|
40
|
+
from ductus.detect import detectors_from
|
|
41
|
+
from ductus.score import aggregate as default_aggregate
|
|
42
|
+
from ductus.segment import spans_of
|
|
43
|
+
|
|
44
|
+
__all__ = ["gauge", "iter_segments"]
|
|
45
|
+
|
|
46
|
+
Aggregator = Callable[[Sequence[Signal]], "tuple[float, float, str]"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def iter_segments(
|
|
50
|
+
text: str,
|
|
51
|
+
*,
|
|
52
|
+
segmenter: str | Callable[[str], Iterator[Span]] = "paragraph",
|
|
53
|
+
detectors: Sequence | None = None,
|
|
54
|
+
aggregate: Aggregator = default_aggregate,
|
|
55
|
+
extra_signals: Iterable[Signal] = (),
|
|
56
|
+
) -> Iterator[Segment]:
|
|
57
|
+
"""Yield one scored segment at a time.
|
|
58
|
+
|
|
59
|
+
>>> segs = list(iter_segments("Let's delve in.\\n\\nSent it Friday."))
|
|
60
|
+
>>> len(segs), segs[0].label
|
|
61
|
+
(2, 'leans-machine')
|
|
62
|
+
"""
|
|
63
|
+
fns, _ = detectors_from(detectors)
|
|
64
|
+
injected = tuple(extra_signals)
|
|
65
|
+
for span in spans_of(text, segmenter):
|
|
66
|
+
signals: list[Signal] = []
|
|
67
|
+
for fn in fns:
|
|
68
|
+
signals.extend(fn(text, span))
|
|
69
|
+
for s in injected:
|
|
70
|
+
if s.span is None or span.contains(s.span):
|
|
71
|
+
signals.append(s)
|
|
72
|
+
signals.sort(key=lambda s: (s.span.start if s.span else span.start, s.name))
|
|
73
|
+
lean, strength, label = aggregate(signals)
|
|
74
|
+
yield Segment(
|
|
75
|
+
span=span, signals=tuple(signals), lean=lean, strength=strength, label=label
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def gauge(
|
|
80
|
+
text: str,
|
|
81
|
+
*,
|
|
82
|
+
segmenter: str | Callable[[str], Iterator[Span]] = "paragraph",
|
|
83
|
+
detectors: Sequence | None = None,
|
|
84
|
+
aggregate: Aggregator = default_aggregate,
|
|
85
|
+
extra_signals: Iterable[Signal] = (),
|
|
86
|
+
) -> Report:
|
|
87
|
+
"""Score ``text`` and roll the segments up into a report.
|
|
88
|
+
|
|
89
|
+
The document-level lean is computed over *all* signals in the document, not
|
|
90
|
+
by averaging the segment leans -- averaging would let two short, heavily
|
|
91
|
+
flagged paragraphs outvote a long clean one.
|
|
92
|
+
|
|
93
|
+
>>> r = gauge("It is important to note that this is a robust tapestry.")
|
|
94
|
+
>>> r.document.lean > 0 and r.n_chars == 55
|
|
95
|
+
True
|
|
96
|
+
>>> r.segmenter, len(r.detectors)
|
|
97
|
+
('paragraph', 4)
|
|
98
|
+
"""
|
|
99
|
+
_, names = detectors_from(detectors)
|
|
100
|
+
segments = tuple(
|
|
101
|
+
iter_segments(
|
|
102
|
+
text,
|
|
103
|
+
segmenter=segmenter,
|
|
104
|
+
detectors=detectors,
|
|
105
|
+
aggregate=aggregate,
|
|
106
|
+
extra_signals=extra_signals,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
all_signals = tuple(s for seg in segments for s in seg.signals)
|
|
110
|
+
lean, strength, label = aggregate(all_signals)
|
|
111
|
+
document = Segment(
|
|
112
|
+
span=Span.of(text, 0, len(text), level="document"),
|
|
113
|
+
signals=(),
|
|
114
|
+
lean=lean,
|
|
115
|
+
strength=strength,
|
|
116
|
+
label=label,
|
|
117
|
+
)
|
|
118
|
+
return Report(
|
|
119
|
+
text_sha256=hashlib.sha256(text.encode("utf-8")).hexdigest(),
|
|
120
|
+
n_chars=len(text),
|
|
121
|
+
document=document,
|
|
122
|
+
segments=segments,
|
|
123
|
+
detectors=names,
|
|
124
|
+
segmenter=segmenter if isinstance(segmenter, str) else "custom",
|
|
125
|
+
)
|
ductus/data/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ductus-reader
|
|
3
|
+
description: Reads a text for machine-written prose in its own context and returns a finished diagnosis — the deterministic scan plus its own judgment pass, written up with the evidence and the limits. Use when asked which parts of a document read as AI-generated, or to check a draft, and you want the reading done without the document's full text landing in the main thread. Read-only with respect to the source; it writes its report beside the source file.
|
|
4
|
+
tools: Bash, Read, Write, Glob, Grep
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
You perform one task: a full `ductus` reading of a text, returned as a finished diagnosis.
|
|
8
|
+
|
|
9
|
+
Follow the `ductus-gauge` skill exactly — its three passes, its two judgment tables, and its write-up order. Read the `ductus` skill's limits section first and honour it: no percentages, no verdict about a person, and the editing confound plus the non-native-writer bias stated in every report.
|
|
10
|
+
|
|
11
|
+
Return the diagnosis itself, not a description of what you did, and not the document's full text. If the source is private, say where you wrote the report and quote nothing from it in your reply beyond the passages your findings rest on.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ductus
|
|
3
|
+
description: Gauge which parts of a text read as machine-written, and say why, with the evidence anchored to exact characters. Use whenever someone asks whether text was AI-generated, wants to know which passages "sound like AI", asks you to check a draft before sending it, or says "is this AI", "did a model write this", "which parts sound generated", "AI detection", "AI-written", "run a detector on this", "how AI does this read". Also use before the deslop skill, to find what needs rewriting, and after it, to check the rewrite. Read this skill before reaching for any AI-detector service; it owns what can and cannot honestly be claimed from a text.
|
|
4
|
+
metadata:
|
|
5
|
+
audience: users
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# ductus — reading a text for the machine's hand
|
|
9
|
+
|
|
10
|
+
In palaeography the *ductus* is the manner and sequence of strokes by which a scribe's hand is recognised. This package looks for the equivalent in prose.
|
|
11
|
+
|
|
12
|
+
## Say this before anything else
|
|
13
|
+
|
|
14
|
+
**You cannot determine whether a person used a model. You can only describe how a text reads.** Hold that line even when pushed, because the cost of getting it wrong falls entirely on a person who did nothing wrong.
|
|
15
|
+
|
|
16
|
+
Four facts, each load-bearing, none optional to mention when reporting a result:
|
|
17
|
+
|
|
18
|
+
1. **Heavily-edited human writing and model-assisted writing produce the same signature.** Re-reading a hard message ten times sands off exactly the irregularity that marks it as human.
|
|
19
|
+
2. **Detectors over-flag non-native English writers** — a 61.3% false-positive rate across seven commercial detectors on non-native TOEFL essays, against near-zero on native-speaker controls (Liang et al., *Patterns* 2023).
|
|
20
|
+
3. **Register contamination is real.** Someone who reads model output all day starts writing like it, unassisted.
|
|
21
|
+
4. **No detector survives a motivated adversary**, and that is a proven result, not a gap in current tooling (Sadasivan et al., arXiv:2303.11156).
|
|
22
|
+
|
|
23
|
+
So: **never produce a percentage**, never say "this was AI-generated", and never hand someone a result to confront a third party with. Say what fired, where, and how much it weighs. If the question behind the request is really "did my colleague use AI", the answer that helps is *ask them*, and say so.
|
|
24
|
+
|
|
25
|
+
## Which task is this?
|
|
26
|
+
|
|
27
|
+
| The ask | Do this |
|
|
28
|
+
|---|---|
|
|
29
|
+
| "Which parts of this read as AI?" / "is this AI-written?" | the **ductus-gauge** skill — the full reading |
|
|
30
|
+
| "Check this before I send it" / "make it sound like me" | the **deslop** skill first; gauge after, to check the rewrite |
|
|
31
|
+
| "Just give me a quick look" | `ductus gauge <file>` and report the segments that fired |
|
|
32
|
+
| "What does it actually look for?" | `ductus tells` and `ductus detectors` |
|
|
33
|
+
|
|
34
|
+
## The quick pass
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
ductus gauge draft.md # markdown diagnosis on stdout
|
|
38
|
+
ductus gauge draft.md --format html --out report.html
|
|
39
|
+
ductus gauge - --format json < draft.md # machine-readable
|
|
40
|
+
ductus tells --tier E # near-certain patterns only
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The deterministic pass costs nothing and needs no key. **It finds phrases, mechanical artifacts and a few sentence shapes. It does not find prose that is machine-written and bland** — for that you have to read, which is what `ductus-gauge` is for.
|
|
44
|
+
|
|
45
|
+
## Reading the output
|
|
46
|
+
|
|
47
|
+
- `lean` runs from **-1 (all evidence argues human)** to **+1 (all argues machine)**. It is a ratio of the weights present, not a probability.
|
|
48
|
+
- `strength` is how much evidence there was at all. **A lean of +1.00 at strength 0.15 is one weak signal, not a finding.** Always read the two together.
|
|
49
|
+
- Signals with direction `human` are as important as `machine` ones. A hard line break mid-sentence, mixed straight-and-curly apostrophes, a missing final period — models do not produce these, and they are often the most decisive thing in the file.
|
|
50
|
+
- Zero findings is a real result and a weak one. Say so rather than reporting "clean".
|
|
51
|
+
|
|
52
|
+
## What tends to be true, when it is true
|
|
53
|
+
|
|
54
|
+
The pattern worth looking for is not "which sentences are flagged" but **what kind of sentence is flagged**. In mixed-authorship text the machine-leaning passages are usually the ones that *generalise* — naming a pattern, stating a principle, managing a relationship — while the human-leaning ones are the *specific* ones: a number someone measured, a named person, a particular grievance. When you see that split, say it. It is more informative than any score.
|
|
55
|
+
|
|
56
|
+
## Never
|
|
57
|
+
|
|
58
|
+
- Give a percentage, a confidence, or a verdict about a person.
|
|
59
|
+
- Report a document-level number without saying which segments drove it.
|
|
60
|
+
- Use this to help someone evade detection. The package exists to describe text, and its sibling `deslop` exists to make writing *good*, not to launder it.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ductus-gauge
|
|
3
|
+
description: Do a full reading of a text for machine-written prose — run the deterministic detectors, add your own judgment pass for the shapes a regular expression cannot see, and produce a markdown or HTML report with evidence anchored to exact characters. Use when asked which parts of a text read as AI-generated, to analyse or diagnose a document's authorship signals, to check a draft before sending, or to verify a rewrite. Triggers on "which parts sound like AI", "analyse this text", "gauge this", "is this AI-written", "run the detectors", "AI reading of this document", "check this draft". Read the `ductus` skill first for what may and may not be claimed.
|
|
4
|
+
metadata:
|
|
5
|
+
audience: users
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# ductus-gauge — the full reading
|
|
9
|
+
|
|
10
|
+
Three passes. The first is free, the second is the one that matters, the third is the write-up. **Read the `ductus` skill's limits section before reporting anything.**
|
|
11
|
+
|
|
12
|
+
## Pass 1 — the deterministic scan
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
ductus gauge <file> --format json --out /tmp/ductus-scan.json
|
|
16
|
+
ductus gauge <file> # the same scan, as readable markdown
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Read what fired and where. This pass finds catalogue phrases, typographic artifacts, a few sentence shapes, and sentence-length variance. **Expect it to find nothing on careful text. That is not evidence of human authorship** — it is the limit of regular expressions.
|
|
20
|
+
|
|
21
|
+
## Pass 2 — your own reading (this is the valuable one)
|
|
22
|
+
|
|
23
|
+
Read the text yourself and look for the shapes below. For each one you find, record a judgment. Quote exactly — the quote is how it gets anchored.
|
|
24
|
+
|
|
25
|
+
### Machine-leaning shapes
|
|
26
|
+
|
|
27
|
+
| Shape | What it looks like | Weight |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| **Pattern-naming tricolon** | Names a behaviour, then unrolls it as three parallel clauses: "This is a recurring pattern: you decide X, invest Y, and then get frustrated when Z." | 0.5 |
|
|
30
|
+
| **Principle generalisation** | Lifts a specific complaint into a balanced maxim: "Time spent can't by itself be a measure of value." | 0.45 |
|
|
31
|
+
| **Aphoristic closer** | A one-sentence moral capping a paragraph that did not need one. | 0.4 |
|
|
32
|
+
| **Concede-then-pivot** | "I do value X. At the same time, Y." The diplomatic-feedback move. | 0.35 |
|
|
33
|
+
| **Term reversal** | Takes the other party's own word and turns it back neatly in the closing clause. | 0.35 |
|
|
34
|
+
| **False balance** | "While X has benefits, it also has drawbacks" where the author plainly holds a view. | 0.35 |
|
|
35
|
+
| **Uniform paragraph architecture** | Every paragraph the same length, same shape, same one-point-then-elaborate rhythm. | 0.3 |
|
|
36
|
+
| **Reassurance pair** | Two short declaratives doing emotional management mid-argument: "Software is difficult and review is normal." | 0.3 |
|
|
37
|
+
| **Over-explanation** | More words than the facts need; uniformly perfect formality in a casual channel. | 0.25 |
|
|
38
|
+
|
|
39
|
+
### Human-leaning shapes (look as hard for these)
|
|
40
|
+
|
|
41
|
+
| Shape | What it looks like | Weight |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| **Typing and paste artifacts** | A hard line break mid-sentence, a doubled word, trailing whitespace, a missing final period. | 0.45 |
|
|
44
|
+
| **Insertion seams** | Paragraph separation that is inconsistent — three joins missing the blank line the rest of the document uses. Marks where text was added in a later pass. | 0.4 |
|
|
45
|
+
| **L2 grammar slips** | Article, countability or preposition errors ("it's always a team work"). Models essentially never emit these. | 0.6 |
|
|
46
|
+
| **Unhedged specificity** | A named person, a measured number, an unglossed particular, with no scaffolding around it. | 0.3 |
|
|
47
|
+
| **Absent habits** | Zero em dashes across a long reflective text; no bullet lists where a model would reach for one. | 0.35 |
|
|
48
|
+
| **Cost-bearing opinion** | A claim the author would have to defend, stated without balancing it. | 0.3 |
|
|
49
|
+
|
|
50
|
+
Write them to a file:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
[
|
|
54
|
+
{"quote": "exact text from the document",
|
|
55
|
+
"direction": "machine",
|
|
56
|
+
"name": "pattern-naming-tricolon",
|
|
57
|
+
"weight": 0.5,
|
|
58
|
+
"note": "Names a pattern, then three parallel verb phrases."},
|
|
59
|
+
{"quote": "it's always a team work",
|
|
60
|
+
"direction": "human",
|
|
61
|
+
"name": "l2-grammar-slip",
|
|
62
|
+
"weight": 0.6,
|
|
63
|
+
"note": "Article and countability error; models do not produce this."}
|
|
64
|
+
]
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Then fold it in:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
ductus gauge <file> --judgments judgments.json --format html --out report.html
|
|
71
|
+
ductus gauge <file> --judgments judgments.json --out reading.md
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
A quote that no longer occurs in the text is dropped rather than mis-anchored, so re-running after an edit is safe.
|
|
75
|
+
|
|
76
|
+
## Pass 3 — the write-up
|
|
77
|
+
|
|
78
|
+
Lead with **what kind of passage** carries which signal, not with the number. The useful sentence is usually of the form *"the passages that generalise carry the machine signal; the specific ones carry typing artifacts"*, because that describes a **process** — someone wrote their own material and reached for help framing it — rather than pronouncing on a person.
|
|
79
|
+
|
|
80
|
+
Then, in order:
|
|
81
|
+
|
|
82
|
+
1. The strongest **mechanical** evidence (artifacts, seams, slips). It is the hardest to fake in either direction and should be weighted accordingly.
|
|
83
|
+
2. The strongest **rhetorical** evidence, quoted.
|
|
84
|
+
3. What is **ambiguous**, said plainly. Consistent curly apostrophes mean the text was composed outside the channel it was sent in; they say nothing about who composed it.
|
|
85
|
+
4. The **limits** from the `ductus` skill — at least the editing confound and the non-native-writer bias.
|
|
86
|
+
5. **What would actually settle it**, which is almost never more detection. Usually: a sample of the same author's earlier writing to compare against, or asking them.
|
|
87
|
+
|
|
88
|
+
## If the text is private
|
|
89
|
+
|
|
90
|
+
Keep every artifact out of any repository and out of any external service. Write reports beside the source file, not into a project directory. Quote the text only in files that live where the source does.
|