dravoice 0.1.3 → 0.1.4

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/src/v2/review.js CHANGED
@@ -10,11 +10,11 @@ const REVIEW_MODES = {
10
10
  exitOnDrift: false,
11
11
  },
12
12
  balanced: {
13
- findingThresholds: { evidence: 65, rhythm: 55, rhetoricalShape: 50 },
13
+ findingThresholds: { evidence: 65, rhythm: 55, rhetoricalShape: 50, discourse: 55, lexical: 55, register: 55, structure: 55 },
14
14
  exitOnDrift: false,
15
15
  },
16
16
  strict: {
17
- findingThresholds: { evidence: 75, rhythm: 70, rhetoricalShape: 65 },
17
+ findingThresholds: { evidence: 75, rhythm: 70, rhetoricalShape: 65, discourse: 65, lexical: 65, register: 70, structure: 91 },
18
18
  exitOnDrift: true,
19
19
  },
20
20
  };
@@ -34,6 +34,7 @@ export function reviewVoiceDraftV2({ file, voice, cwd = process.cwd(), mode = "b
34
34
  draftDocument.wordCount < sourceProfile.calibration.minimumDraftSize.words ||
35
35
  draftDocument.sentences.length < sourceProfile.calibration.minimumDraftSize.sentences
36
36
  ) {
37
+ const familyConfidence = familyConfidenceFor(sourceProfile, {});
37
38
  return {
38
39
  schemaVersion: 2,
39
40
  file: displayPath(filePath, cwd),
@@ -41,6 +42,8 @@ export function reviewVoiceDraftV2({ file, voice, cwd = process.cwd(), mode = "b
41
42
  mode: reviewMode,
42
43
  fit: { band: "insufficient-evidence", distance: 0 },
43
44
  familyScores: {},
45
+ familyConfidence,
46
+ suppressedFindings: suppressedFindingsFor(sourceProfile, familyConfidence),
44
47
  corpusConfidence: sourceProfile.source.confidence,
45
48
  },
46
49
  findings: [],
@@ -51,7 +54,8 @@ export function reviewVoiceDraftV2({ file, voice, cwd = process.cwd(), mode = "b
51
54
  const familyDiagnostics = familyDiagnosticsFor(sourceProfile, draftProfile);
52
55
  const familyScores = Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.score]));
53
56
  const distance = styleDistanceFromDiagnostics(familyDiagnostics);
54
- const findings = reviewFindings(sourceProfile, draftProfile, familyScores, reviewMode);
57
+ const familyConfidence = familyConfidenceFor(sourceProfile, familyDiagnostics);
58
+ const findings = reviewFindings(sourceProfile, draftProfile, familyScores, reviewMode, familyConfidence);
55
59
  const fit = {
56
60
  band: fitBand(distance, findings, familyDiagnostics),
57
61
  distance,
@@ -64,6 +68,8 @@ export function reviewVoiceDraftV2({ file, voice, cwd = process.cwd(), mode = "b
64
68
  mode: reviewMode,
65
69
  fit,
66
70
  familyScores,
71
+ familyConfidence,
72
+ suppressedFindings: suppressedFindingsFor(sourceProfile, familyConfidence),
67
73
  familyDistances: Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.distance])),
68
74
  familyDrift: Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.drift])),
69
75
  thresholds: Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.threshold])),
