@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.
- package/CHANGELOG.md +138 -0
- package/LICENSE +9 -0
- package/README.md +236 -0
- package/SBOM.cdx.json +172 -0
- package/SECURITY.md +9 -0
- package/THIRD_PARTY_NOTICES.md +36 -0
- package/dist/build-report-html.d.ts +3 -0
- package/dist/highlight.js +155 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +43 -0
- package/dist/options.d.ts +21 -0
- package/dist/options.js +6 -0
- package/dist/receipt.d.ts +15 -0
- package/dist/receipt.js +100 -0
- package/dist/report.d.ts +55 -0
- package/dist/report.js +12 -0
- package/dist/sections.js +181 -0
- package/dist/share.d.ts +47 -0
- package/dist/share.js +72 -0
- package/dist/shared-EX54KI6H.js +1503 -0
- package/dist/shared-MMXWTX2U.js +43 -0
- package/dist/toolbar.js +13478 -0
- package/node_modules/@opacedev/ai-content-checker-browser/LICENSE +21 -0
- package/node_modules/@opacedev/ai-content-checker-browser/README.md +145 -0
- package/node_modules/@opacedev/ai-content-checker-browser/dist/dom/visible-text.d.ts +14 -0
- package/node_modules/@opacedev/ai-content-checker-browser/dist/dom/visible-text.js +47 -0
- package/node_modules/@opacedev/ai-content-checker-browser/dist/index.d.ts +3 -0
- package/node_modules/@opacedev/ai-content-checker-browser/dist/index.js +99 -0
- package/node_modules/@opacedev/ai-content-checker-browser/dist/worker/client.d.ts +12 -0
- package/node_modules/@opacedev/ai-content-checker-browser/dist/worker/client.js +17 -0
- package/node_modules/@opacedev/ai-content-checker-browser/dist/worker/entry.d.ts +1 -0
- package/node_modules/@opacedev/ai-content-checker-browser/dist/worker/entry.js +3984 -0
- package/node_modules/@opacedev/ai-content-checker-browser/dist/worker/protocol.d.ts +30 -0
- package/node_modules/@opacedev/ai-content-checker-browser/dist/worker/protocol.js +1 -0
- package/node_modules/@opacedev/ai-content-checker-browser/package.json +60 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/LICENSE +21 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/README.md +93 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/dist/index.d.ts +58 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/dist/index.js +17 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/package.json +57 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/analysis-request.schema.d.ts +44 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/analysis-result.schema.d.ts +122 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/candidate.schema.d.ts +81 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/capabilities.schema.d.ts +24 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/checker-result.schema.d.ts +342 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/common.schema.d.ts +10 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/envelope.schema.d.ts +14 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/gate-result.schema.d.ts +19 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/integrity-receipt.schema.d.ts +122 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/job.schema.d.ts +188 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/method-result.schema.d.ts +39 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/pattern-finding.schema.d.ts +26 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/protected-span.schema.d.ts +35 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/rewrite-request.schema.d.ts +65 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/index.ts +63 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/semantic-validation.mjs +32 -0
- package/node_modules/@opacedev/ai-content-checker-core/LICENSE +21 -0
- package/node_modules/@opacedev/ai-content-checker-core/README.md +186 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/bundle.js +4744 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/capabilities.d.ts +16 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/capabilities.js +7 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/diff/diff.d.ts +17 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/diff/diff.js +76 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/fixes/preview.d.ts +27 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/fixes/preview.js +47 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/gates/policy.d.ts +14 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/gates/policy.js +16 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/index.d.ts +15 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/index.js +15 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/inspect.d.ts +8 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/inspect.js +84 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-gb-v1.d.ts +12 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-gb-v1.js +68 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v2-data.d.ts +73 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v2-data.js +499 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v2.d.ts +76 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v2.js +1067 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v3-data.d.ts +165 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v3-data.js +614 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v3.d.ts +19 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v3.js +542 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v4-corpus.d.ts +40 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v4-corpus.js +40 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v4-data.d.ts +138 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v4-data.js +200 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v4.d.ts +71 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v4.js +388 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/protected/extract.d.ts +17 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/protected/extract.js +75 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/protected/validate.d.ts +3 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/protected/validate.js +17 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/provenance/c2pa-text.d.ts +55 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/provenance/c2pa-text.js +86 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/receipts/build.d.ts +48 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/receipts/build.js +61 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/report/checker-result.d.ts +241 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/report/checker-result.js +419 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/source/offsets.d.ts +34 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/source/offsets.js +51 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/source/utf8.d.ts +3 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/source/utf8.js +58 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/source/visible-text.d.ts +12 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/source/visible-text.js +30 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/unicode/data.d.ts +22 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/unicode/data.js +137 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/unicode/inspect.d.ts +16 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/unicode/inspect.js +84 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/verdict/combine.d.ts +162 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/verdict/combine.js +501 -0
- package/node_modules/@opacedev/ai-content-checker-core/package.json +62 -0
- package/node_modules/canonicalize/LICENSE +201 -0
- package/node_modules/canonicalize/README.md +65 -0
- package/node_modules/canonicalize/bin/canonicalize.js +17 -0
- package/node_modules/canonicalize/lib/canonicalize.d.ts +2 -0
- package/node_modules/canonicalize/lib/canonicalize.js +74 -0
- package/node_modules/canonicalize/package.json +64 -0
- package/node_modules/entities/LICENSE +11 -0
- package/node_modules/entities/lib/decode.d.ts +211 -0
- package/node_modules/entities/lib/decode.d.ts.map +1 -0
- package/node_modules/entities/lib/decode.js +536 -0
- package/node_modules/entities/lib/decode.js.map +1 -0
- package/node_modules/entities/lib/decode_codepoint.d.ts +19 -0
- package/node_modules/entities/lib/decode_codepoint.d.ts.map +1 -0
- package/node_modules/entities/lib/decode_codepoint.js +76 -0
- package/node_modules/entities/lib/decode_codepoint.js.map +1 -0
- package/node_modules/entities/lib/encode.d.ts +22 -0
- package/node_modules/entities/lib/encode.d.ts.map +1 -0
- package/node_modules/entities/lib/encode.js +77 -0
- package/node_modules/entities/lib/encode.js.map +1 -0
- package/node_modules/entities/lib/escape.d.ts +43 -0
- package/node_modules/entities/lib/escape.d.ts.map +1 -0
- package/node_modules/entities/lib/escape.js +122 -0
- package/node_modules/entities/lib/escape.js.map +1 -0
- package/node_modules/entities/lib/esm/decode.d.ts +211 -0
- package/node_modules/entities/lib/esm/decode.d.ts.map +1 -0
- package/node_modules/entities/lib/esm/decode.js +496 -0
- package/node_modules/entities/lib/esm/decode.js.map +1 -0
- package/node_modules/entities/lib/esm/decode_codepoint.d.ts +19 -0
- package/node_modules/entities/lib/esm/decode_codepoint.d.ts.map +1 -0
- package/node_modules/entities/lib/esm/decode_codepoint.js +71 -0
- package/node_modules/entities/lib/esm/decode_codepoint.js.map +1 -0
- package/node_modules/entities/lib/esm/encode.d.ts +22 -0
- package/node_modules/entities/lib/esm/encode.d.ts.map +1 -0
- package/node_modules/entities/lib/esm/encode.js +69 -0
- package/node_modules/entities/lib/esm/encode.js.map +1 -0
- package/node_modules/entities/lib/esm/escape.d.ts +43 -0
- package/node_modules/entities/lib/esm/escape.d.ts.map +1 -0
- package/node_modules/entities/lib/esm/escape.js +116 -0
- package/node_modules/entities/lib/esm/escape.js.map +1 -0
- package/node_modules/entities/lib/esm/generated/decode-data-html.d.ts +3 -0
- package/node_modules/entities/lib/esm/generated/decode-data-html.d.ts.map +1 -0
- package/node_modules/entities/lib/esm/generated/decode-data-html.js +7 -0
- package/node_modules/entities/lib/esm/generated/decode-data-html.js.map +1 -0
- package/node_modules/entities/lib/esm/generated/decode-data-xml.d.ts +3 -0
- package/node_modules/entities/lib/esm/generated/decode-data-xml.d.ts.map +1 -0
- package/node_modules/entities/lib/esm/generated/decode-data-xml.js +7 -0
- package/node_modules/entities/lib/esm/generated/decode-data-xml.js.map +1 -0
- package/node_modules/entities/lib/esm/generated/encode-html.d.ts +8 -0
- package/node_modules/entities/lib/esm/generated/encode-html.d.ts.map +1 -0
- package/node_modules/entities/lib/esm/generated/encode-html.js +10 -0
- package/node_modules/entities/lib/esm/generated/encode-html.js.map +1 -0
- package/node_modules/entities/lib/esm/index.d.ts +96 -0
- package/node_modules/entities/lib/esm/index.d.ts.map +1 -0
- package/node_modules/entities/lib/esm/index.js +99 -0
- package/node_modules/entities/lib/esm/index.js.map +1 -0
- package/node_modules/entities/lib/esm/package.json +1 -0
- package/node_modules/entities/lib/generated/decode-data-html.d.ts +3 -0
- package/node_modules/entities/lib/generated/decode-data-html.d.ts.map +1 -0
- package/node_modules/entities/lib/generated/decode-data-html.js +9 -0
- package/node_modules/entities/lib/generated/decode-data-html.js.map +1 -0
- package/node_modules/entities/lib/generated/decode-data-xml.d.ts +3 -0
- package/node_modules/entities/lib/generated/decode-data-xml.d.ts.map +1 -0
- package/node_modules/entities/lib/generated/decode-data-xml.js +9 -0
- package/node_modules/entities/lib/generated/decode-data-xml.js.map +1 -0
- package/node_modules/entities/lib/generated/encode-html.d.ts +8 -0
- package/node_modules/entities/lib/generated/encode-html.d.ts.map +1 -0
- package/node_modules/entities/lib/generated/encode-html.js +12 -0
- package/node_modules/entities/lib/generated/encode-html.js.map +1 -0
- package/node_modules/entities/lib/index.d.ts +96 -0
- package/node_modules/entities/lib/index.d.ts.map +1 -0
- package/node_modules/entities/lib/index.js +126 -0
- package/node_modules/entities/lib/index.js.map +1 -0
- package/node_modules/entities/package.json +90 -0
- package/node_modules/entities/readme.md +122 -0
- 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
|
+
}
|