@maestroagora/agora 1.2.2 → 1.3.0
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/.agents/plugins/marketplace.json +21 -21
- package/.claude-plugin/marketplace.json +13 -13
- package/.claude-plugin/plugin.json +21 -21
- package/.codex-plugin/plugin.json +33 -33
- package/LICENSE +21 -21
- package/README.md +58 -5
- package/assets/agora-orbit.svg +158 -158
- package/package.json +58 -55
- package/scripts/install.mjs +400 -400
- package/scripts/voice/check.mjs +175 -0
- package/scripts/voice/features.mjs +359 -0
- package/scripts/voice/gates.mjs +244 -0
- package/scripts/voice/ingest.mjs +226 -0
- package/scripts/voice/lexicon.mjs +162 -0
- package/scripts/voice/pipeline.mjs +186 -0
- package/scripts/voice/profile.mjs +528 -0
- package/scripts/voice-measure.mjs +369 -0
- package/skills/agora/SKILL.md +161 -15
- package/skills/agora/references/agora-craft.md +391 -0
- package/skills/agora/references/agora-marketing.md +653 -23
- package/skills/agora/references/agora-voice.md +262 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Adherence checking.
|
|
2
|
+
//
|
|
3
|
+
// The check computes the same features on the draft, using the same frozen
|
|
4
|
+
// pipeline, and reports drift against the profile with the largest deviations
|
|
5
|
+
// first. It reports rhythm, phrase overlap, and provenance separately, because
|
|
6
|
+
// merging them is how a check becomes a claim it cannot support. Author
|
|
7
|
+
// approval is never asserted.
|
|
8
|
+
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
|
|
11
|
+
import { coreFeatureVector, measure } from "./features.mjs";
|
|
12
|
+
import { round, tokenize } from "./pipeline.mjs";
|
|
13
|
+
|
|
14
|
+
// Governance defaults. Engineering settings, not published cutoffs.
|
|
15
|
+
export const REVIEW_DEVIATION_SIGMA = 1.5;
|
|
16
|
+
export const TARGET_MEDIAN_ABSOLUTE_DEVIATION = 0.75;
|
|
17
|
+
export const MAX_FEATURES_IN_REVIEW_SHARE = 0.2;
|
|
18
|
+
export const OVERLAP_TOKEN_RUN = 8;
|
|
19
|
+
|
|
20
|
+
export const DRAFT_BANDS = [
|
|
21
|
+
{ max: 500, label: "local checks only", scored: false, note: "Below roughly 500 words, only local checks are permitted: sentence lengths, openings, punctuation, contractions, paragraph shape, and phrase overlap. No global match score is issued. Governance default." },
|
|
22
|
+
{ max: 2000, label: "provisional", scored: true, note: "Between roughly 500 and 2,000 words, common features are compared with wide tolerances and the result is labelled provisional. Governance default." },
|
|
23
|
+
{ max: 5000, label: "full set with sampling uncertainty", scored: true, note: "Between roughly 2,000 and 5,000 words, the full feature set is compared and sampling uncertainty is reported alongside it. Governance default." },
|
|
24
|
+
{ max: Infinity, label: "full distributional comparison", scored: true, note: "Above 5,000 words a full distributional comparison is defensible when the register matches and the reference corpus is itself adequate." },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export function bandFor(words) {
|
|
28
|
+
return DRAFT_BANDS.find((band) => words <= band.max);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Exact overlap of 8 or more consecutive tokens with the source corpus. The
|
|
33
|
+
* token count is a governance default and an engineering review trigger. It is
|
|
34
|
+
* not a legal safe harbour, and no word count is one. An overlap flag is a
|
|
35
|
+
* prompt to look, not a verdict.
|
|
36
|
+
*/
|
|
37
|
+
function runHash(tokens) {
|
|
38
|
+
return createHash("sha256").update(tokens.join(" ")).digest("hex").slice(0, 16);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build the stored overlap index. Only truncated hashes of each token run are
|
|
43
|
+
* kept, never the prose, so the profile store never becomes a second copy of
|
|
44
|
+
* the author's corpus and cannot serve as a phrase reservoir.
|
|
45
|
+
*/
|
|
46
|
+
export function buildOverlapIndex(corpusDocuments, run = OVERLAP_TOKEN_RUN) {
|
|
47
|
+
const hashes = {};
|
|
48
|
+
for (const document of corpusDocuments) {
|
|
49
|
+
const tokens = document.tokens;
|
|
50
|
+
for (let index = 0; index + run <= tokens.length; index += 1) {
|
|
51
|
+
const key = runHash(tokens.slice(index, index + run));
|
|
52
|
+
if (!(key in hashes)) hashes[key] = document.source;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { run, hashes };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function phraseOverlap(draftText, index) {
|
|
59
|
+
const run = index.run ?? OVERLAP_TOKEN_RUN;
|
|
60
|
+
const draftTokens = tokenize(draftText);
|
|
61
|
+
const hits = new Map();
|
|
62
|
+
for (let position = 0; position + run <= draftTokens.length; position += 1) {
|
|
63
|
+
const slice = draftTokens.slice(position, position + run);
|
|
64
|
+
const key = runHash(slice);
|
|
65
|
+
const source = index.hashes[key];
|
|
66
|
+
if (source && !hits.has(key)) hits.set(key, { phrase: slice.join(" "), source });
|
|
67
|
+
}
|
|
68
|
+
return [...hits.values()];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Compare a draft against a stored profile. Deviations are expressed in profile
|
|
73
|
+
* standard deviations where the profile recorded a spread for that feature, and
|
|
74
|
+
* as a relative difference where it did not.
|
|
75
|
+
*/
|
|
76
|
+
export function compare(draftText, profileMeasured, { registerMatched = true } = {}) {
|
|
77
|
+
const draftMeasured = measure(draftText);
|
|
78
|
+
const words = draftMeasured.counts.tokens;
|
|
79
|
+
const band = bandFor(words);
|
|
80
|
+
|
|
81
|
+
const profileVector = coreFeatureVector(profileMeasured);
|
|
82
|
+
const draftVector = coreFeatureVector(draftMeasured);
|
|
83
|
+
const spread = profileMeasured.sentence_length.standard_deviation;
|
|
84
|
+
|
|
85
|
+
const deviations = [];
|
|
86
|
+
for (const [name, profileValue] of Object.entries(profileVector)) {
|
|
87
|
+
const draftValue = draftVector[name];
|
|
88
|
+
if (profileValue === null || draftValue === null) {
|
|
89
|
+
deviations.push({ feature: name, profile: profileValue, draft: draftValue, deviation: null, basis: "not comparable" });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const scale = name.startsWith("sentence length") && spread ? spread : Math.abs(profileValue) || 1;
|
|
93
|
+
const deviation = Math.abs(draftValue - profileValue) / scale;
|
|
94
|
+
deviations.push({
|
|
95
|
+
feature: name,
|
|
96
|
+
profile: profileValue,
|
|
97
|
+
draft: draftValue,
|
|
98
|
+
deviation: round(deviation, 2),
|
|
99
|
+
basis: name.startsWith("sentence length") && spread ? "profile standard deviations" : "relative to the profile value",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const comparable = deviations.filter((entry) => entry.deviation !== null);
|
|
104
|
+
comparable.sort((left, right) => right.deviation - left.deviation);
|
|
105
|
+
const inReview = comparable.filter((entry) => entry.deviation > REVIEW_DEVIATION_SIGMA);
|
|
106
|
+
const sortedDeviations = [...comparable].map((entry) => entry.deviation).sort((left, right) => left - right);
|
|
107
|
+
const medianDeviation =
|
|
108
|
+
sortedDeviations.length === 0
|
|
109
|
+
? null
|
|
110
|
+
: round(sortedDeviations[Math.floor((sortedDeviations.length - 1) / 2)], 2);
|
|
111
|
+
const reviewShare = comparable.length === 0 ? null : inReview.length / comparable.length;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
words,
|
|
115
|
+
band,
|
|
116
|
+
register_matched: registerMatched,
|
|
117
|
+
comparable_features: comparable.length,
|
|
118
|
+
largest_deviations: comparable.slice(0, 3),
|
|
119
|
+
features_in_review: inReview.map((entry) => entry.feature),
|
|
120
|
+
median_absolute_deviation: medianDeviation,
|
|
121
|
+
thresholds: {
|
|
122
|
+
review_deviation: REVIEW_DEVIATION_SIGMA,
|
|
123
|
+
target_median_absolute_deviation: TARGET_MEDIAN_ABSOLUTE_DEVIATION,
|
|
124
|
+
max_features_in_review_share: MAX_FEATURES_IN_REVIEW_SHARE,
|
|
125
|
+
},
|
|
126
|
+
// A wrong-register comparison produces no score at all.
|
|
127
|
+
verdict: !registerMatched
|
|
128
|
+
? "no score: the draft register does not match a subprofile, and a wrong-register comparison produces no score"
|
|
129
|
+
: !band.scored
|
|
130
|
+
? "no score: local checks only at this draft length"
|
|
131
|
+
: reviewShare > MAX_FEATURES_IN_REVIEW_SHARE || (medianDeviation ?? 0) > TARGET_MEDIAN_ABSOLUTE_DEVIATION
|
|
132
|
+
? "drifted"
|
|
133
|
+
: "within profile",
|
|
134
|
+
all_deviations: comparable,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const OVERLAP_LISTING_LIMIT = 10;
|
|
139
|
+
|
|
140
|
+
function overlapLine(overlaps) {
|
|
141
|
+
if (overlaps.length === 0) return "clear";
|
|
142
|
+
const shown = overlaps.slice(0, OVERLAP_LISTING_LIMIT);
|
|
143
|
+
const more = overlaps.length - shown.length;
|
|
144
|
+
const listing = shown.map((hit) => `\n "${hit.phrase}" (${hit.source})`).join("");
|
|
145
|
+
const tail = more > 0 ? `\n and ${more} more` : "";
|
|
146
|
+
return `flagged ${overlaps.length} run(s) of ${OVERLAP_TOKEN_RUN} or more tokens. An overlap flag is a prompt to look, not a verdict; common phrasing in a technical domain will trip it.${listing}${tail}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Render the four-line report. The last line is never asserted by this tool. */
|
|
150
|
+
export function renderReport(result, overlaps) {
|
|
151
|
+
const rhythm =
|
|
152
|
+
result.verdict === "within profile"
|
|
153
|
+
? "within profile"
|
|
154
|
+
: result.verdict.startsWith("no score")
|
|
155
|
+
? result.verdict
|
|
156
|
+
: `drifted. Largest deviations: ${result.largest_deviations
|
|
157
|
+
.map((entry) => `${entry.feature} (draft ${entry.draft} against profile ${entry.profile}, ${entry.deviation} ${entry.basis})`)
|
|
158
|
+
.join("; ")}`;
|
|
159
|
+
|
|
160
|
+
return [
|
|
161
|
+
`Rhythm and syntax match: ${rhythm}`,
|
|
162
|
+
`Phrase-overlap check: ${overlapLine(overlaps)}`,
|
|
163
|
+
"Content provenance: not checked by this tool. Trace every checkable claim to the brief or a source before publication.",
|
|
164
|
+
"Author approval: not established by this tool.",
|
|
165
|
+
"",
|
|
166
|
+
result.band.note,
|
|
167
|
+
result.verdict.startsWith("no score")
|
|
168
|
+
? ""
|
|
169
|
+
: `Median absolute deviation across ${result.comparable_features} core features: ${result.median_absolute_deviation} against a governance-default target of ${TARGET_MEDIAN_ABSOLUTE_DEVIATION}. ${result.features_in_review.length} feature(s) in review.`,
|
|
170
|
+
"",
|
|
171
|
+
"A feature match does not prove the writing sounds right to its author. Stylometry is optimized for measurable differentiation, not subjective approval. Never treat a detector score as evidence of authorship.",
|
|
172
|
+
]
|
|
173
|
+
.filter((line) => line !== "")
|
|
174
|
+
.join("\n");
|
|
175
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// The measured feature families.
|
|
2
|
+
//
|
|
3
|
+
// Stylometry has no single fingerprint. These families are partially
|
|
4
|
+
// discriminative on their own and are recorded as a bundle, never as a
|
|
5
|
+
// fingerprint. Anything the corpus is too small to estimate is returned as
|
|
6
|
+
// null so the profile can write "insufficient data" rather than guess.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
BOOSTERS,
|
|
10
|
+
CONTRACTION_PAIRS,
|
|
11
|
+
FUNCTION_WORDS,
|
|
12
|
+
HEDGES,
|
|
13
|
+
MODALS,
|
|
14
|
+
OPENING_CLASSES,
|
|
15
|
+
PERSON_CLASSES,
|
|
16
|
+
classSet,
|
|
17
|
+
} from "./lexicon.mjs";
|
|
18
|
+
import {
|
|
19
|
+
mean,
|
|
20
|
+
percentile,
|
|
21
|
+
round,
|
|
22
|
+
segmentParagraphs,
|
|
23
|
+
segmentSentences,
|
|
24
|
+
standardDeviation,
|
|
25
|
+
tokenize,
|
|
26
|
+
} from "./pipeline.mjs";
|
|
27
|
+
|
|
28
|
+
// Governance defaults for the minimum observations a statistic needs before it
|
|
29
|
+
// is reported. Below these counts the value is unstable rather than merely
|
|
30
|
+
// noisy, so it is withheld.
|
|
31
|
+
export const MIN_SENTENCES_FOR_SPREAD = 30;
|
|
32
|
+
export const MIN_SENTENCES_FOR_TAILS = 60;
|
|
33
|
+
export const MIN_PARAGRAPHS_FOR_SPREAD = 20;
|
|
34
|
+
export const MATTR_WINDOW = 500;
|
|
35
|
+
export const MTLD_THRESHOLD = 0.72;
|
|
36
|
+
|
|
37
|
+
const HEDGE_SET = classSet(HEDGES);
|
|
38
|
+
const BOOSTER_SET = classSet(BOOSTERS);
|
|
39
|
+
const MODAL_SET = classSet(MODALS);
|
|
40
|
+
|
|
41
|
+
function rate(count, total, per) {
|
|
42
|
+
if (!total) return null;
|
|
43
|
+
return round((count / total) * per, 2);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function countIn(tokens, set) {
|
|
47
|
+
return tokens.reduce((total, token) => total + (set.has(token) ? 1 : 0), 0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Moving-average type-token ratio. Raw ratio is never reported on its own. */
|
|
51
|
+
export function movingAverageTypeTokenRatio(tokens, window = MATTR_WINDOW) {
|
|
52
|
+
if (tokens.length < window) return null;
|
|
53
|
+
const counts = new Map();
|
|
54
|
+
let types = 0;
|
|
55
|
+
let total = 0;
|
|
56
|
+
let windows = 0;
|
|
57
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
58
|
+
const entering = tokens[index];
|
|
59
|
+
const enteringCount = counts.get(entering) || 0;
|
|
60
|
+
if (enteringCount === 0) types += 1;
|
|
61
|
+
counts.set(entering, enteringCount + 1);
|
|
62
|
+
if (index >= window) {
|
|
63
|
+
const leaving = tokens[index - window];
|
|
64
|
+
const leavingCount = counts.get(leaving);
|
|
65
|
+
if (leavingCount === 1) types -= 1;
|
|
66
|
+
counts.set(leaving, leavingCount - 1);
|
|
67
|
+
}
|
|
68
|
+
if (index >= window - 1) {
|
|
69
|
+
total += types / window;
|
|
70
|
+
windows += 1;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return windows === 0 ? null : round(total / windows, 3);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function mtldPass(tokens) {
|
|
77
|
+
let factors = 0;
|
|
78
|
+
let types = new Set();
|
|
79
|
+
let counted = 0;
|
|
80
|
+
let ratio = 1;
|
|
81
|
+
for (const token of tokens) {
|
|
82
|
+
counted += 1;
|
|
83
|
+
types.add(token);
|
|
84
|
+
ratio = types.size / counted;
|
|
85
|
+
if (ratio <= MTLD_THRESHOLD) {
|
|
86
|
+
factors += 1;
|
|
87
|
+
types = new Set();
|
|
88
|
+
counted = 0;
|
|
89
|
+
ratio = 1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (counted > 0 && ratio < 1) factors += (1 - ratio) / (1 - MTLD_THRESHOLD);
|
|
93
|
+
return factors === 0 ? null : tokens.length / factors;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Decay-based lexical diversity, forward and backward passes averaged. */
|
|
97
|
+
export function measureOfTextualLexicalDiversity(tokens) {
|
|
98
|
+
if (tokens.length < 100) return null;
|
|
99
|
+
const forward = mtldPass(tokens);
|
|
100
|
+
const backward = mtldPass([...tokens].reverse());
|
|
101
|
+
if (forward === null || backward === null) return null;
|
|
102
|
+
return round((forward + backward) / 2, 2);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function distribution(values, { minimum, tailMinimum, places = 2 }) {
|
|
106
|
+
if (values.length === 0) return { observations: 0 };
|
|
107
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
108
|
+
const average = mean(sorted);
|
|
109
|
+
const spread = values.length >= minimum ? standardDeviation(sorted) : null;
|
|
110
|
+
const tails = values.length >= tailMinimum;
|
|
111
|
+
return {
|
|
112
|
+
observations: values.length,
|
|
113
|
+
mean: round(average, places),
|
|
114
|
+
median: round(percentile(sorted, 0.5), places),
|
|
115
|
+
standard_deviation: round(spread, places),
|
|
116
|
+
coefficient_of_variation: spread === null || !average ? null : round(spread / average, 3),
|
|
117
|
+
p10: tails ? round(percentile(sorted, 0.1), places) : null,
|
|
118
|
+
p90: tails ? round(percentile(sorted, 0.9), places) : null,
|
|
119
|
+
min: sorted[0],
|
|
120
|
+
max: sorted[sorted.length - 1],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function sentenceLengthBins(lengths) {
|
|
125
|
+
if (lengths.length === 0) return null;
|
|
126
|
+
const edges = [8, 15, 25, 35];
|
|
127
|
+
const labels = ["under_8", "8_to_14", "15_to_24", "25_to_34", "35_and_over"];
|
|
128
|
+
const counts = new Array(labels.length).fill(0);
|
|
129
|
+
for (const length of lengths) {
|
|
130
|
+
let bin = edges.length;
|
|
131
|
+
for (let index = 0; index < edges.length; index += 1) {
|
|
132
|
+
if (length < edges[index]) {
|
|
133
|
+
bin = index;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
counts[bin] += 1;
|
|
138
|
+
}
|
|
139
|
+
return Object.fromEntries(labels.map((label, index) => [label, rate(counts[index], lengths.length, 100)]));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Clause structure, computed as a conjunction and punctuation proxy. No
|
|
144
|
+
* dependency parser ships here, so these are labelled proxies everywhere they
|
|
145
|
+
* appear and must never be compared against parser-derived figures.
|
|
146
|
+
*/
|
|
147
|
+
function clauseProxy(sentences) {
|
|
148
|
+
const subordinators = classSet(OPENING_CLASSES.subordinator);
|
|
149
|
+
const coordinators = classSet(OPENING_CLASSES.coordinator);
|
|
150
|
+
let units = 0;
|
|
151
|
+
let subordinate = 0;
|
|
152
|
+
for (const sentence of sentences) {
|
|
153
|
+
const tokens = tokenize(sentence);
|
|
154
|
+
const subordinateHere = countIn(tokens, subordinators);
|
|
155
|
+
const coordinateHere = countIn(tokens, coordinators);
|
|
156
|
+
units += 1 + subordinateHere + coordinateHere;
|
|
157
|
+
subordinate += subordinateHere;
|
|
158
|
+
}
|
|
159
|
+
if (sentences.length === 0) return { clause_units_per_sentence: null, subordination_ratio: null };
|
|
160
|
+
return {
|
|
161
|
+
clause_units_per_sentence: round(units / sentences.length, 2),
|
|
162
|
+
subordination_ratio: units === 0 ? null : round(subordinate / units, 3),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Checked in this order: the first matching class wins, so a word appearing in
|
|
167
|
+
// two lists is always resolved the same way.
|
|
168
|
+
const OPENING_ORDER = [
|
|
169
|
+
"coordinator",
|
|
170
|
+
"subordinator",
|
|
171
|
+
"question_word",
|
|
172
|
+
"discourse_marker",
|
|
173
|
+
"subject_pronoun",
|
|
174
|
+
"expletive",
|
|
175
|
+
"determiner",
|
|
176
|
+
"adverbial",
|
|
177
|
+
"preposition",
|
|
178
|
+
];
|
|
179
|
+
const OPENING_SETS = OPENING_ORDER.map((name) => [name, classSet(OPENING_CLASSES[name])]);
|
|
180
|
+
|
|
181
|
+
function openingClass(sentence) {
|
|
182
|
+
const tokens = tokenize(sentence);
|
|
183
|
+
if (tokens.length === 0) return "other";
|
|
184
|
+
const first = tokens[0];
|
|
185
|
+
for (const [name, words] of OPENING_SETS) {
|
|
186
|
+
if (words.has(first)) return name;
|
|
187
|
+
}
|
|
188
|
+
return "other";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function openings(sentences) {
|
|
192
|
+
const counts = new Map();
|
|
193
|
+
let repeats = 0;
|
|
194
|
+
let previous = null;
|
|
195
|
+
for (const sentence of sentences) {
|
|
196
|
+
const name = openingClass(sentence);
|
|
197
|
+
counts.set(name, (counts.get(name) || 0) + 1);
|
|
198
|
+
if (name === previous) repeats += 1;
|
|
199
|
+
previous = name;
|
|
200
|
+
}
|
|
201
|
+
const shares = Object.fromEntries(
|
|
202
|
+
[...counts.entries()]
|
|
203
|
+
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
|
|
204
|
+
.map(([name, count]) => [name, rate(count, sentences.length, 100)]),
|
|
205
|
+
);
|
|
206
|
+
return {
|
|
207
|
+
class_share_percent: shares,
|
|
208
|
+
distinct_classes: counts.size,
|
|
209
|
+
consecutive_repeat_percent: sentences.length < 2 ? null : rate(repeats, sentences.length - 1, 100),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Contraction rate inside contexts where both forms were grammatical. A raw
|
|
215
|
+
* count is not comparable across registers; an opportunity-scoped rate is.
|
|
216
|
+
*/
|
|
217
|
+
function contractions(tokens) {
|
|
218
|
+
const joined = ` ${tokens.join(" ")} `;
|
|
219
|
+
let contracted = 0;
|
|
220
|
+
let expanded = 0;
|
|
221
|
+
for (const pair of CONTRACTION_PAIRS) {
|
|
222
|
+
for (const form of pair.contracted) {
|
|
223
|
+
contracted += (joined.split(` ${form} `).length - 1);
|
|
224
|
+
}
|
|
225
|
+
for (const words of pair.expanded) {
|
|
226
|
+
expanded += (joined.split(` ${words.join(" ")} `).length - 1);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const eligible = contracted + expanded;
|
|
230
|
+
return {
|
|
231
|
+
eligible_contexts: eligible,
|
|
232
|
+
contracted,
|
|
233
|
+
rate_percent: eligible < 20 ? null : rate(contracted, eligible, 100),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function punctuation(text, tokenCount) {
|
|
238
|
+
const marks = {
|
|
239
|
+
comma: /,/g,
|
|
240
|
+
semicolon: /;/g,
|
|
241
|
+
colon: /:/g,
|
|
242
|
+
parenthesis: /\(/g,
|
|
243
|
+
exclamation: /!/g,
|
|
244
|
+
question: /\?/g,
|
|
245
|
+
dash_hyphen: / - /g,
|
|
246
|
+
};
|
|
247
|
+
const output = {};
|
|
248
|
+
for (const [name, pattern] of Object.entries(marks)) {
|
|
249
|
+
output[name] = rate((text.match(pattern) || []).length, tokenCount, 1000);
|
|
250
|
+
}
|
|
251
|
+
return output;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function functionWords(tokens) {
|
|
255
|
+
const output = {};
|
|
256
|
+
for (const [name, words] of Object.entries(FUNCTION_WORDS)) {
|
|
257
|
+
output[name] = rate(countIn(tokens, classSet(words)), tokens.length, 1000);
|
|
258
|
+
}
|
|
259
|
+
return output;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function personAndStance(tokens, sentenceCount, questionCount) {
|
|
263
|
+
const person = {};
|
|
264
|
+
for (const [name, words] of Object.entries(PERSON_CLASSES)) {
|
|
265
|
+
person[name] = rate(countIn(tokens, classSet(words)), tokens.length, 1000);
|
|
266
|
+
}
|
|
267
|
+
const hedges = countIn(tokens, HEDGE_SET);
|
|
268
|
+
const boosters = countIn(tokens, BOOSTER_SET);
|
|
269
|
+
return {
|
|
270
|
+
person,
|
|
271
|
+
hedges_per_1000: rate(hedges, tokens.length, 1000),
|
|
272
|
+
boosters_per_1000: rate(boosters, tokens.length, 1000),
|
|
273
|
+
modals_per_1000: rate(countIn(tokens, MODAL_SET), tokens.length, 1000),
|
|
274
|
+
hedge_to_booster_ratio: boosters === 0 ? null : round(hedges / boosters, 3),
|
|
275
|
+
questions_per_100_sentences: rate(questionCount, sentenceCount, 100),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Measure one body of text. Returns every family plus its observation counts. */
|
|
280
|
+
export function measure(text) {
|
|
281
|
+
const paragraphs = segmentParagraphs(text);
|
|
282
|
+
const sentences = paragraphs.flatMap((paragraph) => segmentSentences(paragraph));
|
|
283
|
+
const tokens = tokenize(text);
|
|
284
|
+
const sentenceLengths = sentences.map((sentence) => tokenize(sentence).length);
|
|
285
|
+
const paragraphSentenceCounts = paragraphs.map((paragraph) => segmentSentences(paragraph).length);
|
|
286
|
+
const paragraphWordCounts = paragraphs.map((paragraph) => tokenize(paragraph).length);
|
|
287
|
+
const questionCount = sentences.filter((sentence) => sentence.trim().endsWith("?")).length;
|
|
288
|
+
|
|
289
|
+
const stance = personAndStance(tokens, sentences.length, questionCount);
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
counts: { tokens: tokens.length, sentences: sentences.length, paragraphs: paragraphs.length },
|
|
293
|
+
sentence_length: {
|
|
294
|
+
...distribution(sentenceLengths, {
|
|
295
|
+
minimum: MIN_SENTENCES_FOR_SPREAD,
|
|
296
|
+
tailMinimum: MIN_SENTENCES_FOR_TAILS,
|
|
297
|
+
}),
|
|
298
|
+
bins_percent: sentences.length >= MIN_SENTENCES_FOR_SPREAD ? sentenceLengthBins(sentenceLengths) : null,
|
|
299
|
+
},
|
|
300
|
+
clause_structure_proxy: clauseProxy(sentences),
|
|
301
|
+
function_words_per_1000: functionWords(tokens),
|
|
302
|
+
punctuation_per_1000: punctuation(text, tokens.length),
|
|
303
|
+
paragraph_shape: {
|
|
304
|
+
sentences: distribution(paragraphSentenceCounts, {
|
|
305
|
+
minimum: MIN_PARAGRAPHS_FOR_SPREAD,
|
|
306
|
+
tailMinimum: MIN_PARAGRAPHS_FOR_SPREAD,
|
|
307
|
+
}),
|
|
308
|
+
words: distribution(paragraphWordCounts, {
|
|
309
|
+
minimum: MIN_PARAGRAPHS_FOR_SPREAD,
|
|
310
|
+
tailMinimum: MIN_PARAGRAPHS_FOR_SPREAD,
|
|
311
|
+
}),
|
|
312
|
+
one_sentence_share_percent: rate(
|
|
313
|
+
paragraphSentenceCounts.filter((count) => count === 1).length,
|
|
314
|
+
paragraphs.length,
|
|
315
|
+
100,
|
|
316
|
+
),
|
|
317
|
+
},
|
|
318
|
+
lexical_diversity: {
|
|
319
|
+
mattr_window: MATTR_WINDOW,
|
|
320
|
+
mattr: movingAverageTypeTokenRatio(tokens),
|
|
321
|
+
mtld: measureOfTextualLexicalDiversity(tokens),
|
|
322
|
+
raw_type_token_ratio_withheld:
|
|
323
|
+
"raw ratio falls mechanically as texts grow and is never reported across unequal lengths",
|
|
324
|
+
},
|
|
325
|
+
person_and_stance: stance,
|
|
326
|
+
sentence_openings: openings(sentences),
|
|
327
|
+
contractions: contractions(tokens),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The subset of features `voice check` compares. Each is a scalar. */
|
|
332
|
+
export function coreFeatureVector(measured) {
|
|
333
|
+
return {
|
|
334
|
+
"sentence length mean": measured.sentence_length.mean,
|
|
335
|
+
"sentence length median": measured.sentence_length.median,
|
|
336
|
+
"sentence length standard deviation": measured.sentence_length.standard_deviation,
|
|
337
|
+
"clause units per sentence (proxy)": measured.clause_structure_proxy.clause_units_per_sentence,
|
|
338
|
+
"subordination ratio (proxy)": measured.clause_structure_proxy.subordination_ratio,
|
|
339
|
+
"articles per 1000": measured.function_words_per_1000.articles,
|
|
340
|
+
"prepositions per 1000": measured.function_words_per_1000.prepositions,
|
|
341
|
+
"auxiliaries per 1000": measured.function_words_per_1000.auxiliaries,
|
|
342
|
+
"conjunctions per 1000": measured.function_words_per_1000.conjunctions,
|
|
343
|
+
"pronouns per 1000": measured.function_words_per_1000.pronouns,
|
|
344
|
+
"commas per 1000": measured.punctuation_per_1000.comma,
|
|
345
|
+
"semicolons per 1000": measured.punctuation_per_1000.semicolon,
|
|
346
|
+
"colons per 1000": measured.punctuation_per_1000.colon,
|
|
347
|
+
"parentheses per 1000": measured.punctuation_per_1000.parenthesis,
|
|
348
|
+
"questions per 100 sentences": measured.person_and_stance.questions_per_100_sentences,
|
|
349
|
+
"first person singular per 1000": measured.person_and_stance.person.first_singular,
|
|
350
|
+
"first person plural per 1000": measured.person_and_stance.person.first_plural,
|
|
351
|
+
"second person per 1000": measured.person_and_stance.person.second,
|
|
352
|
+
"hedges per 1000": measured.person_and_stance.hedges_per_1000,
|
|
353
|
+
"boosters per 1000": measured.person_and_stance.boosters_per_1000,
|
|
354
|
+
"paragraph sentences mean": measured.paragraph_shape.sentences.mean,
|
|
355
|
+
"one-sentence paragraph share": measured.paragraph_shape.one_sentence_share_percent,
|
|
356
|
+
"contraction rate percent": measured.contractions.rate_percent,
|
|
357
|
+
"moving-window lexical diversity": measured.lexical_diversity.mattr,
|
|
358
|
+
};
|
|
359
|
+
}
|