Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Docs/CALIBRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ It is **not an accuracy figure**. Accuracy needs machine-written text to measure

- **Corpus** `signsofai-human-baseline`, fingerprint `123fa5b9ebca3f29`
- **Texts** 90 (280,221 words)
- **Lengths measured** 662 – 9,328 words (median 2,772)
- **Engine** SignsOfAI.Core 0.4.0
- **Run** 2026-08-24
- **Target false-positive rate** 5%
Expand All @@ -20,6 +21,8 @@ Every text here was published before generative models could have written it. Th

**At a threshold of 25/100, this tool flags at most 5% of writing known to be human** — 0 of 90 texts in this corpus, an observed 0% with a 95% interval of 0% – 4.1%.

**It covers documents of 662 words and up, because that is what was measured.** Nothing shorter was: the corpus has no text below that length, so the boundary below is not supported there and the tool withholds its verdict rather than extrapolating. That is a statement about coverage, not about where the tool breaks — though the direction of the length effect *has* been measured, and it goes the wrong way: the same documents flagged 0 of 32 whole and 6 of 32 as 400-word excerpts of themselves (`Docs/PARAPHRASE.md`, section *Length*). Lowering this floor means measuring short writing people actually composed at that length, not slicing long documents into pieces.

Read the interval, not the percentage. On a small corpus an observed rate is compatible with a much wider range, and the recommendation below is made from the **upper** end of that range rather than the flattering one — so it stays cautious while the corpus is thin and tightens on its own as it grows.

## By language
Expand Down
17 changes: 17 additions & 0 deletions Docs/Calibration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,23 @@ exist, which is a stronger guarantee than any classifier can offer about anythin
No text is admitted on the grounds that it "reads human". That judgement is the thing being measured
and cannot also be the thing doing the measuring.

## What the corpus does not cover, and what it costs

Every text here is **662 words or longer** — that is the shortest one, and the Wikipedia fetcher skips
anything under 700 by design. The threshold is therefore supported over that range and nowhere else,
so since #59 the engine **withholds its verdict below 662 words** rather than extrapolating onto a
population it never sampled.

That is not a small exclusion. It is most of how the tool is used: somebody pastes a paragraph. And
the direction of the error is known — the same documents flag 0 of 32 whole and 6 of 32 as 400-word
excerpts of themselves (`Docs/PARAPHRASE.md`, section *Length*), so short text drifts toward the
machine rather than merely getting noisier.

**The most wanted contribution is therefore short complete texts published before 2022**: encyclopedia
stubs, short news pieces, abstracts — writing somebody *composed* at that length. A window cut out of
a longer document is not the same population and must not be used: it has the sentence rhythm of a
fragment, which is the very thing being measured. See issue #66.

## The texts are not in this repository

`Docs/Calibration/texts/` is git-ignored. Licences differ per source, the bulk would dwarf the code,
Expand Down
10 changes: 9 additions & 1 deletion src/SignsOfAI.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -364,10 +364,13 @@ static void PrintReport(string path, AnalysisResult r, int top, bool useColor)
string Col(string s, int code) => useColor ? $"[{code}m{s}" : s;
string Bold(string s) => useColor ? $"{s}" : s;

int scoreColor = VerdictBands.Emphasis(r.OverallScore) switch
// Green says "nothing here"; the terminal has no way to un-say it four lines later. A document
// outside what was measured gets grey, because the honest colour for a refusal is not a result.
int scoreColor = VerdictBands.Emphasis(r.OverallScore, r.Language, r.Statistics.WordCount) switch
{
VerdictEmphasis.High => 31,
VerdictEmphasis.Elevated or VerdictEmphasis.Present => 33,
VerdictEmphasis.Unmeasured => 90,
_ => 32,
};
Console.WriteLine();
Expand All @@ -377,6 +380,11 @@ static void PrintReport(string path, AnalysisResult r, int top, bool useColor)
Console.WriteLine($" words {r.Statistics.WordCount} · sentences {r.Statistics.SentenceCount} · " +
$"burstiness {r.Statistics.Burstiness:0.00} · lexical diversity {r.Statistics.LexicalDiversity:0.00}");

if (!VerdictBands.Measured(r.Statistics.WordCount))
Console.WriteLine(Col(
$" The boundary was measured only on texts of {VerdictBands.MinimumWords:N0} words and " +
"longer, so no verdict is given here. The findings below are unaffected.", 90));

var cats = r.CategoryScores.Where(c => c.FindingCount > 0).ToList();
if (cats.Count > 0)
Console.WriteLine(" " + string.Join(" ", cats.Select(c => $"{c.Category} {c.FindingCount}")));
Expand Down
19 changes: 19 additions & 0 deletions src/SignsOfAI.Core/Calibration/CalibrationModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,25 @@ public sealed record StratumCalibration

