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 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
+ )
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.