@opacedev/astro-ai-content-checker 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/LICENSE +9 -0
  3. package/README.md +236 -0
  4. package/SBOM.cdx.json +172 -0
  5. package/SECURITY.md +9 -0
  6. package/THIRD_PARTY_NOTICES.md +36 -0
  7. package/dist/build-report-html.d.ts +3 -0
  8. package/dist/highlight.js +155 -0
  9. package/dist/index.d.ts +6 -0
  10. package/dist/index.js +43 -0
  11. package/dist/options.d.ts +21 -0
  12. package/dist/options.js +6 -0
  13. package/dist/receipt.d.ts +15 -0
  14. package/dist/receipt.js +100 -0
  15. package/dist/report.d.ts +55 -0
  16. package/dist/report.js +12 -0
  17. package/dist/sections.js +181 -0
  18. package/dist/share.d.ts +47 -0
  19. package/dist/share.js +72 -0
  20. package/dist/shared-EX54KI6H.js +1503 -0
  21. package/dist/shared-MMXWTX2U.js +43 -0
  22. package/dist/toolbar.js +13478 -0
  23. package/node_modules/@opacedev/ai-content-checker-browser/LICENSE +21 -0
  24. package/node_modules/@opacedev/ai-content-checker-browser/README.md +145 -0
  25. package/node_modules/@opacedev/ai-content-checker-browser/dist/dom/visible-text.d.ts +14 -0
  26. package/node_modules/@opacedev/ai-content-checker-browser/dist/dom/visible-text.js +47 -0
  27. package/node_modules/@opacedev/ai-content-checker-browser/dist/index.d.ts +3 -0
  28. package/node_modules/@opacedev/ai-content-checker-browser/dist/index.js +99 -0
  29. package/node_modules/@opacedev/ai-content-checker-browser/dist/worker/client.d.ts +12 -0
  30. package/node_modules/@opacedev/ai-content-checker-browser/dist/worker/client.js +17 -0
  31. package/node_modules/@opacedev/ai-content-checker-browser/dist/worker/entry.d.ts +1 -0
  32. package/node_modules/@opacedev/ai-content-checker-browser/dist/worker/entry.js +3984 -0
  33. package/node_modules/@opacedev/ai-content-checker-browser/dist/worker/protocol.d.ts +30 -0
  34. package/node_modules/@opacedev/ai-content-checker-browser/dist/worker/protocol.js +1 -0
  35. package/node_modules/@opacedev/ai-content-checker-browser/package.json +60 -0
  36. package/node_modules/@opacedev/ai-content-checker-contracts/LICENSE +21 -0
  37. package/node_modules/@opacedev/ai-content-checker-contracts/README.md +93 -0
  38. package/node_modules/@opacedev/ai-content-checker-contracts/dist/index.d.ts +58 -0
  39. package/node_modules/@opacedev/ai-content-checker-contracts/dist/index.js +17 -0
  40. package/node_modules/@opacedev/ai-content-checker-contracts/package.json +57 -0
  41. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/analysis-request.schema.d.ts +44 -0
  42. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/analysis-result.schema.d.ts +122 -0
  43. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/candidate.schema.d.ts +81 -0
  44. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/capabilities.schema.d.ts +24 -0
  45. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/checker-result.schema.d.ts +342 -0
  46. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/common.schema.d.ts +10 -0
  47. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/envelope.schema.d.ts +14 -0
  48. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/gate-result.schema.d.ts +19 -0
  49. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/integrity-receipt.schema.d.ts +122 -0
  50. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/job.schema.d.ts +188 -0
  51. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/method-result.schema.d.ts +39 -0
  52. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/pattern-finding.schema.d.ts +26 -0
  53. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/protected-span.schema.d.ts +35 -0
  54. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/rewrite-request.schema.d.ts +65 -0
  55. package/node_modules/@opacedev/ai-content-checker-contracts/src/index.ts +63 -0
  56. package/node_modules/@opacedev/ai-content-checker-contracts/src/semantic-validation.mjs +32 -0
  57. package/node_modules/@opacedev/ai-content-checker-core/LICENSE +21 -0
  58. package/node_modules/@opacedev/ai-content-checker-core/README.md +186 -0
  59. package/node_modules/@opacedev/ai-content-checker-core/dist/bundle.js +4744 -0
  60. package/node_modules/@opacedev/ai-content-checker-core/dist/capabilities.d.ts +16 -0
  61. package/node_modules/@opacedev/ai-content-checker-core/dist/capabilities.js +7 -0
  62. package/node_modules/@opacedev/ai-content-checker-core/dist/diff/diff.d.ts +17 -0
  63. package/node_modules/@opacedev/ai-content-checker-core/dist/diff/diff.js +76 -0
  64. package/node_modules/@opacedev/ai-content-checker-core/dist/fixes/preview.d.ts +27 -0
  65. package/node_modules/@opacedev/ai-content-checker-core/dist/fixes/preview.js +47 -0
  66. package/node_modules/@opacedev/ai-content-checker-core/dist/gates/policy.d.ts +14 -0
  67. package/node_modules/@opacedev/ai-content-checker-core/dist/gates/policy.js +16 -0
  68. package/node_modules/@opacedev/ai-content-checker-core/dist/index.d.ts +15 -0
  69. package/node_modules/@opacedev/ai-content-checker-core/dist/index.js +15 -0
  70. package/node_modules/@opacedev/ai-content-checker-core/dist/inspect.d.ts +8 -0
  71. package/node_modules/@opacedev/ai-content-checker-core/dist/inspect.js +84 -0
  72. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-gb-v1.d.ts +12 -0
  73. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-gb-v1.js +68 -0
  74. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v2-data.d.ts +73 -0
  75. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v2-data.js +499 -0
  76. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v2.d.ts +76 -0
  77. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v2.js +1067 -0
  78. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v3-data.d.ts +165 -0
  79. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v3-data.js +614 -0
  80. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v3.d.ts +19 -0
  81. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v3.js +542 -0
  82. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v4-corpus.d.ts +40 -0
  83. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v4-corpus.js +40 -0
  84. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v4-data.d.ts +138 -0
  85. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v4-data.js +200 -0
  86. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v4.d.ts +71 -0
  87. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v4.js +388 -0
  88. package/node_modules/@opacedev/ai-content-checker-core/dist/protected/extract.d.ts +17 -0
  89. package/node_modules/@opacedev/ai-content-checker-core/dist/protected/extract.js +75 -0
  90. package/node_modules/@opacedev/ai-content-checker-core/dist/protected/validate.d.ts +3 -0
  91. package/node_modules/@opacedev/ai-content-checker-core/dist/protected/validate.js +17 -0
  92. package/node_modules/@opacedev/ai-content-checker-core/dist/provenance/c2pa-text.d.ts +55 -0
  93. package/node_modules/@opacedev/ai-content-checker-core/dist/provenance/c2pa-text.js +86 -0
  94. package/node_modules/@opacedev/ai-content-checker-core/dist/receipts/build.d.ts +48 -0
  95. package/node_modules/@opacedev/ai-content-checker-core/dist/receipts/build.js +61 -0
  96. package/node_modules/@opacedev/ai-content-checker-core/dist/report/checker-result.d.ts +241 -0
  97. package/node_modules/@opacedev/ai-content-checker-core/dist/report/checker-result.js +419 -0
  98. package/node_modules/@opacedev/ai-content-checker-core/dist/source/offsets.d.ts +34 -0
  99. package/node_modules/@opacedev/ai-content-checker-core/dist/source/offsets.js +51 -0
  100. package/node_modules/@opacedev/ai-content-checker-core/dist/source/utf8.d.ts +3 -0
  101. package/node_modules/@opacedev/ai-content-checker-core/dist/source/utf8.js +58 -0
  102. package/node_modules/@opacedev/ai-content-checker-core/dist/source/visible-text.d.ts +12 -0
  103. package/node_modules/@opacedev/ai-content-checker-core/dist/source/visible-text.js +30 -0
  104. package/node_modules/@opacedev/ai-content-checker-core/dist/unicode/data.d.ts +22 -0
  105. package/node_modules/@opacedev/ai-content-checker-core/dist/unicode/data.js +137 -0
  106. package/node_modules/@opacedev/ai-content-checker-core/dist/unicode/inspect.d.ts +16 -0
  107. package/node_modules/@opacedev/ai-content-checker-core/dist/unicode/inspect.js +84 -0
  108. package/node_modules/@opacedev/ai-content-checker-core/dist/verdict/combine.d.ts +162 -0
  109. package/node_modules/@opacedev/ai-content-checker-core/dist/verdict/combine.js +501 -0
  110. package/node_modules/@opacedev/ai-content-checker-core/package.json +62 -0
  111. package/node_modules/canonicalize/LICENSE +201 -0
  112. package/node_modules/canonicalize/README.md +65 -0
  113. package/node_modules/canonicalize/bin/canonicalize.js +17 -0
  114. package/node_modules/canonicalize/lib/canonicalize.d.ts +2 -0
  115. package/node_modules/canonicalize/lib/canonicalize.js +74 -0
  116. package/node_modules/canonicalize/package.json +64 -0
  117. package/node_modules/entities/LICENSE +11 -0
  118. package/node_modules/entities/lib/decode.d.ts +211 -0
  119. package/node_modules/entities/lib/decode.d.ts.map +1 -0
  120. package/node_modules/entities/lib/decode.js +536 -0
  121. package/node_modules/entities/lib/decode.js.map +1 -0
  122. package/node_modules/entities/lib/decode_codepoint.d.ts +19 -0
  123. package/node_modules/entities/lib/decode_codepoint.d.ts.map +1 -0
  124. package/node_modules/entities/lib/decode_codepoint.js +76 -0
  125. package/node_modules/entities/lib/decode_codepoint.js.map +1 -0
  126. package/node_modules/entities/lib/encode.d.ts +22 -0
  127. package/node_modules/entities/lib/encode.d.ts.map +1 -0
  128. package/node_modules/entities/lib/encode.js +77 -0
  129. package/node_modules/entities/lib/encode.js.map +1 -0
  130. package/node_modules/entities/lib/escape.d.ts +43 -0
  131. package/node_modules/entities/lib/escape.d.ts.map +1 -0
  132. package/node_modules/entities/lib/escape.js +122 -0
  133. package/node_modules/entities/lib/escape.js.map +1 -0
  134. package/node_modules/entities/lib/esm/decode.d.ts +211 -0
  135. package/node_modules/entities/lib/esm/decode.d.ts.map +1 -0
  136. package/node_modules/entities/lib/esm/decode.js +496 -0
  137. package/node_modules/entities/lib/esm/decode.js.map +1 -0
  138. package/node_modules/entities/lib/esm/decode_codepoint.d.ts +19 -0
  139. package/node_modules/entities/lib/esm/decode_codepoint.d.ts.map +1 -0
  140. package/node_modules/entities/lib/esm/decode_codepoint.js +71 -0
  141. package/node_modules/entities/lib/esm/decode_codepoint.js.map +1 -0
  142. package/node_modules/entities/lib/esm/encode.d.ts +22 -0
  143. package/node_modules/entities/lib/esm/encode.d.ts.map +1 -0
  144. package/node_modules/entities/lib/esm/encode.js +69 -0
  145. package/node_modules/entities/lib/esm/encode.js.map +1 -0
  146. package/node_modules/entities/lib/esm/escape.d.ts +43 -0
  147. package/node_modules/entities/lib/esm/escape.d.ts.map +1 -0
  148. package/node_modules/entities/lib/esm/escape.js +116 -0
  149. package/node_modules/entities/lib/esm/escape.js.map +1 -0
  150. package/node_modules/entities/lib/esm/generated/decode-data-html.d.ts +3 -0
  151. package/node_modules/entities/lib/esm/generated/decode-data-html.d.ts.map +1 -0
  152. package/node_modules/entities/lib/esm/generated/decode-data-html.js +7 -0
  153. package/node_modules/entities/lib/esm/generated/decode-data-html.js.map +1 -0
  154. package/node_modules/entities/lib/esm/generated/decode-data-xml.d.ts +3 -0
  155. package/node_modules/entities/lib/esm/generated/decode-data-xml.d.ts.map +1 -0
  156. package/node_modules/entities/lib/esm/generated/decode-data-xml.js +7 -0
  157. package/node_modules/entities/lib/esm/generated/decode-data-xml.js.map +1 -0
  158. package/node_modules/entities/lib/esm/generated/encode-html.d.ts +8 -0
  159. package/node_modules/entities/lib/esm/generated/encode-html.d.ts.map +1 -0
  160. package/node_modules/entities/lib/esm/generated/encode-html.js +10 -0
  161. package/node_modules/entities/lib/esm/generated/encode-html.js.map +1 -0
  162. package/node_modules/entities/lib/esm/index.d.ts +96 -0
  163. package/node_modules/entities/lib/esm/index.d.ts.map +1 -0
  164. package/node_modules/entities/lib/esm/index.js +99 -0
  165. package/node_modules/entities/lib/esm/index.js.map +1 -0
  166. package/node_modules/entities/lib/esm/package.json +1 -0
  167. package/node_modules/entities/lib/generated/decode-data-html.d.ts +3 -0
  168. package/node_modules/entities/lib/generated/decode-data-html.d.ts.map +1 -0
  169. package/node_modules/entities/lib/generated/decode-data-html.js +9 -0
  170. package/node_modules/entities/lib/generated/decode-data-html.js.map +1 -0
  171. package/node_modules/entities/lib/generated/decode-data-xml.d.ts +3 -0
  172. package/node_modules/entities/lib/generated/decode-data-xml.d.ts.map +1 -0
  173. package/node_modules/entities/lib/generated/decode-data-xml.js +9 -0
  174. package/node_modules/entities/lib/generated/decode-data-xml.js.map +1 -0
  175. package/node_modules/entities/lib/generated/encode-html.d.ts +8 -0
  176. package/node_modules/entities/lib/generated/encode-html.d.ts.map +1 -0
  177. package/node_modules/entities/lib/generated/encode-html.js +12 -0
  178. package/node_modules/entities/lib/generated/encode-html.js.map +1 -0
  179. package/node_modules/entities/lib/index.d.ts +96 -0
  180. package/node_modules/entities/lib/index.d.ts.map +1 -0
  181. package/node_modules/entities/lib/index.js +126 -0
  182. package/node_modules/entities/lib/index.js.map +1 -0
  183. package/node_modules/entities/package.json +90 -0
  184. package/node_modules/entities/readme.md +122 -0
  185. package/package.json +91 -0
