@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,501 @@
|
|
|
1
|
+
// Combined verdict (2026.08.8) — three independent axes, never collapsed.
|
|
2
|
+
//
|
|
3
|
+
// ─── What changed in 2026.08.8, and why ──────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// The 2026.08.7 module had a single published `classification` drawn from the
|
|
6
|
+
// same three-value scale as the writing-signals score, and several combination
|
|
7
|
+
// paths escalated it to `ai_like`. A carrier payload, a run of zero-width
|
|
8
|
+
// characters, an interior homoglyph or a watermark hit could therefore make the
|
|
9
|
+
// engine say "ai_like" about a draft.
|
|
10
|
+
//
|
|
11
|
+
// That is a category error, and the independent audit was right to call it one.
|
|
12
|
+
// A hidden zero-width character proves text MANIPULATION, not AI ORIGIN. It
|
|
13
|
+
// says a tool wrote into the bytes after somebody typed them; it says nothing
|
|
14
|
+
// at all about who or what composed the sentences. Any of these can put a
|
|
15
|
+
// zero-width space in a paragraph: a CMS paste handler, a translation memory,
|
|
16
|
+
// a DTP export, a plagiarism-evasion service, a mail client — or a person doing
|
|
17
|
+
// it deliberately to a wholly human document.
|
|
18
|
+
//
|
|
19
|
+
// OBJECTIVE.md records the binding decision behind this rewrite: the 113-rule
|
|
20
|
+
// writing-signals tier is demoted to editorial suggestions and stops
|
|
21
|
+
// contributing to any AI judgement, because measured on 5,558 fresh long-form
|
|
22
|
+
// documents it detected 45.1% of AI writing while flagging 24.8% of human
|
|
23
|
+
// writing — worse than the trained model on both axes at once. Only the trained
|
|
24
|
+
// model gives an authorship reading.
|
|
25
|
+
//
|
|
26
|
+
// So this module now publishes three readings and never reduces them to one:
|
|
27
|
+
//
|
|
28
|
+
// AXIS A — `ai_probability`. How likely it is that a machine composed this
|
|
29
|
+
// text. ONLY a trained model may set this. No character finding, no
|
|
30
|
+
// writing rule and no watermark hit may write to it. With no model
|
|
31
|
+
// reading supplied it is `not_assessed`, and `not_assessed` is an
|
|
32
|
+
// honest answer, not a default of "human".
|
|
33
|
+
//
|
|
34
|
+
// AXIS B — `text_integrity`. What was done TO the text: invisible carriers,
|
|
35
|
+
// homoglyph substitution, private-use clusters, watermark marks and
|
|
36
|
+
// provenance. It reports manipulation. It is allowed to say "this
|
|
37
|
+
// text contains hidden characters". It is never allowed to say, or
|
|
38
|
+
// to imply, "this is AI".
|
|
39
|
+
//
|
|
40
|
+
// AXIS C — `editorial`. Writing suggestions from the named rules: generic
|
|
41
|
+
// phrasing, unusually uniform structure. Editorial feedback only.
|
|
42
|
+
//
|
|
43
|
+
// The evidence streams feeding axis B are:
|
|
44
|
+
// (b1) invisible-Unicode carrier findings (unicode/inspect.ts),
|
|
45
|
+
// (b2) homoglyph findings (unicode/inspect.ts),
|
|
46
|
+
// (b3) the watermark scan, and only when a signal is genuinely found.
|
|
47
|
+
//
|
|
48
|
+
// Protected spans are deliberately NOT an input to any axis. They are facts the
|
|
49
|
+
// editor must preserve, not evidence about origin; conflating the two was a
|
|
50
|
+
// separate reported defect and must not be reintroduced here.
|
|
51
|
+
//
|
|
52
|
+
// Four contracts bind this module:
|
|
53
|
+
// 1. Axis independence. Evidence from one axis may never set, raise or lower
|
|
54
|
+
// another axis. `assertAxisIndependence` enforces this at runtime and
|
|
55
|
+
// throws rather than publishing a collapsed verdict.
|
|
56
|
+
// 2. Evidence quality, not enthusiasm. Only characters with near-zero
|
|
57
|
+
// innocent explanation are allowed to raise the integrity status. Every
|
|
58
|
+
// character with a documented legitimate use (typographic spaces,
|
|
59
|
+
// bidirectional controls, script-specific format marks, emoji joiners and
|
|
60
|
+
// variation selectors that survived no context exemption, edge homoglyphs
|
|
61
|
+
// in multilingual text) is classified as supporting or excluded and can
|
|
62
|
+
// never raise a status on its own.
|
|
63
|
+
// 3. Raise-only within an axis. An integrity finding raises the integrity
|
|
64
|
+
// status and names itself in `applied`; nothing ever lowers it silently.
|
|
65
|
+
// 4. The honesty contract. No path presents any finding as proof of
|
|
66
|
+
// authorship, no integrity or editorial string uses the vocabulary of AI
|
|
67
|
+
// authorship, and every path contributes its own limitation line.
|
|
68
|
+
export const COMBINED_VERDICT_VERSION = "combined:2026.08.8";
|
|
69
|
+
const DESCRIPTION = "Three independent readings, published side by side and never merged: the AI probability, which only a trained " +
|
|
70
|
+
"model may set; text-integrity and provenance findings, which describe what was done to the text; and editorial " +
|
|
71
|
+
"suggestions about phrasing and structure. Integrity findings describe manipulation, not authorship.";
|
|
72
|
+
const AXES_LIMIT = "These three readings are independent and are never combined into a single verdict. Hidden characters, " +
|
|
73
|
+
"homoglyph substitutions and watermark marks show that something was done to the text; they are not evidence " +
|
|
74
|
+
"of AI authorship. Only the trained model gives an AI reading.";
|
|
75
|
+
const AUTHORSHIP_LIMIT = "Authorship cannot be proved from these checks; character evidence shows how text was produced or pasted, not by whom.";
|
|
76
|
+
const ABSENCE_LIMIT = "Absence of carrier characters is not evidence of human authorship. Most published prose carries none, and any copy-paste, CMS save or format strip removes them.";
|
|
77
|
+
const PROTECTED_LIMIT = "Protected spans are excluded from this verdict by design: they are facts to preserve, not evidence about origin.";
|
|
78
|
+
const EDITORIAL_LIMIT = "Writing suggestions are editorial feedback on phrasing and structure. Measured on 5,558 fresh long-form " +
|
|
79
|
+
"documents the named rules flagged 24.8% of human writing, so they say nothing about who or what wrote a draft " +
|
|
80
|
+
"and are never counted toward the AI reading.";
|
|
81
|
+
const NO_MODEL_LIMIT = "No trained model ran on this text, so no AI probability is available. That is reported as not assessed, and " +
|
|
82
|
+
"not assessed does not mean human.";
|
|
83
|
+
const INTEGRITY_RANK = { clean: 0, attention: 1, manipulated: 2 };
|
|
84
|
+
// ─── Evidence tiers ──────────────────────────────────────────────────
|
|
85
|
+
//
|
|
86
|
+
// Tiering is by code point, applied AFTER unicode/inspect.ts has already run
|
|
87
|
+
// its context exemptions. A zero-width joiner inside an emoji sequence or
|
|
88
|
+
// between cursive letters never reaches this module at all; one that does has
|
|
89
|
+
// already failed every documented innocent explanation the engine knows.
|
|
90
|
+
/** Deliberate carriers: no ordinary authoring tool emits these into prose. */
|
|
91
|
+
function isDeliberateCarrier(cp) {
|
|
92
|
+
return (cp === 0x200b || // ZERO WIDTH SPACE
|
|
93
|
+
cp === 0x200c || // ZWNJ that survived the cursive/Indic joining exemption
|
|
94
|
+
cp === 0x200d || // ZWJ that survived the emoji and joining-script exemptions
|
|
95
|
+
cp === 0x2060 || // WORD JOINER
|
|
96
|
+
(cp >= 0x206a && cp <= 0x206f) || // deprecated format characters
|
|
97
|
+
(cp >= 0xfe00 && cp <= 0xfe0f) || // variation selectors past the emoji/CJK exemption
|
|
98
|
+
(cp >= 0xe0100 && cp <= 0xe01ef) || // supplementary variation selectors past the CJK exemption
|
|
99
|
+
cp === 0xe0001 || // LANGUAGE TAG
|
|
100
|
+
(cp >= 0xe0020 && cp <= 0xe007f) // tag characters (flag sequences exempted separately)
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
function isVariationSelector(cp) {
|
|
104
|
+
return (cp >= 0xfe00 && cp <= 0xfe0f) || (cp >= 0xe0100 && cp <= 0xe01ef);
|
|
105
|
+
}
|
|
106
|
+
/** Corroborating characters: unusual in prose, but with a documented innocent path. */
|
|
107
|
+
function isSupportingCarrier(cp) {
|
|
108
|
+
return (cp === 0x00ad || // SOFT HYPHEN - justified typography and word-processor exports
|
|
109
|
+
cp === 0x034f || // COMBINING GRAPHEME JOINER - rare collation and diacritic ordering
|
|
110
|
+
cp === 0xfeff || // BYTE ORDER MARK - routine file-encoding artefact
|
|
111
|
+
(cp >= 0x2061 && cp <= 0x2064) || // invisible operators - typeset mathematics
|
|
112
|
+
(cp >= 0xfff9 && cp <= 0xfffb) || // interlinear annotation - Japanese ruby pipelines
|
|
113
|
+
(cp >= 0x180b && cp <= 0x180f) || // Mongolian free variation selectors and vowel separator
|
|
114
|
+
isPrivateUse(cp));
|
|
115
|
+
}
|
|
116
|
+
export function isPrivateUse(cp) {
|
|
117
|
+
return (cp >= 0xe000 && cp <= 0xf8ff) || (cp >= 0xf0000 && cp <= 0xffffd) || (cp >= 0x100000 && cp <= 0x10fffd);
|
|
118
|
+
}
|
|
119
|
+
/** Invisible carriers for run detection: deliberate plus supporting, excluding
|
|
120
|
+
* private-use characters, which occupy visible width in the fonts that map them. */
|
|
121
|
+
function isInvisibleCarrier(cp) {
|
|
122
|
+
return isDeliberateCarrier(cp) || (isSupportingCarrier(cp) && !isPrivateUse(cp));
|
|
123
|
+
}
|
|
124
|
+
const DELIBERATE_RATIONALE = "No ordinary authoring or publishing tool emits this character into prose, and every documented legitimate context for it was checked and did not apply.";
|
|
125
|
+
const SUPPORTING_RATIONALE = "Unusual in prose but with a documented innocent origin, so it corroborates other evidence and never raises a status on its own.";
|
|
126
|
+
const EXCLUDED_RATIONALE = "This character has a documented legitimate use in typography, multilingual text or encoding, so it is reported but excluded from the integrity status.";
|
|
127
|
+
const PUA_RATIONALE = "A private-use character carries meaning only under a private agreement. A single one is common in icon fonts and vendor logos, so one alone only corroborates.";
|
|
128
|
+
// ─── Homoglyph grading ───────────────────────────────────────────────
|
|
129
|
+
const LATIN_LETTER = /\p{Script=Latin}/u;
|
|
130
|
+
/**
|
|
131
|
+
* An INTERIOR homoglyph has a Latin letter immediately either side: a
|
|
132
|
+
* substitution inside an otherwise-Latin word. Genuinely multilingual text
|
|
133
|
+
* produces EDGE homoglyphs instead - a Greek or Cyrillic letter at a token
|
|
134
|
+
* boundary, next to a hyphen, digit or space, as in scientific notation such
|
|
135
|
+
* as alpha-hydroxylation. Edge homoglyphs never raise a status.
|
|
136
|
+
*/
|
|
137
|
+
function isInteriorHomoglyph(text, finding) {
|
|
138
|
+
const start = finding.span.start_utf16;
|
|
139
|
+
const end = finding.span.end_utf16;
|
|
140
|
+
if (start <= 0 || end >= text.length)
|
|
141
|
+
return false;
|
|
142
|
+
return LATIN_LETTER.test(text[start - 1]) && LATIN_LETTER.test(text[end]);
|
|
143
|
+
}
|
|
144
|
+
// ─── Tag-character flag exemption ────────────────────────────────────
|
|
145
|
+
const BLACK_FLAG = 0x1f3f4;
|
|
146
|
+
/**
|
|
147
|
+
* Subdivision flag emoji are a legitimate tag run: BLACK FLAG, then tag
|
|
148
|
+
* characters, then CANCEL TAG. A tag character inside such a sequence is
|
|
149
|
+
* excluded; a tag run anywhere else is the classic covert payload carrier.
|
|
150
|
+
*/
|
|
151
|
+
function tagIsInFlagSequence(text, index) {
|
|
152
|
+
// Every tag character and the black flag itself is a surrogate pair, so the
|
|
153
|
+
// run is walked two UTF-16 units at a time.
|
|
154
|
+
let i = index;
|
|
155
|
+
while (i >= 2) {
|
|
156
|
+
const previous = text.codePointAt(i - 2);
|
|
157
|
+
if (previous !== undefined && previous >= 0xe0020 && previous <= 0xe007f) {
|
|
158
|
+
i -= 2;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
if (i < 2)
|
|
164
|
+
return false;
|
|
165
|
+
return text.codePointAt(i - 2) === BLACK_FLAG;
|
|
166
|
+
}
|
|
167
|
+
// ─── Axis C: editorial banding ───────────────────────────────────────
|
|
168
|
+
//
|
|
169
|
+
// The rules tier still emits its historic three-way label. That label is a
|
|
170
|
+
// rules-tier artefact and its `ai_like` value is NOT an authorship reading, so
|
|
171
|
+
// it is translated here into neutral editorial vocabulary and never leaves this
|
|
172
|
+
// module wearing its old name.
|
|
173
|
+
const SUGGESTION_LEVEL = {
|
|
174
|
+
human_like: "none",
|
|
175
|
+
mixed_signals: "some",
|
|
176
|
+
ai_like: "many",
|
|
177
|
+
};
|
|
178
|
+
const SUGGESTION_REASON = {
|
|
179
|
+
none: "The named writing rules found nothing worth suggesting a change to.",
|
|
180
|
+
some: "The named writing rules produced some editorial suggestions about phrasing or structure. Genuine human copy triggers them routinely.",
|
|
181
|
+
many: "The named writing rules produced a lot of editorial suggestions about phrasing and structure. That is a comment on the writing, not on who wrote it: measured on fresh long-form documents these rules flag roughly one human document in four.",
|
|
182
|
+
};
|
|
183
|
+
// ─── Combination ─────────────────────────────────────────────────────
|
|
184
|
+
/**
|
|
185
|
+
* Compute the three independent readings. Nothing here merges them: each axis
|
|
186
|
+
* is derived only from its own evidence, and `assertAxisIndependence` throws
|
|
187
|
+
* rather than let a collapsed verdict be published.
|
|
188
|
+
*/
|
|
189
|
+
export function computeCombinedVerdict(input) {
|
|
190
|
+
const findings = input.unicodeFindings ?? [];
|
|
191
|
+
const text = input.text;
|
|
192
|
+
const signals = input.signals;
|
|
193
|
+
const watermarkOutcome = input.watermark?.outcome ?? "not_available";
|
|
194
|
+
const deliberate = [];
|
|
195
|
+
const supporting = [];
|
|
196
|
+
const excluded = [];
|
|
197
|
+
let interiorHomoglyphs = 0;
|
|
198
|
+
const carrierPositions = [];
|
|
199
|
+
for (const finding of findings) {
|
|
200
|
+
const cp = codePointOf(finding);
|
|
201
|
+
const isHomoglyph = finding.id.includes("_homoglyph_");
|
|
202
|
+
if (isHomoglyph) {
|
|
203
|
+
// Without the text we cannot tell a substitution from multilingual text,
|
|
204
|
+
// so we grade down rather than guess.
|
|
205
|
+
const interior = text !== undefined && isInteriorHomoglyph(text, finding);
|
|
206
|
+
if (interior) {
|
|
207
|
+
interiorHomoglyphs++;
|
|
208
|
+
deliberate.push(item(finding, "deliberate", "A Latin-lookalike character sits between two Latin letters, which is a substitution inside a Latin word rather than multilingual text."));
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
supporting.push(item(finding, "supporting", "A Latin-lookalike character at a token boundary, which is the shape genuinely multilingual and scientific text produces."));
|
|
212
|
+
}
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (cp === null) {
|
|
216
|
+
excluded.push(item(finding, "excluded", EXCLUDED_RATIONALE));
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (cp >= 0xe0020 && cp <= 0xe007f && text !== undefined && tagIsInFlagSequence(text, finding.span.start_utf16)) {
|
|
220
|
+
excluded.push(item(finding, "excluded", "This tag character belongs to a subdivision flag emoji sequence, which is a legitimate tag run."));
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
// unicode/inspect.ts downgrades a variation selector to severity "note" when
|
|
224
|
+
// it follows a base character that commonly takes registered glyph variants
|
|
225
|
+
// (Han, Mongolian). That is an ideographic variation sequence, not a carrier.
|
|
226
|
+
if (isVariationSelector(cp) && finding.severity === "note") {
|
|
227
|
+
excluded.push(item(finding, "excluded", "This variation selector follows a base character that commonly takes registered glyph variants, which is its documented legitimate use."));
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (isInvisibleCarrier(cp))
|
|
231
|
+
carrierPositions.push(finding.span.start_utf16);
|
|
232
|
+
if (isDeliberateCarrier(cp)) {
|
|
233
|
+
deliberate.push(item(finding, "deliberate", DELIBERATE_RATIONALE));
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (isSupportingCarrier(cp)) {
|
|
237
|
+
supporting.push(item(finding, "supporting", isPrivateUse(cp) ? PUA_RATIONALE : SUPPORTING_RATIONALE));
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
excluded.push(item(finding, "excluded", EXCLUDED_RATIONALE));
|
|
241
|
+
}
|
|
242
|
+
// Two or more private-use characters is no longer a stray vendor logo.
|
|
243
|
+
const privateUseCount = supporting.filter((x) => { const cp = codePointFromLabel(x.code_point); return cp !== null && isPrivateUse(cp); }).length;
|
|
244
|
+
const longestRun = longestAdjacentRun(carrierPositions, text);
|
|
245
|
+
const deliberateCount = deliberate.length;
|
|
246
|
+
const tagCharacters = deliberate.filter((x) => { const cp = codePointFromLabel(x.code_point); return cp !== null && cp >= 0xe0001 && cp <= 0xe007f; }).length;
|
|
247
|
+
const inputs = [];
|
|
248
|
+
if (input.model)
|
|
249
|
+
inputs.push("model");
|
|
250
|
+
if (signals)
|
|
251
|
+
inputs.push("writing_signals");
|
|
252
|
+
if (findings.some((f) => !f.id.includes("_homoglyph_")))
|
|
253
|
+
inputs.push("invisible_unicode");
|
|
254
|
+
if (findings.some((f) => f.id.includes("_homoglyph_")))
|
|
255
|
+
inputs.push("homoglyphs");
|
|
256
|
+
if (watermarkOutcome === "detected")
|
|
257
|
+
inputs.push("watermark");
|
|
258
|
+
const limitations = [AXES_LIMIT, AUTHORSHIP_LIMIT, ABSENCE_LIMIT, PROTECTED_LIMIT];
|
|
259
|
+
// ── AXIS B: text integrity and provenance ───────────────────────────
|
|
260
|
+
//
|
|
261
|
+
// Every finding here describes what was DONE to the text. None of them may
|
|
262
|
+
// use the vocabulary of authorship, and none of them reaches axis A.
|
|
263
|
+
const integrityFindings = [];
|
|
264
|
+
// (b3) Watermark, and only when a signal was genuinely found. A watermark is
|
|
265
|
+
// provenance: it says a marked generator produced these tokens at some point.
|
|
266
|
+
// It is not an AI probability and it does not survive an unknown amount of
|
|
267
|
+
// human rewriting, so it stays on the provenance axis.
|
|
268
|
+
if (watermarkOutcome === "detected") {
|
|
269
|
+
integrityFindings.push({
|
|
270
|
+
applied: "watermark_signal",
|
|
271
|
+
status: "manipulated",
|
|
272
|
+
reason: "A watermark detector reported a positive signal, so this text carries a generator's mark. That is provenance evidence about the text, and it is reported here rather than as an AI probability.",
|
|
273
|
+
});
|
|
274
|
+
limitations.push("A watermark signal identifies the generator that marked the text, not the person who published it, and says nothing about later human editing. It is not, on its own, an AI reading.");
|
|
275
|
+
}
|
|
276
|
+
// A payload, not an artefact: a run of adjacent carriers, a tag run outside a
|
|
277
|
+
// flag sequence, or a heavy accumulation of deliberate carriers.
|
|
278
|
+
if (longestRun >= 3 || tagCharacters >= 2 || deliberateCount >= 8) {
|
|
279
|
+
integrityFindings.push({
|
|
280
|
+
applied: "carrier_payload",
|
|
281
|
+
status: "manipulated",
|
|
282
|
+
reason: describePayload(longestRun, tagCharacters, deliberateCount),
|
|
283
|
+
});
|
|
284
|
+
limitations.push("A carrier payload shows that something deliberately encoded data into this text. It does not identify what encoded it, it can be inserted by any tool in the chain including one the author never saw, and it is not evidence that a machine wrote the words.");
|
|
285
|
+
}
|
|
286
|
+
// (b1) Deliberate invisible carriers on their own.
|
|
287
|
+
if (deliberateCount - interiorHomoglyphs >= 1) {
|
|
288
|
+
integrityFindings.push({
|
|
289
|
+
applied: "carrier_deliberate",
|
|
290
|
+
status: "attention",
|
|
291
|
+
reason: `This text contains an invisible character with no ordinary authoring explanation (${describeEvidence(deliberate.filter((x) => !x.rationale.startsWith("A Latin-lookalike")))}). Every documented legitimate context for it was checked and did not apply.`,
|
|
292
|
+
});
|
|
293
|
+
limitations.push("Invisible carriers can be introduced by any tool that touched the text after it was written, including editors, CMS filters and paste handlers, so they place the text in a pipeline rather than with an author, and they say nothing about whether a person or a machine composed it.");
|
|
294
|
+
}
|
|
295
|
+
// (b2) Homoglyph substitution inside a Latin word.
|
|
296
|
+
if (interiorHomoglyphs >= 1) {
|
|
297
|
+
integrityFindings.push({
|
|
298
|
+
applied: "homoglyph_substitution",
|
|
299
|
+
status: "attention",
|
|
300
|
+
reason: `${interiorHomoglyphs} Latin-lookalike character${interiorHomoglyphs === 1 ? " sits" : "s sit"} between Latin letters inside a word. Genuinely multilingual and scientific text places such characters at token boundaries instead.`,
|
|
301
|
+
});
|
|
302
|
+
limitations.push("Homoglyph substitution indicates the text passed through a tool that rewrites characters. It is never corrected automatically, it does not establish intent or authorship, and it is not an AI signal.");
|
|
303
|
+
}
|
|
304
|
+
// Two or more private-use characters stops being a stray vendor glyph.
|
|
305
|
+
if (privateUseCount >= 2) {
|
|
306
|
+
integrityFindings.push({
|
|
307
|
+
applied: "private_use_cluster",
|
|
308
|
+
status: "attention",
|
|
309
|
+
reason: `${privateUseCount} private-use characters are present. One is a routine icon-font or vendor logo; a cluster is a private encoding.`,
|
|
310
|
+
});
|
|
311
|
+
limitations.push("Private-use characters carry meaning only under a private agreement, which this engine cannot read, so their presence is described and not interpreted.");
|
|
312
|
+
}
|
|
313
|
+
let integrityStatus = "clean";
|
|
314
|
+
let integrityApplied = null;
|
|
315
|
+
let integrityReason = "No hidden characters, homoglyph substitutions or watermark marks were found that lack an ordinary explanation.";
|
|
316
|
+
for (const finding of integrityFindings) {
|
|
317
|
+
if (INTEGRITY_RANK[finding.status] > INTEGRITY_RANK[integrityStatus]) {
|
|
318
|
+
integrityStatus = finding.status;
|
|
319
|
+
integrityApplied = finding.applied;
|
|
320
|
+
integrityReason = finding.reason;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// Character evidence is measured rather than inferred, so a finding carries
|
|
324
|
+
// real confidence about the TEXT. It never carries confidence about authorship.
|
|
325
|
+
const integrityConfidence = watermarkOutcome === "detected" || integrityStatus === "manipulated" ? "high"
|
|
326
|
+
: integrityStatus === "attention" ? "medium"
|
|
327
|
+
: "low";
|
|
328
|
+
// ── AXIS A: the AI reading. Only `input.model` may set it ───────────
|
|
329
|
+
const model = input.model;
|
|
330
|
+
const aiProbability = model
|
|
331
|
+
? {
|
|
332
|
+
source: model.version ? `${model.name}@${model.version}` : model.name,
|
|
333
|
+
value: model.probability,
|
|
334
|
+
threshold: model.threshold,
|
|
335
|
+
reading: model.below_reliable_range
|
|
336
|
+
? "not_assessed"
|
|
337
|
+
: model.probability >= model.threshold
|
|
338
|
+
? "ai_like"
|
|
339
|
+
: model.probability >= model.threshold * 0.5
|
|
340
|
+
? "uncertain"
|
|
341
|
+
: "human_like",
|
|
342
|
+
confidence: model.below_reliable_range ? "not_assessed" : modelConfidence(model),
|
|
343
|
+
reason: model.below_reliable_range
|
|
344
|
+
? "The trained model ran but this text is outside the length range where its reading is reliable, so no AI probability is published."
|
|
345
|
+
: `The trained model scored this text at ${(model.probability * 100).toFixed(1)}% against an operating point of ${(model.threshold * 100).toFixed(1)}%. This is the only AI reading the engine publishes.`,
|
|
346
|
+
}
|
|
347
|
+
: {
|
|
348
|
+
source: null,
|
|
349
|
+
value: null,
|
|
350
|
+
threshold: null,
|
|
351
|
+
reading: "not_assessed",
|
|
352
|
+
confidence: "not_assessed",
|
|
353
|
+
reason: "No trained model ran on this text, so no AI probability is available. Character findings and writing rules cannot supply one.",
|
|
354
|
+
};
|
|
355
|
+
if (!model)
|
|
356
|
+
limitations.push(NO_MODEL_LIMIT);
|
|
357
|
+
if (model?.below_reliable_range) {
|
|
358
|
+
limitations.push("Short samples remain outside the reliable range. Cycle 5 detects 43 of 56 held-out 100-word AI passages (76.8%) on the server evaluation route; that cell is small and does not justify publishing a probability for this text.");
|
|
359
|
+
}
|
|
360
|
+
// ── AXIS C: editorial suggestions ───────────────────────────────────
|
|
361
|
+
const suggestionLevel = signals ? SUGGESTION_LEVEL[signals.classification] : "none";
|
|
362
|
+
const editorial = {
|
|
363
|
+
suggestion_level: suggestionLevel,
|
|
364
|
+
score: signals?.score ?? null,
|
|
365
|
+
categories_hit: signals?.categoriesHit ?? null,
|
|
366
|
+
finding_count: signals?.findingCount ?? null,
|
|
367
|
+
confidence: signals?.confidence ?? "not_assessed",
|
|
368
|
+
reason: signals
|
|
369
|
+
? SUGGESTION_REASON[suggestionLevel]
|
|
370
|
+
: "The named writing rules were not requested for this text.",
|
|
371
|
+
rule_probabilities: signals?.probabilities ?? null,
|
|
372
|
+
};
|
|
373
|
+
if (signals)
|
|
374
|
+
limitations.push(EDITORIAL_LIMIT);
|
|
375
|
+
const result = {
|
|
376
|
+
ai_probability: aiProbability,
|
|
377
|
+
text_integrity: {
|
|
378
|
+
status: integrityStatus,
|
|
379
|
+
applied: integrityApplied,
|
|
380
|
+
reason: integrityReason,
|
|
381
|
+
findings: integrityFindings,
|
|
382
|
+
character_evidence: {
|
|
383
|
+
deliberate,
|
|
384
|
+
supporting,
|
|
385
|
+
excluded,
|
|
386
|
+
interior_homoglyph_count: interiorHomoglyphs,
|
|
387
|
+
longest_carrier_run: longestRun,
|
|
388
|
+
},
|
|
389
|
+
watermark: { outcome: watermarkOutcome, counted_as_evidence: watermarkOutcome === "detected" },
|
|
390
|
+
confidence: integrityConfidence,
|
|
391
|
+
},
|
|
392
|
+
editorial,
|
|
393
|
+
inputs_considered: inputs,
|
|
394
|
+
version: COMBINED_VERDICT_VERSION,
|
|
395
|
+
limitations: dedupe(limitations),
|
|
396
|
+
description: DESCRIPTION,
|
|
397
|
+
};
|
|
398
|
+
assertAxisIndependence(result, input);
|
|
399
|
+
return result;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Contract 1, enforced rather than documented. Publishing a collapsed verdict is
|
|
403
|
+
* a correctness failure, so it throws instead of degrading quietly.
|
|
404
|
+
*
|
|
405
|
+
* The three things that must never happen again:
|
|
406
|
+
* 1. an AI reading that no model produced;
|
|
407
|
+
* 2. an integrity or editorial string that talks about AI or human authorship;
|
|
408
|
+
* 3. an integrity status that character evidence and the watermark scan do not
|
|
409
|
+
* independently justify.
|
|
410
|
+
*/
|
|
411
|
+
export function assertAxisIndependence(result, input) {
|
|
412
|
+
if (!input.model && result.ai_probability.reading !== "not_assessed") {
|
|
413
|
+
throw new Error("combined_verdict_axis_violation: an AI reading was published without a trained model");
|
|
414
|
+
}
|
|
415
|
+
if (!input.model && result.ai_probability.value !== null) {
|
|
416
|
+
throw new Error("combined_verdict_axis_violation: an AI probability was published without a trained model");
|
|
417
|
+
}
|
|
418
|
+
const evidence = result.text_integrity.character_evidence;
|
|
419
|
+
const hasCharacterEvidence = evidence.deliberate.length > 0 || result.text_integrity.watermark.counted_as_evidence ||
|
|
420
|
+
evidence.supporting.filter((x) => { const cp = codePointFromLabel(x.code_point); return cp !== null && isPrivateUse(cp); }).length >= 2;
|
|
421
|
+
if (result.text_integrity.status !== "clean" && !hasCharacterEvidence) {
|
|
422
|
+
throw new Error("combined_verdict_axis_violation: the integrity status was raised without character or watermark evidence");
|
|
423
|
+
}
|
|
424
|
+
// No integrity or editorial string may make an authorship claim. `AI` is
|
|
425
|
+
// matched as a standalone token so ordinary words are not caught.
|
|
426
|
+
const AUTHORSHIP_VOCABULARY = /\b(?:ai[-\s]?(?:like|generated|written|authored)|likely\s+ai|machine[-\s]written|human[-\s]?(?:like|written|authored))\b/i;
|
|
427
|
+
const strings = [
|
|
428
|
+
result.text_integrity.reason,
|
|
429
|
+
...result.text_integrity.findings.map((f) => f.reason),
|
|
430
|
+
result.editorial.reason,
|
|
431
|
+
];
|
|
432
|
+
for (const line of strings) {
|
|
433
|
+
if (AUTHORSHIP_VOCABULARY.test(line)) {
|
|
434
|
+
throw new Error(`combined_verdict_axis_violation: an integrity or editorial string made an authorship claim: ${line.slice(0, 80)}`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
439
|
+
function modelConfidence(model) {
|
|
440
|
+
// Distance from the operating point, not the raw probability: a score sitting
|
|
441
|
+
// on the threshold is the least confident reading the model can give.
|
|
442
|
+
const distance = Math.abs(model.probability - model.threshold);
|
|
443
|
+
if (distance >= 0.3)
|
|
444
|
+
return "high";
|
|
445
|
+
if (distance >= 0.1)
|
|
446
|
+
return "medium";
|
|
447
|
+
return "low";
|
|
448
|
+
}
|
|
449
|
+
function item(finding, tier, rationale) {
|
|
450
|
+
return { finding_id: finding.id, code_point: finding.code_point, name: finding.name, tier, rationale };
|
|
451
|
+
}
|
|
452
|
+
function codePointFromLabel(label) {
|
|
453
|
+
const parsed = Number.parseInt(label.replace(/^U\+/, ""), 16);
|
|
454
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
455
|
+
}
|
|
456
|
+
function codePointOf(finding) {
|
|
457
|
+
if (finding.name === "UNPAIRED SURROGATE")
|
|
458
|
+
return null;
|
|
459
|
+
return codePointFromLabel(finding.code_point);
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Longest run of invisible carriers with no visible character between them.
|
|
463
|
+
* Positions are utf16 indices of carrier findings; adjacency is measured in the
|
|
464
|
+
* source text so a surrogate-pair carrier counts as one link, not two.
|
|
465
|
+
*/
|
|
466
|
+
function longestAdjacentRun(positions, text) {
|
|
467
|
+
if (text === undefined || positions.length === 0)
|
|
468
|
+
return 0;
|
|
469
|
+
const sorted = [...new Set(positions)].sort((a, b) => a - b);
|
|
470
|
+
let best = 1;
|
|
471
|
+
let run = 1;
|
|
472
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
473
|
+
const previous = sorted[i - 1];
|
|
474
|
+
const previousCp = text.codePointAt(previous);
|
|
475
|
+
const width = previousCp !== undefined && previousCp > 0xffff ? 2 : 1;
|
|
476
|
+
if (previous + width === sorted[i]) {
|
|
477
|
+
run++;
|
|
478
|
+
best = Math.max(best, run);
|
|
479
|
+
}
|
|
480
|
+
else
|
|
481
|
+
run = 1;
|
|
482
|
+
}
|
|
483
|
+
return best;
|
|
484
|
+
}
|
|
485
|
+
function describeEvidence(items) {
|
|
486
|
+
const names = [...new Set(items.map((x) => `${x.name} ${x.code_point}`))];
|
|
487
|
+
return names.slice(0, 4).join(", ") + (names.length > 4 ? `, and ${names.length - 4} more` : "");
|
|
488
|
+
}
|
|
489
|
+
function describePayload(run, tags, deliberateCount) {
|
|
490
|
+
const parts = [];
|
|
491
|
+
if (run >= 3)
|
|
492
|
+
parts.push(`${run} invisible carriers sit adjacent to one another with no visible character between them`);
|
|
493
|
+
if (tags >= 2)
|
|
494
|
+
parts.push(`${tags} tag characters form a run outside any flag sequence`);
|
|
495
|
+
if (deliberateCount >= 8)
|
|
496
|
+
parts.push(`${deliberateCount} deliberate carriers are present`);
|
|
497
|
+
return `This text carries the shape of an encoded payload rather than a stray character: ${parts.join("; ")}. It shows the text was written into, not who composed it.`;
|
|
498
|
+
}
|
|
499
|
+
function dedupe(values) {
|
|
500
|
+
return [...new Set(values)];
|
|
501
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opacedev/ai-content-checker-core",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Deterministic offline text checks, invisible-character forensics, reviewed diffs and hash-only receipts for the Opace AI Content Checker & Detector.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ai-checker",
|
|
7
|
+
"ai-detector",
|
|
8
|
+
"ai-content-checker",
|
|
9
|
+
"invisible-characters",
|
|
10
|
+
"receipts",
|
|
11
|
+
"offline",
|
|
12
|
+
"content-integrity"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/bundle.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc -p tsconfig.json && esbuild src/index.ts --bundle --format=esm --platform=neutral --target=es2022 --outfile=dist/bundle.js",
|
|
30
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
31
|
+
"test": "npm run build && node --test ../../tests/core/unit/*.test.mjs ../../tests/core/imports/*.test.mjs ../../tests/core/performance/*.test.mjs",
|
|
32
|
+
"pack:check": "npm pack --dry-run"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@opacedev/ai-content-checker-contracts": "0.3.1",
|
|
36
|
+
"canonicalize": "4.0.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"esbuild": "0.28.2",
|
|
40
|
+
"typescript": "5.9.2"
|
|
41
|
+
},
|
|
42
|
+
"author": {
|
|
43
|
+
"name": "Opace Digital Agency",
|
|
44
|
+
"url": "https://opace.agency/"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://opace.agency/tools/ai/content-verification-integrity/",
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/OpaceDigitalAgency/opace-ai-content-checker-detector/issues"
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public",
|
|
52
|
+
"provenance": true
|
|
53
|
+
},
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=20"
|
|
56
|
+
},
|
|
57
|
+
"repository": {
|
|
58
|
+
"type": "git",
|
|
59
|
+
"url": "git+https://github.com/OpaceDigitalAgency/opace-ai-content-checker-detector.git",
|
|
60
|
+
"directory": "packages/core"
|
|
61
|
+
}
|
|
62
|
+
}
|