From e8240d6ec13eb9f27c779ae3dd5f03dde312c426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Hern=C3=A1ndez?= Date: Wed, 9 Sep 2026 00:31:42 -0400 Subject: [PATCH 1/2] Take our own advice about the em-dash, in the advice itself Closes #82. The packs delivered their advice with the mark one of the rules is about, the three empty-intensifier rules among them. Measured with the project's own instrument, rules.en.json advised at 2.1 em-dashes per 100 words -- which EmDashAnalyzer calls Medium severity on anybody else's prose. 49 strings reworded. A list of alternatives keeps a comma ("mix, blend, range, or just name the thing"); a description that follows a label takes a colon ("empty intensifier: cut it"). Two were left with a full stop instead, because "who wrote it: and a text inside the range" is not English -- the issue's own guard rail: if a rewrite reads worse, leave it. The part that was NOT cosmetic, and the issue said it was: SuggestionParser cuts a suggestion at the first aside marker, and the em-dash is one of them. Twenty built-in rules state no `replacements` and fall through to that parser, so "empty intensifier -- cut it" yielded one term and was refused for being alone. A comma would have yielded two, and the live rewriter substitutes two: the words "empty intensifier" would have gone into somebody's sentence in place of "just". A colon is filtered by the parser; a comma is not. So the guard for that is the one that matters, and it is not a ban on dashes: - every built-in rule without explicit replacements must salvage nothing - each pack is held to the same 1.0 per 100 words the rule applies to everyone, rather than to zero, which would be a stricter rule than the one we publish Both verified by mutation: the comma version fails by rule id, and the previous pack fails the density test at 2.1. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015PEbbiYSNPw7jE3LrPNhyF --- src/SignsOfAI.Core/Rules/PackMessages.cs | 14 ++-- src/SignsOfAI.Core/Rules/Packs/rules.en.json | 68 +++++++++---------- src/SignsOfAI.Core/Rules/Packs/rules.es.json | 40 +++++------ tests/SignsOfAI.Core.Tests/PackAdviceTests.cs | 67 ++++++++++++++++++ 4 files changed, 128 insertions(+), 61 deletions(-) create mode 100644 tests/SignsOfAI.Core.Tests/PackAdviceTests.cs diff --git a/src/SignsOfAI.Core/Rules/PackMessages.cs b/src/SignsOfAI.Core/Rules/PackMessages.cs index 100e1a7..30c01ef 100644 --- a/src/SignsOfAI.Core/Rules/PackMessages.cs +++ b/src/SignsOfAI.Core/Rules/PackMessages.cs @@ -132,15 +132,15 @@ public static class PackMessages [EmDashSuggestion] = "Replace most with a period, comma, or parentheses; keep em-dashes rare and deliberate.", [EmDashEvidence] = "Human prose averages well under one em-dash per 100 words.", [CatalogLexical] = "Overused AI vocabulary.", - [ArtifactInvisible] = "{1} ({0}) — a character that occupies no space when the text is displayed.", - [ArtifactBidi] = "{1} ({0}) — a control character that can make the displayed text differ from the stored text.", + [ArtifactInvisible] = "{1} ({0}): a character that occupies no space when the text is displayed.", + [ArtifactBidi] = "{1} ({0}): a control character that can make the displayed text differ from the stored text.", [ArtifactLookalike] = "“{3}” contains {1} ({0}) where the Latin letter “{2}” belongs. " + "The two are indistinguishable on screen.", [ArtifactSpace] = "{1} ({0}) in place of an ordinary space.", - [ArtifactSoftHyphen] = "{1} ({0}) — an invisible hyphenation point, routine in text copied out of a PDF.", - [ArtifactVariationSelector] = "{1} ({0}) — a rendering modifier attached to something that is not an emoji.", - [ArtifactPrivateUse] = "{1} ({0}) — a codepoint with no meaning outside the font that defined it.", - [ArtifactTag] = "{1} ({0}) — an invisible character of the kind used to carry hidden text alongside visible text.", + [ArtifactSoftHyphen] = "{1} ({0}): an invisible hyphenation point, routine in text copied out of a PDF.", + [ArtifactVariationSelector] = "{1} ({0}): a rendering modifier attached to something that is not an emoji.", + [ArtifactPrivateUse] = "{1} ({0}): a codepoint with no meaning outside the font that defined it.", + [ArtifactTag] = "{1} ({0}): an invisible character of the kind used to carry hidden text alongside visible text.", [ArtifactSummaryIncidental] = "{0} unusual characters, not spread through the document. " + "Copying from a web page or a PDF produces these.", [ArtifactSummarySystematic] = "{0} characters that typing does not produce, spread across {1} of {2} " + @@ -177,7 +177,7 @@ public static class PackMessages "weak — check that they are all by the same person and of a similar kind.", [StyleAdvice] = "Style moves with the assignment, the genre, the deadline, and with a person simply " + "getting better. A text outside the range is a reason to ask what changed, never a " + - "conclusion about who wrote it — and a text inside the range is the more useful " + + "conclusion about who wrote it. And a text inside the range is the more useful " + "result, because it is the one that settles a suspicion.", [StyleNeedBaseline] = "Not enough of this writer’s own work to measure against: {0} words, and this " + "needs at least {1}.", diff --git a/src/SignsOfAI.Core/Rules/Packs/rules.en.json b/src/SignsOfAI.Core/Rules/Packs/rules.en.json index a415a0b..1c55a45 100644 --- a/src/SignsOfAI.Core/Rules/Packs/rules.en.json +++ b/src/SignsOfAI.Core/Rules/Packs/rules.en.json @@ -10,14 +10,14 @@ "emdash.suggestion": "Replace most with a period, comma, or parentheses; keep em-dashes rare and deliberate.", "emdash.evidence": "Human prose averages well under one em-dash per 100 words.", "catalog.lexical": "Overused AI vocabulary.", - "artifact.invisible": "{1} ({0}) — a character that occupies no space when the text is displayed.", - "artifact.bidi": "{1} ({0}) — a control character that can make the displayed text differ from the stored text.", + "artifact.invisible": "{1} ({0}): a character that occupies no space when the text is displayed.", + "artifact.bidi": "{1} ({0}): a control character that can make the displayed text differ from the stored text.", "artifact.lookalike": "“{3}” contains {1} ({0}) where the Latin letter “{2}” belongs. The two are indistinguishable on screen.", "artifact.space": "{1} ({0}) in place of an ordinary space.", - "artifact.soft-hyphen": "{1} ({0}) — an invisible hyphenation point, routine in text copied out of a PDF.", - "artifact.variation-selector": "{1} ({0}) — a rendering modifier attached to something that is not an emoji.", - "artifact.private-use": "{1} ({0}) — a codepoint with no meaning outside the font that defined it.", - "artifact.tag": "{1} ({0}) — an invisible character of the kind used to carry hidden text alongside visible text.", + "artifact.soft-hyphen": "{1} ({0}): an invisible hyphenation point, routine in text copied out of a PDF.", + "artifact.variation-selector": "{1} ({0}): a rendering modifier attached to something that is not an emoji.", + "artifact.private-use": "{1} ({0}): a codepoint with no meaning outside the font that defined it.", + "artifact.tag": "{1} ({0}): an invisible character of the kind used to carry hidden text alongside visible text.", "artifact.summary.incidental": "{0} unusual characters, not spread through the document. Copying from a web page or a PDF produces these.", "artifact.summary.systematic": "{0} characters that typing does not produce, spread across {1} of {2} sections of the document. That distribution is what a tool leaves behind when it processes a whole text.", "artifact.advice": "This says nothing about who wrote the text, and it is not evidence of dishonesty. It is a question about where the file has been: ask the writer to open the document and describe how it was produced.", @@ -35,8 +35,8 @@ "style.summary.within": "Distance {0}. This writer’s own pieces sit up to {1} from their centre, so this one is inside the range they already cover.", "style.summary.edge": "Distance {0}, a little past the {1} that this writer’s own pieces cover between themselves.", "style.summary.beyond": "Distance {0}, against {1} for the widest gap between this writer’s own pieces. This one sits outside the range they cover.", - "style.note.broad": "Those samples also disagree with each other a lot, which makes any comparison weak — check that they are all by the same person and of a similar kind.", - "style.advice": "Style moves with the assignment, the genre, the deadline, and with a person simply getting better. A text outside the range is a reason to ask what changed, never a conclusion about who wrote it — and a text inside the range is the more useful result, because it is the one that settles a suspicion.", + "style.note.broad": "Those samples also disagree with each other a lot, which makes any comparison weak: check that they are all by the same person and of a similar kind.", + "style.advice": "Style moves with the assignment, the genre, the deadline, and with a person simply getting better. A text outside the range is a reason to ask what changed, never a conclusion about who wrote it. And a text inside the range is the more useful result, because it is the one that settles a suspicion.", "style.need.baseline": "Not enough of this writer’s own work to measure against: {0} words, and this needs at least {1}.", "style.need.questioned": "The text being checked is too short: {0} words, and this needs at least {1}.", "style.need.samples": "Not enough separate pieces to measure the writer’s own variation: {0} usable, and this needs at least {1}.", @@ -71,7 +71,7 @@ ], "weight": 5.5, "severity": "High", - "suggestion": "mix, blend, range — or just name the thing", + "suggestion": "mix, blend, range, or just name the thing", "replacements": [ "mix", "blend", @@ -100,7 +100,7 @@ ], "weight": 4.0, "severity": "Medium", - "suggestion": "subtle, detailed — or state the specific distinction", + "suggestion": "subtle, detailed, or state the specific distinction", "replacements": [ "subtle", "detailed" @@ -197,7 +197,7 @@ ], "weight": 4.5, "severity": "High", - "suggestion": "proof, evidence, sign — or drop the flourish", + "suggestion": "proof, evidence, sign, or drop the flourish", "replacements": [ "proof", "evidence", @@ -280,7 +280,7 @@ ], "weight": 3.5, "severity": "Medium", - "suggestion": "smooth, smoothly — or show it, don't claim it", + "suggestion": "smooth, smoothly, or show it, don't claim it", "replacements": [ "smooth", "smoothly" @@ -308,7 +308,7 @@ ], "weight": 3.5, "severity": "Medium", - "suggestion": "many, countless — or give the number", + "suggestion": "many, countless, or give the number", "replacements": [ "many", "countless" @@ -335,7 +335,7 @@ ], "weight": 3.5, "severity": "Medium", - "suggestion": "major, far-reaching — or show the change", + "suggestion": "major, far-reaching, or show the change", "replacements": [ "major", "far-reaching" @@ -348,7 +348,7 @@ ], "weight": 3.0, "severity": "Low", - "suggestion": "lively, colorful — or a concrete detail", + "suggestion": "lively, colorful, or a concrete detail", "replacements": [ "lively", "colorful" @@ -361,7 +361,7 @@ ], "weight": 3.5, "severity": "Medium", - "suggestion": "busy, crowded — or describe the scene", + "suggestion": "busy, crowded, or describe the scene", "replacements": [ "busy", "crowded" @@ -497,7 +497,7 @@ ], "weight": 3.5, "severity": "Medium", - "suggestion": "changing — or say how it changes", + "suggestion": "changing, or say how it changes", "replacements": [ "changing" ] @@ -523,7 +523,7 @@ ], "weight": 3.0, "severity": "Low", - "suggestion": "major shift — or state the actual impact", + "suggestion": "major shift, or state the actual impact", "replacements": [ "major shift" ] @@ -645,7 +645,7 @@ ], "weight": 3.5, "severity": "Medium", - "suggestion": "example, model — or drop the metaphor", + "suggestion": "example, model, or drop the metaphor", "replacements": [ "example", "model" @@ -675,7 +675,7 @@ ], "weight": 1.0, "severity": "Info", - "suggestion": "empty intensifier — usually deletable", + "suggestion": "empty intensifier: usually deletable", "delete": true }, { @@ -686,7 +686,7 @@ ], "weight": 1.0, "severity": "Info", - "suggestion": "empty intensifier — usually deletable", + "suggestion": "empty intensifier: usually deletable", "delete": true }, { @@ -697,7 +697,7 @@ ], "weight": 1.0, "severity": "Info", - "suggestion": "empty intensifier — usually deletable", + "suggestion": "empty intensifier: usually deletable", "delete": true }, { @@ -707,7 +707,7 @@ ], "weight": 1.5, "severity": "Info", - "suggestion": "empty intensifier — cut unless it's meant literally", + "suggestion": "empty intensifier: cut unless it's meant literally", "delete": true }, { @@ -717,7 +717,7 @@ ], "weight": 1.5, "severity": "Info", - "suggestion": "empty intensifier — cut it", + "suggestion": "empty intensifier: cut it", "delete": true }, { @@ -727,7 +727,7 @@ ], "weight": 1.5, "severity": "Info", - "suggestion": "empty intensifier — cut it", + "suggestion": "empty intensifier: cut it", "delete": true }, { @@ -748,7 +748,7 @@ ], "weight": 2.0, "severity": "Low", - "suggestion": "empty intensifier — cut it", + "suggestion": "empty intensifier: cut it", "delete": true }, { @@ -758,7 +758,7 @@ ], "weight": 2.0, "severity": "Low", - "suggestion": "empty intensifier — cut it", + "suggestion": "empty intensifier: cut it", "delete": true }, { @@ -768,7 +768,7 @@ ], "weight": 2.0, "severity": "Low", - "suggestion": "empty intensifier — cut it", + "suggestion": "empty intensifier: cut it", "delete": true }, { @@ -778,7 +778,7 @@ ], "weight": 2.0, "severity": "Low", - "suggestion": "empty intensifier — cut it", + "suggestion": "empty intensifier: cut it", "delete": true } ], @@ -789,7 +789,7 @@ "regex": "\\bit'?s not (just|only|merely|about)\\b[^.?!\\n]{1,60}?,\\s*it'?s\\b", "weight": 6.0, "severity": "High", - "message": "Negative parallelism (“it's not just X, it's Y”) — feigns depth.", + "message": "Negative parallelism (“it's not just X, it's Y”): feigns depth.", "suggestion": "State the claim directly. Cut the “not just… it's…” frame." }, { @@ -862,7 +862,7 @@ "regex": "\\bon (the )?one hand\\b", "weight": 3.0, "severity": "Medium", - "message": "Symmetrical framing (“on one hand… on the other”) — false balance.", + "message": "Symmetrical framing (“on one hand… on the other”): false balance.", "suggestion": "If the evidence favors one side, say so. Don't force a two-sided frame." }, { @@ -891,7 +891,7 @@ "regex": "\\b(\\w+), (\\w+),? and (\\w+)[.,;]", "weight": 1.2, "severity": "Info", - "message": "Possible “rule of three” — AI defaults to tricolons.", + "message": "Possible “rule of three”: AI defaults to tricolons.", "suggestion": "Fine occasionally; if every list is three items, vary the count." }, { @@ -1103,7 +1103,7 @@ "regex": "(?m)^\\s{0,3}#{1,6}\\s+[^\\n]*[\\u2190-\\u21FF\\u2300-\\u27BF\\u2600-\\u26FF\\u2B00-\\u2BFF\\uD83C-\\uDBFF]", "weight": 3.0, "severity": "Low", - "message": "Emoji in a heading — formatting slop.", + "message": "Emoji in a heading: formatting slop.", "suggestion": "Headings don't need emoji." }, { @@ -1112,7 +1112,7 @@ "regex": "(?m)(?<=[a-z,;])\\s\\*\\*[^\\n*]{1,60}\\*\\*", "weight": 2.0, "severity": "Info", - "message": "Mid-sentence bold — formatting slop.", + "message": "Mid-sentence bold: formatting slop.", "suggestion": "Emphasis rarely helps prose; cut the bold." }, { diff --git a/src/SignsOfAI.Core/Rules/Packs/rules.es.json b/src/SignsOfAI.Core/Rules/Packs/rules.es.json index 8b81b9f..14c0423 100644 --- a/src/SignsOfAI.Core/Rules/Packs/rules.es.json +++ b/src/SignsOfAI.Core/Rules/Packs/rules.es.json @@ -35,8 +35,8 @@ "style.summary.within": "Distancia {0}. Los propios trabajos de esta persona se separan hasta {1} de su centro, así que este queda dentro del rango que ya cubren.", "style.summary.edge": "Distancia {0}, un poco por encima del {1} que los propios trabajos de esta persona cubren entre sí.", "style.summary.beyond": "Distancia {0}, frente a {1} de separación máxima entre los propios trabajos de esta persona. Este queda fuera del rango que cubren.", - "style.note.broad": "Esas muestras además discrepan bastante entre sí, lo que debilita cualquier comparación — compruebe que todas son de la misma persona y del mismo tipo.", - "style.advice": "El estilo cambia con la tarea, con el género, con el plazo y con que una persona simplemente mejore. Un texto fuera del rango es una razón para preguntar qué cambió, nunca una conclusión sobre quién lo escribió — y un texto dentro del rango es el resultado más útil, porque es el que zanja una sospecha.", + "style.note.broad": "Esas muestras además discrepan bastante entre sí, lo que debilita cualquier comparación: compruebe que todas son de la misma persona y del mismo tipo.", + "style.advice": "El estilo cambia con la tarea, con el género, con el plazo y con que una persona simplemente mejore. Un texto fuera del rango es una razón para preguntar qué cambió, nunca una conclusión sobre quién lo escribió. Y un texto dentro del rango es el resultado más útil, porque es el que zanja una sospecha.", "style.need.baseline": "No hay suficiente trabajo previo de esta persona para comparar: {0} palabras, y hacen falta al menos {1}.", "style.need.questioned": "El texto a revisar es demasiado corto: {0} palabras, y hacen falta al menos {1}.", "style.need.samples": "No hay suficientes piezas separadas para medir la variación de la propia persona: {0} utilizables, y hacen falta al menos {1}.", @@ -159,7 +159,7 @@ ], "weight": 4.0, "severity": "Medium", - "suggestion": "complejo — o nombra las facetas reales", + "suggestion": "complejo, o nombra las facetas reales", "replacements": [ "complejo" ], @@ -174,7 +174,7 @@ ], "weight": 3.5, "severity": "Medium", - "suggestion": "sutil, detallado — o precisa la distinción", + "suggestion": "sutil, detallado, o precisa la distinción", "replacements": [ "sutil", "detallado" @@ -341,7 +341,7 @@ ], "weight": 3.0, "severity": "Low", - "suggestion": "animado, lleno de vida — o un detalle concreto", + "suggestion": "animado, lleno de vida, o un detalle concreto", "replacements": [ "animado", "lleno de vida" @@ -373,7 +373,7 @@ ], "weight": 3.5, "severity": "Medium", - "suggestion": "muchos, montones — o da la cifra", + "suggestion": "muchos, montones, o da la cifra", "replacements": [ "muchos", "montones" @@ -388,7 +388,7 @@ ], "weight": 3.5, "severity": "Medium", - "suggestion": "de gran impacto — o muestra el cambio", + "suggestion": "de gran impacto, o muestra el cambio", "replacements": [ "de gran impacto" ] @@ -464,7 +464,7 @@ ], "weight": 2.5, "severity": "Low", - "suggestion": "nuevo — o di qué lo hace nuevo", + "suggestion": "nuevo, o di qué lo hace nuevo", "replacements": [ "nuevo" ] @@ -551,7 +551,7 @@ ], "weight": 1.0, "severity": "Info", - "suggestion": "muletilla — suele sobrar", + "suggestion": "muletilla: suele sobrar", "delete": true }, { @@ -561,7 +561,7 @@ ], "weight": 1.0, "severity": "Info", - "suggestion": "muletilla — suele sobrar", + "suggestion": "muletilla: suele sobrar", "delete": true }, { @@ -572,7 +572,7 @@ ], "weight": 1.5, "severity": "Info", - "suggestion": "muletilla — suele sobrar", + "suggestion": "muletilla: suele sobrar", "delete": true }, { @@ -582,7 +582,7 @@ ], "weight": 1.5, "severity": "Info", - "suggestion": "muletilla — suele sobrar", + "suggestion": "muletilla: suele sobrar", "delete": true }, { @@ -593,7 +593,7 @@ ], "weight": 1.5, "severity": "Info", - "suggestion": "muletilla — córtala", + "suggestion": "muletilla: córtala", "delete": true }, { @@ -603,7 +603,7 @@ ], "weight": 1.5, "severity": "Info", - "suggestion": "muletilla — córtala salvo sentido literal", + "suggestion": "muletilla: córtala salvo sentido literal", "delete": true }, { @@ -613,7 +613,7 @@ ], "weight": 2.0, "severity": "Low", - "suggestion": "muletilla — córtala", + "suggestion": "muletilla: córtala", "delete": true }, { @@ -623,7 +623,7 @@ ], "weight": 2.0, "severity": "Low", - "suggestion": "muletilla — córtala", + "suggestion": "muletilla: córtala", "delete": true } ], @@ -751,7 +751,7 @@ "regex": "\\bpor un lado\\b", "weight": 3.0, "severity": "Medium", - "message": "Marco simétrico (“por un lado… por otro”) — falso equilibrio.", + "message": "Marco simétrico (“por un lado… por otro”): falso equilibrio.", "suggestion": "Si la evidencia favorece un lado, dilo." }, { @@ -761,7 +761,7 @@ "regex": "\\b(\\w+), (\\w+) y (\\w+)[.,;]", "weight": 1.2, "severity": "Info", - "message": "Posible “regla de tres” — la IA abusa de los tríos.", + "message": "Posible “regla de tres”: la IA abusa de los tríos.", "suggestion": "Bien de vez en cuando; si todas las listas son de tres, varía." }, { @@ -905,7 +905,7 @@ "regex": "(?m)^\\s{0,3}#{1,6}\\s+[^\\n]*[\\u2190-\\u21FF\\u2300-\\u27BF\\u2600-\\u26FF\\u2B00-\\u2BFF\\uD83C-\\uDBFF]", "weight": 3.0, "severity": "Low", - "message": "Emoji en un encabezado — ruido de formato.", + "message": "Emoji en un encabezado: ruido de formato.", "suggestion": "Los encabezados no necesitan emoji." }, { @@ -914,7 +914,7 @@ "regex": "(?m)(?<=[a-z,;áéíóúñ])\\s\\*\\*[^\\n*]{1,60}\\*\\*", "weight": 2.0, "severity": "Info", - "message": "Negrita a media frase — ruido de formato.", + "message": "Negrita a media frase: ruido de formato.", "suggestion": "El énfasis rara vez ayuda a la prosa; quita la negrita." }, { diff --git a/tests/SignsOfAI.Core.Tests/PackAdviceTests.cs b/tests/SignsOfAI.Core.Tests/PackAdviceTests.cs new file mode 100644 index 0000000..dd4326a --- /dev/null +++ b/tests/SignsOfAI.Core.Tests/PackAdviceTests.cs @@ -0,0 +1,67 @@ +using SignsOfAI.Core.Rules; +using Xunit; + +namespace SignsOfAI.Core.Tests; + +/// +/// The rule packs deliver their advice to a reader, and #82 noticed they delivered it with the very +/// mark one of the rules is about — including, with perfect irony, the three empty-intensifier rules. +/// +/// Neither test below forbids an em-dash. The product measures density, and a prohibition would +/// contradict the thing it tells everyone else: keep them rare and deliberate. So the packs are held +/// to the rule's own threshold, and the second test guards the part of this that was never cosmetic. +/// +public class PackAdviceTests +{ + private static readonly string[] Languages = ["en", "es"]; + + /// Every string a pack shows a reader, joined as the prose it effectively is. + private static IEnumerable ReaderFacing(RulePack pack) => + pack.Lexical.Select(r => r.Suggestion) + .Concat(pack.Patterns.SelectMany(r => new[] { r.Message, r.Suggestion })) + .Concat(pack.Messages?.Values ?? Enumerable.Empty()) + .Where(s => !string.IsNullOrWhiteSpace(s)); + + [Theory] + [InlineData("en")] + [InlineData("es")] + public void A_pack_keeps_its_own_advice_about_the_em_dash(string language) + { + var pack = RulePackLoader.Load(language); + var prose = string.Join(' ', ReaderFacing(pack)); + + var words = prose.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; + var dashes = prose.Count(c => c == '\u2014'); + var per100 = dashes / (double)words * 100.0; + + // The same 1.0 per 100 words EmDashAnalyzer applies to everyone. Held here rather than + // asserted as zero, because a pack that may never use the mark is a stricter rule than the + // one the product publishes, and the product would then be wrong about itself again. + Assert.True(per100 < 1.0, + $"rules.{language}.json advises at {per100:0.0} em-dashes per 100 words ({dashes} in " + + $"{words}), which is the density this project's own rule calls high."); + } + + [Theory] + [InlineData("en")] + [InlineData("es")] + public void Advice_that_describes_a_problem_never_becomes_a_replacement(string language) + { + // This is the part of #82 that was not cosmetic. SuggestionParser cuts a suggestion at the + // first aside marker, and the em-dash was one of them, so "empty intensifier — cut it" + // yielded one term and was refused for being alone. Rewriting it with a comma would have + // produced two terms and let the rewriter substitute the words "empty intensifier" into + // somebody's sentence. A colon is filtered by the parser; a comma is not. + var pack = RulePackLoader.Load(language); + + foreach (var rule in pack.Lexical.Where(r => r.Replacements is not { Length: > 0 })) + { + var salvaged = rule.RewriteOptions(); + + Assert.True(salvaged.Count == 0, + $"{rule.Id} states no replacements, yet its suggestion \"{rule.Suggestion}\" parses " + + $"as [{string.Join(", ", salvaged)}] — which the live rewriter would substitute " + + "into the writer's text. Describe the problem after a colon, not a comma."); + } + } +} From daf21d53599cd08a18919032cbcccef44456dba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Hern=C3=A1ndez?= Date: Wed, 9 Sep 2026 00:36:15 -0400 Subject: [PATCH 2/2] The teacher package was still arguing from a corpus of ninety Closes #78. The issue listed ten surfaces still saying "before generative models existed" and its own comment reported them swept in #85. Verified against the code first, and that was true: all ten are clean. Two the issue never listed were not. Docs/Paraphrase/README.md was the small one -- the phrase, nothing else. Docs/Teaching/README.md is the teacher package, and it carried five stale claims in a single paragraph. The paragraph whose entire job is "this project can afford to say all that because it publishes how often it is wrong": ninety texts -> 296 before generative models -> before 2022 none flagged -> two are, at the recommended threshold below 4.1% -> below 2.4% neither language supports its own threshold -> English has since 0.6.0 Six weeks of a page telling teachers the error rate was zero, on the artefact written for the person who has to act on a score. #85 fixed exactly this rot on why.html and never looked here, because nothing pointed from one to the other. So TeacherPackageTests reads the same published-calibration.json the engine ships, the way WhyPageTests does. It also pins the two distinctions that are easy to get wrong and were: that "ninguno marcado" must never come back while anything is flagged, and that the figure quoted beside English is the bound *at its threshold*, not its best bound -- the error a reviewer caught in the report on 1 September. Verified by mutation: all three fail against the paragraph that was live, each naming its own defect. The guard normalises whitespace, because a phrase can straddle a hand-wrapped line and a guard that breaks on reflowing is one somebody weakens instead of satisfying. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015PEbbiYSNPw7jE3LrPNhyF --- Docs/Paraphrase/README.md | 2 +- Docs/Teaching/README.md | 17 ++-- .../TeacherPackageTests.cs | 90 +++++++++++++++++++ 3 files changed, 100 insertions(+), 9 deletions(-) create mode 100644 tests/SignsOfAI.Core.Tests/TeacherPackageTests.cs diff --git a/Docs/Paraphrase/README.md b/Docs/Paraphrase/README.md index b7fe00d..bbf06e0 100644 --- a/Docs/Paraphrase/README.md +++ b/Docs/Paraphrase/README.md @@ -40,7 +40,7 @@ noise is large at four hundred words. "Moved with the rewrite" is what this desi because of the rewrite" is a stronger claim and an earlier version of the report made it. The human halves are drawn from the calibration corpus, so every one of them was published before -generative models existed. That remains the only basis for calling writing human, and it is a +2022. That remains the only basis for calling writing human, and it is a stronger one than any classifier offers about anything. ## The controls, which were not planned diff --git a/Docs/Teaching/README.md b/Docs/Teaching/README.md index 40b8e54..e023fa2 100644 --- a/Docs/Teaching/README.md +++ b/Docs/Teaching/README.md @@ -92,13 +92,14 @@ estas herramientas. Pídale que hable de su trabajo. ## Por qué este proyecto puede permitirse decir todo esto Porque publica con qué frecuencia se equivoca, cosa que casi nadie hace en esta categoría: -[`Docs/CALIBRATION.md`](../CALIBRATION.md). Noventa textos publicados antes de que existieran los -modelos generativos, ninguno marcado en el umbral recomendado, y la lectura honesta es el intervalo y -no el cero: por debajo del 4,1%. - -Siga leyendo esa página y dice algo menos halagador, que es justo lo que importa aquí: **ni el -español ni el inglés respaldan por sí solos un umbral propio.** El corpus es demasiado delgado por -idioma. Una cifra medida mayoritariamente en un idioma, citada frente a un estudiante que escribe en -otro, es exactamente el error que estos documentos le piden a un comité que no cometa. +[`Docs/CALIBRATION.md`](../CALIBRATION.md). 296 textos publicados antes de 2022, dos de ellos +marcados en el umbral recomendado, y la lectura honesta es el intervalo y no el 0,7 % observado: por +debajo del 2,4 %. + +Siga leyendo esa página y dice algo menos halagador, que es justo lo que importa aquí: **el corpus +español son 25 textos y no respalda un umbral propio**, y su mejor cota es del 13,3 %. El inglés sí +lo respalda, con 271 textos, y aun así su cota en ese umbral es del 2,7 %. Una cifra medida +mayoritariamente en un idioma, citada frente a un estudiante que escribe en otro, es exactamente el +error que estos documentos le piden a un comité que no cometa. Todo funciona sin conexión. Ningún documento, ni fragmento de él, sale del equipo donde está. diff --git a/tests/SignsOfAI.Core.Tests/TeacherPackageTests.cs b/tests/SignsOfAI.Core.Tests/TeacherPackageTests.cs new file mode 100644 index 0000000..aa480cf --- /dev/null +++ b/tests/SignsOfAI.Core.Tests/TeacherPackageTests.cs @@ -0,0 +1,90 @@ +using SignsOfAI.Core.Calibration; +using Xunit; + +namespace SignsOfAI.Core.Tests; + +/// +/// The teacher package is the artefact aimed at the person who has to act on a score, and its whole +/// argument is one paragraph: this project can afford to say all that because it publishes how +/// often it is wrong. That paragraph is typed by hand. +/// +/// It was wrong in five ways at once. Six weeks after the corpus grew and the boundary moved it +/// still said ninety texts, none flagged, and a bound of 4.1% -- and that neither language supported +/// a threshold of its own, which stopped being true when English earned one at 0.6.0. #85 fixed the +/// same rot on why.html and never looked here, because nothing pointed from one to the other. +/// +/// So these read the same published-calibration.json the engine ships and require the page to +/// agree with it, the way does for the page teachers land on first. +/// +public class TeacherPackageTests +{ + // Whitespace-normalised, because the file is hand-wrapped Markdown and a phrase the guard checks + // may straddle a line break. A guard that fails when a paragraph is reflowed is a guard someone + // will weaken rather than satisfy. + private static readonly string Page = System.Text.RegularExpressions.Regex.Replace( + File.ReadAllText(PagePath()), @"\s+", " "); + + private static PublishedCalibration Shipped => + PublishedCalibration.Current ?? throw new InvalidOperationException( + "The build carries no published calibration, so the teacher package cannot be checked."); + + private static string PagePath() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "src"))) + dir = dir.Parent; + + Assert.NotNull(dir); + return Path.Combine(dir!.FullName, "Docs", "Teaching", "README.md"); + } + + // The package is Spanish, so every figure is written with a decimal comma. + private static string Es(double value, string format) => + value.ToString(format, System.Globalization.CultureInfo.InvariantCulture).Replace('.', ','); + + private static void Must(string text, string what) + => Assert.True(Page.Contains(text, StringComparison.Ordinal), + $"Docs/Teaching/README.md no longer states {what} (\"{text}\"). The calibration moved; " + + "rewrite the paragraph under \"Por qué este proyecto puede permitirse decir todo esto\"."); + + [Fact] + public void The_corpus_it_quotes_is_the_one_the_engine_ships() + { + Must($"{Shipped.Texts} textos", "the corpus size"); + Must($"por debajo del {Es(Shipped.RateHigh * 100, "0.0")} %", "the upper bound of the interval"); + Must($"el {Es(Shipped.FlaggedAtThreshold / (double)Shipped.Texts * 100, "0.0")} % observado", + "the observed rate"); + } + + [Fact] + public void It_does_not_go_back_to_claiming_nothing_was_flagged() + { + // The specific sentence that outlived its own truth. Two of the 296 are flagged at 30, and a + // page arguing that this project publishes its errors must not round its own down to zero. + Assert.True(Shipped.FlaggedAtThreshold > 0, + "Nothing is flagged any more, so this guard and the paragraph it guards both need rewriting."); + Assert.DoesNotContain("ninguno marcado", Page, StringComparison.Ordinal); + } + + [Fact] + public void What_it_says_about_each_language_is_what_the_calibration_says() + { + var spanish = Shipped.For("es"); + var english = Shipped.For("en"); + Assert.NotNull(spanish); + Assert.NotNull(english); + + // Spanish has no threshold of its own, so its best bound is the honest figure. English has + // one, so the figure beside it must be the bound *at that threshold* -- quoting the best + // bound next to a threshold is the error a reviewer caught in the report on 1 September. + Assert.Null(spanish!.RecommendedThreshold); + Must($"son {spanish.Texts} textos", "the size of the Spanish corpus"); + Must($"del {Es(spanish.BestBound * 100, "0.0")} %", "the best bound Spanish allows"); + + Assert.NotNull(english!.RecommendedThreshold); + Assert.NotNull(english.RateHighAtThreshold); + Must($"con {english.Texts} textos", "the size of the English corpus"); + Must($"del {Es(english.RateHighAtThreshold!.Value * 100, "0.0")} %", + "the English bound at its own threshold"); + } +}