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/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.");
+ }
+ }
+}
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");
+ }
+}