@@ -76,71 +82,175 @@ export function reviewVoiceDraftV2({ file, voice, cwd = process.cwd(), mode = "b
76
82
 
77
83
  export function renderVoiceReviewV2(result) {
78
84
  const lines = [
79
- "Voice rewrite notes, not AI detection.",
85
+ "Voice review notes, not AI detection.",
80
86
  "",
81
87
  result.file,
82
- `Voice fit: ${capitalize(result.summary.fit.band)} (${result.summary.fit.distance} distance)`,
88
+ `Fit: ${capitalize(result.summary.fit.band)} (${result.summary.fit.distance} style distance)`,
83
89
  `Corpus confidence: ${capitalize(result.summary.corpusConfidence.band)} - ${result.summary.corpusConfidence.message}`,
84
90
  "Family scores:",
85
91
  ];
86
92
 
87
93
  for (const [family, score] of Object.entries(result.summary.familyScores)) {
88
- lines.push(`- ${family}: ${score}`);
94
+ lines.push(`- ${familyLabel(family)}: ${score}`);
89
95
  }
90
96
 
91
97
  lines.push("");
92
98
  if (result.findings.length === 0) {
93
- lines.push("No high-confidence V2 voice drift findings.");
99
+ lines.push("No high-confidence voice drift findings.");
100
+ appendSuppressedFindings(lines, result.summary.suppressedFindings);
94
101
  lines.push("");
95
102
  return lines.join("\n");
96
103
  }
97
104
 
98
105
  lines.push("Start here:");
99
- for (const finding of result.findings.slice(0, 6)) {
100
- lines.push(`${finding.priority} ${finding.family} ${finding.id}`);
101
- lines.push(`Why flagged: ${finding.why}`);
102
- lines.push(`Revise by: ${finding.action}`);
106
+ result.findings.slice(0, 6).forEach((finding, index) => {
107
+ lines.push(`${index + 1}. ${priorityLabel(finding.priority)} ${familyLabel(finding.family)}`);
108
+ if (finding.confidence) {
109
+ lines.push(`Confidence: ${capitalize(finding.confidence.band)}; stability ${finding.confidence.stability}`);
110
+ }
111
+ lines.push(`Why: ${finding.why}`);
112
+ lines.push(`Do this: ${finding.action}`);
103
113
  lines.push("");
104
- }
114
+ });
115
+ appendSuppressedFindings(lines, result.summary.suppressedFindings);
105
116
  return lines.join("\n");
106
117
  }
107
118
 
108
- function reviewFindings(source, draft, scores, mode) {
119
+ function reviewFindings(source, draft, scores, mode, familyConfidence) {
109
120
  const modeConfig = REVIEW_MODES[mode];
110
121
  if (source.source?.confidence?.band === "weak" || !modeConfig.findingThresholds) {
111
122
  return [];
112
123
  }
113
124
  const findings = [];
114
- if (scores.evidence < modeConfig.findingThresholds.evidence) {
125
+ if (shouldFlag("evidence", scores, modeConfig, familyConfidence)) {
115
126
  findings.push({
116
127
  id: "v2.evidence-drift",
117
128
  family: "evidence",
118
129
  priority: "review",
130
+ confidence: familyConfidence.evidence,
119
131
  why: `Source evidence sentence rate is ${source.families.evidence.features.evidenceSentenceRate}; draft rate is ${draft.families.evidence.features.evidenceSentenceRate}.`,
120
132
  action: "Add concrete support before broad claims: a scene, quote, number, citation, URL, sensory detail, or specific example.",
121
133
  });
122
134
  }
123
- if (scores.rhythm < modeConfig.findingThresholds.rhythm) {
135
+ if (shouldFlag("rhythm", scores, modeConfig, familyConfidence)) {
124
136
  findings.push({
125
137
  id: "v2.rhythm-drift",
126
138
  family: "rhythm",
127
139
  priority: "consider",
140
+ confidence: familyConfidence.rhythm,
128
141
  why: `Source median sentence/paragraph length is ${source.families.rhythm.features.sentenceWords.median}/${source.families.rhythm.features.paragraphWords.median}; draft is ${draft.families.rhythm.features.sentenceWords.median}/${draft.families.rhythm.features.paragraphWords.median}.`,
129
142
  action: "Revise sentence and paragraph pacing toward the learned range.",
130
143
  });
131
144
  }
132
- if (scores.rhetoricalShape < modeConfig.findingThresholds.rhetoricalShape) {
145
+ if (shouldFlag("rhetoricalShape", scores, modeConfig, familyConfidence)) {
133
146
  findings.push({
134
147
  id: "v2.shape-drift",
135
148
  family: "rhetoricalShape",
136
149
  priority: "consider",
150
+ confidence: familyConfidence.rhetoricalShape,
137
151
  why: `Source opening shape is ${source.families.rhetoricalShape.features.openingMoves.slice(0, 3).join(" -> ")}; draft opening shape is ${draft.families.rhetoricalShape.features.openingMoves.slice(0, 3).join(" -> ")}.`,
138
152
  action: "Rework the opening so it uses a compatible scene, claim, contrast, reflection, or example sequence.",
139
153
  });
140
154
  }
155
+ if (shouldFlag("discourse", scores, modeConfig, familyConfidence)) {
156
+ findings.push({
157
+ id: "v2.discourse-drift",
158
+ family: "discourse",
159
+ priority: "consider",
160
+ confidence: familyConfidence.discourse,
161
+ why: `Source transition rates are ${rateMapLabel(source.families.discourse.features.transitionRates)}; draft rates are ${rateMapLabel(draft.families.discourse.features.transitionRates)}.`,
162
+ action: "Revise repeated sentence turns, callbacks, and transitions so the draft does not lean on a different discourse pattern.",
163
+ });
164
+ }
165
+ if (shouldFlag("lexical", scores, modeConfig, familyConfidence)) {
166
+ findings.push({
167
+ id: "v2.lexical-drift",
168
+ family: "lexical",
169
+ priority: "consider",
170
+ confidence: familyConfidence.lexical,
171
+ why: `Function-word, masked character, punctuation, or boundary-token habits drift from the calibrated source profile.`,
172
+ action: "Revise diction and punctuation where it improves the article; do not stuff source topic words or add artificial imperfections.",
173
+ });
174
+ }
175
+ if (shouldFlag("register", scores, modeConfig, familyConfidence)) {
176
+ findings.push({
177
+ id: "v2.register-drift",
178
+ family: "register",
179
+ priority: "consider",
180
+ confidence: familyConfidence.register,
181
+ why: `Source primary register is ${source.families.register.features.primary.value}; draft primary register is ${draft.families.register.features.primary.value}.`,
182
+ action: "Bring the stance closer to the learned genre mix while preserving the draft's real subject and audience.",
183
+ });
184
+ }
185
+ if (shouldFlag("structure", scores, modeConfig, familyConfidence)) {
186
+ findings.push({
187
+ id: "v2.structure-drift",
188
+ family: "structure",
189
+ priority: "consider",
190
+ confidence: familyConfidence.structure,
191
+ why: `Source heading/list/quote and section-size patterns differ from the draft's document structure.`,
192
+ action: "Adjust section shape, opening order, list use, or quote placement only where the article benefits from that structure.",
193
+ });
194
+ }
141
195
  return findings;
142
196
  }
143
197
 
198
+ function shouldFlag(family, scores, modeConfig, familyConfidence) {
199
+ return familyConfidence[family]?.usableForFindings &&
200
+ Number.isFinite(scores[family]) &&
201
+ scores[family] < modeConfig.findingThresholds[family];
202
+ }
203
+
204
+ function familyConfidenceFor(sourceProfile, familyDiagnostics) {
205
+ const profileDiagnostics = sourceProfile.calibration?.familyDiagnostics ?? {};
206
+ return Object.fromEntries(Object.keys(sourceProfile.families ?? {}).map((family) => {
207
+ const profile = profileDiagnostics[family] ?? {};
208
+ const runtime = familyDiagnostics[family] ?? {};
209
+ return [family, {
210
+ band: sourceProfile.families[family]?.confidence ?? "low",
211
+ stability: runtime.stability ?? profile.stability ?? 0.45,
212
+ observations: profile.observations ?? runtime.observations ?? 0,
213
+ usableForFindings: profile.usableForFindings ?? sourceProfile.source?.confidence?.band !== "weak",
214
+ minimumEvidence: profile.minimumEvidence ?? null,
215
+ }];
216
+ }));
217
+ }
218
+
219
+ function suppressedFindingsFor(sourceProfile, familyConfidence) {
220
+ return Object.entries(familyConfidence)
221
+ .filter(([, confidence]) => !confidence.usableForFindings)
222
+ .map(([family, confidence]) => ({
223
+ family,
224
+ reason: suppressedReasonFor(sourceProfile, confidence),
225
+ }));
226
+ }
227
+
228
+ function suppressedReasonFor(sourceProfile, confidence) {
229
+ if (sourceProfile.source?.confidence?.band === "weak") {
230
+ return "weak corpus confidence; add more representative source documents before trusting this family.";
231
+ }
232
+ const minimum = confidence.minimumEvidence;
233
+ if (minimum && (!minimum.documentsMet || !minimum.sentencesMet || !minimum.wordsMet)) {
234
+ return `minimum evidence not met; requires ${minimum.requiredDocuments} document(s), ${minimum.requiredSentences} sentence(s), and ${minimum.requiredWords} word(s).`;
235
+ }
236
+ return "calibration stability is too low for a high-confidence finding.";
237
+ }
238
+
239
+ function appendSuppressedFindings(lines, suppressedFindings = []) {
240
+ if (!suppressedFindings.length) {
241
+ return;
242
+ }
243
+ lines.push("");
244
+ lines.push("Suppressed findings:");
245
+ suppressedFindings.slice(0, 6).forEach((item) => {
246
+ lines.push(`- ${familyLabel(item.family)}: ${item.reason}`);
247
+ });
248
+ }
249
+
250
+ function rateMapLabel(value = {}) {
251
+ return Object.entries(value).map(([key, rate]) => `${key}:${rate}`).join(", ") || "none";
252
+ }
253
+
144
254
  function normalizeReviewMode(mode) {
145
255
  const normalized = String(mode ?? "balanced").toLowerCase();
146
256
  if (!Object.hasOwn(REVIEW_MODES, normalized)) {
@@ -175,3 +285,19 @@ function resolvePath(cwd, value) {
175
285
  function capitalize(value) {
176
286
  return value.charAt(0).toUpperCase() + value.slice(1);
177
287
  }
288
+
289
+ function familyLabel(family) {
290
+ return {
291
+ evidence: "Evidence",
292
+ rhythm: "Rhythm",
293
+ rhetoricalShape: "Rhetorical shape",
294
+ discourse: "Discourse",
295
+ lexical: "Lexical style",
296
+ register: "Register",
297
+ structure: "Structure",
298
+ }[family] ?? capitalize(family);
299
+ }
300
+
301
+ function priorityLabel(priority) {
302
+ return priority === "review" ? "Review" : "Consider";
303
+ }
@@ -13,7 +13,7 @@ import {
13
13
  styleDistanceFromDiagnostics,
14
14
  } from "./stylometry.js";
15
15
 
16
- const MAX_ACTIONS = 8;
16
+ const MAX_ACTIONS = 10;
17
17
 
18
18
  const EDITABILITY = {
19
19
  evidence: 1.00,
@@ -36,11 +36,13 @@ export function revisePlanDraftV2({ file, voice, cwd = process.cwd(), maxActions
36
36
  const draftProfile = buildVoiceProfileV2({ documents: [draftDocument] });
37
37
  const familyDiagnostics = familyDiagnosticsFor(sourceProfile, draftProfile);
38
38
  const rollingWindows = rollingWindowsFor({ sourceProfile, draftDocument });
39
+ const paragraphWindows = paragraphWindowsFor({ sourceProfile, draftDocument });
39
40
  const actions = rankedActions({
40
41
  sourceProfile,
41
42
  draftDocument,
42
43
  familyDiagnostics,
43
44
  rollingWindows,
45
+ paragraphWindows,
44
46
  maxActions,
45
47
  });
46
48
 
@@ -61,6 +63,7 @@ export function revisePlanDraftV2({ file, voice, cwd = process.cwd(), maxActions
61
63
  familyDrift: Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.drift])),
62
64
  thresholds: Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.threshold])),
63
65
  rollingWindows,
66
+ paragraphWindows,
64
67
  },
65
68
  actions,
66
69
  };
@@ -81,7 +84,7 @@ export function renderRevisePlanV2(plan) {
81
84
 
82
85
  for (const [family, score] of Object.entries(plan.summary.familyScores)) {
83
86
  const drift = plan.summary.familyDrift[family];
84
- lines.push(`- ${family}: ${score} (drift ${drift})`);
87
+ lines.push(`- ${familyLabel(family)}: ${score} (drift ${drift})`);
85
88
  }
86
89
 
87
90
  lines.push("");
@@ -93,17 +96,17 @@ export function renderRevisePlanV2(plan) {
93
96
 
94
97
  lines.push("Start here:");
95
98
  plan.actions.forEach((action, index) => {
96
- lines.push(`${index + 1}. ${action.priority} ${action.family} ${action.id}`);
97
- lines.push(` Unit: ${action.unit.type} at line ${action.unit.line}`);
98
- lines.push(` Score: ${action.actionScore}`);
99
- lines.push(` Why flagged: ${action.why}`);
100
- lines.push(` Revise by: ${action.reviseBy}`);
99
+ lines.push(`${index + 1}. ${priorityLabel(action.priority)} ${familyLabel(action.family)}`);
100
+ lines.push(` Where: ${unitLabel(action.unit)}`);
101
+ lines.push(` Priority score: ${action.actionScore}`);
102
+ lines.push(` Why: ${action.why}`);
103
+ lines.push(` Do this: ${action.reviseBy}`);
101
104
  });
102
105
  lines.push("");
103
106
  return lines.join("\n");
104
107
  }
105
108
 
106
- function rankedActions({ sourceProfile, draftDocument, familyDiagnostics, rollingWindows, maxActions }) {
109
+ function rankedActions({ sourceProfile, draftDocument, familyDiagnostics, rollingWindows, paragraphWindows, maxActions }) {
107
110
  const confidence = confidenceWeight(sourceProfile.source.confidence.band);
108
111
  const actions = [
109
112
  ...evidenceActions({ sourceProfile, draftDocument, familyDiagnostics, confidence }),
@@ -111,6 +114,7 @@ function rankedActions({ sourceProfile, draftDocument, familyDiagnostics, rollin
111
114
  ...shapeActions({ sourceProfile, draftDocument, familyDiagnostics, confidence }),
112
115
  ...discourseActions({ sourceProfile, draftDocument, familyDiagnostics, confidence }),
113
116
  ...rollingWindowActions({ rollingWindows, confidence }),
117
+ ...paragraphWindowActions({ paragraphWindows, confidence }),
114
118
  ...documentLevelActions({ sourceProfile, draftDocument, familyDiagnostics, confidence }),
115
119
  ].filter((action) => action.actionScore > 0);
116
120
 
@@ -322,6 +326,41 @@ function rollingWindowsFor({ sourceProfile, draftDocument }) {
322
326
  .slice(0, 4);
323
327
  }
324
328
 
329
+ function paragraphWindowsFor({ sourceProfile, draftDocument }) {
330
+ const result = [];
331
+ for (const [index, paragraph] of draftDocument.paragraphs.entries()) {
332
+ const sentences = draftDocument.sentences.filter((sentence) =>
333
+ sentence.line >= paragraph.line &&
334
+ sentence.line <= (paragraph.lineNumbers?.at(-1) ?? paragraph.line)
335
+ );
336
+ if (sentences.length < 2) {
337
+ continue;
338
+ }
339
+ const paragraphProfile = buildVoiceProfileV2({ documents: [documentForParagraph(draftDocument, paragraph, sentences, index)] });
340
+ const diagnostics = familyDiagnosticsFor(sourceProfile, paragraphProfile);
341
+ const ranked = ["evidence", "rhythm", "discourse", "rhetoricalShape", "lexical"]
342
+ .map((family) => ({ family, ...diagnostics[family] }))
343
+ .sort((left, right) => right.drift - left.drift || (100 - right.score) - (100 - left.score));
344
+ const best = ranked[0];
345
+ if (best?.drift > 0) {
346
+ result.push({
347
+ family: best.family,
348
+ paragraph: index + 1,
349
+ startLine: paragraph.line,
350
+ endLine: paragraph.lineNumbers?.at(-1) ?? paragraph.line,
351
+ distance: best.distance,
352
+ drift: best.drift,
353
+ score: best.score,
354
+ threshold: best.threshold,
355
+ stability: best.stability,
356
+ });
357
+ }
358
+ }
359
+ return result
360
+ .sort((left, right) => right.drift - left.drift || left.startLine - right.startLine)
361
+ .slice(0, 4);
362
+ }
363
+
325
364
  function rollingWindowStarts(sentenceCount, windowSize, stride) {
326
365
  const starts = [];
327
366
  for (let start = 0; start <= sentenceCount - windowSize; start += stride) {
@@ -363,6 +402,29 @@ function documentForSentences(draftDocument, sentences, windowIndex) {
363
402
  };
364
403
  }
365
404
 
405
+ function documentForParagraph(draftDocument, paragraph, sentences, paragraphIndex) {
406
+ const block = {
407
+ type: paragraph.type,
408
+ line: paragraph.line,
409
+ heading: paragraph.heading,
410
+ headingId: paragraph.headingId,
411
+ headingDepth: 0,
412
+ lines: [paragraph.text],
413
+ lineNumbers: paragraph.lineNumbers ?? [paragraph.line],
414
+ };
415
+ return {
416
+ file: `${draftDocument.file ?? "draft"}#paragraph-${paragraphIndex + 1}`,
417
+ path: draftDocument.path,
418
+ headings: [],
419
+ sections: [{ heading: null, blocks: [block] }],
420
+ blocks: [block],
421
+ paragraphs: [paragraph],
422
+ sentences,
423
+ wordCount: sentences.reduce((sum, sentence) => sum + sentence.tokens.length, 0),
424
+ text: paragraph.text,
425
+ };
426
+ }
427
+
366
428
  function rollingWindowActions({ rollingWindows, confidence }) {
367
429
  return rollingWindows.map((window, index) => makeAction({
368
430
  family: window.family,
@@ -378,6 +440,21 @@ function rollingWindowActions({ rollingWindows, confidence }) {
378
440
  }));
379
441
  }
380
442
 
443
+ function paragraphWindowActions({ paragraphWindows, confidence }) {
444
+ return paragraphWindows.map((window, index) => makeAction({
445
+ family: window.family,
446
+ ordinal: `paragraph-${index + 1}`,
447
+ priority: window.family === "evidence" ? "review" : "consider",
448
+ unit: { type: "paragraph", line: window.startLine, endLine: window.endLine },
449
+ confidence,
450
+ drift: window.drift,
451
+ stability: window.stability,
452
+ localMismatch: Math.min(0.45, window.drift / Math.max(1, window.drift + 0.5)),
453
+ why: `Paragraph ${window.paragraph} shows localized ${window.family} drift beyond the writer's calibrated range.`,
454
+ reviseBy: rollingWindowReviseBy(window.family),
455
+ }));
456
+ }
457
+
381
458
  function rollingWindowReviseBy(family) {
382
459
  if (family === "evidence") {
383
460
  return "Add or move concrete support into this local passage, or narrow the unsupported claims in the same window.";
@@ -435,3 +512,29 @@ function resolvePath(cwd, value) {
435
512
  function capitalize(value) {
436
513
  return value.charAt(0).toUpperCase() + value.slice(1);
437
514
  }
515
+
516
+ function familyLabel(family) {
517
+ return {
518
+ evidence: "Evidence",
519
+ rhythm: "Rhythm",
520
+ rhetoricalShape: "Rhetorical shape",
521
+ discourse: "Discourse",
522
+ lexical: "Lexical style",
523
+ register: "Register",
524
+ structure: "Structure",
525
+ }[family] ?? capitalize(family);
526
+ }
527
+
528
+ function priorityLabel(priority) {
529
+ return priority === "review" ? "Review" : "Consider";
530
+ }
531
+
532
+ function unitLabel(unit) {
533
+ if (unit.type === "window") {
534
+ return `sentence window at lines ${unit.line}-${unit.endLine}`;
535
+ }
536
+ if (unit.type === "paragraph") {
537
+ return `paragraph at lines ${unit.line}-${unit.endLine}`;
538
+ }
539
+ return `${unit.type} at line ${unit.line}`;
540
+ }
@@ -159,9 +159,9 @@ function discourseDistance(source, draft) {
159
159
  const transitionDelta = rateMapDistance(source.transitionRates, draft.transitionRates);
160
160
  const callbackDelta = Math.abs((source.sentenceCallbacks ?? 0) - (draft.sentenceCallbacks ?? 0));
161
161
  return weightedMean([
162
- [transitionDelta, 0.55],
163
- [topItemDistance(source.transitionBigrams, draft.transitionBigrams), 0.25],
164
- [topItemDistance(source.transitionTrigrams, draft.transitionTrigrams), 0.10],
162
+ [transitionDelta, 0.65],
163
+ [topItemDistance(source.transitionBigrams, draft.transitionBigrams), 0.10],
164
+ [topItemDistance(source.transitionTrigrams, draft.transitionTrigrams), 0.025],
165
165
  [callbackDelta, 0.20],
166
166
  ]);
167
167
  }
@@ -188,10 +188,14 @@ function shapeDistance(source, draft) {
188
188
 
189
189
  function structureDistance(source, draft) {
190
190
  return weightedMean([
191
- [distributionDelta(source.sectionWords, draft.sectionWords), 0.35],
192
- [distributionDelta(source.headingCount, draft.headingCount), 0.20],
193
- [Math.abs((source.listDocumentRate ?? 0) - (draft.listDocumentRate ?? 0)), 0.18],
194
- [Math.abs((source.quoteDocumentRate ?? 0) - (draft.quoteDocumentRate ?? 0)), 0.12],
191
+ [distributionDelta(source.sectionWords, draft.sectionWords), 0.30],
192
+ [distributionDelta(source.headingCount, draft.headingCount), 0.16],
193
+ [distributionDelta(source.maxHeadingDepth, draft.maxHeadingDepth), 0.12],
194
+ [topItemDistance(source.sectionOrderPatterns, draft.sectionOrderPatterns), 0.12],
195
+ [topItemDistance(source.listPlacementPatterns, draft.listPlacementPatterns), 0.08],
196
+ [topItemDistance(source.quotePlacementPatterns, draft.quotePlacementPatterns), 0.08],
197
+ [Math.abs((source.listDocumentRate ?? 0) - (draft.listDocumentRate ?? 0)), 0.12],
198
+ [Math.abs((source.quoteDocumentRate ?? 0) - (draft.quoteDocumentRate ?? 0)), 0.08],
195
199
  [sequenceDistance(source.openingMoves, draft.openingMoves), 0.15],
196
200
  ]);
197
201
  }