public required int TotalWords { get; init; }

/// <summary>
/// The length of the shortest and longest text in this group, in words.
///
/// Not decoration on the table. The threshold below is only supported over the lengths that were
/// actually measured, and this group's shortest text is where that support stops — the whole of
/// issue #59 is that a boundary fitted here was being spent on a pasted paragraph a quarter of
/// its length.
/// </summary>
public required int ShortestWords { get; init; }

/// <inheritdoc cref="ShortestWords"/>
public required int LongestWords { get; init; }

/// <summary>
/// The median length, which is the honest middle of a range this skewed: the corpus runs from 712
/// words to 9,772, and quoting the mean would put the centre where few of the texts actually are.
/// </summary>
public required double MedianWords { get; init; }

public required double MedianScore { get; init; }

/// <summary>The score nine in ten of these human texts stay below.</summary>
Expand Down
4 changes: 4 additions & 0 deletions src/SignsOfAI.Core/Calibration/Calibrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ public static StratumCalibration Measure(
return new StratumCalibration
{
Name = name, Count = 0, TotalWords = 0,
ShortestWords = 0, LongestWords = 0, MedianWords = 0,
MedianScore = 0, NinetiethScore = 0, HighestScore = 0,
Thresholds = [], ThresholdForTarget = null,
};
Expand All @@ -98,6 +99,9 @@ public static StratumCalibration Measure(
Name = name,
Count = samples.Count,
TotalWords = samples.Sum(s => s.WordCount),
ShortestWords = samples.Min(s => s.WordCount),
LongestWords = samples.Max(s => s.WordCount),
MedianWords = Quantile([.. samples.Select(s => (double)s.WordCount).Order()], 0.50),
MedianScore = Quantile(scores, 0.50),
NinetiethScore = Quantile(scores, 0.90),
HighestScore = scores[^1],
Expand Down
19 changes: 19 additions & 0 deletions src/SignsOfAI.Core/Calibration/PublishedCalibration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,25 @@ public sealed record PublishedCalibration

public double RateHigh { get; init; }

/// <summary>
/// The length of the shortest and longest text the threshold above was measured on, in words as
/// the analyzer counts them.
///
/// Recorded because a bound measured on one population must not be spent on another, and length
/// is such a population: the shipped boundary was fitted on texts of 662 words and up, and was
/// being applied to a pasted paragraph. <see cref="Model.VerdictBands.Measured(int)"/> reads
/// <see cref="ShortestWords"/> and withholds the verdict below it; nothing reads
/// <see cref="LongestWords"/> yet, and it is here so the range on the page is a range rather
/// than half of one. See issue #59.
///
/// Null in snapshots written before this field existed. That case does not gate — see
/// <see cref="Model.VerdictBands.Measured(int)"/> for why it differs from the language rule.
/// </summary>
public int? ShortestWords { get; init; }

/// <inheritdoc cref="ShortestWords"/>
public int? LongestWords { get; init; }

/// <summary>
/// The rules most often seen on human writing, worst first. Printed alongside a report's findings
/// so a reader can see whether the evidence they are holding leans on a rule that is known to be
Expand Down
2 changes: 1 addition & 1 deletion src/SignsOfAI.Core/Calibration/published-calibration.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"corpusId":"signsofai-human-baseline","texts":90,"measuredOn":"2026-08-24","engine":"0.4.0","recommendedThreshold":25,"flaggedAtThreshold":0,"rateLow":0,"rateHigh":0.04093562618368095,"noisiestRules":[{"ruleId":"stat.burstiness","textShare":0.2777777777777778},{"ruleId":"rhet.in-terms-of","textShare":0.1},{"ruleId":"rhet.not-only-but","textShare":0.08888888888888889},{"ruleId":"rhet.in-order-to","textShare":0.07777777777777778},{"ruleId":"lex.furthermore","textShare":0.07777777777777778},{"ruleId":"lex.robust","textShare":0.07777777777777778},{"ruleId":"lex.just","textShare":0.07777777777777778},{"ruleId":"lex.simply","textShare":0.07777777777777778}],"languages":[{"language":"en","texts":65,"recommendedThreshold":null,"bestBound":0.05580153215404492},{"language":"es","texts":25,"recommendedThreshold":null,"bestBound":0.13319225276039096}]}
{"corpusId":"signsofai-human-baseline","texts":90,"measuredOn":"2026-08-24","engine":"0.4.0","recommendedThreshold":25,"flaggedAtThreshold":0,"rateLow":0,"rateHigh":0.04093562618368095,"shortestWords":662,"longestWords":9328,"noisiestRules":[{"ruleId":"stat.burstiness","textShare":0.2777777777777778},{"ruleId":"rhet.in-terms-of","textShare":0.1},{"ruleId":"rhet.not-only-but","textShare":0.08888888888888889},{"ruleId":"rhet.in-order-to","textShare":0.07777777777777778},{"ruleId":"lex.furthermore","textShare":0.07777777777777778},{"ruleId":"lex.robust","textShare":0.07777777777777778},{"ruleId":"lex.just","textShare":0.07777777777777778},{"ruleId":"lex.simply","textShare":0.07777777777777778}],"languages":[{"language":"en","texts":65,"recommendedThreshold":null,"bestBound":0.05580153215404492},{"language":"es","texts":25,"recommendedThreshold":null,"bestBound":0.13319225276039096}]}
22 changes: 20 additions & 2 deletions src/SignsOfAI.Core/Model/AnalysisResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,33 @@ public sealed record AnalysisResult
/// </summary>
public CitationReport Citations { get; init; } = CitationReport.Empty;

/// <summary>
/// Whether this build will say anything at all about this document — score, language and length
/// together.
///
/// Derived here so that every surface asks the same question of the same three facts. The last
/// time each host decided for itself, one engine gave three answers about the same text; see
/// <see cref="VerdictBands"/>, whose whole existence is that failure.
/// </summary>
public bool HasVerdict =>
VerdictBands.Holds(OverallScore, Language, Statistics.WordCount);

/// <summary>
/// Human-readable one-line verdict derived from <see cref="OverallScore"/>, in English.
///
/// English-only on purpose: this is what a machine consumer gets — the CLI's `--json`, the MCP
/// tool's payload — where a stable string is more use than a translated one. Anything shown to a
/// person goes through the interface's localiser or the report's own resources, both of which
/// take their boundary from <see cref="VerdictBands"/> exactly as this does.
///
/// Four states, not two, and the order matters: the reasons this build cannot speak are checked
/// before the reading it would otherwise give. Collapsing "we did not measure anything this
/// short" into "no signs above the measured boundary" would turn a refusal into a finding, which
/// is the failure this whole property exists to avoid.
/// </summary>
public string Verdict => VerdictBands.Holds(OverallScore)
? "Signs of AI writing"
public string Verdict =>
!VerdictBands.Measured(Statistics.WordCount) ? "No verdict: below the measured length"
: !VerdictBands.Measured(Language) ? "No verdict: language not measured"
: VerdictBands.Holds(OverallScore) ? "Signs of AI writing"
: "No signs above the measured boundary";
}
79 changes: 79 additions & 0 deletions src/SignsOfAI.Core/Model/VerdictBands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,52 @@ public static class VerdictBands
/// </summary>
public static bool Holds(double score) => Threshold is { } threshold && score >= threshold;

