measurement · 28 August 2026

How often do open AI-text detectors call pre-ChatGPT books machine-written?

Every passage scored here was published before ChatGPT existed, so every “AI” verdict is a false positive by construction. On a corpus assembled to look as machine-written as human prose gets, chatgpt-detector-roberta called 4.84% of passages AI-generated; on a genre-neutral control drawn from the same archive by the same extraction code, 2.49%. The older GPT-2 output detector was wrong less often — 1.95% and 1.63% — and could not separate the two corpora at all. The distance between those two behaviours is the result.

Why this is worth measuring

Open-weight AI-text detectors are free and run on a laptop. The two measured here are not obscure: in the thirty days before this was written, Hugging Face reported about 94,000 downloads of the GPT-2 output detector and about 51,000 of the ChatGPT detector — one reading, taken on 2026-08-28 and published as hf_downloads.json. What anyone does with them after downloading is not something I can observe, so I am not going to claim it — but a tool this cheap to deploy is cheap to deploy on writing that is definitely human, and the obvious question about any such tool is how often it is wrong there. The obvious way to answer it is to find human writing that cannot be machine-generated, because it was published before the machines existed.

That is what this is. Every passage scored here comes from a book, pamphlet, newspaper or report catalogued before 2001 and scanned by the Internet Archive — the latest-catalogued item in either corpus is from 2000, and the build refuses to render this page if that ever stops being true. ChatGPT shipped in December 2022. Whatever these detectors say about this text, they are saying it about prose typed by a person on a typewriter or a word processor, and every "AI" verdict is a false positive by construction.

This grew out of a failed attempt to find a thirty-year-old textbook that a commercial detector would call AI-generated. That search failed — sixteen pre-2000 passages from fourteen documents, every one of them Human. The seventeenth check was a paragraph I wrote myself as a positive control; it came back AI, which is the only reason the sixteen mean anything. But the open detector I was using to rank candidates was extremely confident about texts the commercial one waved through: 0.994 machine-written for a 1992 US Navy training manual. That gap was the more interesting finding, and this measures it.

The number

Four rows: two detectors, each on both corpora. The adversarial rate is a ceiling — how wrong a detector gets on human writing chosen to give it the worst possible time — and the control rate is the one that speaks to ordinary pre-2001 books, within the limits of what one archive holds. Neither is a false-positive rate for “old books” in general, and the section below says so at more length. What the pair is good for is the comparison: a detector whose two rates are far apart is reacting to register rather than to authorship, and a detector whose two rates sit on top of one another is not.

detector and corpuspassagesbookscalled AIrate95% CI (passages)95% CI (by book)
chatgpt-detector-roberta · adversarial12,2471,8095934.84%4.48–5.24%4.26–5.44%
chatgpt-detector-roberta · control1,287212322.49%1.77–3.49%1.49–3.69%
roberta-base-openai-detector · adversarial12,2471,8092391.95%1.72–2.21%1.66–2.23%
roberta-base-openai-detector · control1,287212211.63%1.07–2.48%0.97–2.31%

Two intervals, because passages are not independent draws: they come in books, and passages from one book share an author, a register, a translator and one scanner's OCR. The first interval treats every passage as its own observation; the second resamples whole books. The pre-registration says the wider of the two is the headline, and it is the one in bold on each row — the by-book interval on 3 of the 4 rows and the passage-level one on 1. Taking the wider is always the weaker claim, so the rule cannot be steered by which number I would prefer.

The pre-registration fixed a condition under which this page has no finding in it: if both detectors flagged under 2% of the adversarial corpus, there is no story, and I said in advance that I would publish the numbers and say so. The higher of the two adversarial rates is 4.84%, so the condition does not hold. The build refuses to render if it does — a null result would need different copy, and it should not be possible to ship the confident version by not noticing.

What I am not claiming

The big corpus was assembled adversarially, and its rate is a ceiling, not a population rate. Every query behind those 12,247 passages was written to find prose that looks machine-written — programmed instruction, military training series, committee reports, beginner tutorials, translated textbooks. It is the opposite of a random sample of twentieth-century publishing. It answers "how wrong can these detectors get on human writing?" It does not answer "how often are they wrong on old books," and I will not quote it as if it did.

