@maestroagora/agora 1.2.2 → 1.5.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.
@@ -0,0 +1,528 @@
1
+ // Profile rendering.
2
+ //
3
+ // Measurement, interpretation, calibration, and source excerpts stay separate.
4
+ // The profile leads with numbers because a number is checkable and a later
5
+ // draft can be measured against it. Adjectives belong in the interpretation
6
+ // sections, underneath the measurements they interpret.
7
+ //
8
+ // Nothing in this module is authored prose about a person. Every line is either
9
+ // a computed value, a stated governance default, or an explicit statement that
10
+ // the corpus could not supply the value.
11
+
12
+ import { FUNCTION_WORDS, GENERIC_AI_VOCABULARY, classSet } from "./lexicon.mjs";
13
+ import { round, segmentParagraphs, segmentSentences, tokenize } from "./pipeline.mjs";
14
+
15
+ const OWNED_MIN_DOCUMENTS = 3;
16
+ const OWNED_MIN_RATE_PER_1000 = 0.1;
17
+ const EXCERPT_TARGET = 8;
18
+ const EXCERPT_MAX_CHARACTERS = 420;
19
+ const AI_VOCABULARY = classSet(GENERIC_AI_VOCABULARY);
20
+ // Function words are reported as rates in `## Measured`. Repeating them as
21
+ // recurring vocabulary would bury the content words the section is for.
22
+ const ALL_FUNCTION_WORDS = classSet(Object.values(FUNCTION_WORDS).flat());
23
+
24
+ const INSUFFICIENT = "insufficient data";
25
+
26
+ function value(input, suffix = "") {
27
+ if (input === null || input === undefined) return INSUFFICIENT;
28
+ return `${input}${suffix}`;
29
+ }
30
+
31
+ function table(rows) {
32
+ return ["| Feature | Value | Observations |", "|---|---|---|", ...rows].join("\n");
33
+ }
34
+
35
+ function row(name, input, observations, suffix = "") {
36
+ return `| ${name} | ${value(input, suffix)} | ${observations} |`;
37
+ }
38
+
39
+ /**
40
+ * Words measured as this author's own. Only a word that recurs across
41
+ * independent documents qualifies; a word the draft wanted does not. Below the
42
+ * certification floor no list is issued at all, because a word cannot be shown
43
+ * to recur across genres in a corpus that has one.
44
+ */
45
+ export function ownedVocabulary(documents, certified) {
46
+ if (!certified) return { allowlist: [], distinctive: [], withheld: true };
47
+ const totals = new Map();
48
+ const documentCounts = new Map();
49
+ const registers = new Map();
50
+ let tokenTotal = 0;
51
+
52
+ for (const document of documents) {
53
+ tokenTotal += document.tokens.length;
54
+ const seen = new Set();
55
+ for (const token of document.tokens) {
56
+ totals.set(token, (totals.get(token) || 0) + 1);
57
+ seen.add(token);
58
+ }
59
+ for (const token of seen) {
60
+ documentCounts.set(token, (documentCounts.get(token) || 0) + 1);
61
+ if (!registers.has(token)) registers.set(token, new Set());
62
+ registers.get(token).add(document.register || "unlabelled");
63
+ }
64
+ }
65
+
66
+ const labelledRegisters = new Set(documents.map((document) => document.register || "unlabelled"));
67
+ const needsTwoRegisters = labelledRegisters.size > 1;
68
+ const qualifying = [];
69
+ for (const [token, count] of totals) {
70
+ const perThousand = (count / tokenTotal) * 1000;
71
+ const documentsWith = documentCounts.get(token) || 0;
72
+ const registersWith = registers.get(token)?.size || 0;
73
+ if (documentsWith < OWNED_MIN_DOCUMENTS) continue;
74
+ if (perThousand < OWNED_MIN_RATE_PER_1000) continue;
75
+ if (needsTwoRegisters && registersWith < 2) continue;
76
+ qualifying.push({ token, count, documents: documentsWith, registers: registersWith, per_1000: round(perThousand, 2) });
77
+ }
78
+ qualifying.sort((left, right) => right.per_1000 - left.per_1000 || left.token.localeCompare(right.token));
79
+
80
+ return {
81
+ withheld: false,
82
+ // The allowlist is the intersection with the generic ban list. Only a word
83
+ // the tell gate would otherwise strip needs an exception written down.
84
+ allowlist: qualifying.filter((entry) => AI_VOCABULARY.has(entry.token)),
85
+ distinctive: qualifying
86
+ .filter((entry) => !AI_VOCABULARY.has(entry.token) && !ALL_FUNCTION_WORDS.has(entry.token))
87
+ .slice(0, 40),
88
+ };
89
+ }
90
+
91
+ /**
92
+ * An avoidance is recorded only where a stable alternative appears in repeated
93
+ * eligible contexts. Absence alone is weak evidence and is never recorded.
94
+ */
95
+ export function measuredAvoidances(measured) {
96
+ const contractions = measured.contractions;
97
+ if (contractions.eligible_contexts < 20 || contractions.rate_percent === null) return [];
98
+ if (contractions.rate_percent <= 5) {
99
+ return [
100
+ `Contracted forms, in ${contractions.eligible_contexts} eligible contexts where both forms were grammatical. The expanded form is the stable alternative at ${contractions.rate_percent} percent contraction.`,
101
+ ];
102
+ }
103
+ if (contractions.rate_percent >= 95) {
104
+ return [
105
+ `Expanded forms, in ${contractions.eligible_contexts} eligible contexts. The contracted form is the stable alternative at ${contractions.rate_percent} percent contraction.`,
106
+ ];
107
+ }
108
+ return [];
109
+ }
110
+
111
+ function excerptCandidates(documents) {
112
+ const candidates = [];
113
+ for (const document of documents) {
114
+ for (const paragraph of segmentParagraphs(document.text)) {
115
+ const sentences = segmentSentences(paragraph);
116
+ const words = tokenize(paragraph).length;
117
+ if (words < 25 || paragraph.length > EXCERPT_MAX_CHARACTERS) continue;
118
+ candidates.push({
119
+ source: document.source,
120
+ register: document.register || "unlabelled",
121
+ text: paragraph,
122
+ words,
123
+ sentences: sentences.length,
124
+ mean_sentence_length: sentences.length === 0 ? 0 : words / sentences.length,
125
+ });
126
+ }
127
+ }
128
+ return candidates;
129
+ }
130
+
131
+ /**
132
+ * Excerpts are stratified, not curated: selected by register, source, and
133
+ * distance from the profile's own central tendency rather than by quality. A
134
+ * profile built only from an author's strongest work encodes an exceptional
135
+ * performance as the central tendency and produces drafts the author does not
136
+ * recognize. At least one deliberately weaker passage is included.
137
+ */
138
+ export function selectExcerpts(documents, measured) {
139
+ const candidates = excerptCandidates(documents);
140
+ if (candidates.length === 0) return [];
141
+ const target = measured.sentence_length.mean ?? 0;
142
+ const scored = candidates.map((candidate) => ({
143
+ ...candidate,
144
+ deviation: Math.abs(candidate.mean_sentence_length - target),
145
+ }));
146
+
147
+ const selected = [];
148
+ const usedSources = new Set();
149
+ const byRegister = new Map();
150
+ for (const candidate of scored) {
151
+ if (!byRegister.has(candidate.register)) byRegister.set(candidate.register, []);
152
+ byRegister.get(candidate.register).push(candidate);
153
+ }
154
+
155
+ // One typical passage per register first, then one per remaining source, so
156
+ // the set spans the corpus before it fills up.
157
+ for (const [, group] of [...byRegister.entries()].sort((left, right) => left[0].localeCompare(right[0]))) {
158
+ const typical = [...group].sort((left, right) => left.deviation - right.deviation)[0];
159
+ if (typical && !usedSources.has(typical.source)) {
160
+ selected.push({ ...typical, demonstrates: `typical cadence for the ${typical.register} register` });
161
+ usedSources.add(typical.source);
162
+ }
163
+ }
164
+ for (const candidate of [...scored].sort((left, right) => left.deviation - right.deviation)) {
165
+ if (selected.length >= EXCERPT_TARGET - 1) break;
166
+ if (usedSources.has(candidate.source)) continue;
167
+ selected.push({ ...candidate, demonstrates: "central tendency, sampled by source rather than by quality" });
168
+ usedSources.add(candidate.source);
169
+ }
170
+
171
+ const weakest = [...scored].sort((left, right) => right.deviation - left.deviation)[0];
172
+ if (weakest) {
173
+ selected.push({
174
+ ...weakest,
175
+ demonstrates: "deliberately included weaker passage: the furthest from the profile's central tendency",
176
+ });
177
+ }
178
+ return selected.slice(0, EXCERPT_TARGET);
179
+ }
180
+
181
+ function measuredSection(measured) {
182
+ const sentence = measured.sentence_length;
183
+ const paragraph = measured.paragraph_shape;
184
+ const stance = measured.person_and_stance;
185
+ const sentenceObservations = `${sentence.observations} sentences`;
186
+ const tokenObservations = `${measured.counts.tokens} tokens`;
187
+
188
+ const blocks = [
189
+ "### Sentence length",
190
+ "",
191
+ table([
192
+ row("Mean", sentence.mean, sentenceObservations, " words"),
193
+ row("Median", sentence.median, sentenceObservations, " words"),
194
+ row("Standard deviation", sentence.standard_deviation, sentenceObservations),
195
+ row("Coefficient of variation", sentence.coefficient_of_variation, sentenceObservations),
196
+ row("10th percentile", sentence.p10, sentenceObservations, " words"),
197
+ row("90th percentile", sentence.p90, sentenceObservations, " words"),
198
+ row("Shortest", sentence.min, sentenceObservations, " words"),
199
+ row("Longest", sentence.max, sentenceObservations, " words"),
200
+ ]),
201
+ "",
202
+ "Binned shape:",
203
+ "",
204
+ sentence.bins_percent
205
+ ? table(
206
+ Object.entries(sentence.bins_percent).map(([bin, share]) =>
207
+ row(bin.replaceAll("_", " "), share, sentenceObservations, " percent"),
208
+ ),
209
+ )
210
+ : `Binned shape: ${INSUFFICIENT}. Tail and shape statistics are unstable on very few observations.`,
211
+ "",
212
+ "### Clause structure (proxy)",
213
+ "",
214
+ "No dependency parser ships with this pipeline. The two figures below are conjunction and punctuation proxies. Never compare them against parser-derived numbers.",
215
+ "",
216
+ table([
217
+ row("Clause units per sentence", measured.clause_structure_proxy.clause_units_per_sentence, sentenceObservations),
218
+ row("Subordination ratio", measured.clause_structure_proxy.subordination_ratio, sentenceObservations),
219
+ ]),
220
+ "",
221
+ "### Function words, per thousand tokens",
222
+ "",
223
+ table(
224
+ Object.entries(measured.function_words_per_1000).map(([name, input]) =>
225
+ row(name, input, tokenObservations),
226
+ ),
227
+ ),
228
+ "",
229
+ "### Punctuation, per thousand tokens",
230
+ "",
231
+ table(
232
+ Object.entries(measured.punctuation_per_1000).map(([name, input]) =>
233
+ row(name.replaceAll("_", " "), input, tokenObservations),
234
+ ),
235
+ ),
236
+ "",
237
+ "### Paragraph shape",
238
+ "",
239
+ table([
240
+ row("Sentences per paragraph, mean", paragraph.sentences.mean, `${paragraph.sentences.observations} paragraphs`),
241
+ row("Sentences per paragraph, median", paragraph.sentences.median, `${paragraph.sentences.observations} paragraphs`),
242
+ row("Words per paragraph, mean", paragraph.words.mean, `${paragraph.words.observations} paragraphs`),
243
+ row("One-sentence paragraphs", paragraph.one_sentence_share_percent, `${paragraph.sentences.observations} paragraphs`, " percent"),
244
+ ]),
245
+ "",
246
+ "### Lexical diversity",
247
+ "",
248
+ "Raw type-token ratio is withheld by rule: it falls mechanically as texts grow, so a comparison across unequal lengths is an artifact.",
249
+ "",
250
+ table([
251
+ row(`Moving-window ratio, ${measured.lexical_diversity.mattr_window}-token window`, measured.lexical_diversity.mattr, tokenObservations),
252
+ row("Decay-based measure", measured.lexical_diversity.mtld, tokenObservations),
253
+ ]),
254
+ "",
255
+ "### Person and stance",
256
+ "",
257
+ table([
258
+ ...Object.entries(stance.person).map(([name, input]) =>
259
+ row(`${name.replaceAll("_", " ")}, per 1000`, input, tokenObservations),
260
+ ),
261
+ row("Hedges, per 1000", stance.hedges_per_1000, tokenObservations),
262
+ row("Boosters, per 1000", stance.boosters_per_1000, tokenObservations),
263
+ row("Modals, per 1000", stance.modals_per_1000, tokenObservations),
264
+ row("Hedge to booster ratio", stance.hedge_to_booster_ratio, tokenObservations),
265
+ row("Questions, per 100 sentences", stance.questions_per_100_sentences, sentenceObservations),
266
+ ]),
267
+ "",
268
+ "### Sentence openings",
269
+ "",
270
+ table([
271
+ ...Object.entries(measured.sentence_openings.class_share_percent).map(([name, share]) =>
272
+ row(`opens with ${name.replaceAll("_", " ")}`, share, sentenceObservations, " percent"),
273
+ ),
274
+ row("Distinct opening classes", measured.sentence_openings.distinct_classes, sentenceObservations),
275
+ row("Consecutive same-class openings", measured.sentence_openings.consecutive_repeat_percent, sentenceObservations, " percent"),
276
+ ]),
277
+ "",
278
+ "### Contractions",
279
+ "",
280
+ table([
281
+ row("Eligible contexts", measured.contractions.eligible_contexts, "both forms grammatical"),
282
+ row("Contracted", measured.contractions.contracted, "count"),
283
+ row("Rate within eligible contexts", measured.contractions.rate_percent, "opportunity-scoped", " percent"),
284
+ ]),
285
+ ];
286
+ return blocks.join("\n");
287
+ }
288
+
289
+ function structuralHabits(measured, gates) {
290
+ const openings = measured.sentence_openings;
291
+ const dominant = Object.entries(openings.class_share_percent)[0];
292
+ const paragraph = measured.paragraph_shape;
293
+ const lines = [
294
+ "Computed from the corpus. Each statement below restates a measured value; nothing here is inferred about the author's intent.",
295
+ "",
296
+ `- Opening habit: the most frequent first constituent is ${dominant ? `${dominant[0].replaceAll("_", " ")} at ${dominant[1]} percent of sentences` : INSUFFICIENT}. The corpus uses ${openings.distinct_classes} distinct opening classes, and ${value(openings.consecutive_repeat_percent, " percent")} of adjacent sentence pairs open with the same class.`,
297
+ `- Paragraph handling: ${value(paragraph.sentences.mean)} sentences per paragraph on average, with ${value(paragraph.one_sentence_share_percent, " percent")} of paragraphs carrying a single sentence.`,
298
+ `- Pacing spread: a coefficient of variation of ${value(measured.sentence_length.coefficient_of_variation)} across ${measured.sentence_length.observations} sentences. This is the clearest available check for uniform pacing.`,
299
+ `- Question use: ${value(measured.person_and_stance.questions_per_100_sentences)} questions per 100 sentences.`,
300
+ "",
301
+ "The following structural habits cannot be derived from the frozen pipeline alone, because they need a parser or a human reading: where the qualifier sits inside a sentence, whether the conclusion is front-loaded, how a list is introduced, and how long the piece waits before its first concrete example. They are listed in `## Not captured` rather than guessed.",
302
+ ];
303
+ if (gates.registers.filter((entry) => entry.numeric).length > 1) {
304
+ lines.push(
305
+ "",
306
+ "Register overrides exist. Apply the subprofile that matches the register being written, never the pooled numbers alone.",
307
+ );
308
+ }
309
+ return lines.join("\n");
310
+ }
311
+
312
+ function calibrationSection(measured) {
313
+ const stance = measured.person_and_stance;
314
+ const certainty =
315
+ stance.hedge_to_booster_ratio === null
316
+ ? INSUFFICIENT
317
+ : stance.hedge_to_booster_ratio < 0.5
318
+ ? `asserts more than it qualifies (hedge to booster ratio ${stance.hedge_to_booster_ratio})`
319
+ : stance.hedge_to_booster_ratio > 1.5
320
+ ? `qualifies more than it asserts (hedge to booster ratio ${stance.hedge_to_booster_ratio})`
321
+ : `balanced between qualification and assertion (hedge to booster ratio ${stance.hedge_to_booster_ratio})`;
322
+
323
+ return [
324
+ "Each scale below is stated with the measurement that sets it. A scale with no measurement behind it is written as insufficient data rather than estimated.",
325
+ "",
326
+ `- **Directness.** Second-person rate ${value(stance.person.second, " per 1000 tokens")}, first-person singular ${value(stance.person.first_singular, " per 1000 tokens")}, median sentence length ${value(measured.sentence_length.median, " words")}.`,
327
+ `- **Certainty.** ${certainty}. Hedges ${value(stance.hedges_per_1000, " per 1000")}, boosters ${value(stance.boosters_per_1000, " per 1000")}, modals ${value(stance.modals_per_1000, " per 1000")}.`,
328
+ `- **Authority.** ${INSUFFICIENT} from the frozen pipeline. Whether this author cites, asserts, or shows needs a source-attribution pass the pipeline does not run.`,
329
+ `- **Humor.** ${INSUFFICIENT}. No measurable proxy exists in this pipeline, and an estimate here would be the flattery the measurement requirement exists to prevent.`,
330
+ `- **Disagreement.** ${INSUFFICIENT}. How this author pushes back needs passages labelled as disagreement, which the corpus does not mark.`,
331
+ "",
332
+ "**Boundary:** a feature match is descriptive. None of these establishes that a draft sounds right to its author, and author approval is never asserted by this tool.",
333
+ ].join("\n");
334
+ }
335
+
336
+ function notCaptured(measured, gates, corpus) {
337
+ const lines = [
338
+ "This section is not optional. A profile that hides its own gaps produces confident wrong output, and the gaps are the part a user cannot infer from the rest of the file.",
339
+ "",
340
+ "### Not measurable from this corpus",
341
+ "",
342
+ ];
343
+
344
+ const missing = [];
345
+ if (measured.sentence_length.p10 === null) missing.push("Sentence-length tails and binned shape: too few sentences for percentile estimates to be stable.");
346
+ if (measured.lexical_diversity.mattr === null) missing.push(`Moving-window lexical diversity: the corpus is shorter than the ${measured.lexical_diversity.mattr_window}-token window.`);
347
+ if (measured.lexical_diversity.mtld === null) missing.push("Decay-based lexical diversity: the corpus is too short for the measure to converge.");
348
+ if (measured.contractions.rate_percent === null) missing.push("Contraction rate: fewer than 20 eligible contexts where both forms were grammatical.");
349
+ if (measured.paragraph_shape.sentences.standard_deviation === null) missing.push("Paragraph-length spread: too few paragraphs.");
350
+ missing.push(
351
+ "Qualifier placement, conclusion front-loading, list handling, and distance to the first concrete example: these need a parser or a human reading, neither of which is in the frozen pipeline.",
352
+ "Humor, disagreement behaviour, and citation posture: no measurable proxy exists here.",
353
+ );
354
+ lines.push(...missing.map((item) => `- ${item}`));
355
+
356
+ lines.push("", "### Registers missing or under-sampled", "");
357
+ const under = gates.registers.filter((entry) => !entry.numeric);
358
+ if (under.length === 0) lines.push("- Every labelled register met the governance default for its own numbers.");
359
+ else
360
+ lines.push(
361
+ ...under.map(
362
+ (entry) => `- \`${entry.register}\`: ${entry.clean_words} clean words across ${entry.documents} documents, ${entry.note}.`,
363
+ ),
364
+ );
365
+ if (gates.registers.length === 1 && gates.registers[0].register === "unlabelled") {
366
+ lines.push(
367
+ "- No registers were labelled. Personal voice cannot be separated from genre in an unlabelled corpus, so treat this profile as genre-bound and pass `--register` on the next build.",
368
+ );
369
+ }
370
+
371
+ lines.push("", "### Features dropped for instability", "");
372
+ const unstable = gates.stability.filter((entry) => !entry.stable);
373
+ if (unstable.length === 0) lines.push("- None. Every evaluated core feature passed both stability rules.");
374
+ else lines.push(...unstable.map((entry) => `- ${entry.feature}: ${entry.reason}.`));
375
+
376
+ lines.push("", "### Habits recorded as unusable", "");
377
+ const emDashes = corpus.documents.reduce((total, document) => total + document.typography.em_dash, 0);
378
+ lines.push(
379
+ emDashes > 0
380
+ ? `- The corpus contains ${emDashes} U+2014 characters. That habit is recorded and is unusable: the U+2014 ban is an immutable output constraint at level 1 and voice never overrides it. Output uses other punctuation.`
381
+ : "- The corpus contains no U+2014 characters, so the U+2014 ban costs this profile nothing.",
382
+ );
383
+ const curly = corpus.documents.reduce(
384
+ (total, document) => total + document.typography.curly_single + document.typography.curly_double,
385
+ 0,
386
+ );
387
+ if (curly > 0) {
388
+ lines.push(
389
+ `- The corpus contains ${curly} curly quote characters. The curly-quote ban is not vocabulary, so the owned-vocabulary exception does not reach it and final copy uses straight quotes.`,
390
+ );
391
+ }
392
+
393
+ lines.push("", "### What would improve this profile", "");
394
+ const wants = [];
395
+ if (gates.clean_words < 10000) wants.push(`Raise the corpus above ${10000} clean words for a persistent production profile; it currently holds ${gates.clean_words}.`);
396
+ else if (gates.clean_words < 20000) wants.push(`Raise the corpus toward 20,000 to 30,000 clean words to estimate tails, rare punctuation, and more than one register.`);
397
+ if (!gates.independence.passed) wants.push(...gates.independence.failures.map((failure) => `Fix document independence: ${failure}.`));
398
+ if (corpus.refused.length > 0) wants.push(`${corpus.refused.length} source(s) were refused and contributed nothing. See the build report.`);
399
+ wants.push("Supply drafts alongside published versions where both exist, so editor-sensitive features can be separated from author features.");
400
+ lines.push(...wants.map((item) => `- ${item}`));
401
+
402
+ return lines.join("\n");
403
+ }
404
+
405
+ function frontmatter(name, gates, corpus, pipeline, now) {
406
+ const registers = gates.registers.map((entry) => entry.register).join(", ");
407
+ const excluded = new Map();
408
+ for (const document of corpus.documents) {
409
+ for (const [reason, count] of Object.entries(document.removed || {})) {
410
+ if (count > 0) excluded.set(reason, (excluded.get(reason) || 0) + count);
411
+ }
412
+ }
413
+ const excludedList = [...excluded.entries()]
414
+ .sort((left, right) => left[0].localeCompare(right[0]))
415
+ .map(([reason, count]) => `${reason.replaceAll("_", " ")} (${count})`);
416
+ if (corpus.refused.length > 0) excludedList.push(`refused sources (${corpus.refused.length})`);
417
+
418
+ return [
419
+ "---",
420
+ `name: ${name}`,
421
+ `created: ${now}`,
422
+ `updated: ${now}`,
423
+ "corpus:",
424
+ ` documents: ${corpus.documents.length}`,
425
+ ` clean_words: ${gates.clean_words}`,
426
+ ` raw_words: ${corpus.documents.reduce((total, document) => total + document.raw_words, 0)}`,
427
+ ` registers: [${registers}]`,
428
+ ` excluded: [${excludedList.join(", ")}]`,
429
+ "pipeline:",
430
+ ` tokenizer: "${pipeline.tokenizer}"`,
431
+ ` segmenter: "${pipeline.segmenter}"`,
432
+ ` lexicon: "${pipeline.lexicon}"`,
433
+ ` parser: "${pipeline.parser}"`,
434
+ `confidence: ${gates.tier.confidence ?? "none"}`,
435
+ `certified: ${gates.certified}`,
436
+ "---",
437
+ ].join("\n");
438
+ }
439
+
440
+ /** Render the complete profile document. */
441
+ export function renderProfile({ name, measured, gates, corpus, pipeline, now }) {
442
+ const owned = ownedVocabulary(corpus.documents, gates.certified);
443
+ const avoided = measuredAvoidances(measured);
444
+ const excerpts = selectExcerpts(corpus.documents, measured);
445
+
446
+ const vocabulary = [
447
+ "**Owned.** Words measured as recurring across independent documents in this corpus. The list is an allowlist against the generic AI-vocabulary ban and reaches those words only. It does not suppress the stock-template bans, the significance-tail bans, the structural-tell rules, the curly-quote ban, or the U+2014 ban, and it never converts a banned claim into an acceptable one.",
448
+ "",
449
+ ];
450
+ if (owned.withheld) {
451
+ vocabulary.push(
452
+ "No allowlist is issued. The corpus is below the certification floor, and a word cannot be shown to recur across genres in a corpus that has one. An allowlist assembled from a thin corpus is a licence built on noise.",
453
+ );
454
+ } else if (owned.allowlist.length === 0) {
455
+ vocabulary.push(
456
+ "No word on the generic AI-vocabulary ban list met the measurement bar in this corpus, so no exception is issued. The generic ban applies in full.",
457
+ );
458
+ } else {
459
+ vocabulary.push(
460
+ "| Word | Per 1000 tokens | Documents | Registers |",
461
+ "|---|---|---|---|",
462
+ ...owned.allowlist.map(
463
+ (entry) => `| \`${entry.token}\` | ${entry.per_1000} | ${entry.documents} | ${entry.registers} |`,
464
+ ),
465
+ );
466
+ }
467
+
468
+ vocabulary.push("", "**Recurring vocabulary outside the generic ban list.** Recorded for calibration. These need no exception and carry no licence.", "");
469
+ vocabulary.push(
470
+ owned.distinctive.length === 0
471
+ ? INSUFFICIENT
472
+ : owned.distinctive.map((entry) => `\`${entry.token}\` (${entry.per_1000})`).join(", "),
473
+ );
474
+
475
+ vocabulary.push(
476
+ "",
477
+ "**Avoided.** An avoidance is recorded only from a stated preference or a stable alternative in repeated eligible contexts. Absence alone is weak evidence and is never recorded here.",
478
+ "",
479
+ );
480
+ vocabulary.push(avoided.length === 0 ? `${INSUFFICIENT}: no stable alternation met the bar.` : avoided.map((item) => `- ${item}`).join("\n"));
481
+
482
+ const excerptBlock =
483
+ excerpts.length === 0
484
+ ? `${INSUFFICIENT}: no paragraph met the length bounds for a calibration excerpt.`
485
+ : excerpts
486
+ .map(
487
+ (excerpt, index) =>
488
+ `**${index + 1}. ${excerpt.source}** (${excerpt.register}) demonstrates ${excerpt.demonstrates}.\n\n> ${excerpt.text.replace(/\n/g, " ")}`,
489
+ )
490
+ .join("\n\n");
491
+
492
+ return [
493
+ frontmatter(name, gates, corpus, pipeline, now),
494
+ "",
495
+ `# Voice profile: ${name}`,
496
+ "",
497
+ "A profile authorizes how a proposition is expressed. It never authorizes the proposition. Facts, numbers, quotations, legal conclusions, policy positions, preferences, and endorsements come from the current brief and the approved sources, never from this file.",
498
+ "",
499
+ "Voice enters at level 6 of the conflict hierarchy and never rises above it.",
500
+ "",
501
+ "## Measured",
502
+ "",
503
+ measuredSection(measured),
504
+ "",
505
+ "## Structural habits",
506
+ "",
507
+ structuralHabits(measured, gates),
508
+ "",
509
+ "## Vocabulary",
510
+ "",
511
+ vocabulary.join("\n"),
512
+ "",
513
+ "## Calibration",
514
+ "",
515
+ calibrationSection(measured),
516
+ "",
517
+ "## Excerpts",
518
+ "",
519
+ "Selected by register, source, and distance from the corpus central tendency, not by quality. Excerpts are for calibration and verification, never for sentence completion: transfer the distributions and the tendencies, never the metaphors, slogans, anecdotes, or source sentences.",
520
+ "",
521
+ excerptBlock,
522
+ "",
523
+ "## Not captured",
524
+ "",
525
+ notCaptured(measured, gates, corpus),
526
+ "",
527
+ ].join("\n");
528
+ }