/// <summary>
/// The shortest text the boundary was ever measured on, or null when the embedded calibration
/// predates this field.
///
/// This is a statement about **coverage, not about reliability**. It does not claim the tool
/// breaks below this length; it says nothing was measured there, which is a different and much
/// weaker claim — and the only one the corpus can support. The 25/100 boundary was fitted on 90
/// texts whose shortest is 662 words and whose median is 2,772, and it was being applied to a
/// pasted paragraph with nothing on the page to say so. See issue #59.
///
/// The number comes from the analyzer's own word count, not from a naive split on whitespace —
/// the two disagree by about 7% on this corpus. Deriving the floor with one counter and comparing
/// against another would silently rescale the gate, which is the same trap
/// <see cref="Rules.GenreGate"/> documents for its rates.
///
/// It is an observation rather than a fitted parameter, and that is the whole point of choosing
/// it: no grid of lengths, no windows cut out of longer documents, no subset selected to make a
/// number come out. Every earlier attempt at this measured a rate against synthetic short text
/// and inherited the problem it was fixing — a 400-word window sliced out of a paper is not a
/// paragraph somebody *composed* at 400 words, and a floor fitted on the first does not describe
/// the second.
///
/// The way to lower it is to measure shorter writing: complete texts, published before 2022, at
/// the lengths people actually paste. That is issue #66, and every one of them extends this
/// downward by evidence rather than by decision.
/// </summary>
public static int? MinimumWords => PublishedCalibration.Current?.ShortestWords;