The second number, from the control corpus, is the one that speaks to the second question — within the limits of what the Internet Archive happens to hold.

How the two corpora differ, and how they don't

The control was drawn to be identical to the adversarial corpus in every respect except the one under test. Same archive. Same extraction code — one passages(), called with the same target and the same length bounds, so both sides were cut to one rule and the two median lengths in the table below are outputs of it rather than of two conventions; the build refuses if they drift more than 15 words apart, because at that point the sentence would be describing something other than what ran. Same detector, same threshold, same scoring function — not a second implementation of it, which is where that guarantee usually dies. The thing removed is the register targeting: the control queries carry no title terms at all, just a collection, a media type and a range of years.

A second difference, smaller, that I would rather state than have found. The control draw applies a language gate to each book's own text and drops anything that is not English, because a genre-neutral pull from these collections returns Hungarian and French computer books and an English-trained detector scoring Hungarian measures nothing. The adversarial corpus never got that gate — it is English by construction, since every query behind it was English title terms. Two draws, two procedures, so "identical except the register targeting" was not quite true. The question is whether it matters, and it is measurable: taking every passage on both sides and asking whether a non-English function word sits among its three commonest tokens, the adversarial corpus has 4 such passages out of 12,247 and the control has 0 out of 1,287 — and three of the four are the same false alarm on the token dos: two MS-DOS manuals, and an ERIC report about the novelist John Dos Passos. What survives is one Spanish passage. The gate the control had and the corpus did not was, on this data, removing something that was not there.

A third, of the same shape, and this one is not small. The control also drops a book whose own text mentions post-1999 years more than twice, before cutting any passage from it. Only the later adversarial pulls did — that filter did not exist when the earlier ones ran — so 3,751 adversarial passages are date-verified against the book's own text and 8,496 are dated by the catalogue entry alone. The passage-level version of the rule did apply everywhere, to both draws. This section is where a reader should be able to find every way the two draws differ, so it is named here; what it costs, what the tier split does to the flag rate, and the rule that was pre-registered on the gap are all under Limits.

One thing did not match, and I had claimed it did. An earlier draft of this paragraph said “same era”. It is not: both corpora stop at 2000, but the control's median year is 1985 against the adversarial corpus's 1994, because a genre-neutral draw lands wherever the archive's mass is while register-targeted queries pull toward the 1990s computer-book boom. Era could move a detector on its own — typesetting, scan quality and house style all change across three decades. So the difference is also reported restricted to passages from 1990 onward, where the two corpora do overlap, using the identical statistic. Both numbers are below; per-decade rates are in the published analysis output.

AdversarialControl
Selected ontitle keywords chosen to find machine-like prosenothing but collection, media type, year
Passages12,2471,287
Source items1,809212
Median passage length313 words313 words
Typical contentNavy training series, For Dummies, programmed instruction, standards documentsState and municipal reports, college catalogues, environmental impact statements, trade newsletters, church and mission periodicals, engineering theses, hardware manuals

4 of the source items were drawn into both corpora, contributing 19 passages to the adversarial side and 19 to the control — byte-identical text, since both corpora run the same extractor over the same scan. They are left in. Removing them would strip from the control precisely those books that happened to match a register-targeted query, which biases the control away from the thing under test and makes the difference larger; leaving them in pulls the two rates together, so the difference is if anything understated. The difference with them excluded is published beside the headline one.

It also buys a check for nothing. Those passages are scored twice by each detector, in two corpora under two different shuffles — so each copy sat in a different batch, beside different passages, at a different padding width. Across the 38 such comparisons — each passage, on each detector, scored once in each corpus — the largest difference between the two scores of the same text is 0, and the number of verdict flips is 0. Scores are recorded to six decimal places, so a difference of zero here means below that resolution and not a proof of bit-identity.

And the comparison itself. This is the pre-registered statement of the result — the difference between the two rates, with a 95% interval from resampling whole books within each corpus. It is here rather than left to the reader because comparing the two rates by eye, or by whether their intervals overlap, is not a valid test: non-overlapping intervals do imply a difference, but overlapping ones do not imply its absence, and that asymmetry has a way of being discovered in whichever direction suits the author.