@@ -0,0 +1,1067 @@
1
+ import { alignUtf16Range, rangeFromUtf16 } from "../source/offsets.js";
2
+ import { prefixedSha256 } from "../source/utf8.js";
3
+ import { ACKNOWLEDGMENT_LOOPS, AI_CITATION_MARKUP, AI_PLACEHOLDERS, AI_UTM_SOURCE, CATEGORY_META, CHATBOT_ARTIFACTS, CONFIDENCE_CALIBRATION, CUTOFF_DISCLAIMERS, CYRILLIC_LOOKALIKES, EMOTIONAL_FLATLINE, FALSE_CONCESSION, FILLERS, FORMULAIC_OPENERS, FUNC_WORDS, FUNCTION_WORD_IN_TITLE, FUTURE_NARRATIVE, GENERIC_CONCLUSIONS, GREEK_LOOKALIKES, HEDGE_STACK, HOLLOW_INTENSIFIERS, ISSUE_WEIGHTS, LETS_PATTERNS, LINGERING_ATTENTION, MD_HEADING_PREFIX, NOT_JUST_CONTRAST, NOVELTY_INFLATION, PARENTHETICAL_HEDGE, REAL_ACTUAL_INFLATION, REASONING_ARTIFACTS, RHETORICAL_QUESTIONS, ROLEPLAY_VERBS, SEPARATOR_DASH_RE, SIGNIFICANCE_INFLATION, SOCIAL_CTA_CLOSER, SPECULATIVE_OPENERS, SYCOPHANTIC, TEMPLATE_PHRASES, TIER1, TIER1_PHRASES, TIER2, TIER2_CONDITIONAL, TIER3, TIER3_PHRASES, TITLE_CASE_HEADER, TRANSITIONS, VAGUE_ATTRIBUTIONS, VERSION_HEADING_DASH_RE, } from "./en-signals-v2-data.js";
4
+ import { collectV3Issues } from "./en-signals-v3.js";
5
+ import { ARTEFACT_CORE_CATEGORIES, ARTEFACT_SUPPORT_CATEGORIES, CORROBORATION_CATEGORIES, FORMATTING_CLUSTER_CATEGORIES, RULE_ERA, STYLOMETRIC_CATEGORIES, V3_CATEGORY_META, V3_ISSUE_WEIGHTS, } from "./en-signals-v3-data.js";
6
+ import { collectV4Issues } from "./en-signals-v4.js";
7
+ import { V4_CATEGORY_META, V4_ISSUE_WEIGHTS, V4_RHYTHM_CATEGORIES } from "./en-signals-v4-data.js";
8
+ // 2026.08.3: the research-harvest merge (AI-TELLS-MEGA-PACK / tells-seed
9
+ // 2026.08.1 / OWNER-DOCS-TELLS). New rules live in en-signals-v3*.ts and are
10
+ // folded into the same analysis, dedup, scoring and envelope; Tier C tells
11
+ // are documented in EXCLUDED_TELLS rather than implemented.
12
+ // 2026.08.4: post-scoring escalation policy from the real-world evaluation
13
+ // (research/REAL-WORLD-EVAL-2026-08.md §4a) — argmax(probabilities) stays the
14
+ // BASE classification; five documented escalations may then raise (never
15
+ // lower) it, reported in the additive `escalation` result field.
16
+ // 2026.08.5: measured stylometrics + owner-rhythm pack (research/
17
+ // CLEAN-PROSE-DETECTION-PLAN.md Tier 1, research/OWNER-RHYTHM-NOTES.md).
18
+ // New rules live in en-signals-v4*.ts: all tier-B corroboration weight, low
19
+ // severity, density/threshold based, capped with the other stylometrics, and
20
+ // counted as ONE combined contribution by the finding-breadth escalation.
21
+ // 2026.08.6: provider-eval calibration (services/local-engine/research/
22
+ // provider-eval/PROVIDER-EVAL-2026-08.md §4.1) — surrogate-pair span fix,
23
+ // markdown-furniture rules (R3/R4/R5), formatting/furniture escalation
24
+ // floors (R1/R5) and the relaxed finding-breadth gate (R2). Only the
25
+ // zero-FP tier shipped; §4.2 risk-tiered candidates are documented in
26
+ // EXCLUDED_TELLS pending an owner decision.
27
+ export const EN_SIGNALS_PATTERN_VERSION = "en-signals:2026.08.6";
28
+ // Category tables merged across the v2 port, the 2026.08.3 harvest pack and
29
+ // the 2026.08.5 rhythm pack.
30
+ const MERGED_WEIGHTS = { ...ISSUE_WEIGHTS, ...V3_ISSUE_WEIGHTS, ...V4_ISSUE_WEIGHTS };
31
+ const MERGED_META = { ...CATEGORY_META, ...V3_CATEGORY_META, ...V4_CATEGORY_META };
32
+ /** Upstream refuses to score above this word count (browser page budget). */
33
+ const MAX_SCORED_WORDS = 10000;
34
+ const DESCRIPTION = "Editorial writing-signals score from documented writing-pattern rules and stylometric measurements. " +
35
+ "It is stylistic evidence about how the text reads, not proof of who or what wrote it.";
36
+ // ─── Normalisation pre-pass with offset map ──────────────────────────
37
+ const ZERO_WIDTH = /[\u200B\u200C\u200D\uFEFF\u2060]/;
38
+ const CYRILLIC_GREEK = /[Ѐ-ӿͰ-Ͽ]/;
39
+ function normalise(original) {
40
+ const flags = { zeroWidth: 0, homoglyph: 0, roleplay: 0 };
41
+ let firstStrippedAt = -1;
42
+ let chars = [];
43
+ let map = [];
44
+ for (let i = 0; i < original.length; i += 1) {
45
+ const ch = original[i];
46
+ if (ZERO_WIDTH.test(ch)) {
47
+ flags.zeroWidth += 1;
48
+ if (firstStrippedAt < 0)
49
+ firstStrippedAt = i;
50
+ continue;
51
+ }
52
+ if (CYRILLIC_GREEK.test(ch)) {
53
+ const swap = CYRILLIC_LOOKALIKES[ch] ?? GREEK_LOOKALIKES[ch];
54
+ if (swap !== undefined) {
55
+ flags.homoglyph += 1;
56
+ if (firstStrippedAt < 0)
57
+ firstStrippedAt = i;
58
+ chars.push(swap);
59
+ map.push(i);
60
+ continue;
61
+ }
62
+ }
63
+ chars.push(ch);
64
+ map.push(i);
65
+ }
66
+ // Roleplay-action *marker* strip (paired *...* whose inner phrase opens with
67
+ // an action verb). Markdown **bold** is rejected by the guards.
68
+ const joined = chars.join("");
69
+ const roleplayRe = /(?<!\*)\*([^*\n]{1,80}?)\*(?!\*)/gu;
70
+ const removals = [];
71
+ let m;
72
+ while ((m = roleplayRe.exec(joined)) !== null) {
73
+ if (ROLEPLAY_VERBS.test(m[1])) {
74
+ flags.roleplay += 1;
75
+ removals.push([m.index, m.index + m[0].length]);
76
+ }
77
+ }
78
+ if (removals.length > 0) {
79
+ const keptChars = [];
80
+ const keptMap = [];
81
+ let r = 0;
82
+ for (let i = 0; i < joined.length; i += 1) {
83
+ while (r < removals.length && i >= removals[r][1])
84
+ r += 1;
85
+ if (r < removals.length && i >= removals[r][0] && i < removals[r][1])
86
+ continue;
87
+ keptChars.push(joined[i]);
88
+ keptMap.push(map[i]);
89
+ }
90
+ chars = keptChars;
91
+ map = keptMap;
92
+ }
93
+ return { text: chars.join(""), map, flags, firstStrippedAt };
94
+ }
95
+ // ─── Small helpers ───────────────────────────────────────────────────
96
+ function countWords(text) {
97
+ return (text.match(/\S+/g) ?? []).length;
98
+ }
99
+ function tokenizeWithIndex(text) {
100
+ const out = [];
101
+ const re = /[\w'-]+/g;
102
+ let m;
103
+ while ((m = re.exec(text)) !== null)
104
+ out.push({ token: m[0].toLowerCase(), index: m.index });
105
+ return out;
106
+ }
107
+ function paragraphsWithOffsets(text) {
108
+ const parts = [];
109
+ const re = /\n\s*\n/g;
110
+ let last = 0;
111
+ let m;
112
+ while ((m = re.exec(text)) !== null) {
113
+ parts.push({ text: text.slice(last, m.index), start: last });
114
+ last = m.index + m[0].length;
115
+ }
116
+ parts.push({ text: text.slice(last), start: last });
117
+ return parts.filter((p) => p.text.trim().length > 0);
118
+ }
119
+ function getSentences(text) {
120
+ return text.split(/[.!?]+/).filter((s) => s.trim().length > 5);
121
+ }
122
+ function execAll(pattern, text) {
123
+ const re = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g");
124
+ const out = [];
125
+ let m;
126
+ while ((m = re.exec(text)) !== null) {
127
+ out.push(m);
128
+ if (m[0].length === 0)
129
+ re.lastIndex += 1;
130
+ }
131
+ return out;
132
+ }
133
+ /** Fenced-code byte ranges (CommonMark closing rules), ported from upstream. */
134
+ function fenceRanges(text) {
135
+ const re = /^[ \t]{0,3}(`{3,}|~{3,})([^\n]*)$/gm;
136
+ const ranges = [];
137
+ let open = null;
138
+ let m;
139
+ while ((m = re.exec(text)) !== null) {
140
+ const marker = m[1];
141
+ if (!open) {
142
+ open = { char: marker[0], len: marker.length, start: m.index };
143
+ }
144
+ else if (marker[0] === open.char && marker.length >= open.len && /^[ \t]*\r?$/.test(m[2])) {
145
+ ranges.push([open.start, m.index + m[0].length]);
146
+ open = null;
147
+ }
148
+ }
149
+ if (open)
150
+ ranges.push([open.start, text.length]);
151
+ return ranges;
152
+ }
153
+ function inFenceRange(ranges, index) {
154
+ return ranges.some(([a, b]) => index >= a && index < b);
155
+ }
156
+ /** Index-preserving copy with fenced blocks and inline code spans blanked. */
157
+ function maskCode(text) {
158
+ const chars = text.split("");
159
+ const blank = (a, b) => {
160
+ for (let i = a; i < b && i < chars.length; i += 1)
161
+ if (chars[i] !== "\n")
162
+ chars[i] = " ";
163
+ };
164
+ for (const [a, b] of fenceRanges(text))
165
+ blank(a, b);
166
+ const withoutFences = chars.join("");
167
+ const inlineRe = /(`+)(?:(?!\1)[^\n])+\1/g;
168
+ let m;
169
+ while ((m = inlineRe.exec(withoutFences)) !== null)
170
+ blank(m.index, m.index + m[0].length);
171
+ return chars.join("");
172
+ }
173
+ const HEX_COLOUR = /^(?=[0-9a-f]*\d)(?:[0-9a-f]{6}|[0-9a-f]{8})$/i;
174
+ const CPP_DIRECTIVE = /^(?:include|define|undef|if|ifdef|ifndef|elif|else|endif|pragma|error|warning|line)$/;
175
+ function isSocialTag(tag) {
176
+ return !/^\d+$/.test(tag) && !HEX_COLOUR.test(tag) && !CPP_DIRECTIVE.test(tag);
177
+ }
178
+ // Alternation regexes built once from the word tables (longest-first so
179
+ // e.g. "meticulously" wins over "meticulous").
180
+ const byLengthDesc = (a, b) => b.length - a.length || a.localeCompare(b);
181
+ const TIER1_WORD_RE = new RegExp("\\b(?:" + Object.keys(TIER1).sort(byLengthDesc).join("|") + ")\\b", "gi");
182
+ const TIER2_WORD_RE = new RegExp("\\b(?:" + Object.keys(TIER2).sort(byLengthDesc).join("|") + ")\\b", "gi");
183
+ const TIER3_LOOKUP = new Map();
184
+ for (const word of TIER3) {
185
+ TIER3_LOOKUP.set(word, word);
186
+ const dashless = word.replace(/-/g, "");
187
+ if (dashless !== word)
188
+ TIER3_LOOKUP.set(dashless, word);
189
+ }
190
+ // ─── Core analysis (ported from upstream analyzeText) ────────────────
191
+ function analyse(original) {
192
+ const norm = normalise(original);
193
+ const text = norm.text;
194
+ const map = norm.map;
195
+ // Map a normalised-text span back to the original input.
196
+ const span = (nStart, nEnd) => {
197
+ if (nEnd <= nStart || nStart >= map.length)
198
+ return [null, null];
199
+ const last = Math.min(nEnd, map.length) - 1;
200
+ return [map[nStart], map[last] + 1];
201
+ };
202
+ const issues = [];
203
+ const push = (category, key, nStart, nEnd, suggestion, count) => {
204
+ let s = null;
205
+ let e = null;
206
+ if (nStart !== null && nEnd !== null)
207
+ [s, e] = span(nStart, nEnd);
208
+ issues.push({ category, key, start: s, end: e, ...(suggestion !== undefined ? { suggestion } : {}), ...(count !== undefined ? { count } : {}) });
209
+ };
210
+ const pushEx = (category, key, nStart, nEnd, opts = {}) => {
211
+ let s = null;
212
+ let e = null;
213
+ if (nStart !== null && nEnd !== null)
214
+ [s, e] = span(nStart, nEnd);
215
+ issues.push({
216
+ category, key, start: s, end: e,
217
+ ...(opts.suggestion !== undefined ? { suggestion: opts.suggestion } : {}),
218
+ ...(opts.count !== undefined ? { count: opts.count } : {}),
219
+ ...(opts.extra !== undefined ? { extra: opts.extra } : {}),
220
+ ...(opts.severityOverride !== undefined ? { severityOverride: opts.severityOverride } : {}),
221
+ });
222
+ };
223
+ const pushPatterns = (patterns, category) => {
224
+ const added = [];
225
+ for (const pattern of patterns) {
226
+ for (const m of execAll(pattern, text)) {
227
+ push(category, m[0], m.index, m.index + m[0].length);
228
+ added.push(issues[issues.length - 1]);
229
+ }
230
+ }
231
+ return added;
232
+ };
233
+ const wordCount = countWords(text);
234
+ const tokens = tokenizeWithIndex(text);
235
+ const paragraphs = paragraphsWithOffsets(text);
236
+ const sentences = getSentences(text);
237
+ if (wordCount < 10) {
238
+ return { issues: [], wordCount, tier2Clusters: 0, tier1Distinct: 0, normFlags: norm.flags };
239
+ }
240
+ // 1. Tier 1 single words (first occurrence per distinct token).
241
+ const tier1Found = new Set();
242
+ for (const m of execAll(TIER1_WORD_RE, text)) {
243
+ const lower = m[0].toLowerCase();
244
+ if (tier1Found.has(lower))
245
+ continue;
246
+ tier1Found.add(lower);
247
+ push("tier1", lower, m.index, m.index + m[0].length, TIER1[lower]);
248
+ }
249
+ // Tier 1 multi-word phrases.
250
+ for (const phrase of TIER1_PHRASES) {
251
+ for (const m of execAll(phrase.pattern, text)) {
252
+ const lower = m[0].toLowerCase();
253
+ if (tier1Found.has(lower))
254
+ continue;
255
+ tier1Found.add(lower);
256
+ push(phrase.clarity ? "tier1-clarity" : "tier1", lower, m.index, m.index + m[0].length, phrase.replace);
257
+ }
258
+ }
259
+ // 2. Tier 2 clusters — ≥2 distinct table words within one paragraph.
260
+ let tier2Clusters = 0;
261
+ for (const para of paragraphs) {
262
+ const found = new Map();
263
+ for (const m of execAll(TIER2_WORD_RE, para.text)) {
264
+ const lower = m[0].toLowerCase();
265
+ if (!found.has(lower))
266
+ found.set(lower, { index: para.start + m.index, suggestion: TIER2[lower] ?? "" });
267
+ }
268
+ for (const cond of TIER2_CONDITIONAL) {
269
+ if (found.has(cond.word))
270
+ continue;
271
+ const m = new RegExp(cond.pattern.source, cond.pattern.flags).exec(para.text);
272
+ if (m)
273
+ found.set(cond.word, { index: para.start + m.index, suggestion: cond.suggestion });
274
+ }
275
+ if (found.size >= 2) {
276
+ tier2Clusters += 1;
277
+ for (const [word, at] of found)
278
+ push("tier2", word, at.index, at.index + word.length, at.suggestion);
279
+ }
280
+ }
281
+ // 3. Tier 3 density — ≥ max(3, 3% of words) occurrences of one word.
282
+ const tier3Counts = new Map();
283
+ for (const t of tokens) {
284
+ const canonical = TIER3_LOOKUP.get(t.token);
285
+ if (!canonical)
286
+ continue;
287
+ const entry = tier3Counts.get(canonical);
288
+ if (entry)
289
+ entry.count += 1;
290
+ else
291
+ tier3Counts.set(canonical, { count: 1, first: t.index, firstLen: t.token.length });
292
+ }
293
+ const densityThreshold = Math.max(3, Math.floor(wordCount * 0.03));
294
+ for (const [word, entry] of tier3Counts) {
295
+ if (entry.count >= densityThreshold) {
296
+ push("tier3", `"${word}" x${entry.count}`, entry.first, entry.first + entry.firstLen, `Used ${entry.count} times in ${wordCount} words; vary the wording.`, entry.count);
297
+ }
298
+ }
299
+ // 4–21. Weighted phrase categories.
300
+ pushPatterns(TRANSITIONS, "transition");
301
+ pushPatterns(CHATBOT_ARTIFACTS, "chatbot");
302
+ pushPatterns(SYCOPHANTIC, "sycophantic");
303
+ pushPatterns(FILLERS, "filler");
304
+ pushPatterns(GENERIC_CONCLUSIONS, "generic-conclusion");
305
+ pushPatterns(LETS_PATTERNS, "lets-construction");
306
+ pushPatterns(REASONING_ARTIFACTS, "reasoning-artifact");
307
+ pushPatterns(ACKNOWLEDGMENT_LOOPS, "acknowledgment-loop");
308
+ pushPatterns(SIGNIFICANCE_INFLATION, "significance-inflation");
309
+ pushPatterns(VAGUE_ATTRIBUTIONS, "vague-attribution");
310
+ pushPatterns(HOLLOW_INTENSIFIERS, "hollow-intensifier");
311
+ pushPatterns(EMOTIONAL_FLATLINE, "emotional-flatline");
312
+ pushPatterns(LINGERING_ATTENTION, "lingering-attention");
313
+ pushPatterns(NOVELTY_INFLATION, "novelty-inflation");
314
+ pushPatterns(CUTOFF_DISCLAIMERS, "cutoff-disclaimer");
315
+ pushPatterns(AI_PLACEHOLDERS, "ai-placeholder");
316
+ pushPatterns(AI_CITATION_MARKUP, "ai-citation-markup");
317
+ pushPatterns(AI_UTM_SOURCE, "ai-utm-source");
318
+ pushPatterns(TEMPLATE_PHRASES, "template-phrase");
319
+ pushPatterns(FALSE_CONCESSION, "false-concession");
320
+ pushPatterns(RHETORICAL_QUESTIONS, "rhetorical-question");
321
+ pushPatterns(HEDGE_STACK, "hedge-stack");
322
+ pushPatterns(FUTURE_NARRATIVE, "future-narrative");
323
+ pushPatterns(REAL_ACTUAL_INFLATION, "real-actual-inflation");
324
+ pushPatterns(SOCIAL_CTA_CLOSER, "social-cta-closer");
325
+ pushPatterns(NOT_JUST_CONTRAST, "not-just-contrast");
326
+ pushPatterns(FORMULAIC_OPENERS, "formulaic-opener");
327
+ pushPatterns(SPECULATIVE_OPENERS, "speculative-opener");
328
+ pushPatterns(PARENTHETICAL_HEDGE, "parenthetical-hedge");
329
+ // Title-case headers (general register). Requires ≥4 tokens, a mid-title
330
+ // function word, and a position outside fenced code.
331
+ {
332
+ const hits = execAll(TITLE_CASE_HEADER, text).filter((m) => {
333
+ const title = m[0].replace(MD_HEADING_PREFIX, "");
334
+ const parts = title.trim().split(/\s+/);
335
+ if (parts.length < 4)
336
+ return false;
337
+ return FUNCTION_WORD_IN_TITLE.test(parts.slice(1).join(" "));
338
+ });
339
+ const fences = hits.length ? fenceRanges(text) : [];
340
+ for (const m of hits) {
341
+ if (!inFenceRange(fences, m.index))
342
+ push("title-case-header", m[0], m.index, m.index + m[0].length);
343
+ }
344
+ }
345
+ // Normalisation-trigger flags. Bypass-trick characters in prose are a
346
+ // strong style signal; anchored to the first stripped character.
347
+ if (norm.flags.zeroWidth > 0 || norm.flags.homoglyph >= 2) {
348
+ const at = norm.firstStrippedAt;
349
+ issues.push({
350
+ category: "normalization-flag",
351
+ key: `${norm.flags.zeroWidth} zero-width + ${norm.flags.homoglyph} homoglyph swaps`,
352
+ start: at >= 0 ? at : null,
353
+ end: at >= 0 ? at + 1 : null,
354
+ count: norm.flags.zeroWidth + norm.flags.homoglyph,
355
+ });
356
+ }
357
+ if (norm.flags.roleplay >= 2) {
358
+ issues.push({
359
+ category: "normalization-flag",
360
+ key: `${norm.flags.roleplay} roleplay-action markers stripped`,
361
+ start: null, end: null, count: norm.flags.roleplay,
362
+ });
363
+ }
364
+ // Smart-punctuation co-occurrence signature.
365
+ {
366
+ const hasCurly = /[“”‘’]/.test(text);
367
+ const totalEmDashes = (text.match(/—/g) ?? []).length;
368
+ const separatorEmDashes = (text.match(SEPARATOR_DASH_RE) ?? []).length
369
+ + (text.match(VERSION_HEADING_DASH_RE) ?? []).length;
370
+ const hasEmDash = totalEmDashes > separatorEmDashes;
371
+ const hasOxford = (text.match(/\b\w+,\s+\w+,\s+and\s+\w+/g)?.length ?? 0) >= 1;
372
+ const doubleSpaces = (text.match(/[^.!?] +/g) ?? []).length;
373
+ const missingApos = /\b(?:dont|wont|cant|isnt|wasnt|shouldnt|wouldnt|couldnt|youre|theyre|its\s+a\s+\w+ing)\b/i.test(text);
374
+ const clean = doubleSpaces === 0 && !missingApos;
375
+ const signals = [hasCurly, hasEmDash, hasOxford, clean].filter(Boolean).length;
376
+ if (signals >= 4 && wordCount >= 80) {
377
+ const first = text.search(/[“”‘’—]/);
378
+ push("smart-punct-signature", "curly quotes + em dash + Oxford comma + zero typos", first >= 0 ? first : null, first >= 0 ? first + 1 : null);
379
+ }
380
+ }
381
+ // Punctuation-distribution uniformity across paragraphs.
382
+ if (paragraphs.length >= 4) {
383
+ const densities = paragraphs
384
+ .map((p) => {
385
+ const words = (p.text.match(/\S+/g) ?? []).length;
386
+ if (words < 5)
387
+ return null;
388
+ return ((p.text.match(/[,;:—()]/g) ?? []).length) / words;
389
+ })
390
+ .filter((d) => d !== null);
391
+ if (densities.length >= 4) {
392
+ const mean = densities.reduce((a, b) => a + b, 0) / densities.length;
393
+ const variance = densities.reduce((s, d) => s + (d - mean) ** 2, 0) / densities.length;
394
+ const cv = mean > 0 ? Math.sqrt(variance) / mean : 0;
395
+ if (cv < 0.25 && mean >= 0.04) {
396
+ push("punct-distribution", `Punctuation density uniform across paragraphs (CV=${cv.toFixed(2)})`, null, null);
397
+ }
398
+ }
399
+ }
400
+ // Function-word trigram entropy.
401
+ if (wordCount >= 150) {
402
+ const mapped = tokens.map((t) => (FUNC_WORDS.has(t.token) ? t.token : "_"));
403
+ const seq = mapped.filter((v, i, arr) => v !== "_" || (i > 0 && arr[i - 1] !== "_"));
404
+ if (seq.length >= 50) {
405
+ const trigrams = new Map();
406
+ for (let i = 0; i < seq.length - 2; i += 1) {
407
+ const tg = `${seq[i]}|${seq[i + 1]}|${seq[i + 2]}`;
408
+ trigrams.set(tg, (trigrams.get(tg) ?? 0) + 1);
409
+ }
410
+ const total = seq.length - 2;
411
+ let entropy = 0;
412
+ for (const c of trigrams.values()) {
413
+ const p = c / total;
414
+ entropy -= p * Math.log2(p);
415
+ }
416
+ const distinct = trigrams.size;
417
+ const normalized = distinct > 1 ? entropy / Math.log2(distinct) : 1;
418
+ if (normalized < 0.82 && total >= 50) {
419
+ push("fnword-trigram-entropy", `Function-word trigram entropy ${normalized.toFixed(2)} (low)`, null, null);
420
+ }
421
+ if (distinct === 1 && total >= 50) {
422
+ push("fnword-trigram-entropy", "Single function-word trigram repeated across document", null, null);
423
+ }
424
+ }
425
+ }
426
+ // Cross-paragraph burstiness.
427
+ if (paragraphs.length >= 4) {
428
+ const cvs = paragraphs
429
+ .map((p) => {
430
+ const sents = getSentences(p.text);
431
+ if (sents.length < 3)
432
+ return null;
433
+ const lens = sents.map(countWords);
434
+ const mean = lens.reduce((a, b) => a + b, 0) / lens.length;
435
+ if (mean === 0)
436
+ return null;
437
+ const v = lens.reduce((s, l) => s + (l - mean) ** 2, 0) / lens.length;
438
+ return Math.sqrt(v) / mean;
439
+ })
440
+ .filter((c) => c !== null);
441
+ if (cvs.length >= 4) {
442
+ const cvMean = cvs.reduce((a, b) => a + b, 0) / cvs.length;
443
+ const cvStd = Math.sqrt(cvs.reduce((s, c) => s + (c - cvMean) ** 2, 0) / cvs.length);
444
+ if (cvStd < 0.08 && cvMean < 0.45) {
445
+ push("cross-para-burstiness", `Sentence rhythm uniform across paragraphs (sigmaCV=${cvStd.toFixed(2)})`, null, null);
446
+ }
447
+ }
448
+ }
449
+ // Tier 3 multi-word phrase density + cross-phrase clustering.
450
+ {
451
+ const claimed = [];
452
+ const overlaps = (a, b) => claimed.some(([s, e]) => a < e && b > s);
453
+ let distinctPhrasesHit = 0;
454
+ for (const phrase of TIER3_PHRASES) {
455
+ const phraseSpans = [];
456
+ for (const m of execAll(phrase, text)) {
457
+ const a = m.index;
458
+ const b = a + m[0].length;
459
+ if (!overlaps(a, b))
460
+ phraseSpans.push([a, b, m[0]]);
461
+ }
462
+ if (phraseSpans.length === 0)
463
+ continue;
464
+ for (const [a, b] of phraseSpans)
465
+ claimed.push([a, b]);
466
+ distinctPhrasesHit += 1;
467
+ if (phraseSpans.length >= 2) {
468
+ const [a, b, matched] = phraseSpans[0];
469
+ push("tier3-phrase", `"${matched.toLowerCase()}" x${phraseSpans.length}`, a, b, `Boilerplate phrase repeated ${phraseSpans.length} times; replace at least one with specifics.`, phraseSpans.length);
470
+ }
471
+ }
472
+ if (distinctPhrasesHit >= 3) {
473
+ const firstClaim = claimed.slice().sort((x, y) => x[0] - y[0])[0];
474
+ push("tier3-phrase-cluster", `${distinctPhrasesHit} distinct boilerplate phrases`, firstClaim ? firstClaim[0] : null, firstClaim ? firstClaim[1] : null, undefined, distinctPhrasesHit);
475
+ }
476
+ }
477
+ // Hashtag stuffing (code-masked, non-tag # forms subtracted).
478
+ {
479
+ const tagMatches = [...maskCode(text).matchAll(/(?:^|\W)#(\w[\w-]*)/g)].filter((m) => isSocialTag(m[1]));
480
+ if (tagMatches.length >= 6) {
481
+ const first = tagMatches[0];
482
+ const hashAt = first.index + first[0].indexOf("#");
483
+ push("hashtag-stuff", `${tagMatches.length} hashtags`, hashAt, hashAt + 1 + first[1].length, undefined, tagMatches.length);
484
+ }
485
+ }
486
+ // Bullet list of bare noun phrases.
487
+ {
488
+ const lines = text.split(/\r?\n/);
489
+ const bulletRe = /^\s*(?:\*|-|•|\+)\s+(.+)$/;
490
+ const verbRe = /\b(?:is|are|was|were|has|have|had|will|would|should|must|do|does|did|can|could|may|might|am|been|being)\b/i;
491
+ const fenceRe = /^\s*(?:```|~~~)/;
492
+ let run = [];
493
+ let runStart = -1;
494
+ let blankStreak = 0;
495
+ let inFence = false;
496
+ let offset = 0;
497
+ const flushRun = () => {
498
+ if (run.length >= 5) {
499
+ const bareNP = run.filter((it) => {
500
+ const wc = (it.match(/\S+/g) ?? []).length;
501
+ return wc > 0 && wc <= 6 && !verbRe.test(it);
502
+ });
503
+ if (bareNP.length >= 5 && bareNP.length / run.length >= 0.75) {
504
+ push("bullet-np-list", `${run.length}-item bullet list of bare noun phrases`, runStart, Math.min(text.length, runStart + 1), undefined, run.length);
505
+ }
506
+ }
507
+ run = [];
508
+ runStart = -1;
509
+ blankStreak = 0;
510
+ };
511
+ for (const line of lines) {
512
+ if (fenceRe.test(line)) {
513
+ flushRun();
514
+ inFence = !inFence;
515
+ }
516
+ else if (!inFence) {
517
+ const m = line.match(bulletRe);
518
+ if (m) {
519
+ if (run.length === 0)
520
+ runStart = offset;
521
+ run.push(m[1].trim());
522
+ blankStreak = 0;
523
+ }
524
+ else if (line.trim() === "") {
525
+ blankStreak += 1;
526
+ if (blankStreak >= 2)
527
+ flushRun();
528
+ }
529
+ else {
530
+ flushRun();
531
+ }
532
+ }
533
+ offset += line.length + 1;
534
+ }
535
+ flushRun();
536
+ }
537
+ // Confidence calibration — only when it stacks (≥3 raw matches).
538
+ {
539
+ const confMatches = [];
540
+ for (const pattern of CONFIDENCE_CALIBRATION) {
541
+ for (const m of execAll(pattern, text))
542
+ confMatches.push({ text: m[0], index: m.index });
543
+ }
544
+ if (confMatches.length >= 3) {
545
+ for (const m of confMatches)
546
+ push("confidence-calibration", m.text, m.index, m.index + m.text.length);
547
+ }
548
+ }
549
+ // Em-dash density (separator-position dashes excluded). Counts true em/en
550
+ // dashes, spaced "--", and a spaced single hyphen used as a dash. Calibrated
551
+ // so ordinary business copy (a dash or two per piece) passes and prose with
552
+ // a dash every sentence or two fires.
553
+ {
554
+ const rawEmDashCount = (text.match(/—|(?<=\s)--(?=\s|$)|(?<=^|\s)--(?=\s)/gm) ?? []).length;
555
+ const spacedHyphenCount = (text.match(/(?<=\S) (?:-|–) (?=\S)/g) ?? []).length;
556
+ const separatorDashCount = (text.match(SEPARATOR_DASH_RE) ?? []).length
557
+ + (text.match(VERSION_HEADING_DASH_RE) ?? []).length;
558
+ const dashCount = rawEmDashCount + spacedHyphenCount - separatorDashCount;
559
+ const rate = dashCount / (wordCount / 1000);
560
+ if (dashCount >= 3 && rate > 6) {
561
+ // Document-level by construction (FIX-SPAN): the finding is a rate over
562
+ // the whole text, and no single dash demonstrates it. The old anchor
563
+ // took the first match of a pattern whose spaced-hyphen alternative
564
+ // begins with a space, so the one-code-unit slice was usually a space —
565
+ // an anchor that pointed at nothing. Both ends are null; the counts that
566
+ // were implicit in the anchor are already carried in `extra`.
567
+ issues.push({
568
+ category: "em-dash-density",
569
+ key: `${dashCount} dash separators in ${wordCount} words`,
570
+ start: null, end: null,
571
+ count: dashCount,
572
+ extra: { rate_per_1000_words: Math.round(rate * 10) / 10, em_dash_count: rawEmDashCount, spaced_hyphen_count: spacedHyphenCount },
573
+ });
574
+ }
575
+ }
576
+ // Sentence-length flatline — low variance of sentence length document-wide.
577
+ if (sentences.length >= 5) {
578
+ const lengths = sentences.map(countWords);
579
+ const avg = lengths.reduce((a, b) => a + b, 0) / lengths.length;
580
+ const stdDev = Math.sqrt(lengths.reduce((s, l) => s + (l - avg) ** 2, 0) / lengths.length);
581
+ const cv = avg > 0 ? stdDev / avg : 0;
582
+ if (cv < 0.25 && avg > 10) {
583
+ issues.push({
584
+ category: "sentence-flatline",
585
+ key: `Sentence lengths cluster around ${Math.round(avg)} words (CV=${cv.toFixed(2)})`,
586
+ start: null, end: null, count: sentences.length,
587
+ extra: { sentence_count: sentences.length, mean_words: Math.round(avg * 10) / 10, std_dev: Math.round(stdDev * 100) / 100, cv: Math.round(cv * 100) / 100 },
588
+ });
589
+ }
590
+ }
591
+ // Uniform section / answer length. Split on Markdown or HTML heading lines
592
+ // when at least two are present, otherwise on blank-line paragraphs. With
593
+ // ≥4 sections of ≥20 words each, near-identical word counts (low CV) are an
594
+ // editorial rhythm signal typical of generated multi-section content.
595
+ {
596
+ const headingRe = /^(?:#{1,6}[ \t]+\S.*|<h[1-6][^>]*>.*)$/gim;
597
+ const headings = execAll(headingRe, text);
598
+ let sectionLengths = [];
599
+ if (headings.length >= 2) {
600
+ for (let i = 0; i < headings.length; i += 1) {
601
+ const bodyStart = headings[i].index + headings[i][0].length;
602
+ const bodyEnd = i + 1 < headings.length ? headings[i + 1].index : text.length;
603
+ const words = countWords(text.slice(bodyStart, bodyEnd));
604
+ if (words >= 20) {
605
+ sectionLengths.push(words);
606
+ }
607
+ }
608
+ }
609
+ else {
610
+ for (const p of paragraphs) {
611
+ const words = countWords(p.text);
612
+ if (words >= 20) {
613
+ sectionLengths.push(words);
614
+ }
615
+ }
616
+ }
617
+ if (sectionLengths.length >= 4) {
618
+ const mean = sectionLengths.reduce((a, b) => a + b, 0) / sectionLengths.length;
619
+ const std = Math.sqrt(sectionLengths.reduce((s, l) => s + (l - mean) ** 2, 0) / sectionLengths.length);
620
+ const cv = mean > 0 ? std / mean : 0;
621
+ if (cv < 0.15) {
622
+ // Document-level by construction (FIX-SPAN): the finding is the low
623
+ // variance of word counts ACROSS sections, so it exists only in the
624
+ // relationship between them and no one section demonstrates it. The
625
+ // old anchor took the first qualifying section's first code unit,
626
+ // which on the paragraph branch is the document's first character.
627
+ issues.push({
628
+ category: "uniform-sections",
629
+ key: `${sectionLengths.length} sections of near-identical length (CV=${cv.toFixed(2)})`,
630
+ start: null, end: null,
631
+ count: sectionLengths.length,
632
+ ...(sectionLengths.length >= 8 && cv < 0.1 ? { severityOverride: "high" } : {}),
633
+ extra: { section_count: sectionLengths.length, mean_words: Math.round(mean * 10) / 10, cv: Math.round(cv * 100) / 100 },
634
+ });
635
+ }
636
+ }
637
+ }
638
+ // Uniform list items — a run of ≥4 bullet or numbered items whose word
639
+ // counts barely vary.
640
+ {
641
+ const lines = text.split(/\r?\n/);
642
+ const itemRe = /^\s*(?:[-*+•]|\d+[.)])\s+(\S.*)$/;
643
+ let offset = 0;
644
+ let run = [];
645
+ let runStart = null;
646
+ // Span rule (FIX-SPAN): a run of near-identical list items is a block, so
647
+ // the span runs from the first item's first character to the end of the
648
+ // last item in the run. The old anchor was the run's first code unit.
649
+ let runEnd = null;
650
+ const flush = () => {
651
+ if (run.length >= 4) {
652
+ const mean = run.reduce((a, b) => a + b, 0) / run.length;
653
+ const std = Math.sqrt(run.reduce((s, l) => s + (l - mean) ** 2, 0) / run.length);
654
+ const cv = mean > 0 ? std / mean : 0;
655
+ if (mean >= 3 && cv < 0.15) {
656
+ issues.push({
657
+ category: "uniform-list-items",
658
+ key: `${run.length} list items of near-identical length (CV=${cv.toFixed(2)})`,
659
+ ...(runStart !== null && runEnd !== null && runEnd > runStart ? (([s, e]) => ({ start: s, end: e }))(span(runStart, runEnd)) : { start: null, end: null }),
660
+ count: run.length,
661
+ extra: { item_count: run.length, mean_words: Math.round(mean * 10) / 10, cv: Math.round(cv * 100) / 100 },
662
+ });
663
+ }
664
+ }
665
+ run = [];
666
+ runStart = null;
667
+ runEnd = null;
668
+ };
669
+ for (const line of lines) {
670
+ const m = line.match(itemRe);
671
+ if (m) {
672
+ if (run.length === 0)
673
+ runStart = offset + (line.length - line.trimStart().length);
674
+ runEnd = offset + line.replace(/\s+$/, "").length;
675
+ run.push(countWords(m[1]));
676
+ }
677
+ else if (line.trim() !== "") {
678
+ flush();
679
+ }
680
+ offset += line.length + 1;
681
+ }
682
+ flush();
683
+ }
684
+ // Type-token ratio.
685
+ if (tokens.length >= 200) {
686
+ const unique = new Set(tokens.map((t) => t.token)).size;
687
+ const ttr = unique / tokens.length;
688
+ if (ttr < 0.4) {
689
+ push("low-ttr", `Vocabulary diversity ${(ttr * 100).toFixed(1)}% (${unique} unique / ${tokens.length} tokens)`, null, null);
690
+ }
691
+ }
692
+ // Paragraph-length uniformity.
693
+ if (paragraphs.length >= 4) {
694
+ const paraLengths = paragraphs.map((p) => getSentences(p.text).length);
695
+ const avg = paraLengths.reduce((a, b) => a + b, 0) / paraLengths.length;
696
+ if (paraLengths.every((l) => Math.abs(l - avg) <= 1) && avg >= 3) {
697
+ push("uniformity", `All paragraphs are ~${Math.round(avg)} sentences`, null, null);
698
+ }
699
+ }
700
+ // Bold overuse.
701
+ {
702
+ const boldRe = /\*\*[^*]+\*\*/g;
703
+ const bolds = execAll(boldRe, text);
704
+ if (bolds.length > 3) {
705
+ const first = bolds[0];
706
+ push("formatting", `${bolds.length} bold phrases`, first.index, first.index + first[0].length, undefined, bolds.length);
707
+ }
708
+ }
709
+ // 2026.08.3 harvest-merge rules (artefact forensics, tier A phrase and
710
+ // structural tells, corroboration-weight tier B rules, calibrated
711
+ // stylometric measures). Same coordinate space, same dedup below.
712
+ collectV3Issues({
713
+ text, wordCount, paragraphs, sentences,
714
+ push,
715
+ pushEx,
716
+ pushPatterns: (patterns, category) => { pushPatterns(patterns, category); },
717
+ });
718
+ // 2026.08.5 measured-stylometrics + owner-rhythm rules (all tier-B
719
+ // corroboration, low severity, density-gated; en-signals-v4.ts). Same
720
+ // coordinate space, same dedup below; each rule pushes at most one
721
+ // document-level finding.
722
+ collectV4Issues({
723
+ text, wordCount, paragraphs, sentences,
724
+ push,
725
+ pushEx,
726
+ pushPatterns: (patterns, category) => { pushPatterns(patterns, category); },
727
+ });
728
+ // Dedup by (category, key) — mirrors upstream deduplicateIssues so the
729
+ // score reflects exactly the distinct signals a caller sees.
730
+ const seen = new Set();
731
+ const deduped = issues.filter((issue) => {
732
+ const k = `${issue.category}:${issue.key.toLowerCase()}`;
733
+ if (seen.has(k))
734
+ return false;
735
+ seen.add(k);
736
+ return true;
737
+ });
738
+ const tier1Distinct = new Set(deduped.filter((i) => i.category === "tier1").map((i) => i.key.toLowerCase())).size;
739
+ return { issues: deduped, wordCount, tier2Clusters, tier1Distinct, normFlags: norm.flags };
740
+ }
741
+ // ─── PatternFinding output ───────────────────────────────────────────
742
+ /** First whole code point of the text — the anchor for document-level findings. */
743
+ function docAnchor(text) {
744
+ const cp = text.codePointAt(0);
745
+ return [0, cp !== undefined && cp > 0xffff ? 2 : 1];
746
+ }
747
+ function toFinding(original, issue) {
748
+ const meta = MERGED_META[issue.category] ?? { severity: "low", message: "This passage set off one of our writing checks.", suggestion: "Have a look at the flagged text." };
749
+ let start = issue.start;
750
+ let end = issue.end;
751
+ let documentLevel = false;
752
+ if (start === null || end === null || end <= start) {
753
+ [start, end] = docAnchor(original);
754
+ documentLevel = true;
755
+ }
756
+ // 2026.08.6 surrogate-pair snap (provider-eval bug fix): several rules
757
+ // anchor document-level evidence on a single UTF-16 code unit (line starts,
758
+ // first flagged character). When that unit is half of a surrogate pair —
759
+ // an emoji at a paragraph or list-line start — the span used to split the
760
+ // pair and rangeFromUtf16 threw RangeError("split_surrogate") on 20/1,896
761
+ // provider-eval samples. Align outward first so the sliced `matched`, its
762
+ // hash and the recorded offsets all describe the same whole code points.
763
+ [start, end] = alignUtf16Range(original, start, end);
764
+ const matched = original.slice(start, end);
765
+ const weight = MERGED_WEIGHTS[issue.category] ?? 2;
766
+ // Era metadata (tells-seed:2026.08.1): every rule carries the model era in
767
+ // which the tell peaked, plus a model-family attribution hint where the
768
+ // research supports one. Per-finding attribution (e.g. a specific leaked
769
+ // citation token) arrives via issue.extra and overrides the category hint.
770
+ const eraInfo = RULE_ERA[issue.category] ?? { era: "evergreen" };
771
+ return {
772
+ rule_id: "signals." + issue.category.replace(/-/g, "_"),
773
+ rule_version: EN_SIGNALS_PATTERN_VERSION,
774
+ severity: issue.severityOverride ?? meta.severity,
775
+ message: meta.message,
776
+ suggestion: issue.suggestion !== undefined && issue.suggestion !== "" ? `Consider: ${issue.suggestion}.` : meta.suggestion,
777
+ span: rangeFromUtf16(original, start, end),
778
+ matched_text_hash: prefixedSha256(matched),
779
+ evidence: {
780
+ matched,
781
+ count: issue.count ?? 1,
782
+ weight,
783
+ category: issue.category,
784
+ detail: issue.key,
785
+ era: eraInfo.era,
786
+ ...(eraInfo.attribution !== undefined ? { attribution: eraInfo.attribution } : {}),
787
+ ...(CORROBORATION_CATEGORIES.has(issue.category) || V4_RHYTHM_CATEGORIES.has(issue.category) ? { corroboration: true } : {}),
788
+ ...(issue.extra ?? {}),
789
+ ...(documentLevel ? { document_level: true } : {}),
790
+ },
791
+ };
792
+ }
793
+ /**
794
+ * Run the full en-signals v2 rule set and return per-signal findings.
795
+ * Findings are editorial hints (Tier B evidence): stylistic, never authorship proof.
796
+ */
797
+ export function inspectSignalsV2(text) {
798
+ if (!text || text.trim().length === 0)
799
+ return [];
800
+ const analysis = analyse(text);
801
+ return analysis.issues
802
+ .map((issue) => toFinding(text, issue))
803
+ .sort((a, b) => a.span.start_utf16 - b.span.start_utf16 || a.rule_id.localeCompare(b.rule_id));
804
+ }
805
+ // ─── Document-level score (ported scoring + trinary classifier) ──────
806
+ function classify(score, issues, normFlags, wordCount, denseAIVocab) {
807
+ const has = (category) => issues.some((i) => i.category === category);
808
+ const hasCutoff = has("cutoff-disclaimer");
809
+ const hasNormFlag = normFlags.zeroWidth >= 2 || normFlags.homoglyph >= 2;
810
+ const strongCorrob = (hasCutoff ? 1 : 0) +
811
+ (hasNormFlag ? 1 : 0) +
812
+ (has("reasoning-artifact") && has("chatbot") ? 1 : 0) +
813
+ (denseAIVocab ? 1 : 0);
814
+ const stylometricHits = ["punct-distribution", "cross-para-burstiness", "fnword-trigram-entropy"].filter(has).length;
815
+ const weakCorrob = (stylometricHits >= 2 ? 1 : 0) + (has("smart-punct-signature") ? 1 : 0);
816
+ const totalCorrob = strongCorrob + weakCorrob;
817
+ // Preliminary band from the ported thresholds. This only selects which soft
818
+ // probability shape applies — it is never the final label, so a strong
819
+ // corroborator (e.g. the normalization flag) can raise the AI probability
820
+ // but can no longer hard-override the classification. Invisible characters
821
+ // are reported independently by the unicode carriers check; letting them
822
+ // force an AI verdict here would double-count that evidence.
823
+ let band;
824
+ if (score < 15 && strongCorrob === 0)
825
+ band = "human_like";
826
+ else if (strongCorrob >= 1 || score >= 70)
827
+ band = "ai_like";
828
+ else if (score >= 40 && totalCorrob >= 1)
829
+ band = "ai_like";
830
+ else
831
+ band = "mixed_signals";
832
+ const aiSoft = Math.min(0.97, score / 100 + totalCorrob * 0.06 + strongCorrob * 0.08);
833
+ let p;
834
+ if (band === "human_like")
835
+ p = { human: Math.max(0.6, 1 - aiSoft), mixed: Math.min(0.35, aiSoft * 0.8), ai: Math.min(0.1, aiSoft * 0.3) };
836
+ else if (band === "ai_like")
837
+ p = { human: Math.max(0.02, 1 - aiSoft - 0.05), mixed: 0.1, ai: aiSoft };
838
+ else
839
+ p = { human: Math.max(0.15, 0.6 - aiSoft * 0.5), mixed: 0.5, ai: aiSoft * 0.7 };
840
+ const rawSum = p.human + p.mixed + p.ai;
841
+ const human = +(p.human / rawSum).toFixed(3);
842
+ const mixed = +(p.mixed / rawSum).toFixed(3);
843
+ const ai = Math.max(0, +(1 - human - mixed).toFixed(3));
844
+ // The published classification is always the argmax of the published
845
+ // probabilities, with ties broken toward the more cautious class
846
+ // (human_like > mixed_signals > ai_like) to preserve the false-negative
847
+ // bias. The two can therefore never contradict each other in a UI.
848
+ let classification;
849
+ if (human >= mixed && human >= ai)
850
+ classification = "human_like";
851
+ else if (mixed >= ai)
852
+ classification = "mixed_signals";
853
+ else
854
+ classification = "ai_like";
855
+ let confidence;
856
+ if (strongCorrob >= 2 || hasCutoff || (score < 8 && wordCount >= 100))
857
+ confidence = "high";
858
+ else if (strongCorrob >= 1 || (score >= 45 && weakCorrob >= 1) || score < 20)
859
+ confidence = "medium";
860
+ else
861
+ confidence = "low";
862
+ return { classification, probabilities: { human_like: human, mixed_signals: mixed, ai_like: ai }, confidence };
863
+ }
864
+ // ─── 2026.08.4 escalation policy ─────────────────────────────────────
865
+ // Evidence base: research/REAL-WORLD-EVAL-2026-08.md. On 30 real-world AI
866
+ // samples the engine recorded artefact evidence on 7/7 artefact-bearing
867
+ // samples but escalated only 1/30 beyond human_like — the false-negative bias
868
+ // was wasting near-zero-FP evidence. The five refinements below are the
869
+ // evaluation's §4a "safe" list, verified against the four human controls
870
+ // (which fired zero artefact/formatting-cluster categories and at most 2
871
+ // findings). The do-not-do list (§4c) is respected: no weight changes to
872
+ // adjacent-lemma-repeat / normalization-flag / tier1 / token-cutoff, and no
873
+ // generic threshold drop — escalations key ONLY on artefact and compound
874
+ // evidence. Escalations raise, never lower, and the argmax verdict remains
875
+ // the reported base (probabilities are not rewritten).
876
+ const CLASS_RANK = { human_like: 0, mixed_signals: 1, ai_like: 2 };
877
+ function applyEscalationPolicy(base, confidence, score, findingCount, categories) {
878
+ const cats = new Set(categories);
879
+ const coreArtefacts = categories.filter((c) => ARTEFACT_CORE_CATEGORIES.has(c));
880
+ const supportArtefacts = categories.filter((c) => ARTEFACT_SUPPORT_CATEGORIES.has(c));
881
+ // Support categories (arrows, escaped-markup literals) count only alongside
882
+ // other artefact evidence — the evaluation kept them corroboration-only.
883
+ const artefactHit = coreArtefacts.length >= 1 || supportArtefacts.length >= 2;
884
+ const artefactCats = artefactHit ? [...coreArtefacts, ...supportArtefacts] : [];
885
+ const formattingCats = categories.filter((c) => FORMATTING_CLUSTER_CATEGORIES.has(c));
886
+ // Candidate escalations in precedence order. Each names the eval rule,
887
+ // the classification it argues for, and a UI-ready reason.
888
+ const candidates = [];
889
+ if (cats.has("ai-citation-markup") && cats.has("ai-citation-token")) {
890
+ candidates.push({
891
+ applied: "citation_co_occurrence",
892
+ classification: "ai_like",
893
+ reason: "Internal citation markup and a leaked citation token both appear — the residue of an unstripped chatbot export, with no plausible human origin. This remains stylistic-artefact evidence, not proof of authorship.",
894
+ });
895
+ }
896
+ // 2026.08.6: gate relaxed from (>=8 & >=5) to (>=6 & >=4) on provider-eval
897
+ // §4.1 R2, which measured 0/169 human false positives and a human maximum of
898
+ // 2 findings.
899
+ //
900
+ // 2026.08.8 — BOTH of those measurements are falsified, and the reason string
901
+ // below no longer repeats them. The 169-document corpus they came from was
902
+ // 76% encyclopaedic and question-and-answer text, so it never described
903
+ // published prose. Re-measured on the representative 4,144-sample corpus
904
+ // (tests/battery/HUMAN-CORPUS-V2.md), genuine human writing reaches 5, 6 and
905
+ // in one case 11 categories, and this rule alone produced 135 of the 139
906
+ // rules-layer false positives.
907
+ //
908
+ // The gate is kept rather than removed because the whole 113-rule tier is now
909
+ // editorial suggestions only and contributes nothing to any AI verdict
910
+ // (verdict/combine.ts, combined:2026.08.8), so its effect is confined to how
911
+ // many writing suggestions a draft is shown. What could not stand is telling
912
+ // a user, in the interface, a thing that was measured to be untrue.
913
+ if (findingCount >= 6 && cats.size >= 4) {
914
+ const bumped = base === "human_like" ? "mixed_signals" : "ai_like";
915
+ candidates.push({
916
+ applied: "finding_breadth",
917
+ classification: bumped,
918
+ reason: `Documented writing signals are unusually broad (${findingCount} findings across ${cats.size} categories), so more editorial suggestions are surfaced. Breadth is an observation about the writing, not evidence of authorship: measured on a representative 4,144-sample human corpus, genuine human writing reaches up to 9 categories and 135 of those documents trip this same gate.`,
919
+ });
920
+ }
921
+ const artefactScore = artefactHit && score >= 10;
922
+ if (artefactScore) {
923
+ candidates.push({
924
+ applied: "artefact_score",
925
+ classification: "mixed_signals",
926
+ reason: `Machine-artefact evidence (${artefactCats.join(", ")}) combines with a score of ${score}. Measured on a representative 4,144-sample human corpus, human writing reaches a score of 11 and 2 of those documents clear this gate, so this is a strong editorial signal rather than a finding about authorship.`,
927
+ });
928
+ }
929
+ if (artefactHit) {
930
+ candidates.push({
931
+ applied: "artefact_floor",
932
+ classification: "mixed_signals",
933
+ reason: `Machine-artefact evidence (${artefactCats.join(", ")}) was found. Artefact-class findings are rare in human writing — 4 of 4,144 documents in a representative human corpus — but they are not absent, so this raises the editorial reading and is not evidence of authorship.`,
934
+ });
935
+ }
936
+ // 2026.08.6 provider-eval floors (§4.1 R5 and R1). Both keyed on
937
+ // categories measured on 0/169 held-out humans; both carry the
938
+ // paste-stripping caveat in the underlying rule messages.
939
+ if (cats.has("markdown-furniture")) {
940
+ candidates.push({
941
+ applied: "furniture_gate",
942
+ classification: "mixed_signals",
943
+ reason: "Chat-export markdown furniture (bold runs, heading lines, or dense bullets) shapes this text — the combined gate fired on 0 of 169 held-out human documents. Absence of furniture (e.g. after a format-stripping paste) never counts the other way.",
944
+ });
945
+ }
946
+ if (cats.has("formatting")) {
947
+ candidates.push({
948
+ applied: "formatting_floor",
949
+ classification: "mixed_signals",
950
+ reason: "Heavy bold styling (the formatting rule) fired — measured on 0 of 169 held-out human documents and 9-95% of AI chat text per provider slice; the classification is floored at mixed_signals.",
951
+ });
952
+ }
953
+ if (new Set(formattingCats).size >= 3) {
954
+ candidates.push({
955
+ applied: "formatting_cluster",
956
+ classification: "mixed_signals",
957
+ reason: `Chat-export formatting furniture clusters (${[...new Set(formattingCats)].join(", ")}). This compound signal fired on 0 of 4,144 documents in a representative human corpus, but it detects how a draft was pasted rather than who wrote it: an editor that strips formatting removes it entirely.`,
958
+ });
959
+ }
960
+ let finalClass = base;
961
+ let applied = null;
962
+ let reason = "No escalation applied; the classification is the argmax of the published probabilities.";
963
+ for (const c of candidates) {
964
+ if (CLASS_RANK[c.classification] > CLASS_RANK[finalClass]) {
965
+ finalClass = c.classification;
966
+ applied = c.applied;
967
+ reason = c.reason;
968
+ }
969
+ }
970
+ // Eval rule 3: artefact evidence with an above-human score also lifts a
971
+ // "low" confidence to "medium", whichever escalation set the final class.
972
+ const finalConfidence = artefactScore && confidence === "low" ? "medium" : confidence;
973
+ return { classification: finalClass, confidence: finalConfidence, escalation: { applied, reason } };
974
+ }
975
+ function unscored(status, wordCount) {
976
+ return {
977
+ score: 0,
978
+ classification: "human_like",
979
+ probabilities: { human_like: 0.334, mixed_signals: 0.333, ai_like: 0.333 },
980
+ confidence: "low",
981
+ categoriesHit: [],
982
+ findingCount: 0,
983
+ wordCount,
984
+ version: EN_SIGNALS_PATTERN_VERSION,
985
+ status,
986
+ escalation: { applied: null, reason: "Text was outside the scoring window; the escalation policy was not evaluated." },
987
+ description: DESCRIPTION + " This text was outside the scoring window (" + status.replace("_", " ") + "), so no stylistic assessment was made.",
988
+ };
989
+ }
990
+ /**
991
+ * Document-level editorial-signals score, ported from the upstream weighting
992
+ * and log-normalisation model. The score, classification and probabilities are
993
+ * stylistic evidence about how the text reads; they are never proof of
994
+ * authorship (BRIEF.md §5, §21 Tier B).
995
+ */
996
+ export function computeEditorialSignals(text) {
997
+ if (!text || text.trim().length === 0)
998
+ return unscored("empty", 0);
999
+ const analysis = analyse(text);
1000
+ const { wordCount } = analysis;
1001
+ if (wordCount < 10)
1002
+ return unscored("too_short", wordCount);
1003
+ if (wordCount > MAX_SCORED_WORDS)
1004
+ return unscored("too_long", wordCount);
1005
+ // Stylometric cap (binding research correction, AI-TELLS-MEGA-PACK §6):
1006
+ // stylometric measurements must never dominate the score — the Stanford
1007
+ // TOEFL study found >50% of genuine non-native essays falsely flagged by
1008
+ // stylometric detectors. Their combined contribution is capped at the
1009
+ // larger of the non-stylometric evidence and 12 raw points, so a document
1010
+ // can never approach an ai_like band on rhythm/uniformity measures alone.
1011
+ let styloRaw = 0;
1012
+ let otherRaw = 0;
1013
+ for (const issue of analysis.issues) {
1014
+ const w = MERGED_WEIGHTS[issue.category] ?? 2;
1015
+ if (STYLOMETRIC_CATEGORIES.has(issue.category) || V4_RHYTHM_CATEGORIES.has(issue.category))
1016
+ styloRaw += w;
1017
+ else
1018
+ otherRaw += w;
1019
+ }
1020
+ const rawScore = otherRaw + Math.min(styloRaw, Math.max(otherRaw, 12));
1021
+ const lengthFactor = Math.max(1, Math.log2(wordCount / 50));
1022
+ const score = Math.min(100, Math.round(rawScore / lengthFactor));
1023
+ const denseAIVocab = wordCount >= 150
1024
+ && analysis.tier1Distinct >= 5
1025
+ && analysis.tier2Clusters >= 2
1026
+ && analysis.issues.some((i) => i.category === "transition");
1027
+ const verdict = classify(score, analysis.issues, analysis.normFlags, wordCount, denseAIVocab);
1028
+ const categoriesHit = [...new Set(analysis.issues.map((i) => i.category))].sort();
1029
+ // Post-scoring escalation policy (2026.08.4): argmax stays the base; the
1030
+ // documented eval refinements may raise the published classification.
1031
+ // 2026.08.5 amendment: for the finding-breadth escalation, every rhythm/
1032
+ // measured-stylometric category from the 2026.08.5 pack counts as ONE
1033
+ // combined stylometric contribution — one finding and one category — so
1034
+ // four rhythm rules alone can never assemble the breadth gate. The
1035
+ // published findingCount/categoriesHit are NOT rewritten; only the values
1036
+ // the escalation policy sees are collapsed.
1037
+ // 2026.08.6 amendment: the three markdown-furniture categories likewise
1038
+ // collapse to ONE combined contribution for breadth purposes — the
1039
+ // relaxed (>=6 & >=4) gate was measured (provider-eval §4.1 R2) WITHOUT
1040
+ // the furniture rules, and furniture already has its own dedicated floor,
1041
+ // so it must not be able to assemble the breadth gate by itself.
1042
+ const FURNITURE_CATS = new Set(["markdown-bold", "markdown-heading", "markdown-furniture"]);
1043
+ const v4IssueCount = analysis.issues.filter((i) => V4_RHYTHM_CATEGORIES.has(i.category)).length;
1044
+ const furnitureIssueCount = analysis.issues.filter((i) => FURNITURE_CATS.has(i.category)).length;
1045
+ const breadthFindingCount = analysis.issues.length
1046
+ - Math.max(0, v4IssueCount - 1)
1047
+ - Math.max(0, furnitureIssueCount - 1);
1048
+ const breadthCategories = categoriesHit.filter((c) => !V4_RHYTHM_CATEGORIES.has(c) && !FURNITURE_CATS.has(c));
1049
+ if (v4IssueCount > 0)
1050
+ breadthCategories.push("stylometric-rhythm-combined");
1051
+ if (furnitureIssueCount > 0)
1052
+ breadthCategories.push("markdown-furniture-combined");
1053
+ const escalated = applyEscalationPolicy(verdict.classification, verdict.confidence, score, breadthFindingCount, breadthCategories);
1054
+ return {
1055
+ score,
1056
+ classification: escalated.classification,
1057
+ probabilities: verdict.probabilities,
1058
+ confidence: escalated.confidence,
1059
+ categoriesHit,
1060
+ findingCount: analysis.issues.length,
1061
+ wordCount,
1062
+ version: EN_SIGNALS_PATTERN_VERSION,
1063
+ status: "scored",
1064
+ escalation: escalated.escalation,
1065
+ description: DESCRIPTION,
1066
+ };
1067
+ }