/// The sentence that has to appear on every report. Written from the embedded calibration so it
diff --git a/src/SignsOfAI.Core/Reporting/ReportMessages.cs b/src/SignsOfAI.Core/Reporting/ReportMessages.cs
index f7bc97d..e7d345e 100644
--- a/src/SignsOfAI.Core/Reporting/ReportMessages.cs
+++ b/src/SignsOfAI.Core/Reporting/ReportMessages.cs
@@ -53,6 +53,13 @@ public static class ReportMessages
public const string AnalysisScoreWithVerdict = "analysis.score.with-verdict";
public const string AnalysisScoreWithoutVerdict = "analysis.score.without-verdict";
public const string AnalysisNoVerdict = "analysis.no-verdict";
+
+ ///
+ /// The other reason a verdict is withheld: the document is shorter than anything the
+ /// boundary was measured on. A different sentence from on
+ /// purpose — that one reports a reading, this one reports a refusal to give one.
+ ///
+ public const string AnalysisNoVerdictShort = "analysis.no-verdict-short";
public const string AnalysisFactsCitationOne = "analysis.facts.citation.one";
public const string AnalysisFactsCitationOther = "analysis.facts.citation.other";
public const string AnalysisFactsArtifactOne = "analysis.facts.artifact.one";
@@ -150,6 +157,7 @@ public static class ReportMessages
[AnalysisScoreWithVerdict] = 2,
[AnalysisScoreWithoutVerdict] = 1,
[AnalysisNoVerdict] = 0,
+ [AnalysisNoVerdictShort] = 2, // {0} words here, {1} shortest measured
[AnalysisFactsCitationOne] = 1,
[AnalysisFactsCitationOther] = 1,
[AnalysisFactsArtifactOne] = 1,
@@ -239,6 +247,7 @@ public static class ReportMessages
[AnalysisScoreWithVerdict] = "**{0}/100 — {1}**",
[AnalysisScoreWithoutVerdict] = "**{0}/100**",
[AnalysisNoVerdict] = "*Below the threshold this build can support, so no verdict is given. A low score is not evidence that a person wrote this.*",
+ [AnalysisNoVerdictShort] = "*No verdict is given: this document is {0} words, and the boundary above was measured only on texts of {1} words and longer. Nothing this short was measured, so the score below stands on its own — it is neither evidence that a machine wrote this nor evidence that a person did.*",
[AnalysisFactsCitationOne] = "**Checkable facts found: {0} source contradiction. These did not move the score.**",
[AnalysisFactsCitationOther] = "**Checkable facts found: {0} source contradictions. These did not move the score.**",
[AnalysisFactsArtifactOne] = "**Checkable facts found: {0} unusual character. These did not move the score.**",
@@ -305,7 +314,7 @@ public static class ReportMessages
SectionAnalysis, SectionCheckable, SectionCharacters, SectionCitations, SectionSignals,
SectionObservations, SectionErrorRate, SectionUnreadable,
VerdictSigns, VerdictNone,
- AnalysisNoVerdict,
+ AnalysisNoVerdict, AnalysisNoVerdictShort,
LanguageEnglish, LanguageSpanish, LanguageOther,
CaveatUncalibrated, CaveatAggregateNoThreshold, CaveatLanguageUnmeasured,
CaveatLanguageNoThreshold, CaveatLanguageMeasured, CaveatAggregateMeasured,
diff --git a/src/SignsOfAI.Core/Reporting/report.en.json b/src/SignsOfAI.Core/Reporting/report.en.json
index 6a177a6..0bacb5e 100644
--- a/src/SignsOfAI.Core/Reporting/report.en.json
+++ b/src/SignsOfAI.Core/Reporting/report.en.json
@@ -1,81 +1,236 @@
{
"language": "en",
- "translators": ["SignsOfAI maintainers"],
+ "translators": [
+ "SignsOfAI maintainers"
+ ],
"messages": {
- "fallback.marker": { "text": "This block has not been translated yet; it is shown in English." },
- "fallback.summary": { "text": "This report contains {0} block(s) not yet translated. Each is marked and shown in English." },
- "fallback.language": { "text": "This report is not available in {0}, so the whole of it is shown in English. Nothing has been withheld or shortened, but a reader who cannot read English cannot read the part that limits the score, and that part is the point of the page." },
- "analysis.no-rule-pack": { "text": "> **There is no rule pack for {0} yet, so this text was examined with the English one.** Treat the score as saying nothing at all: the tells this tool knows are English ones, and few of them can fire on writing in another language — so a low number here means nothing was looked for, not that nothing was found. Rule packs are JSON files anyone can contribute." },
- "default.title": { "text": "Writing analysis report" },
- "meta.document": { "text": "**Document:** {0}" },
- "meta.generated": { "text": "**Generated:** {0} · **Engine:** SignsOfAI {1}" },
- "meta.folder": { "text": "**Folder:** {0}" },
- "section.analysis": { "text": "What the analysis says" },
- "section.checkable": { "text": "Checkable facts" },
- "section.characters": { "text": "Characters found in the file" },
- "section.citations": { "text": "What the document says about its own sources" },
- "section.signals": { "text": "Signals counted" },
- "section.observations": { "text": "Found, but at a rate people write at" },
- "section.error-rate": { "text": "How often this is wrong" },
- "section.unreadable": { "text": "Could not be read" },
- "verdict.signs": { "text": "Signs of AI writing" },
- "verdict.none": { "text": "No signs above the measured boundary" },
- "analysis.score.with-verdict": { "text": "**{0}/100 — {1}**" },
- "analysis.score.without-verdict": { "text": "**{0}/100**" },
- "analysis.no-verdict": { "text": "*Below the threshold this build can support, so no verdict is given. A low score is not evidence that a person wrote this.*" },
- "analysis.facts.citation.one": { "text": "**Checkable facts found: {0} source contradiction. These did not move the score.**" },
- "analysis.facts.citation.other": { "text": "**Checkable facts found: {0} source contradictions. These did not move the score.**" },
- "analysis.facts.artifact.one": { "text": "**Checkable facts found: {0} unusual character. These did not move the score.**" },
- "analysis.facts.artifact.other": { "text": "**Checkable facts found: {0} unusual characters. These did not move the score.**" },
- "analysis.facts.both.one-one": { "text": "**Checkable facts found: {0} source contradiction, {1} unusual character. These did not move the score.**" },
- "analysis.facts.both.one-other": { "text": "**Checkable facts found: {0} source contradiction, {1} unusual characters. These did not move the score.**" },
- "analysis.facts.both.other-one": { "text": "**Checkable facts found: {0} source contradictions, {1} unusual character. These did not move the score.**" },
- "analysis.facts.both.other-other": { "text": "**Checkable facts found: {0} source contradictions, {1} unusual characters. These did not move the score.**" },
- "analysis.counts.one": { "text": "- {0} signal counted" },
- "analysis.counts.other": { "text": "- {0} signals counted" },
- "analysis.counts-with-observations.one": { "text": "- {0} signal counted, plus {1} found at a rate people write at, which count for nothing" },
- "analysis.counts-with-observations.other": { "text": "- {0} signals counted, plus {1} found at a rate people write at, which count for nothing" },
- "analysis.language-stats": { "text": "- Analysed as {0} · {1} words · {2} sentences · sentence-length variability {3}" },
- "language.en": { "text": "English" },
- "language.es": { "text": "Spanish" },
- "language.other": { "text": "language code {0}" },
- "caveat.uncalibrated": { "text": "> **This build has not been calibrated.** No false-positive rate has been measured for it, so the score above should not be used to support a decision about a person." },
- "caveat.aggregate-no-threshold": { "text": "> **No threshold is supported yet.** This build was measured against {0} texts, too few to bound its false-positive rate, so no score on this page should be used to support a decision about a person." },
- "caveat.language-unmeasured": { "text": "> **This build has never been measured for {0}.** It has no false-positive rate or supported threshold for writing in this language, and the aggregate result from other languages is not a substitute. No score on this page should be used to support a decision about a person." },
- "caveat.language-no-threshold": { "text": "> **No threshold is supported for this language yet.** The corpus holds {0} texts in it — too few to bound how often this build is wrong about writing in it, so no score on this page should be used to support a decision about a person. The best bound these texts support is {1}, and the overall figure is not a substitute for it." },
- "caveat.language-measured": { "text": "> **A score is not proof.** On {0} texts in this language, published before generative models existed, this build's false-positive rate at a threshold of {1}/100 was under {2} — the upper end of a 95% interval, not a guarantee, and measured on published articles rather than student work. Below that threshold, treat the score as saying nothing." },
- "caveat.aggregate-measured": { "text": "> **A score is not proof.** On {0} texts published before generative models existed, this build's false-positive rate at a threshold of {1}/100 was under {2} — the upper end of a 95% interval, not a guarantee, and measured on published articles rather than student work. Below that threshold, treat the score as saying nothing." },
- "checkable.intro": { "text": "These are not judgements about the writing and they did not move the score. Each is either present in the file or it is not." },
- "characters.explanation": { "text": "Several of these have ordinary explanations — word processors insert soft hyphens and unusual spaces on their own, and any copy-paste can carry them. Invisible characters and letters borrowed from another alphabet are harder to arrive at by accident, though pasting text can do it. This table says what is in the file, not how it got there." },
- "characters.ordered": { "text": "Listed with the characters hardest to arrive at by accident first, then in the order they appear in the file." },
- "characters.table-header": { "text": "| Character | Codepoint | Line | Column |" },
- "common.more-rows": { "text": "… and {0} more." },
- "citations.issues-note": { "text": "> None of this needed the internet: the document disagrees with itself. It is a question to ask, not a conclusion — the answer is usually one sentence." },
- "citations.no-issues-note": { "text": "> Nothing here is a finding. It describes what could and could not be checked." },
- "signals.none": { "text": "None." },
- "signals.ordered": { "text": "Ordered by how much weight each one carries, heaviest first, rather than by where it appears in the text." },
- "signals.more": { "text": "… and {0} more, none of them carrying more weight than what is shown above." },
- "observations.intro": { "text": "Measured against writing published before generative models existed. Shown because they are real, and counted for nothing because they are ordinary." },
- "observations.row.one": { "text": "- {0} — {1} occurrence" },
- "observations.row.other": { "text": "- {0} — {1} occurrences" },
- "privacy.document": { "text": "*This report was produced on the device that ran the analysis and contains material from the document it describes. It is yours to keep or to send; nothing here was uploaded anywhere.*" },
- "folder.summary.one": { "text": "{0} file scanned." },
- "folder.summary.other": { "text": "{0} files scanned." },
- "folder.summary-unreadable.one": { "text": "{0} file scanned, {1} unreadable." },
- "folder.summary-unreadable.other": { "text": "{0} files scanned, {1} unreadable." },
- "folder.reading-order": { "text": "> **This is a reading order, not a ranking.** A higher score means look sooner, and nothing more. Nothing on this page establishes that anyone did anything." },
- "folder.table-header": { "text": "| File | Score | Signals | Words |" },
- "folder.unreadable-row": { "text": "- {0} — {1}" },
- "privacy.folder": { "text": "*Produced on the device that scanned the folder. It names your students' files, so treat it as you would the coursework itself; nothing here was uploaded anywhere.*" },
- "how.uncalibrated": { "text": "This build ships no calibration, so nothing is known about how often it is wrong. That is itself the most important thing on this page." },
- "how.language-unmeasured": { "text": "This build has never been measured on writing in {0}. No language-specific false-positive rate or threshold exists, and the aggregate result from other languages is not a substitute." },
- "how.language-no-threshold": { "text": "Measured against **{0} texts in this language**, published before generative models existed, on {2} with engine {3}. That sample is too small to support a threshold; the best upper bound it supports is **{1}**, and the overall figure is not a substitute." },
- "how.language-measured": { "text": "Measured against **{0} texts in this language**, published before generative models existed, on {3} with engine {4}. At **{1}/100**, the upper end of the measured 95% false-positive interval was **{2}** — an interval, not a guarantee." },
- "how.aggregate-intro": { "text": "Measured against **{0} texts published before generative models existed**, so their authorship rests on their dates rather than on anybody's judgement. Measured on {1} with engine {2}." },
- "how.aggregate-threshold": { "text": "At **{0}/100**, {1} of those {2} were flagged — an observed {3}, with a 95% interval of {4} – {5}." },
- "how.read-interval": { "text": "Read the interval, not the observed rate. {0} out of {1} is not a false-positive rate you can round down." },
- "how.noisy-intro": { "text": "The rules seen most often on that human writing, worst first — if the evidence above leans on one of these, weigh it accordingly:" },
- "how.noisy-rule": { "text": "- `{0}` — {1} of human texts" },
- "how.limitation": { "text": "What this does **not** tell you: how much machine-written text it catches. That is the other half of the picture and it is deliberately not measured here, because any collection of machine-written text samples whichever models were convenient that month. A tool that flags nothing has a perfect false-positive rate." }
+ "fallback.marker": {
+ "text": "This block has not been translated yet; it is shown in English."
+ },
+ "fallback.summary": {
+ "text": "This report contains {0} block(s) not yet translated. Each is marked and shown in English."
+ },
+ "fallback.language": {
+ "text": "This report is not available in {0}, so the whole of it is shown in English. Nothing has been withheld or shortened, but a reader who cannot read English cannot read the part that limits the score, and that part is the point of the page."
+ },
+ "analysis.no-rule-pack": {
+ "text": "> **There is no rule pack for {0} yet, so this text was examined with the English one.** Treat the score as saying nothing at all: the tells this tool knows are English ones, and few of them can fire on writing in another language — so a low number here means nothing was looked for, not that nothing was found. Rule packs are JSON files anyone can contribute."
+ },
+ "default.title": {
+ "text": "Writing analysis report"
+ },
+ "meta.document": {
+ "text": "**Document:** {0}"
+ },
+ "meta.generated": {
+ "text": "**Generated:** {0} · **Engine:** SignsOfAI {1}"
+ },
+ "meta.folder": {
+ "text": "**Folder:** {0}"
+ },
+ "section.analysis": {
+ "text": "What the analysis says"
+ },
+ "section.checkable": {
+ "text": "Checkable facts"
+ },
+ "section.characters": {
+ "text": "Characters found in the file"
+ },
+ "section.citations": {
+ "text": "What the document says about its own sources"
+ },
+ "section.signals": {
+ "text": "Signals counted"
+ },
+ "section.observations": {
+ "text": "Found, but at a rate people write at"
+ },
+ "section.error-rate": {
+ "text": "How often this is wrong"
+ },
+ "section.unreadable": {
+ "text": "Could not be read"
+ },
+ "verdict.signs": {
+ "text": "Signs of AI writing"
+ },
+ "verdict.none": {
+ "text": "No signs above the measured boundary"
+ },
+ "analysis.score.with-verdict": {
+ "text": "**{0}/100 — {1}**"
+ },
+ "analysis.score.without-verdict": {
+ "text": "**{0}/100**"
+ },
+ "analysis.no-verdict": {
+ "text": "*Below the threshold this build can support, so no verdict is given. A low score is not evidence that a person wrote this.*"
+ },
+ "analysis.no-verdict-short": {
+ "text": "*No verdict is given: this document is {0} words, and the boundary above was measured only on texts of {1} words and longer. Nothing this short was measured, so the score below stands on its own — it is neither evidence that a machine wrote this nor evidence that a person did.*"
+ },
+ "analysis.facts.citation.one": {
+ "text": "**Checkable facts found: {0} source contradiction. These did not move the score.**"
+ },
+ "analysis.facts.citation.other": {
+ "text": "**Checkable facts found: {0} source contradictions. These did not move the score.**"
+ },
+ "analysis.facts.artifact.one": {
+ "text": "**Checkable facts found: {0} unusual character. These did not move the score.**"
+ },
+ "analysis.facts.artifact.other": {
+ "text": "**Checkable facts found: {0} unusual characters. These did not move the score.**"
+ },
+ "analysis.facts.both.one-one": {
+ "text": "**Checkable facts found: {0} source contradiction, {1} unusual character. These did not move the score.**"
+ },
+ "analysis.facts.both.one-other": {
+ "text": "**Checkable facts found: {0} source contradiction, {1} unusual characters. These did not move the score.**"
+ },
+ "analysis.facts.both.other-one": {
+ "text": "**Checkable facts found: {0} source contradictions, {1} unusual character. These did not move the score.**"
+ },
+ "analysis.facts.both.other-other": {
+ "text": "**Checkable facts found: {0} source contradictions, {1} unusual characters. These did not move the score.**"
+ },
+ "analysis.counts.one": {
+ "text": "- {0} signal counted"
+ },
+ "analysis.counts.other": {
+ "text": "- {0} signals counted"
+ },
+ "analysis.counts-with-observations.one": {
+ "text": "- {0} signal counted, plus {1} found at a rate people write at, which count for nothing"
+ },
+ "analysis.counts-with-observations.other": {
+ "text": "- {0} signals counted, plus {1} found at a rate people write at, which count for nothing"
+ },
+ "analysis.language-stats": {
+ "text": "- Analysed as {0} · {1} words · {2} sentences · sentence-length variability {3}"
+ },
+ "language.en": {
+ "text": "English"
+ },
+ "language.es": {
+ "text": "Spanish"
+ },
+ "language.other": {
+ "text": "language code {0}"
+ },
+ "caveat.uncalibrated": {
+ "text": "> **This build has not been calibrated.** No false-positive rate has been measured for it, so the score above should not be used to support a decision about a person."
+ },
+ "caveat.aggregate-no-threshold": {
+ "text": "> **No threshold is supported yet.** This build was measured against {0} texts, too few to bound its false-positive rate, so no score on this page should be used to support a decision about a person."
+ },
+ "caveat.language-unmeasured": {
+ "text": "> **This build has never been measured for {0}.** It has no false-positive rate or supported threshold for writing in this language, and the aggregate result from other languages is not a substitute. No score on this page should be used to support a decision about a person."
+ },
+ "caveat.language-no-threshold": {
+ "text": "> **No threshold is supported for this language yet.** The corpus holds {0} texts in it — too few to bound how often this build is wrong about writing in it, so no score on this page should be used to support a decision about a person. The best bound these texts support is {1}, and the overall figure is not a substitute for it."
+ },
+ "caveat.language-measured": {
+ "text": "> **A score is not proof.** On {0} texts in this language, published before generative models existed, this build's false-positive rate at a threshold of {1}/100 was under {2} — the upper end of a 95% interval, not a guarantee, and measured on published articles rather than student work. Below that threshold, treat the score as saying nothing."
+ },
+ "caveat.aggregate-measured": {
+ "text": "> **A score is not proof.** On {0} texts published before generative models existed, this build's false-positive rate at a threshold of {1}/100 was under {2} — the upper end of a 95% interval, not a guarantee, and measured on published articles rather than student work. Below that threshold, treat the score as saying nothing."
+ },
+ "checkable.intro": {
+ "text": "These are not judgements about the writing and they did not move the score. Each is either present in the file or it is not."
+ },
+ "characters.explanation": {
+ "text": "Several of these have ordinary explanations — word processors insert soft hyphens and unusual spaces on their own, and any copy-paste can carry them. Invisible characters and letters borrowed from another alphabet are harder to arrive at by accident, though pasting text can do it. This table says what is in the file, not how it got there."
+ },
+ "characters.ordered": {
+ "text": "Listed with the characters hardest to arrive at by accident first, then in the order they appear in the file."
+ },
+ "characters.table-header": {
+ "text": "| Character | Codepoint | Line | Column |"
+ },
+ "common.more-rows": {
+ "text": "… and {0} more."
+ },
+ "citations.issues-note": {
+ "text": "> None of this needed the internet: the document disagrees with itself. It is a question to ask, not a conclusion — the answer is usually one sentence."
+ },
+ "citations.no-issues-note": {
+ "text": "> Nothing here is a finding. It describes what could and could not be checked."
+ },
+ "signals.none": {
+ "text": "None."
+ },
+ "signals.ordered": {
+ "text": "Ordered by how much weight each one carries, heaviest first, rather than by where it appears in the text."
+ },
+ "signals.more": {
+ "text": "… and {0} more, none of them carrying more weight than what is shown above."
+ },
+ "observations.intro": {
+ "text": "Measured against writing published before generative models existed. Shown because they are real, and counted for nothing because they are ordinary."
+ },
+ "observations.row.one": {
+ "text": "- {0} — {1} occurrence"
+ },
+ "observations.row.other": {
+ "text": "- {0} — {1} occurrences"
+ },
+ "privacy.document": {
+ "text": "*This report was produced on the device that ran the analysis and contains material from the document it describes. It is yours to keep or to send; nothing here was uploaded anywhere.*"
+ },
+ "folder.summary.one": {
+ "text": "{0} file scanned."
+ },
+ "folder.summary.other": {
+ "text": "{0} files scanned."
+ },
+ "folder.summary-unreadable.one": {
+ "text": "{0} file scanned, {1} unreadable."
+ },
+ "folder.summary-unreadable.other": {
+ "text": "{0} files scanned, {1} unreadable."
+ },
+ "folder.reading-order": {
+ "text": "> **This is a reading order, not a ranking.** A higher score means look sooner, and nothing more. Nothing on this page establishes that anyone did anything."
+ },
+ "folder.table-header": {
+ "text": "| File | Score | Signals | Words |"
+ },
+ "folder.unreadable-row": {
+ "text": "- {0} — {1}"
+ },
+ "privacy.folder": {
+ "text": "*Produced on the device that scanned the folder. It names your students' files, so treat it as you would the coursework itself; nothing here was uploaded anywhere.*"
+ },
+ "how.uncalibrated": {
+ "text": "This build ships no calibration, so nothing is known about how often it is wrong. That is itself the most important thing on this page."
+ },
+ "how.language-unmeasured": {
+ "text": "This build has never been measured on writing in {0}. No language-specific false-positive rate or threshold exists, and the aggregate result from other languages is not a substitute."
+ },
+ "how.language-no-threshold": {
+ "text": "Measured against **{0} texts in this language**, published before generative models existed, on {2} with engine {3}. That sample is too small to support a threshold; the best upper bound it supports is **{1}**, and the overall figure is not a substitute."
+ },
+ "how.language-measured": {
+ "text": "Measured against **{0} texts in this language**, published before generative models existed, on {3} with engine {4}. At **{1}/100**, the upper end of the measured 95% false-positive interval was **{2}** — an interval, not a guarantee."
+ },
+ "how.aggregate-intro": {
+ "text": "Measured against **{0} texts published before generative models existed**, so their authorship rests on their dates rather than on anybody's judgement. Measured on {1} with engine {2}."
+ },
+ "how.aggregate-threshold": {
+ "text": "At **{0}/100**, {1} of those {2} were flagged — an observed {3}, with a 95% interval of {4} – {5}."
+ },
+ "how.read-interval": {
+ "text": "Read the interval, not the observed rate. {0} out of {1} is not a false-positive rate you can round down."
+ },
+ "how.noisy-intro": {
+ "text": "The rules seen most often on that human writing, worst first — if the evidence above leans on one of these, weigh it accordingly:"
+ },
+ "how.noisy-rule": {
+ "text": "- `{0}` — {1} of human texts"
+ },
+ "how.limitation": {
+ "text": "What this does **not** tell you: how much machine-written text it catches. That is the other half of the picture and it is deliberately not measured here, because any collection of machine-written text samples whichever models were convenient that month. A tool that flags nothing has a perfect false-positive rate."
+ }
}
}
diff --git a/src/SignsOfAI.Core/Reporting/report.es.json b/src/SignsOfAI.Core/Reporting/report.es.json
index 750ab32..c2b0519 100644
--- a/src/SignsOfAI.Core/Reporting/report.es.json
+++ b/src/SignsOfAI.Core/Reporting/report.es.json
@@ -1,6 +1,8 @@
{
"language": "es",
- "translators": ["Equipo de SignsOfAI"],
+ "translators": [
+ "Equipo de SignsOfAI"
+ ],
"messages": {
"fallback.marker": {
"text": "Este bloque aún no está traducido; se muestra en inglés.",
@@ -78,6 +80,10 @@
"text": "*Por debajo del umbral que esta compilación puede respaldar, no se emite ningún veredicto. Una puntuación baja no demuestra que una persona haya escrito este texto.*",
"sourceHash": "b249a9a70a6e6c6acbd87df70e9eb60558eddb6bb39e8ac49af95db5c44811e8"
},
+ "analysis.no-verdict-short": {
+ "text": "*No se da veredicto: este documento tiene {0} palabras, y la frontera de arriba se midió solo sobre textos de {1} palabras o más. Nada tan corto se midió, así que la puntuación de abajo queda sola — no es prueba de que lo escribiera una máquina ni de que lo escribiera una persona.*",
+ "sourceHash": "ec90d082e44b2e0eec6defaae43905deb0bb90ee53884fb4e40ff06dc4a92f35"
+ },
"language.en": {
"text": "inglés",
"sourceHash": "ba118bf7fc9c1aedc1edb28a0aa86e0b43b681f222af6616e13c43be87815b06"
diff --git a/src/SignsOfAI.UI/Components/LiveRewritePanel.razor b/src/SignsOfAI.UI/Components/LiveRewritePanel.razor
index ab85da6..646ac44 100644
--- a/src/SignsOfAI.UI/Components/LiveRewritePanel.razor
+++ b/src/SignsOfAI.UI/Components/LiveRewritePanel.razor
@@ -269,11 +269,16 @@
if (cursor < Text.Length) yield return new Seg(Text[cursor..], null);
}
- private static string ScoreClass(double score) => VerdictBands.Emphasis(score) switch
- {
- VerdictEmphasis.High => "danger",
- VerdictEmphasis.Elevated => "warn",
- VerdictEmphasis.Present => "notice",
- _ => "good",
- };
+ // The document's own language and length, so the two numbers here are coloured by the same rule
+ // as the score above them. Below the measured length neither band means anything, and a green
+ // "after" number would read as this panel certifying a rewrite the engine declines to judge.
+ private string ScoreClass(double score) =>
+ VerdictBands.Emphasis(score, Result?.Language, Result?.Statistics.WordCount ?? 0) switch
+ {
+ VerdictEmphasis.High => "danger",
+ VerdictEmphasis.Elevated => "warn",
+ VerdictEmphasis.Present => "notice",
+ VerdictEmphasis.Unmeasured => "unmeasured",
+ _ => "good",
+ };
}
diff --git a/src/SignsOfAI.UI/Pages/Batch.razor b/src/SignsOfAI.UI/Pages/Batch.razor
index f30b20e..0b90029 100644
--- a/src/SignsOfAI.UI/Pages/Batch.razor
+++ b/src/SignsOfAI.UI/Pages/Batch.razor
@@ -83,7 +83,8 @@ else
@if (r.Score is { } s)
{
- @s
+ @s
}
else
{
@@ -101,7 +102,12 @@ else
}
@code {
- private sealed record Row(string Name, string Path, int? Words, int? Score, int? Findings, string? Error);
+ private sealed record Row(
+ string Name, string Path, int? Words, int? Score, int? Findings, string? Error,
+ // Carried so the pill can be coloured by the same rule as every other surface: a language the
+ // corpus never contained and a document below the measured length both mean "no verdict", and
+ // Measured(null) is false, so passing nothing here would grey out the whole table.
+ string? Language = null);
private readonly List _rows = [];
@@ -195,7 +201,7 @@ else
file.Name, file.Path,
result.Statistics.WordCount,
(int)Math.Round(result.OverallScore),
- result.Signals.Count, null));
+ result.Signals.Count, null, result.Language));
}
// Render each row as it lands. Two hundred files behind one silent await looks
@@ -216,10 +222,16 @@ else
// This said 40 where every other surface said 45 — the drift the single source exists to stop,
// and it had already happened: one document could be coloured two ways by the same build.
- private static string BandClass(int score) => VerdictBands.Emphasis(score) switch
- {
- VerdictEmphasis.High => "high",
- VerdictEmphasis.Elevated => "medium",
- _ => "low",
- };
+ //
+ // The row carries its own word count because a folder of submissions is exactly where short ones
+ // turn up, and a triage list that paints an unmeasured document green has answered a question it
+ // was not asked.
+ private static string BandClass(int score, int? words, string? language) =>
+ VerdictBands.Emphasis(score, language, words ?? int.MaxValue) switch
+ {
+ VerdictEmphasis.High => "high",
+ VerdictEmphasis.Elevated => "medium",
+ VerdictEmphasis.Unmeasured => "unmeasured",
+ _ => "low",
+ };
}
diff --git a/src/SignsOfAI.UI/Pages/Home.razor b/src/SignsOfAI.UI/Pages/Home.razor
index 49405e8..95823ea 100644
--- a/src/SignsOfAI.UI/Pages/Home.razor
+++ b/src/SignsOfAI.UI/Pages/Home.razor
@@ -207,7 +207,7 @@
var r = _result;
-
@Math.Round(r.OverallScore)
@@ -215,9 +215,17 @@
- @L.Verdict(r.OverallScore)
+ @L.Verdict(r)
@L.P(r.Signals.Count, "home.signals") · @L["home.analyzedas"]
@L.TextLanguageName(r.Language)
+ @* Why nothing is being claimed, at the moment the reader would otherwise assume the
+ silence means "clean". The evidence below is unaffected: only the claim is. *@
+ @if (!VerdictBands.Measured(r.Statistics.WordCount))
+ {
+
+ @L.F("home.unmeasured.length", r.Statistics.WordCount, VerdictBands.MinimumWords ?? 0)
+
+ }
@foreach (var c in r.CategoryScores.Where(c => c.FindingCount > 0))
{
@@ -308,9 +316,9 @@
@if (_humanizedResult is not null)
{
- @Math.Round(r.OverallScore)
+ @Math.Round(r.OverallScore)
→
- @Math.Round(_humanizedResult.OverallScore)
+ @Math.Round(_humanizedResult.OverallScore)
@{ var delta = r.OverallScore - _humanizedResult.OverallScore; }
@if (delta > 0.5) { −@Math.Round(delta) }
@@ -684,8 +692,8 @@ else
{
var data = new ShareCardData(
Score: r.OverallScore,
- ScoreColor: ScoreHex(r.OverallScore),
- Verdict: L.Verdict(r.OverallScore),
+ ScoreColor: ScoreHex(r),
+ Verdict: L.Verdict(r),
Signals: r.Signals.Count,
Language: L.TextLanguageName(r.Language),
Categories: r.CategoryScores.Where(c => c.FindingCount > 0)
@@ -720,21 +728,27 @@ else
$"signsofai-report-{stamp}.html", EvidenceReport.ToHtml(r, options));
}
- private static string ScoreClass(double score) => VerdictBands.Emphasis(score) switch
- {
- VerdictEmphasis.High => "danger",
- VerdictEmphasis.Elevated => "warn",
- VerdictEmphasis.Present => "notice",
- _ => "good",
- };
+ // Colour is part of the verdict. Painting a 72/100 passage green because the verdict was
+ // withheld would state the opposite of what was withheld, in the loudest channel on the page.
+ private static string ScoreClass(AnalysisResult r) =>
+ VerdictBands.Emphasis(r.OverallScore, r.Language, r.Statistics.WordCount) switch
+ {
+ VerdictEmphasis.High => "danger",
+ VerdictEmphasis.Elevated => "warn",
+ VerdictEmphasis.Present => "notice",
+ VerdictEmphasis.Unmeasured => "unmeasured",
+ _ => "good",
+ };
- private static string ScoreHex(double score) => VerdictBands.Emphasis(score) switch
- {
- VerdictEmphasis.High => "#dc2626",
- VerdictEmphasis.Elevated => "#ea580c",
- VerdictEmphasis.Present => "#ca8a04",
- _ => "#16a34a",
- };
+ private static string ScoreHex(AnalysisResult r) =>
+ VerdictBands.Emphasis(r.OverallScore, r.Language, r.Statistics.WordCount) switch
+ {
+ VerdictEmphasis.High => "#dc2626",
+ VerdictEmphasis.Elevated => "#ea580c",
+ VerdictEmphasis.Present => "#ca8a04",
+ VerdictEmphasis.Unmeasured => "#6b7280",
+ _ => "#16a34a",
+ };
private const string SampleEn =
"In today's digital age, we must delve into the rich tapestry of modern innovation. It's worth " +
diff --git a/src/SignsOfAI.UI/Services/Loc.cs b/src/SignsOfAI.UI/Services/Loc.cs
index a433597..c579669 100644
--- a/src/SignsOfAI.UI/Services/Loc.cs
+++ b/src/SignsOfAI.UI/Services/Loc.cs
@@ -246,14 +246,19 @@ private async Task LogAsync(string message)
public string Sev(Severity severity) => this["sev." + severity.ToString().ToLowerInvariant()];
///
- /// The one-line verdict for an overall score, in the interface's language. The boundary comes
- /// from ; this only chooses the words for it.
+ /// The one-line verdict for a document, in the interface's language. The boundary comes from
+ /// ; this only chooses the words for it.
+ ///
+ /// It takes the result rather than the score because two of the four answers are not about the
+ /// score at all: a language the corpus never contained and a document shorter than anything the
+ /// boundary was measured on both mean *this build has nothing to say*, which is a different
+ /// sentence from "no signs above the boundary" and must not borrow its reassurance. See #59.
///
- public string Verdict(double score) => VerdictBands.Holds(score) switch
- {
- true => this["verdict.signs"],
- false => this["verdict.none"],
- };
+ public string Verdict(AnalysisResult result) =>
+ !VerdictBands.Measured(result.Statistics.WordCount) ? this["verdict.unmeasured.length"]
+ : !VerdictBands.Measured(result.Language) ? this["verdict.unmeasured.language"]
+ : VerdictBands.Holds(result.OverallScore) ? this["verdict.signs"]
+ : this["verdict.none"];
/// How to name the language the analyzer settled on. The engine only knows EN and ES.
public string TextLanguageName(string code) => this[code == "es" ? "lang.spanish" : "lang.english"];
diff --git a/src/SignsOfAI.UI/wwwroot/css/app.css b/src/SignsOfAI.UI/wwwroot/css/app.css
index e43aa16..e9fcca3 100644
--- a/src/SignsOfAI.UI/wwwroot/css/app.css
+++ b/src/SignsOfAI.UI/wwwroot/css/app.css
@@ -214,6 +214,9 @@ button:disabled { opacity: .45; cursor: not-allowed; }
.score-ring.notice { --ring: var(--notice); }
.score-ring.warn { --ring: var(--warn); }
.score-ring.danger { --ring: var(--danger); }
+/* Outside what was measured. Grey rather than green: the page is refusing to answer, and green is
+ an answer. See VerdictEmphasis.Unmeasured. */
+.score-ring.unmeasured { --ring: var(--text-muted); }
.score-inner {
width: 88px; height: 88px; border-radius: 50%; background: var(--surface);
display: grid; place-items: center; text-align: center;
@@ -226,6 +229,8 @@ button:disabled { opacity: .45; cursor: not-allowed; }
.verdict h2.notice { color: var(--notice); }
.verdict h2.warn { color: var(--warn); }
.verdict h2.danger { color: var(--danger); }
+.verdict h2.unmeasured { color: var(--text-muted); }
+.hint.unmeasured { margin-top: .4rem; max-width: 46rem; }
.verdict p { margin: 0 0 .6rem; color: var(--text-muted); font-size: .9rem; }
.cat-chips { display: flex; gap: .4rem; flex-wrap: wrap; }
@@ -1084,6 +1089,7 @@ button.ghost.sm { padding: .35rem .7rem; font-size: .82rem; }
.score-pill.high { background: color-mix(in srgb, var(--danger) 16%, transparent); color: var(--danger); }
.score-pill.medium { background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); }
.score-pill.low { background: color-mix(in srgb, var(--ok) 16%, transparent); color: var(--ok); }
+.score-pill.unmeasured { background: var(--surface-2); color: var(--text-muted); }
/* One-time model download (desktop). The bar only appears once a total is known; when the server
sends no Content-Length the megabytes counter carries the message on its own. */
@@ -1156,3 +1162,6 @@ button.ghost.sm { padding: .35rem .7rem; font-size: .82rem; }
.dl-steps { margin: .6rem 0 .8rem; padding-left: 1.2rem; font-size: .88rem; line-height: 1.6; }
.dl-steps li { margin-bottom: .2rem; }
.dl-steps code { font-size: .84em; }
+
+/* The before/after pair of the rewriter, and the folder-scan pill, share the refusal colour. */
+.sc-num.unmeasured { color: var(--text-muted); }
diff --git a/src/SignsOfAI.UI/wwwroot/i18n/en.json b/src/SignsOfAI.UI/wwwroot/i18n/en.json
index 2b3cb12..72d9bad 100644
--- a/src/SignsOfAI.UI/wwwroot/i18n/en.json
+++ b/src/SignsOfAI.UI/wwwroot/i18n/en.json
@@ -31,6 +31,7 @@
"batch.col.score": "Score",
"batch.col.findings": "Signals",
"batch.col.note": "Note",
+ "batch.unmeasured": "Shorter than anything the boundary was measured on — no verdict",
"dl.pagetitle": "Windows app — Signs of AI Writing",
"dl.h1": "Signs of AI Writing for Windows",
"dl.tagline": "The same tool as this page, in a window: it opens the file formats a browser tab cannot, reads a whole folder at once, and measures on your own machine.",
@@ -92,6 +93,8 @@
"sev.high": "High",
"verdict.signs": "Signs of AI writing",
"verdict.none": "No signs above the measured boundary",
+ "verdict.unmeasured.length": "No verdict at this length",
+ "verdict.unmeasured.language": "No verdict in this language",
"home.pagetitle": "Signs of AI Writing — detect & de-AI-ify your text",
"home.tagline": "We mark the signs of AI writing and show you the evidence, not just a percentage.",
"home.privacy": "Analysis runs entirely in your browser. Your text never leaves your device.",
@@ -149,6 +152,7 @@
"home.saved": "Saved in this browser",
"home.signals.one": "{0} signal found",
"home.signals.other": "{0} signals found",
+ "home.unmeasured.length": "This text is {0} words. The boundary was measured only on texts of {1} words and longer, so no verdict is given — the score is neither evidence that a machine wrote this nor evidence that a person did. Everything below is unaffected.",
"home.analyzedas": "analyzed as",
"home.humanize": "Humanize with AI",
"home.humanizing": "Humanizing…",
diff --git a/src/SignsOfAI.UI/wwwroot/i18n/es.json b/src/SignsOfAI.UI/wwwroot/i18n/es.json
index bf2fc9d..55b7702 100644
--- a/src/SignsOfAI.UI/wwwroot/i18n/es.json
+++ b/src/SignsOfAI.UI/wwwroot/i18n/es.json
@@ -31,6 +31,7 @@
"batch.col.score": "Puntuación",
"batch.col.findings": "Señales",
"batch.col.note": "Nota",
+ "batch.unmeasured": "Más corto que nada sobre lo que se midió la frontera — sin veredicto",
"dl.pagetitle": "App de Windows — Señales de escritura IA",
"dl.h1": "Señales de escritura IA para Windows",
"dl.tagline": "La misma herramienta de esta página, en una ventana: abre los formatos que una pestaña no puede, lee una carpeta entera de una vez y mide en tu propia máquina.",
@@ -92,6 +93,8 @@
"sev.high": "Alta",
"verdict.signs": "Señales de escritura con IA",
"verdict.none": "Sin señales por encima del umbral medido",
+ "verdict.unmeasured.length": "Sin veredicto a esta longitud",
+ "verdict.unmeasured.language": "Sin veredicto en este idioma",
"home.pagetitle": "Señales de escritura IA — detecta y humaniza tu texto",
"home.tagline": "Marcamos las señales de escritura con IA y te enseñamos la evidencia, no solo un porcentaje.",
"home.privacy": "El análisis se ejecuta por completo en tu navegador. Tu texto nunca sale de tu dispositivo.",
@@ -149,6 +152,7 @@
"home.saved": "Guardado en este navegador",
"home.signals.one": "{0} señal encontrada",
"home.signals.other": "{0} señales encontradas",
+ "home.unmeasured.length": "Este texto tiene {0} palabras. La frontera se midió solo sobre textos de {1} palabras o más, así que no se da veredicto — la puntuación no es prueba de que lo escribiera una máquina ni de que lo escribiera una persona. Todo lo de abajo no cambia.",
"home.analyzedas": "analizado como",
"home.humanize": "Humanizar con IA",
"home.humanizing": "Humanizando…",
diff --git a/tests/SignsOfAI.Core.Tests/EvidenceReportTests.cs b/tests/SignsOfAI.Core.Tests/EvidenceReportTests.cs
index d0e6f50..c1f72cd 100644
--- a/tests/SignsOfAI.Core.Tests/EvidenceReportTests.cs
+++ b/tests/SignsOfAI.Core.Tests/EvidenceReportTests.cs
@@ -333,7 +333,10 @@ public void Withholds_the_verdict_below_the_threshold_it_can_support()
// A verdict printed above "treat the score as saying nothing" is a page arguing with itself,
// and the reader keeps whichever half suits them. Below the boundary the report prints the
// score and the reason, and no verdict line of either kind.
- var report = Report();
+ // Long enough that the reason for the silence is the threshold and not the length — the two
+ // are different sentences since #59, and this test is about the first one.
+ var report = EvidenceReport.ToMarkdown(
+ new AiWritingAnalyzer().Analyze(Fixtures.LongEnough(Essay), "en"));
Assert.DoesNotContain("No signs above the measured boundary", report);
Assert.DoesNotContain("Signs of AI writing", report);
diff --git a/tests/SignsOfAI.Core.Tests/Fixtures.cs b/tests/SignsOfAI.Core.Tests/Fixtures.cs
new file mode 100644
index 0000000..18591f3
--- /dev/null
+++ b/tests/SignsOfAI.Core.Tests/Fixtures.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Linq;
+using SignsOfAI.Core.Model;
+
+namespace SignsOfAI.Core.Tests;
+
+///
+/// Shared fixture plumbing, and one piece of it is a fact about the engine rather than a convenience.
+///
+/// Since #59 a document shorter than the shortest text the boundary was measured on gets no verdict
+/// at all — 662 words on the corpus this build ships. Almost every fixture in this suite is a
+/// paragraph, so without a test written to check *what the verdict says*
+/// silently becomes a test of the length gate, passes for the wrong reason or fails for a reason that
+/// has nothing to do with what it was guarding.
+///
+/// Which is itself the finding: the fixtures were short because the way people use this tool is
+/// short, and that is exactly the population the boundary was never measured on.
+///
+internal static class Fixtures
+{
+ ///
+ /// The passage repeated until it clears , so a test about
+ /// the wording of a verdict gets one.
+ ///
+ /// Repetition rather than filler on purpose: padding with unrelated prose would change what the
+ /// rules find and move the score the test is asserting about, while repeating the same sentences
+ /// keeps the sentence-length distribution — and therefore the burstiness — roughly where it was.
+ ///
+ public static string LongEnough(string text)
+ {
+ if (VerdictBands.MinimumWords is not { } floor) return text;
+
+ var words = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
+ if (words == 0) return text;
+
+ // A margin over the floor, because the analyzer's word count and this split disagree by a few
+ // per cent and a fixture that lands one word short fails somewhere far from here.
+ var copies = (int)Math.Ceiling((floor * 1.25) / words);
+ return string.Join(" ", Enumerable.Repeat(text, Math.Max(1, copies)));
+ }
+}
diff --git a/tests/SignsOfAI.Core.Tests/LocaleFileTests.cs b/tests/SignsOfAI.Core.Tests/LocaleFileTests.cs
index a83a3dd..b51404a 100644
--- a/tests/SignsOfAI.Core.Tests/LocaleFileTests.cs
+++ b/tests/SignsOfAI.Core.Tests/LocaleFileTests.cs
@@ -29,6 +29,8 @@ public class LocaleFileTests
.. Enum.GetValues().Select(c => "cat." + c.ToString().ToLowerInvariant()),
.. Enum.GetValues().Select(s => "sev." + s.ToString().ToLowerInvariant()),
"verdict.signs", "verdict.none",
+ // Loc.Verdict picks one of four by asking VerdictBands, so a text search cannot see them.
+ "verdict.unmeasured.length", "verdict.unmeasured.language",
"lang.english", "lang.spanish",
// TaskEntry builds these from its own list, so a plain text search cannot see them and a
// deleted one would render as the raw key on the front door.
diff --git a/tests/SignsOfAI.Core.Tests/VerdictAgreementTests.cs b/tests/SignsOfAI.Core.Tests/VerdictAgreementTests.cs
index 943fb4c..f3022ff 100644
--- a/tests/SignsOfAI.Core.Tests/VerdictAgreementTests.cs
+++ b/tests/SignsOfAI.Core.Tests/VerdictAgreementTests.cs
@@ -80,7 +80,9 @@ public void The_verdict_reaches_its_reader_in_their_own_language()
// translation whose pin no longer matches is treated as stale — the whole report silently
// falls back to English. That is the correct behaviour and a silent way to undo #36, so the
// wording change and the pins have to travel together. This is the test that says they did.
- var result = new AiWritingAnalyzer().Analyze(ObviouslyMachine, "en");
+ // Long enough to earn a verdict at all: since #59 a paragraph gets none, and this test is
+ // about which language the verdict arrives in, not about whether one is given.
+ var result = new AiWritingAnalyzer().Analyze(Fixtures.LongEnough(ObviouslyMachine), "en");
var spanish = EvidenceReport.ToMarkdown(result, new ReportOptions { InterfaceLanguage = "es" });
Assert.Contains("Señales de escritura con IA", spanish);
diff --git a/tests/SignsOfAI.Core.Tests/VerdictLengthTests.cs b/tests/SignsOfAI.Core.Tests/VerdictLengthTests.cs
new file mode 100644
index 0000000..0ca2451
--- /dev/null
+++ b/tests/SignsOfAI.Core.Tests/VerdictLengthTests.cs
@@ -0,0 +1,171 @@
+using System.Linq;
+using SignsOfAI.Core;
+using SignsOfAI.Core.Calibration;
+using SignsOfAI.Core.Model;
+using SignsOfAI.Core.Reporting;
+using Xunit;
+
+namespace SignsOfAI.Core.Tests;
+
+///
+/// The length condition on the verdict — issue #59.
+///
+/// The boundary this build ships was fitted on 90 texts whose shortest is 662 words, and it was being
+/// applied to a pasted paragraph with nothing on the page to say so. The same documents flag 0 of 32
+/// whole and 6 of 32 as 400-word excerpts of themselves, so the error does not merely get noisier as
+/// text gets shorter: it moves one way, toward the machine.
+///
+/// What these guard is a **coverage** claim, not a reliability one. Nothing here asserts that the
+/// tool is wrong below 662 words. It asserts that the tool stops claiming, which is the only thing
+/// the corpus supports — and that it goes on showing the evidence, because the evidence was never
+/// what the boundary was about.
+///
+public class VerdictLengthTests
+{
+ private readonly AiWritingAnalyzer _analyzer = new();
+
+ /// A paragraph with enough tells to score well over the boundary, and far too short.
+ private const string ShortAndMachineLike =
+ "In today's rapidly evolving digital landscape, we must delve into the rich tapestry of " +
+ "innovation. It is worth noting that this multifaceted approach serves as a testament to " +
+ "human ingenuity. It's not just a tool, it's a pivotal, transformative solution that " +
+ "fosters growth, unlocks potential, and empowers teams. Moreover, the seamless integration " +
+ "underscores a paradigm shift, highlighting the profound implications for the realm of " +
+ "modern work.";
+
+ [Fact]
+ public void The_floor_is_the_shortest_text_the_boundary_was_measured_on()
+ {
+ var published = PublishedCalibration.Current;
+ Assert.NotNull(published);
+
+ // Published rather than chosen. If this is ever null while a threshold is published, the
+ // build is claiming a boundary without recording the population it was fitted on.
+ Assert.NotNull(published!.ShortestWords);
+ Assert.Equal(published.ShortestWords, VerdictBands.MinimumWords);
+ Assert.True(published.ShortestWords < published.LongestWords,
+ "A range whose ends are equal is not a range; the snapshot is malformed.");
+ }
+
+ [Fact]
+ public void A_paragraph_gets_no_verdict_however_high_it_scores()
+ {
+ var result = _analyzer.Analyze(ShortAndMachineLike, "en");
+
+ Assert.True(result.OverallScore >= VerdictBands.Threshold,
+ "The fixture stopped scoring above the boundary, so this test no longer proves anything.");
+ Assert.True(result.Statistics.WordCount < VerdictBands.MinimumWords);
+
+ Assert.False(result.HasVerdict);
+ Assert.Equal("No verdict: below the measured length", result.Verdict);
+ }
+
+ [Fact]
+ public void The_same_writing_at_length_does_get_one()
+ {
+ var result = _analyzer.Analyze(Fixtures.LongEnough(ShortAndMachineLike), "en");
+
+ Assert.True(result.Statistics.WordCount >= VerdictBands.MinimumWords);
+ Assert.True(result.HasVerdict);
+ Assert.Equal("Signs of AI writing", result.Verdict);
+ }
+
+ ///
+ /// The verdict is withheld; the evidence is not. Suppressing the findings too would leave a
+ /// teacher with a shorter answer than they had before, and the findings never depended on the
+ /// boundary — "delve" is in the text or it is not.
+ ///
+ [Fact]
+ public void Withholding_the_verdict_does_not_withhold_the_evidence()
+ {
+ var result = _analyzer.Analyze(ShortAndMachineLike, "en");
+
+ Assert.False(result.HasVerdict);
+ Assert.NotEmpty(result.Signals);
+ Assert.Contains(result.Findings, f => f.RuleId == "lex.delve");
+ Assert.True(result.OverallScore > 0, "The score is still computed and still shown.");
+ }
+
+ ///
+ /// Two different silences, two different sentences. "Below the threshold" is a reading — the tool
+ /// looked and found little. "Shorter than anything measured" is a refusal to read. A reader told
+ /// the first when the second is true takes away a reassurance nobody offered.
+ ///
+ [Fact]
+ public void The_report_says_which_silence_it_is()
+ {
+ var shortReport = EvidenceReport.ToMarkdown(_analyzer.Analyze(ShortAndMachineLike, "en"));
+
+ Assert.Contains("measured only on texts of", shortReport);
+ Assert.DoesNotContain("A low score is not evidence that a person wrote this", shortReport);
+ Assert.DoesNotContain("Signs of AI writing", shortReport);
+
+ // And it names both numbers, so the reader can check the claim rather than take it.
+ Assert.Contains(VerdictBands.MinimumWords!.Value.ToString("N0"), shortReport);
+ }
+
+ [Fact]
+ public void A_spanish_reader_is_told_the_same_thing_in_Spanish()
+ {
+ var report = EvidenceReport.ToMarkdown(
+ _analyzer.Analyze(ShortAndMachineLike, "en"),
+ new ReportOptions { InterfaceLanguage = "es" });
+
+ Assert.Contains("se midió solo sobre textos de", report);
+ Assert.DoesNotContain("measured only on texts of", report);
+ }
+
+ ///
+ /// Colour is part of the verdict whatever the design system pretends. Before this, a withheld
+ /// verdict fell through to , which every surface paints green —
+ /// so a 72/100 passage would have been withheld in words and certified in colour.
+ ///
+ [Fact]
+ public void An_unmeasured_document_is_not_painted_as_a_clean_one()
+ {
+ var result = _analyzer.Analyze(ShortAndMachineLike, "en");
+
+ Assert.Equal(
+ VerdictEmphasis.Unmeasured,
+ VerdictBands.Emphasis(result.OverallScore, result.Language, result.Statistics.WordCount));
+
+ // And the plain overload, which knows nothing about length, still says what it always said —
+ // it is not the one to ask, and callers that ask it are the ones this test cannot catch.
+ Assert.NotEqual(VerdictEmphasis.Unmeasured, VerdictBands.Emphasis(result.OverallScore));
+ }
+
+ [Fact]
+ public void A_document_longer_than_the_corpus_is_still_judged()
+ {
+ // No ceiling, and the asymmetry is measured rather than assumed: shortening a text moves its
+ // score toward the machine, and nothing suggests a long thesis is at risk. Silencing the long
+ // end for symmetry would withhold a verdict for a reason nobody has evidence for.
+ Assert.True(VerdictBands.Measured(int.MaxValue));
+ Assert.True(VerdictBands.Measured(PublishedCalibration.Current!.LongestWords!.Value * 10));
+ }
+
+ ///
+ /// One place decides. Before existed the answer was written out in
+ /// eight, and one engine gave three answers about the same text; length must not reintroduce a
+ /// second opinion.
+ ///
+ [Theory]
+ [InlineData("en")]
+ [InlineData("es")]
+ public void Every_way_of_asking_agrees(string language)
+ {
+ var text = language == "es"
+ ? "En el panorama actual, cabe destacar que este enfoque integral no solo optimiza los " +
+ "procesos sino que también facilita una comprensión robusta del fenómeno."
+ : ShortAndMachineLike;
+
+ var result = _analyzer.Analyze(text, language);
+ var speaks = result.HasVerdict;
+
+ Assert.Equal(speaks, VerdictBands.Holds(result.OverallScore, result.Language, result.Statistics.WordCount));
+ Assert.Equal(speaks, result.Verdict == "Signs of AI writing");
+ Assert.Equal(
+ speaks,
+ EvidenceReport.ToMarkdown(result).Contains("**" + System.Math.Round(result.OverallScore) + "/100 —"));
+ }
+}
diff --git a/tools/SignsOfAI.Calibration/Program.cs b/tools/SignsOfAI.Calibration/Program.cs
index accaeb0..09c6d20 100644
--- a/tools/SignsOfAI.Calibration/Program.cs
+++ b/tools/SignsOfAI.Calibration/Program.cs
@@ -553,6 +553,12 @@ static double Median(List values)
FlaggedAtThreshold = atThreshold?.Flagged ?? 0,
RateLow = atThreshold?.RateLow ?? 0,
RateHigh = atThreshold?.RateHigh ?? 1,
+
+ // The lengths the boundary was actually fitted on. The engine withholds its verdict below the
+ // shorter of the two rather than extrapolating a bound onto a population nobody measured — the
+ // same rule the language condition follows, applied to the other dimension. See issue #59.
+ ShortestWords = overall.ShortestWords,
+ LongestWords = overall.LongestWords,
NoisiestRules = [.. calibration.RuleFalsePositives.Take(8)
.Select(r => new PublishedRuleRate { RuleId = r.RuleId, TextShare = r.TextShare })],
@@ -586,6 +592,8 @@ static double Median(List values)
Console.WriteLine($" corpus fingerprint {calibration.CorpusHash}");
Console.WriteLine($" median score {calibration.Overall.MedianScore:0.0}");
Console.WriteLine($" 90th percentile {calibration.Overall.NinetiethScore:0.0}");
+Console.WriteLine($" lengths measured {calibration.Overall.ShortestWords:N0}–{calibration.Overall.LongestWords:N0} words" +
+ $" (median {calibration.Overall.MedianWords:N0}) — below the shortest, no verdict");
Console.WriteLine(calibration.Overall.ThresholdForTarget is { } t
? $" threshold for {calibration.TargetFalsePositiveRate:P0} {t:0}/100"
: $" threshold for {calibration.TargetFalsePositiveRate:P0} not supported by this corpus yet");
diff --git a/tools/SignsOfAI.Calibration/Report.cs b/tools/SignsOfAI.Calibration/Report.cs
index 8d81a0c..f45634c 100644
--- a/tools/SignsOfAI.Calibration/Report.cs
+++ b/tools/SignsOfAI.Calibration/Report.cs
@@ -43,6 +43,8 @@ public static string Render(CalibrationResult r, CorpusManifest manifest, string
sb.AppendLine();
sb.AppendLine($"- **Corpus** `{manifest.Id}`, fingerprint `{r.CorpusHash}`");
sb.AppendLine($"- **Texts** {r.Overall.Count:N0} ({r.Overall.TotalWords:N0} words)");
+ sb.AppendLine($"- **Lengths measured** {r.Overall.ShortestWords:N0} – {r.Overall.LongestWords:N0} words " +
+ $"(median {r.Overall.MedianWords:N0})");
sb.AppendLine($"- **Engine** {version}");
sb.AppendLine($"- **Run** {generatedOn}");
sb.AppendLine($"- **Target false-positive rate** {Pct(r.TargetFalsePositiveRate)}");
@@ -66,6 +68,16 @@ public static string Render(CalibrationResult r, CorpusManifest manifest, string
sb.AppendLine();
sb.AppendLine(Headline(r.Overall, r.TargetFalsePositiveRate));
sb.AppendLine();
+ sb.AppendLine(
+ $"**It covers documents of {r.Overall.ShortestWords:N0} words and up, because that is what was " +
+ "measured.** Nothing shorter was: the corpus has no text below that length, so the boundary " +
+ "below is not supported there and the tool withholds its verdict rather than extrapolating. " +
+ "That is a statement about coverage, not about where the tool breaks — though the direction " +
+ "of the length effect *has* been measured, and it goes the wrong way: the same documents " +
+ "flagged 0 of 32 whole and 6 of 32 as 400-word excerpts of themselves " +
+ "(`Docs/PARAPHRASE.md`, section *Length*). Lowering this floor means measuring short writing " +
+ "people actually composed at that length, not slicing long documents into pieces.");
+ sb.AppendLine();
sb.AppendLine(
"Read the interval, not the percentage. On a small corpus an observed rate is compatible " +
"with a much wider range, and the recommendation below is made from the **upper** end of " +
|