comparisonadversarialcontroldifference95% CI on the difference
chatgpt-detector-roberta · as drawn4.84%2.49%+2.36 pp+1.06 to +3.62 pp
chatgpt-detector-roberta · excluding books in both corpora4.85%2.52%+2.33 pp+1.09 to +3.54 pp
chatgpt-detector-roberta · 1990 onward only5.26%2.34%+2.92 pp+1.43 to +4.41 pp
roberta-base-openai-detector · as drawn1.95%1.63%+0.32 pp-0.45 to +1.02 pp
roberta-base-openai-detector · excluding books in both corpora1.95%1.66%+0.30 pp-0.48 to +1.04 pp
roberta-base-openai-detector · 1990 onward only2.08%2.34%-0.26 pp-1.77 to +1.01 pp

Does a stricter threshold fix it?

Raising the cut-off is the first thing anyone reaches for, so here is what it buys. Every cell is the same set of scores read at a stricter threshold; no text was re-scored and nothing else changes. This is context for the number above, never a second source for it — the headline threshold was fixed at 0.5 before any passage was scored, and choosing the cut-off after seeing the scores is exactly the move this table would otherwise make easy.

thresholdhello advhello ctlopenai advopenai ctl
P(AI) > 0.504.84%2.49%1.95%1.63%
P(AI) > 0.704.70%2.41%1.50%1.32%
P(AI) > 0.904.11%1.94%1.08%0.93%
P(AI) > 0.953.41%1.55%0.74%0.62%
P(AI) > 0.991.67%0.39%0.45%0.23%

What a false positive actually looks like

These are the three passages in the corpus the first detector scored highest, picked by that rule and no other — I did not choose which passages appear here, and if the most confident false positives were boring, boring is what this section would contain. Each is verbatim OCR from a scanned book catalogued before 2001, linked to the scan it came from so you can read the page it sits on. The score beside each one is that detector's confidence that a machine wrote it.

Read them as three of a group, not as first, second and third. The very top of this distribution is compressed: the twenty highest-scoring passages all sit within 0.0001 of one another, and 0.000008 separates the first from the third. That gap is 1.9× the largest run-to-run wobble the batching check below measures, so the ordering is reproducible — but by a margin thin enough that the multiple is worth printing rather than waving at. (An earlier draft here said the wobble was “far lower”. It was, when the batching check had scored a couple of hundred pairs; it kept scoring pairs, the worst wobble grew, and the sentence quietly stopped being true. The build now refuses to render if the wobble ever reaches the gap.) The ordering is real, then. It is just not about anything: these passages all score about as high as this detector goes, and which one edged the others is a fact about the fourth decimal place. Below that group the distribution falls away quickly, and the two-hundredth-highest passage scores 0.990.

Education in the twenty-first century must prepare young people for the high probability that most of them will live long lives in a world that has an increasingly older population. This means that young people need to learn more than just the process of aging or about older people. It means increasing students’ understanding of how population aging affects every aspect of society, including their own present and future lives. It means debunking widespread misinformation about aging, including stereotypes of older people that breed age prejudice and fear of growing old. It especially means helping young people to recognize and take advantage of their own potential for longer, healthy lives. Education for longer life means this and more. Young people need practical and useful information about living longer in an aging world. They must develop an awareness that aging is a lifelong process in which they are already involved. They need to know that they can significantly control their own aging by healthy habits and lifestyle, and that growing older can be a process of growth and development. Young people who have the potential of living longer than all previous generations cannot aff…
"If a student doesn't need special education services but has a disability, what technology services should be provided by the school?" Both federal and state laws call for schools to provide students with disabilities with a free and appropriate education in the least restrictive environment. This means that students not only be allowed to participate in school activities, physical education, health, and transportation services, but that they have an equal opportunity to participate. In each of these areas, technology may be used to help the student to have an equal opportunity to participate. Technologies such as hearing aids, print enlargers, and wheelchairs allow students with disabilities to participate in school activities. "What if the student is told that he or she cannot take their technology home from school?" Sometimes devices are used only at school, and the student is not allowed to take 10 Assistive Technology Guide 5 them home. Parents must be able to show the school how the use of the technology at home allows the student the same opportunity to participate in school-related activities. For example, a language device used in school to talk to other students can be u…
There is no one method that works and no one book that works. Most experienced ESL instructors are eclectic, trying different approaches and using different materials in the class. It is important to remember that there is no single book that teaches every skill students need. We must use many sources, adapting and developing materials appropriate for our particular students. It is also a priority to choose materials that help our students understand and cope with their lives in urban Philadelphia. ESL students often face discrimination based on race, sex, and class in their lives. Even within the classroom or tutoring situation the instructors must deal with tensions and stereotypes, which differ among groups or na- tionalities. Moreover, students who have been successful in their own coun- tries may face a drop in social status and may be working in low-paying, menial jobs where they face discrimination. Within the classroom the teacher must be sensitive to the fact that she or he may be from a different background, race, or socio-economic class from the students. Recognizing that fact, the instructor can set up an atmosphere where students feel free to talk about differences. Th…