/// <summary>
/// Whether a document of this length sits inside what the boundary was measured on.
///
/// **There is deliberately no ceiling.** The asymmetry is measured, not assumed: shortening a
/// text moves its score toward the machine — 0 of 32 documents flagged whole, 6 of the same 32
/// flagged as 400-word excerpts of themselves (`Docs/PARAPHRASE.md`, section *Length*) — while
/// nothing suggests a thesis longer than the corpus is at risk. Silencing the long end too would
/// withhold a verdict for a symmetry nobody has evidence for.
///
/// A null minimum does **not** gate. That is deliberately unlike the language condition, where
/// absence from the corpus is a positive fact each snapshot records. Here null means the snapshot
/// is older than the field, not that the corpus had no lengths; going silent on it would stop
/// every fork carrying a 0.4.0 snapshot from speaking at all, which is a change driven by a
/// missing field rather than by evidence.
/// </summary>
public static bool Measured(int wordCount) =>
MinimumWords is not { } floor || wordCount >= floor;

/// <summary>
/// The same question for a document in a named language, which is stricter and has to be.
///
Expand All @@ -48,6 +94,16 @@ public static class VerdictBands
/// </summary>
public static bool Holds(double score, string? language) => Holds(score) && Measured(language);

/// <summary>
/// The full question, and the one every surface should ask: score, language and length together.
///
/// The three conditions are the same rule applied three times — *a bound measured on one
/// population must not be spent on another* — and they are answered in one place because the
/// last time this was decided in eight, one engine gave three answers about the same text.
/// </summary>
public static bool Holds(double score, string? language, int wordCount) =>
Holds(score, language) && Measured(wordCount);

/// <summary>Whether the corpus contains this language at all, however thinly.</summary>
public static bool Measured(string? language) =>
PublishedCalibration.Current?.For(language) is not null;
Expand All @@ -70,6 +126,17 @@ public static bool Measured(string? language) =>
>= 45 => VerdictEmphasis.Elevated,
_ => VerdictEmphasis.Present,
};

/// <summary>
/// The same, for a document whose language and length are known.
///
/// Colour is part of the verdict whatever the design system pretends. A page that withholds a
/// verdict in words and paints the score red anyway has given the verdict — louder, and without
/// the sentence that qualifies it.
/// </summary>
public static VerdictEmphasis Emphasis(double score, string? language, int wordCount) =>
!Measured(wordCount) || !Measured(language) ? VerdictEmphasis.Unmeasured
: Emphasis(score);
}

/// <summary>
Expand All @@ -87,4 +154,16 @@ public enum VerdictEmphasis
Elevated,

High,

/// <summary>
/// Outside what was measured — a language the corpus never contained, or a document shorter than
/// anything the boundary was fitted on.
///
/// Separate from <see cref="None"/>, and the separation is the point. Both withhold a verdict, but
/// <see cref="None"/> is a reading — the tool looked and found little, and a reassuring colour is
/// honest for it. This one is a refusal to read, and painting a 72/100 passage green because the
/// verdict was withheld would state the opposite of what was withheld, in the loudest channel on
/// the page. See issue #59.
/// </summary>
Unmeasured,
}
18 changes: 15 additions & 3 deletions src/SignsOfAI.Core/Reporting/EvidenceReport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,15 @@ public static string ToMarkdown(AnalysisResult result, ReportOptions? options =
sb.AppendLine();
if (!VerdictHolds(result))
{
AppendBlock(sb, text, ReportMessages.AnalysisNoVerdict);
// Which of the reasons, because they are not the same statement. "Below the threshold"
// is a reading — the tool looked and found little. "Shorter than anything measured" is
// a refusal to read at all, and a reader who is told the first when the second is true
// will take away a reassurance nobody offered.
AppendBlock(sb, text,
VerdictBands.Measured(result.Statistics.WordCount)
? ReportMessages.AnalysisNoVerdict
: ReportMessages.AnalysisNoVerdictShort,
Num(result.Statistics.WordCount), Num(VerdictBands.MinimumWords ?? 0));
sb.AppendLine();
}

Expand Down Expand Up @@ -491,8 +499,12 @@ public static string FolderToHtml(
/// it decides when the tool speaks, and it is published, measured and printed on the page beside
/// the language's own figure. See issue #32.
/// </summary>
private static bool VerdictHolds(AnalysisResult result) =>
VerdictBands.Holds(result.OverallScore, result.Language);
/// <remarks>
/// Length joined score and language in #59, and it arrives through <see cref="AnalysisResult"/>
/// rather than being asked here, so that the CLI's JSON, the MCP payload, the interface and this
/// page cannot drift into disagreeing about the same document.
/// </remarks>
private static bool VerdictHolds(AnalysisResult result) => result.HasVerdict;

/// <summary>
/// The sentence that has to appear on every report. Written from the embedded calibration so it
Expand Down
Loading
Loading