Skip to content

Commit d023d65

Browse files
authored
Merge pull request #65 from peopleworks/chat-residue-rules
Catch the half of the conversation nobody meant to submit
2 parents 79c98a8 + b4215ea commit d023d65

6 files changed

Lines changed: 221 additions & 2 deletions

File tree

Docs/CALIBRATION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ It is **not an accuracy figure**. Accuracy needs machine-written text to measure
1111
- **Corpus** `signsofai-human-baseline`, fingerprint `123fa5b9ebca3f29`
1212
- **Texts** 90 (280,221 words)
1313
- **Engine** SignsOfAI.Core 0.4.0
14-
- **Run** 2026-08-05
14+
- **Run** 2026-08-24
1515
- **Target false-positive rate** 5%
1616

1717
Every text here was published before generative models could have written it. That is the whole basis for calling it human, and it is a stronger guarantee than any classifier offers about anything. The manifest names each source, its licence and its year, so the claim can be traced rather than trusted.

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,3 +384,9 @@ The optional server is a normal ASP.NET Core app (`dotnet publish` the `SignsOfA
384384

385385
Created by **Pedro Hernández — PeopleWorks**, [Microsoft MVP for .NET](https://mvp.microsoft.com/en-US/mvp/profile/24060a02-dbc6-44ec-bca5-c213ff9835c5). Detection markers are grounded in
386386
linguistics research on AI stylometry — see `Docs/GoogleResearch.md`.
387+
388+
The `chat.*` rules — the assistant's own turn, left in the document — were adapted from the pattern
389+
set of [amanmaqsood/prose-humanizer](https://github.com/amanmaqsood/prose-humanizer) (MIT), a writing
390+
skill rather than a detector. They entered the packs the way everything here does: screened against
391+
the calibration corpus first, where all six scored zero. Twelve other candidates from the same source
392+
did not enter, because they fire on writing published before generative models existed.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"corpusId":"signsofai-human-baseline","texts":90,"measuredOn":"2026-08-05","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}]}
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}]}

src/SignsOfAI.Core/Rules/Packs/rules.en.json

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1114,6 +1114,66 @@
11141114
"severity": "Info",
11151115
"message": "Mid-sentence bold — formatting slop.",
11161116
"suggestion": "Emphasis rarely helps prose; cut the bold."
1117+
},
1118+
{
1119+
"id": "chat.model-self-reference",
1120+
"category": "Rhetorical",
1121+
"regex": "\\bas an? (AI|artificial intelligence)( language)?( model| assistant)?\\b|\\bas a large language model\\b|\\bI(’|')?m an AI\\b",
1122+
"weight": 6.0,
1123+
"severity": "High",
1124+
"message": "A sentence the assistant says about itself, not a sentence in a document.",
1125+
"suggestion": "Delete it. If the text was drafted with a tool, say so in the acknowledgements instead.",
1126+
"evidence": "0 occurrences in the 249,455-word en half of the human calibration corpus (pre-2022, 65 texts)"
1127+
},
1128+
{
1129+
"id": "chat.training-cutoff",
1130+
"category": "Rhetorical",
1131+
"regex": "\\bas of my (last )?(training|knowledge)[- ](update|cutoff|cut-off|data)\\b|\\bmy training data (only )?(goes|extends|runs) up to\\b|\\bas of my last update\\b",
1132+
"weight": 6.0,
1133+
"severity": "High",
1134+
"message": "A disclaimer about a model’s training data, left in the text.",
1135+
"suggestion": "Delete it, and check the claim it was hedging.",
1136+
"evidence": "0 occurrences in the 249,455-word en half of the human calibration corpus (pre-2022, 65 texts)"
1137+
},
1138+
{
1139+
"id": "chat.signoff",
1140+
"category": "Rhetorical",
1141+
"regex": "\\bI hope th(is|at) helps\\b|\\bhope th(is|at) helps\\b|\\bwould you like me to\\b|\\blet me know if you(’|')?(d| would) like\\b|\\bis there anything else (I can|you(’|')?d like)\\b",
1142+
"weight": 6.0,
1143+
"severity": "High",
1144+
"message": "The closing line of an assistant’s reply, inside the document.",
1145+
"suggestion": "Delete it. The document has no reader to offer further help to.",
1146+
"evidence": "0 occurrences in the 249,455-word en half of the human calibration corpus (pre-2022, 65 texts)"
1147+
},
1148+
{
1149+
"id": "chat.capability-disclaimer",
1150+
"category": "Rhetorical",
1151+
"regex": "\\bI (can(’|')?t|cannot|do not|don(’|')?t) (browse|access) (the )?(internet|web|real-?time)\\b|\\bI (do not|don(’|')?t) have (access to )?real-?time (data|information|access)\\b|\\bI(’|')?m unable to browse\\b",
1152+
"weight": 5.0,
1153+
"severity": "High",
1154+
"message": "A statement about what the writing tool cannot do.",
1155+
"suggestion": "Delete it, and supply the source it was standing in for.",
1156+
"evidence": "0 occurrences in the 249,455-word en half of the human calibration corpus (pre-2022, 65 texts)"
1157+
},
1158+
{
1159+
"id": "chat.answer-preamble",
1160+
"category": "Rhetorical",
1161+
"regex": "\\bhere(’|')?s (the|a|your) (rewritten|revised|edited|updated|improved|polished) (version|text|draft|paragraph|essay|article)\\b|\\bhere is (the|a|your) (rewritten|revised|edited|updated|improved|polished) (version|text|draft|paragraph|essay|article)\\b",
1162+
"weight": 5.0,
1163+
"severity": "High",
1164+
"message": "The line a tool prints before handing back its answer.",
1165+
"suggestion": "Delete it. The document should start with the document.",
1166+
"evidence": "0 occurrences in the 249,455-word en half of the human calibration corpus (pre-2022, 65 texts)"
1167+
},
1168+
{
1169+
"id": "chat.eager-opener",
1170+
"category": "Rhetorical",
1171+
"regex": "(?m)(?:^|(?<=[.!?])\\s)(Certainly|Of course|Absolutely|Sure thing|Great question)[!,]",
1172+
"weight": 3.5,
1173+
"severity": "Medium",
1174+
"message": "An assistant’s eager opener (“Certainly!”, “Great question!”).",
1175+
"suggestion": "Delete it. Nobody asked the document a question.",
1176+
"evidence": "0 occurrences in the 249,455-word en half of the human calibration corpus (pre-2022, 65 texts)"
11171177
}
11181178
],
11191179
"functionWords": [

src/SignsOfAI.Core/Rules/Packs/rules.es.json

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -916,6 +916,66 @@
916916
"severity": "Info",
917917
"message": "Negrita a media frase — ruido de formato.",
918918
"suggestion": "El énfasis rara vez ayuda a la prosa; quita la negrita."
919+
},
920+
{
921+
"id": "chat.model-self-reference",
922+
"category": "Rhetorical",
923+
"regex": "\\bcomo (una? )?(modelo de lenguaje|IA|inteligencia artificial|asistente de (IA|inteligencia artificial))\\b|\\bsoy una (IA|inteligencia artificial)\\b",
924+
"weight": 6.0,
925+
"severity": "High",
926+
"message": "Una frase que el asistente dice sobre sí mismo, no una frase del documento.",
927+
"suggestion": "Bórrala. Si el texto se redactó con una herramienta, dilo en los agradecimientos.",
928+
"evidence": "0 occurrences in the 39,712-word es half of the human calibration corpus (pre-2022, 25 texts)"
929+
},
930+
{
931+
"id": "chat.training-cutoff",
932+
"category": "Rhetorical",
933+
"regex": "\\bhasta mi última actualización\\b|\\bmis datos de entrenamiento\\b|\\bhasta donde (llega|alcanza) mi entrenamiento\\b|\\bmi conocimiento (llega|alcanza) hasta\\b",
934+
"weight": 6.0,
935+
"severity": "High",
936+
"message": "Una advertencia sobre los datos de entrenamiento de un modelo, olvidada en el texto.",
937+
"suggestion": "Bórrala, y comprueba la afirmación que estaba matizando.",
938+
"evidence": "0 occurrences in the 39,712-word es half of the human calibration corpus (pre-2022, 25 texts)"
939+
},
940+
{
941+
"id": "chat.signoff",
942+
"category": "Rhetorical",
943+
"regex": "\\bespero que (esto |te |le )?(te |le )?(ayude|sirva|haya servido)\\b|¿(quieres|deseas|te gustaría) que (te )?(lo |la |los |las )?(reescriba|amplíe|resuma|ayude|prepare)\\b|\\bavísame si (quieres|necesitas|te)\\b|¿(hay )?algo más en lo que pueda ayudar",
944+
"weight": 6.0,
945+
"severity": "High",
946+
"message": "La línea de cierre de una respuesta de asistente, dentro del documento.",
947+
"suggestion": "Bórrala. El documento no tiene a quién ofrecerle más ayuda.",
948+
"evidence": "0 occurrences in the 39,712-word es half of the human calibration corpus (pre-2022, 25 texts)"
949+
},
950+
{
951+
"id": "chat.capability-disclaimer",
952+
"category": "Rhetorical",
953+
"regex": "\\bno (tengo|puedo) (acceso a |navegar por |consultar )?(internet|la web|información en tiempo real)\\b|\\bno tengo acceso en tiempo real\\b",
954+
"weight": 5.0,
955+
"severity": "High",
956+
"message": "Una declaración sobre lo que la herramienta de escritura no puede hacer.",
957+
"suggestion": "Bórrala, y pon la fuente que estaba sustituyendo.",
958+
"evidence": "0 occurrences in the 39,712-word es half of the human calibration corpus (pre-2022, 25 texts)"
959+
},
960+
{
961+
"id": "chat.answer-preamble",
962+
"category": "Rhetorical",
963+
"regex": "\\baquí (tienes|está|te dejo) (el|la|una|tu) (versión|texto|párrafo|redacción|artículo) (reescrit[ao]|revisad[ao]|corregid[ao]|mejorad[ao]|actualizad[ao])\\b|\\baquí (tienes|está) (el|la) (versión|texto) (reescrit[ao]|revisad[ao])\\b",
964+
"weight": 5.0,
965+
"severity": "High",
966+
"message": "La línea que imprime una herramienta antes de entregar su respuesta.",
967+
"suggestion": "Bórrala. El documento debería empezar por el documento.",
968+
"evidence": "0 occurrences in the 39,712-word es half of the human calibration corpus (pre-2022, 25 texts)"
969+
},
970+
{
971+
"id": "chat.eager-opener",
972+
"category": "Rhetorical",
973+
"regex": "(?m)(?:^|(?<=[.!?¡])\\s)¡?(Por supuesto|Claro|Excelente pregunta|Desde luego)[!,]",
974+
"weight": 3.5,
975+
"severity": "Medium",
976+
"message": "Apertura entusiasta de asistente («¡Por supuesto!», «¡Excelente pregunta!»).",
977+
"suggestion": "Bórrala. Nadie le ha hecho una pregunta al documento.",
978+
"evidence": "0 occurrences in the 39,712-word es half of the human calibration corpus (pre-2022, 25 texts)"
919979
}
920980
],
921981
"functionWords": [
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
using System.Linq;
2+
using SignsOfAI.Core;
3+
using Xunit;
4+
5+
namespace SignsOfAI.Core.Tests;
6+
7+
/// <summary>
8+
/// The half of the conversation that was never meant to be in the document.
9+
///
10+
/// Every other rule in this project is a judgement about style, and a formal writer can lose to any
11+
/// of them. These are not that. "I hope this helps" at the foot of an essay is not a register the
12+
/// student chose; it is an assistant's closing line, pasted in with the answer — the same kind of
13+
/// claim the character scanner makes, about where the file has been rather than about who is
14+
/// talented.
15+
///
16+
/// That is also why this batch was admissible when most of the pattern set it came from was not.
17+
/// The rules were screened against the calibration corpus first — 249,455 words of English and
18+
/// 39,712 of Spanish, all published before generative models existed — and every one of them scored
19+
/// zero. Re-running the calibration afterwards left the published false-positive rate untouched,
20+
/// which is the point: they cost nothing to carry.
21+
///
22+
/// The mine they came from is amanmaqsood/prose-humanizer (MIT). Twelve of its other candidates
23+
/// were rejected here by the same screen: underpin, optimize, elucidate, paradigm, exemplify and
24+
/// illuminate are ordinary research English, and each appears in three to six of those ninety texts.
25+
/// </summary>
26+
public class ChatResidueTests
27+
{
28+
private readonly AiWritingAnalyzer _a = new();
29+
30+
private bool Has(string text, string lang, string ruleId) =>
31+
_a.Analyze(text, lang).Findings.Any(f => f.RuleId == ruleId);
32+
33+
[Theory]
34+
[InlineData("As an AI language model, I should note that the figures are indicative.", "chat.model-self-reference")]
35+
[InlineData("As of my last training update, the tidal survey had not been repeated.", "chat.training-cutoff")]
36+
[InlineData("The wall was built in 1971. I hope this helps.", "chat.signoff")]
37+
[InlineData("Would you like me to expand the section on sediment transport?", "chat.signoff")]
38+
[InlineData("I cannot browse the internet, so the citation below is from memory.", "chat.capability-disclaimer")]
39+
[InlineData("Here is the revised version of your essay on coastal erosion.", "chat.answer-preamble")]
40+
[InlineData("Certainly! The coastline retreated by nine metres.", "chat.eager-opener")]
41+
public void Flags_the_assistants_own_turn_en(string text, string ruleId) =>
42+
Assert.True(Has(text, "en", ruleId), ruleId);
43+
44+
[Theory]
45+
[InlineData("Como modelo de lenguaje, debo señalar que las cifras son indicativas.", "chat.model-self-reference")]
46+
[InlineData("Hasta mi última actualización, el estudio no se había repetido.", "chat.training-cutoff")]
47+
[InlineData("El muro se construyó en 1971. Espero que esto te ayude.", "chat.signoff")]
48+
[InlineData("¿Quieres que amplíe el apartado sobre el transporte de sedimentos?", "chat.signoff")]
49+
[InlineData("No tengo acceso a información en tiempo real sobre las mareas.", "chat.capability-disclaimer")]
50+
[InlineData("Aquí tienes la versión reescrita de tu ensayo sobre la erosión costera.", "chat.answer-preamble")]
51+
[InlineData("¡Por supuesto! La costa retrocedió nueve metros.", "chat.eager-opener")]
52+
public void Flags_the_assistants_own_turn_es(string text, string ruleId) =>
53+
Assert.True(Has(text, "es", ruleId), ruleId);
54+
55+
/// <summary>
56+
/// The regexes have to leave ordinary writing alone, and two of them are close to sentences a
57+
/// person really writes. A tutor's own feedback says "let me know" and a historian writes "of
58+
/// course" mid-sentence; neither is an assistant handing back an answer.
59+
/// </summary>
60+
[Theory]
61+
[InlineData("Let me know when the survey is finished and I will read it.", "chat.signoff")]
62+
[InlineData("The wall was, of course, built long before the survey began.", "chat.eager-opener")]
63+
[InlineData("I hope the council publishes the survey before the winter.", "chat.signoff")]
64+
[InlineData("Here is the revised timetable the committee agreed on Tuesday.", "chat.answer-preamble")]
65+
public void Leaves_a_person_writing_to_a_person_alone_en(string text, string ruleId) =>
66+
Assert.False(Has(text, "en", ruleId), ruleId);
67+
68+
[Theory]
69+
[InlineData("Avísame cuando termine el estudio y lo leo.", "chat.signoff")]
70+
[InlineData("El muro, por supuesto, se construyó mucho antes del estudio.", "chat.eager-opener")]
71+
[InlineData("Aquí tienes el calendario que acordó la comisión el martes.", "chat.answer-preamble")]
72+
public void Leaves_a_person_writing_to_a_person_alone_es(string text, string ruleId) =>
73+
Assert.False(Has(text, "es", ruleId), ruleId);
74+
75+
/// <summary>
76+
/// A rule that fires on nothing measured is a rule with no measured human rate, and the pack
77+
/// must keep saying so rather than inventing one. See <c>PatternRule.HumanRatePer1000</c>: an
78+
/// absent rate means "never observed", which is what these are, and a rate of zero would be a
79+
/// different and much stronger claim.
80+
/// </summary>
81+
[Theory]
82+
[InlineData("en")]
83+
[InlineData("es")]
84+
public void The_new_rules_claim_no_human_rate(string lang)
85+
{
86+
var pack = SignsOfAI.Core.Rules.RulePackLoader.Load(lang);
87+
var chat = pack.Patterns.Where(p => p.Id.StartsWith("chat.")).ToList();
88+
89+
Assert.Equal(6, chat.Count);
90+
Assert.All(chat, rule => Assert.Null(rule.HumanRatePer1000));
91+
Assert.All(chat, rule => Assert.False(string.IsNullOrWhiteSpace(rule.Evidence), rule.Id));
92+
}
93+
}

0 commit comments

Comments
 (0)