It is not the OCR

Every passage here is optical character recognition over a scanned page, so every passage carries some density of scanner noise. The obvious objection to the whole exercise is that the detectors are reacting to that damage rather than to the writing. So I measured it: for each passage, the fraction of four-or-more-letter alphabetic tokens absent from the system word list — /usr/share/dict/american-english, extended with the possessive stems and the regular plurals that list omits, so that a real word is not counted as scanner damage on a technicality.

The absolute values are not an error rate — technical prose is full of real words no dictionary carries. Only the comparison between bands is meaningful.

This table, every other breakdown on this page, and the worked examples above split the scores of Hello-SimpleAI/chatgpt-detector-roberta alone — the detector the tables above call hello — not the two detectors pooled or averaged. The corresponding breakdowns for the other detector are in analysis.txt, which prints every one of them for both. The build refuses to render if those sections ever stop agreeing on which detector they are, because this sentence names one.

Each row carries the same two intervals as the headline table, under the same pre-registered rule: one treating every passage as an independent observation, one resampling whole books, and the wider of the two in bold. That matters more in a subgroup than it does in the headline — a register or an OCR band can be a handful of books contributing many passages each, which is exactly where a passage-level interval is most over-confident. Across the 17 rows in the tables below, the by-book interval is the wider on 14 and the passage-level one on 3.

hellopassagesbookscalled AIrate95% CI (passages)95% CI (by book)
0-4% out-of-dictionary9,0851,6765295.82%5.36–6.32%5.13–6.55%
10-14% out-of-dictionary44928161.34%0.61–2.88%0.23–2.66%
15-19% out-of-dictionary865711.16%0.21–6.30%0.00–3.85%
20%+ out-of-dictionary503600.00%0.00–7.14%0.00–0.00%
5-9% out-of-dictionary2,577985572.21%1.71–2.85%1.52–2.96%

Which registers

Register targeting is the entire design of the adversarial corpus, so it is worth seeing which queries actually did the work. They did not do it evenly. Beginner computer tutorials — Teach Yourself, For Dummies, step-by-step guides — are called AI-generated at 16.26%, double the next family on the list and several times most of the rest, while the translated-textbook and military-training families come in at or below the control's rate — on far fewer flagged passages, with the wide intervals to match. The flat imperative voice of a 1990s software manual is, on this evidence, close to what this detector has learned to hear as a language model.

hellopassagesbookscalled AIrate95% CI (passages)95% CI (by book)
Beginner computer tutorials — Teach Yourself, For Dummies, step-by-step1,11315418116.26%14.21–18.55%12.52–20.16%
Composition and rhetoric textbooks, reference and encyclopedia entries1,825295603.29%2.56–4.21%2.30–4.36%
Management, self-improvement, popular science2,051266532.58%1.98–3.36%1.85–3.38%
Mid-1990s popular introductions to the internet and computers19939168.04%5.01–12.66%1.60–16.15%
Nursing, counselling, social work and education textbooks33739164.75%2.94–7.57%1.80–8.62%
Programmed instruction, technical/maintenance manuals, translated textbooks57697183.12%1.99–4.89%1.57–4.97%
Self-help and advisory writing — how-to, career, study skills1,319232715.38%4.29–6.74%3.90–7.04%
Self-study textbooks, study guides, ESL instructional materials3,7575651534.07%3.49–4.75%3.20–4.96%
Translated / non-native-English textbooks — Mir, Tata McGraw, Progress1201221.67%0.46–5.87%0.00–5.00%
US military training series — NEETS, rate training, correspondence courses950118232.42%1.62–3.61%1.17–4.00%

