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
2 changes: 1 addition & 1 deletion Docs/CALIBRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,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)
- **Engine** SignsOfAI.Core 0.4.0
- **Run** 2026-08-05
- **Run** 2026-08-24
- **Target false-positive rate** 5%

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.
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,3 +384,9 @@ The optional server is a normal ASP.NET Core app (`dotnet publish` the `SignsOfA

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
linguistics research on AI stylometry — see `Docs/GoogleResearch.md`.

The `chat.*` rules — the assistant's own turn, left in the document — were adapted from the pattern
set of [amanmaqsood/prose-humanizer](https://github.com/amanmaqsood/prose-humanizer) (MIT), a writing
skill rather than a detector. They entered the packs the way everything here does: screened against
the calibration corpus first, where all six scored zero. Twelve other candidates from the same source
did not enter, because they fire on writing published before generative models existed.
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-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}]}
{"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}]}
60 changes: 60 additions & 0 deletions src/SignsOfAI.Core/Rules/Packs/rules.en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1114,6 +1114,66 @@
"severity": "Info",
"message": "Mid-sentence bold — formatting slop.",
"suggestion": "Emphasis rarely helps prose; cut the bold."
},
{
"id": "chat.model-self-reference",
"category": "Rhetorical",
"regex": "\\bas an? (AI|artificial intelligence)( language)?( model| assistant)?\\b|\\bas a large language model\\b|\\bI(’|')?m an AI\\b",
"weight": 6.0,
"severity": "High",
"message": "A sentence the assistant says about itself, not a sentence in a document.",
"suggestion": "Delete it. If the text was drafted with a tool, say so in the acknowledgements instead.",
"evidence": "0 occurrences in the 249,455-word en half of the human calibration corpus (pre-2022, 65 texts)"
},
{
"id": "chat.training-cutoff",
"category": "Rhetorical",
"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",
"weight": 6.0,
"severity": "High",
"message": "A disclaimer about a model’s training data, left in the text.",
"suggestion": "Delete it, and check the claim it was hedging.",
"evidence": "0 occurrences in the 249,455-word en half of the human calibration corpus (pre-2022, 65 texts)"
},
{
"id": "chat.signoff",
"category": "Rhetorical",
"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",
"weight": 6.0,
"severity": "High",
"message": "The closing line of an assistant’s reply, inside the document.",
"suggestion": "Delete it. The document has no reader to offer further help to.",
"evidence": "0 occurrences in the 249,455-word en half of the human calibration corpus (pre-2022, 65 texts)"
},
{
"id": "chat.capability-disclaimer",
"category": "Rhetorical",
"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",
"weight": 5.0,
"severity": "High",
"message": "A statement about what the writing tool cannot do.",
"suggestion": "Delete it, and supply the source it was standing in for.",
"evidence": "0 occurrences in the 249,455-word en half of the human calibration corpus (pre-2022, 65 texts)"
},
{
"id": "chat.answer-preamble",
"category": "Rhetorical",
"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",
"weight": 5.0,
"severity": "High",
"message": "The line a tool prints before handing back its answer.",
"suggestion": "Delete it. The document should start with the document.",
"evidence": "0 occurrences in the 249,455-word en half of the human calibration corpus (pre-2022, 65 texts)"
},
{
"id": "chat.eager-opener",
"category": "Rhetorical",
"regex": "(?m)(?:^|(?<=[.!?])\\s)(Certainly|Of course|Absolutely|Sure thing|Great question)[!,]",
"weight": 3.5,
"severity": "Medium",
"message": "An assistant’s eager opener (“Certainly!”, “Great question!”).",
"suggestion": "Delete it. Nobody asked the document a question.",
"evidence": "0 occurrences in the 249,455-word en half of the human calibration corpus (pre-2022, 65 texts)"
}
],
"functionWords": [
Expand Down
60 changes: 60 additions & 0 deletions src/SignsOfAI.Core/Rules/Packs/rules.es.json
Original file line number Diff line number Diff line change
Expand Up @@ -916,6 +916,66 @@
"severity": "Info",
"message": "Negrita a media frase — ruido de formato.",
"suggestion": "El énfasis rara vez ayuda a la prosa; quita la negrita."
},
{
"id": "chat.model-self-reference",
"category": "Rhetorical",
"regex": "\\bcomo (una? )?(modelo de lenguaje|IA|inteligencia artificial|asistente de (IA|inteligencia artificial))\\b|\\bsoy una (IA|inteligencia artificial)\\b",
"weight": 6.0,
"severity": "High",
"message": "Una frase que el asistente dice sobre sí mismo, no una frase del documento.",
"suggestion": "Bórrala. Si el texto se redactó con una herramienta, dilo en los agradecimientos.",
"evidence": "0 occurrences in the 39,712-word es half of the human calibration corpus (pre-2022, 25 texts)"
},
{
"id": "chat.training-cutoff",
"category": "Rhetorical",
"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",
"weight": 6.0,
"severity": "High",
"message": "Una advertencia sobre los datos de entrenamiento de un modelo, olvidada en el texto.",
"suggestion": "Bórrala, y comprueba la afirmación que estaba matizando.",
"evidence": "0 occurrences in the 39,712-word es half of the human calibration corpus (pre-2022, 25 texts)"
},
{
"id": "chat.signoff",
"category": "Rhetorical",
"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",
"weight": 6.0,
"severity": "High",
"message": "La línea de cierre de una respuesta de asistente, dentro del documento.",
"suggestion": "Bórrala. El documento no tiene a quién ofrecerle más ayuda.",
"evidence": "0 occurrences in the 39,712-word es half of the human calibration corpus (pre-2022, 25 texts)"
},
{
"id": "chat.capability-disclaimer",
"category": "Rhetorical",
"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",
"weight": 5.0,
"severity": "High",
"message": "Una declaración sobre lo que la herramienta de escritura no puede hacer.",
"suggestion": "Bórrala, y pon la fuente que estaba sustituyendo.",
"evidence": "0 occurrences in the 39,712-word es half of the human calibration corpus (pre-2022, 25 texts)"
},
{
"id": "chat.answer-preamble",
"category": "Rhetorical",
"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",
"weight": 5.0,
"severity": "High",
"message": "La línea que imprime una herramienta antes de entregar su respuesta.",
"suggestion": "Bórrala. El documento debería empezar por el documento.",
"evidence": "0 occurrences in the 39,712-word es half of the human calibration corpus (pre-2022, 25 texts)"
},
{
"id": "chat.eager-opener",
"category": "Rhetorical",
"regex": "(?m)(?:^|(?<=[.!?¡])\\s)¡?(Por supuesto|Claro|Excelente pregunta|Desde luego)[!,]",
"weight": 3.5,
"severity": "Medium",
"message": "Apertura entusiasta de asistente («¡Por supuesto!», «¡Excelente pregunta!»).",
"suggestion": "Bórrala. Nadie le ha hecho una pregunta al documento.",
"evidence": "0 occurrences in the 39,712-word es half of the human calibration corpus (pre-2022, 25 texts)"
}
],
"functionWords": [
Expand Down
93 changes: 93 additions & 0 deletions tests/SignsOfAI.Core.Tests/ChatResidueTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using System.Linq;
using SignsOfAI.Core;
using Xunit;

namespace SignsOfAI.Core.Tests;

/// <summary>
/// The half of the conversation that was never meant to be in the document.
///
/// Every other rule in this project is a judgement about style, and a formal writer can lose to any
/// of them. These are not that. "I hope this helps" at the foot of an essay is not a register the
/// student chose; it is an assistant's closing line, pasted in with the answer — the same kind of
/// claim the character scanner makes, about where the file has been rather than about who is
/// talented.
///
/// That is also why this batch was admissible when most of the pattern set it came from was not.
/// The rules were screened against the calibration corpus first — 249,455 words of English and
/// 39,712 of Spanish, all published before generative models existed — and every one of them scored
/// zero. Re-running the calibration afterwards left the published false-positive rate untouched,
/// which is the point: they cost nothing to carry.
///
/// The mine they came from is amanmaqsood/prose-humanizer (MIT). Twelve of its other candidates
/// were rejected here by the same screen: underpin, optimize, elucidate, paradigm, exemplify and
/// illuminate are ordinary research English, and each appears in three to six of those ninety texts.
/// </summary>
public class ChatResidueTests
{
private readonly AiWritingAnalyzer _a = new();

private bool Has(string text, string lang, string ruleId) =>
_a.Analyze(text, lang).Findings.Any(f => f.RuleId == ruleId);

[Theory]
[InlineData("As an AI language model, I should note that the figures are indicative.", "chat.model-self-reference")]
[InlineData("As of my last training update, the tidal survey had not been repeated.", "chat.training-cutoff")]
[InlineData("The wall was built in 1971. I hope this helps.", "chat.signoff")]
[InlineData("Would you like me to expand the section on sediment transport?", "chat.signoff")]
[InlineData("I cannot browse the internet, so the citation below is from memory.", "chat.capability-disclaimer")]
[InlineData("Here is the revised version of your essay on coastal erosion.", "chat.answer-preamble")]
[InlineData("Certainly! The coastline retreated by nine metres.", "chat.eager-opener")]
public void Flags_the_assistants_own_turn_en(string text, string ruleId) =>
Assert.True(Has(text, "en", ruleId), ruleId);

[Theory]
[InlineData("Como modelo de lenguaje, debo señalar que las cifras son indicativas.", "chat.model-self-reference")]
[InlineData("Hasta mi última actualización, el estudio no se había repetido.", "chat.training-cutoff")]
[InlineData("El muro se construyó en 1971. Espero que esto te ayude.", "chat.signoff")]
[InlineData("¿Quieres que amplíe el apartado sobre el transporte de sedimentos?", "chat.signoff")]
[InlineData("No tengo acceso a información en tiempo real sobre las mareas.", "chat.capability-disclaimer")]
[InlineData("Aquí tienes la versión reescrita de tu ensayo sobre la erosión costera.", "chat.answer-preamble")]
[InlineData("¡Por supuesto! La costa retrocedió nueve metros.", "chat.eager-opener")]
public void Flags_the_assistants_own_turn_es(string text, string ruleId) =>
Assert.True(Has(text, "es", ruleId), ruleId);

/// <summary>
/// The regexes have to leave ordinary writing alone, and two of them are close to sentences a
/// person really writes. A tutor's own feedback says "let me know" and a historian writes "of
/// course" mid-sentence; neither is an assistant handing back an answer.
/// </summary>
[Theory]
[InlineData("Let me know when the survey is finished and I will read it.", "chat.signoff")]
[InlineData("The wall was, of course, built long before the survey began.", "chat.eager-opener")]
[InlineData("I hope the council publishes the survey before the winter.", "chat.signoff")]
[InlineData("Here is the revised timetable the committee agreed on Tuesday.", "chat.answer-preamble")]
public void Leaves_a_person_writing_to_a_person_alone_en(string text, string ruleId) =>
Assert.False(Has(text, "en", ruleId), ruleId);

[Theory]
[InlineData("Avísame cuando termine el estudio y lo leo.", "chat.signoff")]
[InlineData("El muro, por supuesto, se construyó mucho antes del estudio.", "chat.eager-opener")]
[InlineData("Aquí tienes el calendario que acordó la comisión el martes.", "chat.answer-preamble")]
public void Leaves_a_person_writing_to_a_person_alone_es(string text, string ruleId) =>
Assert.False(Has(text, "es", ruleId), ruleId);

/// <summary>
/// A rule that fires on nothing measured is a rule with no measured human rate, and the pack
/// must keep saying so rather than inventing one. See <c>PatternRule.HumanRatePer1000</c>: an
/// absent rate means "never observed", which is what these are, and a rate of zero would be a
/// different and much stronger claim.
/// </summary>
[Theory]
[InlineData("en")]
[InlineData("es")]
public void The_new_rules_claim_no_human_rate(string lang)
{
var pack = SignsOfAI.Core.Rules.RulePackLoader.Load(lang);
var chat = pack.Patterns.Where(p => p.Id.StartsWith("chat.")).ToList();

Assert.Equal(6, chat.Count);
Assert.All(chat, rule => Assert.Null(rule.HumanRatePer1000));
Assert.All(chat, rule => Assert.False(string.IsNullOrWhiteSpace(rule.Evidence), rule.Id));
}
}
Loading