And the two date-verification tiers. The pre-registration set a rule here rather than an observation: if the book-date-verified subset and the catalogue-year-only subset diverged by more than 2 percentage points, the strict subset would become the headline and this page would be about that number instead. The largest gap between the tiers, across both detectors, is 1.22 points, so the rule did not fire and the headline stands as drawn. The build refuses to render if it does fire, which is the only version of a pre-registered rule that means anything.

hellopassagesbookscalled AIrate95% CI (passages)95% CI (by book)
book-date-verified3,7516151724.59%3.96–5.30%3.63–5.61%
catalogue year only8,4961,2014214.96%4.51–5.44%4.27–5.69%

Does the batching change the answer?

Passages are scored in batches, and a batch is padded to the length of its longest member — so which passages a given passage happened to be shuffled in with sets how much padding it carries. A correctly masked model ignores those pad tokens entirely. "Should" is not "does", and if it were false here then every rate on this page would be conditional on an arbitrary grouping rather than on the text, so it is worth an hour to check rather than an assumption to state.

The check re-scores a sample one passage at a time — a batch of one has nothing to pad to, so its score is the canonical value — and compares against what the batched run recorded. The sample is not random-only: every passage within 0.01 of any published threshold is included by construction, because float-level noise can only change a published number where a passage sits near a decision boundary. A purely random sample would test the question precisely where the answer cannot matter.

detector and corpusre-scored one at a timelargest changeverdict flips
hello, adversarial corpus5264.29e-060
hello, control corpus1684.98e-070
openai, adversarial corpus2562.61e-060
openai, control corpus1581.92e-060

Limits

Method, and everything that could have gone wrong

The bounds, the decision rule and the confounds were written down before a single passage was scored, because after a confirming pass every result looks like a result. That file also records every time I had to amend it, in the amount of detail that lets you decide for yourself whether the amendment was honest:

Two smaller things are in that file too, because a study that only discloses the flattering half of its history is not disclosing anything: one identifier appears in both the discarded control draw and the real one — a legitimate re-draw, since the same book is held in both collections — and I had asserted that both detectors were cached locally when only one of them was, which would have failed the run five hours in.

Everything runs on CPU on a one-core box: two roberta-base checkpoints sequentially, never at once. Scores are appended one line at a time with a resume key, and the corpus is shuffled under a fixed seed before scoring — so that if the run dies halfway, what survives is a uniform random sample rather than an alphabetical slice of one genre. A previous scan of mine died silently two-thirds through and I did not notice, because a crashed writer and a finished writer leave the same file.

Reproducing it

Every script is on this page: merge.py builds the corpus from the raw archive.org pulls under one stated rule, fetch_control.py draws the control, score.py runs a detector over either corpus — the same function for both, because a second implementation is where a paired comparison quietly stops being paired — oov.py computes the out-of-dictionary rate, passage by passage, into oov_passages.jsonl, and analyse.py produces every rate, interval and difference above — not every number above, which is a distinction I got wrong in this paragraph before checking it: the counts beside those rates come from the corpus and the draw logs at build time, each of which is published below. The rest of the tree is here too, because a pipeline described in prose is not a pipeline anyone can check: tokens.py measures how much of each passage the 512-token limit cut off, passage by passage, into tokens.jsonl — which is published for the same reason as the rest: analysis.txt prints a truncation rate off it, and the corpus text that would let you recompute that rate yourself is the one thing here that is not published, registers.py holds the single copy of what each query family was looking for and how a passage is attributed to one, clusterci.py computes the intervals that treat books rather than passages as the unit, padding_check.py is the batching check above, selftest.py exercises the build's newest blocks against a partial scan so they do not run for the first time at the moment of publication, and build_page.py renders this page — including every refusal that stops it rendering. The pipeline itself: chain.sh runs the scans and the checks in order, run_scan.sh supervises a scan the kernel may kill and writes its done marker only once the output file has as many lines as the input, finish.sh waits on the chain and produces analysis.txt, and run_analysis.sh is the one copy of the rule that refuses to overwrite a good analysis file with a short one. chain.run.sh and finish.run.sh are the frozen copies that actually executed: a long chain is launched from a copy, because bash reads a script by byte offset and editing the original mid-run corrupts it hours later — and from that moment the source and the running process are two different objects. They are published because they are not identical to their sources, which is to say the sources describe a run that did not happen: the analysis stage exists in chain.sh and not in the copy that executed, so analysis.txt came from the waiter rather than from the chain. Read finish.run.sh and its own header comment will tell you the opposite — that the chain runs the analysis too and this run is a redundant second one. That comment is wrong about this run, and it is published unedited, because a script being read by a live shell cannot be edited without corrupting it, and because the file that ran is the artifact whether or not its author had understood it yet. The corrected account is in finish.sh. One set of scripts is not in this directory at all, and it is the set the selection claim rests on: the passages were cut in the earlier project this grew out of, and merge.py only merges its output, so the check that keeps this paragraph honest — it walks the study directory — was blind to them. fetch_corpus.py is the shared library and the first pull: the archive search, the OCR normaliser, and the passages() cutter both corpora were cut by, which is what “same extraction code” means above. fetch2.py, fetch3.py, fetch4.py, fetch5.py, fetch6.py, fetch7.py, fetch8.py, fetch9.py and fetch10.py are the query families, one per pull, each writing the corpus file of the same number that index.jsonl names for every passage it produced. Anyone who wants to argue that this corpus was selected for something other than machine-looking prose can now read the queries instead of taking the sentence from me. The raw scores are here too: scores_hello.jsonl, scores_openai.jsonl and the control pair, scores_control_hello.jsonl and scores_control_openai.jsonl, one JSON line per passage. Those files key on an integer index, which is unusable on its own, so index.jsonl gives every scored passage its archive.org item, its passage number within that item, its catalogue year, its date-verification tier, its query families and its length — enough to recompute any rate, any breakdown and any confidence interval on this page without rerunning a model, and enough to pull any passage back out of the source scan. What is not published is the adversarial corpus text itself: twelve thousand verbatim runs of OCR from other people's scans. The control corpus is small enough to publish whole, and is, as control.jsonl — along with the discarded first draw, so you can see what was thrown out and satisfy yourself it was thrown out before any control passage had been scored, which is the claim that matters and is not the same as the two files being disjoint. The batching check publishes its per-passage output too — padding_hello.jsonl, padding_openai.jsonl, padding_control_hello.jsonl and padding_control_openai.jsonl give the batched and single-passage score for every passage re-scored, so the worst-case number in that table can be recomputed or contradicted. analysis.txt is the unedited output of analyse.py: every rate, interval and difference on this page appears in it, next to the ones that did not make the page — and the build refuses to render if a number in one of the tables above is missing from that file, which is how I learned that sentence had been false in three places at once. Both corpus files are pinned by digest in corpus.sha256, and the build refuses to render if either one moves; the control file above is published whole, so its digest is yours to check. Because that text is unpublished, index.jsonl carries a sha1 of each passage instead: re-extract the same books and the hashes have to match, one by one. The control draw kept its own record, and that is published too: control.log lists every item the draw considered, the reason each rejected one was rejected — not English, no text, or dated by its own contents rather than by its catalogue entry — and the summary line that the date-rejection count on this page is read out of at build time. One warning for anyone rerunning this: the two checkpoints order their classes in opposite directions — id2label reads {0: Human, 1: ChatGPT} for one and {0: Fake, 1: Real} for the other — so a script that takes the same softmax column for both reports one detector’s human rate as its AI rate. score.py resolves the class by name and exits if the name is absent, and prints the index it resolved to, which is in the run logs.

Run on a one-core box with no GPU. If you find an arithmetic error on this page, the score files above are enough to prove it, which is the point of publishing them.