novel-writer-cli 0.0.3 → 0.1.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.
Files changed (32) hide show
  1. package/dist/__tests__/advance-refine-invalidates-eval.test.js +37 -0
  2. package/dist/__tests__/character-voice.test.js +1 -1
  3. package/dist/__tests__/gate-decision.test.js +66 -0
  4. package/dist/__tests__/init.test.js +7 -2
  5. package/dist/__tests__/narrative-health-injection.test.js +8 -8
  6. package/dist/__tests__/next-step-gate-decision-routing.test.js +117 -0
  7. package/dist/__tests__/next-step-prejudge-guardrails.test.js +112 -16
  8. package/dist/__tests__/next-step-title-fix.test.js +64 -8
  9. package/dist/__tests__/orchestrator-state-routing.test.js +168 -0
  10. package/dist/__tests__/orchestrator-state-write-path.test.js +59 -0
  11. package/dist/__tests__/steps-id.test.js +23 -0
  12. package/dist/__tests__/volume-pipeline.test.js +227 -0
  13. package/dist/__tests__/volume-review-pipeline.test.js +112 -0
  14. package/dist/__tests__/volume-review-storyline-rhythm.test.js +19 -0
  15. package/dist/advance.js +145 -48
  16. package/dist/checkpoint.js +71 -12
  17. package/dist/cli.js +202 -8
  18. package/dist/commit.js +1 -0
  19. package/dist/fs-utils.js +18 -3
  20. package/dist/gate-decision.js +59 -0
  21. package/dist/init.js +2 -0
  22. package/dist/instructions.js +322 -24
  23. package/dist/next-step.js +198 -34
  24. package/dist/platform-profile.js +3 -0
  25. package/dist/steps.js +60 -17
  26. package/dist/validate.js +275 -2
  27. package/dist/volume-commit.js +101 -0
  28. package/dist/volume-planning.js +143 -0
  29. package/dist/volume-review.js +448 -0
  30. package/docs/user/novel-cli.md +29 -0
  31. package/package.json +3 -2
  32. package/schemas/platform-profile.schema.json +5 -0
@@ -0,0 +1,448 @@
1
+ import { join } from "node:path";
2
+ import { NovelCliError } from "./errors.js";
3
+ import { pathExists, readJsonFile, readTextFile } from "./fs-utils.js";
4
+ import { tryResolveVolumeChapterRange } from "./consistency-auditor.js";
5
+ import { formatStepId, pad2, pad3 } from "./steps.js";
6
+ import { isPlainObject } from "./type-guards.js";
7
+ export const VOL_REVIEW_RELS = {
8
+ dir: "staging/vol-review",
9
+ qualitySummary: "staging/vol-review/quality-summary.json",
10
+ auditReport: "staging/vol-review/audit-report.json",
11
+ reviewReport: "staging/vol-review/review-report.md",
12
+ foreshadowStatus: "staging/vol-review/foreshadow-status.json"
13
+ };
14
+ function safeFiniteNumber(v) {
15
+ if (typeof v !== "number")
16
+ return null;
17
+ if (!Number.isFinite(v))
18
+ return null;
19
+ return v;
20
+ }
21
+ function safeInt(v) {
22
+ if (typeof v !== "number")
23
+ return null;
24
+ if (!Number.isInteger(v))
25
+ return null;
26
+ return v;
27
+ }
28
+ function safeBool(v) {
29
+ if (typeof v !== "boolean")
30
+ return null;
31
+ return v;
32
+ }
33
+ function safeString(v) {
34
+ if (typeof v !== "string")
35
+ return null;
36
+ const trimmed = v.trim();
37
+ return trimmed.length > 0 ? trimmed : null;
38
+ }
39
+ function normalizeForeshadowFile(raw) {
40
+ let obj = raw;
41
+ if (Array.isArray(obj))
42
+ obj = { foreshadowing: obj };
43
+ if (!isPlainObject(obj))
44
+ return null;
45
+ const list = obj.foreshadowing;
46
+ if (!Array.isArray(list))
47
+ return null;
48
+ const items = [];
49
+ for (const it of list) {
50
+ if (!isPlainObject(it))
51
+ continue;
52
+ const id = safeString(it.id);
53
+ if (!id)
54
+ continue;
55
+ items.push({ ...it, id });
56
+ }
57
+ return { foreshadowing: items };
58
+ }
59
+ export async function collectVolumeData(args) {
60
+ const volume = args.checkpoint.current_volume;
61
+ const endChapter = args.checkpoint.last_completed_chapter;
62
+ if (!Number.isInteger(volume) || volume < 1)
63
+ throw new NovelCliError(`Invalid checkpoint.current_volume: ${String(volume)}`, 2);
64
+ if (!Number.isInteger(endChapter) || endChapter < 0)
65
+ throw new NovelCliError(`Invalid checkpoint.last_completed_chapter: ${String(endChapter)}`, 2);
66
+ const warnings = [];
67
+ const resolvedRange = (await tryResolveVolumeChapterRange({ rootDir: args.rootDir, volume })) ??
68
+ (endChapter >= 1 ? { start: Math.max(1, endChapter - 9), end: endChapter } : null);
69
+ if (!resolvedRange) {
70
+ return {
71
+ schema_version: 1,
72
+ generated_at: new Date().toISOString(),
73
+ as_of: { volume, chapter: endChapter },
74
+ chapter_range: [0, 0],
75
+ stats: { chapters_total: 0, chapters_with_eval: 0, overall_avg: null, overall_min: null, overall_max: null },
76
+ chapters: [],
77
+ low_chapters: [],
78
+ warnings: endChapter === 0 ? ["No committed chapters yet; volume review summary is empty."] : ["Unable to resolve chapter range for volume review."]
79
+ };
80
+ }
81
+ const chapterRange = [resolvedRange.start, resolvedRange.end];
82
+ const chapters = [];
83
+ const scores = [];
84
+ const lowChapters = [];
85
+ for (let chapter = resolvedRange.start; chapter <= resolvedRange.end; chapter++) {
86
+ const evalRel = `evaluations/chapter-${pad3(chapter)}-eval.json`;
87
+ const evalAbs = join(args.rootDir, evalRel);
88
+ const exists = await pathExists(evalAbs);
89
+ if (!exists) {
90
+ warnings.push(`Missing eval file: ${evalRel}`);
91
+ chapters.push({
92
+ chapter,
93
+ eval_path: evalRel,
94
+ overall_final: null,
95
+ gate_decision: null,
96
+ revisions: null,
97
+ force_passed: null,
98
+ has_high_confidence_violation: null
99
+ });
100
+ continue;
101
+ }
102
+ let raw;
103
+ try {
104
+ raw = await readJsonFile(evalAbs);
105
+ }
106
+ catch (err) {
107
+ const message = err instanceof Error ? err.message : String(err);
108
+ warnings.push(`Failed to read ${evalRel}: ${message}`);
109
+ chapters.push({
110
+ chapter,
111
+ eval_path: evalRel,
112
+ overall_final: null,
113
+ gate_decision: null,
114
+ revisions: null,
115
+ force_passed: null,
116
+ has_high_confidence_violation: null
117
+ });
118
+ continue;
119
+ }
120
+ if (!isPlainObject(raw)) {
121
+ warnings.push(`Invalid eval JSON (expected object): ${evalRel}`);
122
+ chapters.push({
123
+ chapter,
124
+ eval_path: evalRel,
125
+ overall_final: null,
126
+ gate_decision: null,
127
+ revisions: null,
128
+ force_passed: null,
129
+ has_high_confidence_violation: null
130
+ });
131
+ continue;
132
+ }
133
+ const obj = raw;
134
+ const overall = safeFiniteNumber(obj.overall_final) ??
135
+ safeFiniteNumber(obj.overall) ??
136
+ (isPlainObject(obj.judges) ? safeFiniteNumber(obj.judges.overall_final) : null);
137
+ const gate = isPlainObject(obj.metadata) && isPlainObject(obj.metadata.gate)
138
+ ? obj.metadata.gate
139
+ : isPlainObject(obj.gate)
140
+ ? obj.gate
141
+ : null;
142
+ const gate_decision = gate ? safeString(gate.decision) : null;
143
+ const revisions = gate ? safeInt(gate.revisions) : null;
144
+ const force_passed = gate ? safeBool(gate.force_passed) : null;
145
+ const has_high_confidence_violation = gate ? safeBool(gate.has_high_confidence_violation) : null;
146
+ if (overall !== null) {
147
+ scores.push(overall);
148
+ if (overall < 3.5)
149
+ lowChapters.push({ chapter, overall_final: overall });
150
+ }
151
+ chapters.push({
152
+ chapter,
153
+ eval_path: evalRel,
154
+ overall_final: overall,
155
+ gate_decision,
156
+ revisions,
157
+ force_passed,
158
+ has_high_confidence_violation
159
+ });
160
+ }
161
+ const avg = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : null;
162
+ const min = scores.length > 0 ? Math.min(...scores) : null;
163
+ const max = scores.length > 0 ? Math.max(...scores) : null;
164
+ lowChapters.sort((a, b) => a.overall_final - b.overall_final || a.chapter - b.chapter);
165
+ return {
166
+ schema_version: 1,
167
+ generated_at: new Date().toISOString(),
168
+ as_of: { volume, chapter: endChapter },
169
+ chapter_range: chapterRange,
170
+ stats: {
171
+ chapters_total: resolvedRange.end - resolvedRange.start + 1,
172
+ chapters_with_eval: scores.length,
173
+ overall_avg: avg === null ? null : Number(avg.toFixed(4)),
174
+ overall_min: min,
175
+ overall_max: max
176
+ },
177
+ chapters,
178
+ low_chapters: lowChapters,
179
+ warnings
180
+ };
181
+ }
182
+ export async function computeForeshadowingAudit(args) {
183
+ const volume = args.checkpoint.current_volume;
184
+ const asOfChapter = args.checkpoint.last_completed_chapter;
185
+ const warnings = [];
186
+ const globalRel = "foreshadowing/global.json";
187
+ const volumeRel = `volumes/vol-${pad2(volume)}/foreshadowing.json`;
188
+ const globalAbs = join(args.rootDir, globalRel);
189
+ const volumeAbs = join(args.rootDir, volumeRel);
190
+ const globalRaw = (await pathExists(globalAbs)) ? await readJsonFile(globalAbs).catch((err) => {
191
+ const message = err instanceof Error ? err.message : String(err);
192
+ warnings.push(`Failed to read ${globalRel}: ${message}`);
193
+ return null;
194
+ }) : null;
195
+ const volumeRaw = (await pathExists(volumeAbs)) ? await readJsonFile(volumeAbs).catch((err) => {
196
+ const message = err instanceof Error ? err.message : String(err);
197
+ warnings.push(`Failed to read ${volumeRel}: ${message}`);
198
+ return null;
199
+ }) : null;
200
+ const global = globalRaw === null ? null : normalizeForeshadowFile(globalRaw);
201
+ const plan = volumeRaw === null ? null : normalizeForeshadowFile(volumeRaw);
202
+ if (globalRaw !== null && !global)
203
+ warnings.push(`Invalid ${globalRel}: expected a list or {foreshadowing:[...]}.`);
204
+ if (volumeRaw !== null && !plan)
205
+ warnings.push(`Invalid ${volumeRel}: expected a list or {foreshadowing:[...]}.`);
206
+ const globalItems = global?.foreshadowing ?? [];
207
+ const planItems = plan?.foreshadowing ?? [];
208
+ const globalIndex = new Map(globalItems.map((it) => [it.id, it]));
209
+ const activeCount = globalItems.filter((it) => String(it.status ?? "") !== "resolved").length;
210
+ const resolvedCount = globalItems.filter((it) => String(it.status ?? "") === "resolved").length;
211
+ const overdueShort = [];
212
+ for (const it of globalItems) {
213
+ const scope = safeString(it.scope);
214
+ const status = safeString(it.status);
215
+ if (scope !== "short")
216
+ continue;
217
+ if (status === "resolved")
218
+ continue;
219
+ const trRaw = it.target_resolve_range;
220
+ if (!Array.isArray(trRaw) || trRaw.length !== 2)
221
+ continue;
222
+ const start = safeInt(trRaw[0]);
223
+ const end = safeInt(trRaw[1]);
224
+ if (start === null || end === null)
225
+ continue;
226
+ if (asOfChapter > end) {
227
+ overdueShort.push({ id: it.id, target_resolve_range: [start, end], as_of_chapter: asOfChapter });
228
+ }
229
+ }
230
+ const planMissingInGlobal = [];
231
+ const planResolvedInGlobal = [];
232
+ for (const it of planItems) {
233
+ const existing = globalIndex.get(it.id);
234
+ if (!existing)
235
+ planMissingInGlobal.push(it.id);
236
+ else if (safeString(existing.status) === "resolved")
237
+ planResolvedInGlobal.push(it.id);
238
+ }
239
+ planMissingInGlobal.sort();
240
+ planResolvedInGlobal.sort();
241
+ return {
242
+ schema_version: 1,
243
+ generated_at: new Date().toISOString(),
244
+ as_of: { volume, chapter: asOfChapter },
245
+ global: { total: globalItems.length, active_count: activeCount, resolved_count: resolvedCount },
246
+ overdue_short: overdueShort,
247
+ plan: plan ? { planned_total: planItems.length, missing_in_global: planMissingInGlobal, resolved_in_global: planResolvedInGlobal } : null,
248
+ warnings
249
+ };
250
+ }
251
+ export async function computeBridgeCheck(args) {
252
+ const warnings = [];
253
+ const storylinesRel = "storylines/storylines.json";
254
+ const abs = join(args.rootDir, storylinesRel);
255
+ if (!(await pathExists(abs))) {
256
+ return {
257
+ schema_version: 1,
258
+ generated_at: new Date().toISOString(),
259
+ volume: args.volume,
260
+ broken: [],
261
+ warnings: [`Missing optional file: ${storylinesRel}`]
262
+ };
263
+ }
264
+ let raw;
265
+ try {
266
+ raw = await readJsonFile(abs);
267
+ }
268
+ catch (err) {
269
+ const message = err instanceof Error ? err.message : String(err);
270
+ return {
271
+ schema_version: 1,
272
+ generated_at: new Date().toISOString(),
273
+ volume: args.volume,
274
+ broken: [],
275
+ warnings: [`Failed to read ${storylinesRel}: ${message}`]
276
+ };
277
+ }
278
+ if (!isPlainObject(raw)) {
279
+ return {
280
+ schema_version: 1,
281
+ generated_at: new Date().toISOString(),
282
+ volume: args.volume,
283
+ broken: [],
284
+ warnings: [`Invalid ${storylinesRel}: expected JSON object.`]
285
+ };
286
+ }
287
+ const obj = raw;
288
+ const relsRaw = obj.relationships;
289
+ if (!Array.isArray(relsRaw)) {
290
+ return {
291
+ schema_version: 1,
292
+ generated_at: new Date().toISOString(),
293
+ volume: args.volume,
294
+ broken: [],
295
+ warnings
296
+ };
297
+ }
298
+ const idExists = (id) => args.foreshadowIds.global.has(id) || args.foreshadowIds.plan.has(id);
299
+ const broken = [];
300
+ for (const rel of relsRaw) {
301
+ if (!isPlainObject(rel))
302
+ continue;
303
+ const from = safeString(rel.from);
304
+ const to = safeString(rel.to);
305
+ const type = safeString(rel.type);
306
+ const bridges = rel.bridges;
307
+ if (!isPlainObject(bridges))
308
+ continue;
309
+ const shared = bridges.shared_foreshadowing;
310
+ if (!Array.isArray(shared))
311
+ continue;
312
+ for (const idRaw of shared) {
313
+ const id = safeString(idRaw);
314
+ if (!id)
315
+ continue;
316
+ if (idExists(id))
317
+ continue;
318
+ broken.push({
319
+ missing_id: id,
320
+ relationship: { from, to, type }
321
+ });
322
+ }
323
+ }
324
+ broken.sort((a, b) => String(a.missing_id ?? "").localeCompare(String(b.missing_id ?? "")));
325
+ return {
326
+ schema_version: 1,
327
+ generated_at: new Date().toISOString(),
328
+ volume: args.volume,
329
+ broken,
330
+ warnings
331
+ };
332
+ }
333
+ export async function computeStorylineRhythm(args) {
334
+ const warnings = [];
335
+ const scheduleRel = `volumes/vol-${pad2(args.volume)}/storyline-schedule.json`;
336
+ const scheduleAbs = join(args.rootDir, scheduleRel);
337
+ if (!(await pathExists(scheduleAbs))) {
338
+ warnings.push(`Missing optional file: ${scheduleRel}`);
339
+ }
340
+ else {
341
+ // Best-effort parse schedule: we only use it as a presence signal for now.
342
+ try {
343
+ await readJsonFile(scheduleAbs);
344
+ }
345
+ catch (err) {
346
+ const message = err instanceof Error ? err.message : String(err);
347
+ warnings.push(`Failed to read ${scheduleRel}: ${message}`);
348
+ }
349
+ }
350
+ const appearances = new Map();
351
+ const lastSeen = new Map();
352
+ const [start, end] = args.chapter_range;
353
+ const re = /storyline_id:\s*([a-zA-Z0-9_-]+)/gu;
354
+ for (let chapter = start; chapter <= end; chapter++) {
355
+ const rel = `summaries/chapter-${pad3(chapter)}-summary.md`;
356
+ const abs = join(args.rootDir, rel);
357
+ if (!(await pathExists(abs)))
358
+ continue;
359
+ let text;
360
+ try {
361
+ text = await readTextFile(abs);
362
+ }
363
+ catch {
364
+ continue;
365
+ }
366
+ const idsThisChapter = new Set();
367
+ for (const m of text.matchAll(re)) {
368
+ const id = m[1] ?? "";
369
+ if (!id)
370
+ continue;
371
+ idsThisChapter.add(id);
372
+ }
373
+ if (idsThisChapter.size === 0)
374
+ continue;
375
+ for (const id of idsThisChapter) {
376
+ appearances.set(id, (appearances.get(id) ?? 0) + 1);
377
+ lastSeen.set(id, chapter);
378
+ }
379
+ }
380
+ const appearancesObj = {};
381
+ const lastSeenObj = {};
382
+ for (const [k, v] of appearances.entries())
383
+ appearancesObj[k] = v;
384
+ for (const [k, v] of lastSeen.entries())
385
+ lastSeenObj[k] = v;
386
+ return {
387
+ schema_version: 1,
388
+ generated_at: new Date().toISOString(),
389
+ volume: args.volume,
390
+ chapter_range: args.chapter_range,
391
+ appearances: appearancesObj,
392
+ last_seen: lastSeenObj,
393
+ warnings
394
+ };
395
+ }
396
+ export async function computeReviewNextStep(projectRootDir, checkpoint) {
397
+ const qualitySummaryAbs = join(projectRootDir, VOL_REVIEW_RELS.qualitySummary);
398
+ const auditReportAbs = join(projectRootDir, VOL_REVIEW_RELS.auditReport);
399
+ const reviewReportAbs = join(projectRootDir, VOL_REVIEW_RELS.reviewReport);
400
+ const foreshadowAbs = join(projectRootDir, VOL_REVIEW_RELS.foreshadowStatus);
401
+ const hasQualitySummary = await pathExists(qualitySummaryAbs);
402
+ const hasAuditReport = await pathExists(auditReportAbs);
403
+ const hasReviewReport = await pathExists(reviewReportAbs);
404
+ const hasForeshadowStatus = await pathExists(foreshadowAbs);
405
+ const evidence = { hasQualitySummary, hasAuditReport, hasReviewReport, hasForeshadowStatus };
406
+ if (!hasQualitySummary) {
407
+ return {
408
+ step: formatStepId({ kind: "review", phase: "collect" }),
409
+ reason: "vol_review:missing_quality_summary",
410
+ inflight: { chapter: null, pipeline_stage: null },
411
+ evidence
412
+ };
413
+ }
414
+ if (!hasAuditReport) {
415
+ return {
416
+ step: formatStepId({ kind: "review", phase: "audit" }),
417
+ reason: "vol_review:missing_audit_report",
418
+ inflight: { chapter: null, pipeline_stage: null },
419
+ evidence
420
+ };
421
+ }
422
+ if (!hasReviewReport) {
423
+ return {
424
+ step: formatStepId({ kind: "review", phase: "report" }),
425
+ reason: "vol_review:missing_review_report",
426
+ inflight: { chapter: null, pipeline_stage: null },
427
+ evidence
428
+ };
429
+ }
430
+ if (!hasForeshadowStatus) {
431
+ return {
432
+ step: formatStepId({ kind: "review", phase: "cleanup" }),
433
+ reason: "vol_review:missing_foreshadow_status",
434
+ inflight: { chapter: null, pipeline_stage: null },
435
+ evidence
436
+ };
437
+ }
438
+ return {
439
+ step: formatStepId({ kind: "review", phase: "transition" }),
440
+ reason: "vol_review:ready_transition",
441
+ inflight: { chapter: null, pipeline_stage: null },
442
+ evidence
443
+ };
444
+ }
445
+ // Alias for tasks wording.
446
+ export async function computeReviewNext(projectRootDir, checkpoint) {
447
+ return await computeReviewNextStep(projectRootDir, checkpoint);
448
+ }
@@ -23,6 +23,7 @@
23
23
  | `novel validate <step>` | 校验 step 产物是否齐全/合规 |
24
24
  | `novel advance <step>` | 校验通过后推进 checkpoint |
25
25
  | `novel commit --chapter N` | 提交 staging 事务到正式目录(写入) |
26
+ | `novel commit --volume N` | 提交卷规划 staging 产物到 `volumes/`(写入) |
26
27
  | `novel lock status/clear` | 查看/清理写入锁(解决中断导致的 stale lock) |
27
28
  | `novel promises init/report` | 承诺台账:初始化与窗口报告 |
28
29
  | `novel engagement report` | 参与度密度:窗口报告 |
@@ -190,6 +191,34 @@ novel lock clear
190
191
 
191
192
  > 常见场景:执行器在写入阶段中断(断电/kill/异常退出),留下 stale lock;此时可先 `novel lock status` 确认,再执行 `novel lock clear`。
192
193
 
194
+ ## 卷规划(VOL_PLANNING)
195
+
196
+ 当 `.checkpoint.json.orchestrator_state == "VOL_PLANNING"` 时,`novel next` 会进入卷规划三步流水线:
197
+
198
+ ```bash
199
+ novel next
200
+ # volume:outline
201
+
202
+ novel instructions volume:outline --json
203
+ # 运行 PlotArchitect,写入 staging/volumes/vol-XX/**
204
+
205
+ novel validate volume:outline
206
+ novel advance volume:outline
207
+
208
+ novel next
209
+ # volume:validate
210
+
211
+ novel instructions volume:validate --json
212
+ novel validate volume:validate
213
+ novel advance volume:validate
214
+
215
+ novel next
216
+ # volume:commit
217
+
218
+ novel instructions volume:commit --json
219
+ novel commit --volume <N>
220
+ ```
221
+
193
222
  ## 角色语气漂移(M7H.3,可选)
194
223
 
195
224
  角色语气漂移用于:为关键角色建立“台词基线画像”,并在后续章节检测偏移,生成纠偏指令 `character-voice-drift.json`,直到恢复为止(自动清除)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "novel-writer-cli",
3
- "version": "0.0.3",
3
+ "version": "0.1.0",
4
4
  "description": "Executor-agnostic novel orchestration CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,7 +29,8 @@
29
29
  "homepage": "https://github.com/DankerMu/novel-writer-cli#readme",
30
30
  "scripts": {
31
31
  "dev": "tsx src/cli.ts",
32
- "build": "tsc -p tsconfig.json",
32
+ "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
33
+ "build": "npm run clean && tsc -p tsconfig.json",
33
34
  "prepack": "npm run build",
34
35
  "start": "node dist/cli.js",
35
36
  "typecheck": "tsc -p tsconfig.json --noEmit",
@@ -133,6 +133,11 @@
133
133
  "properties": {
134
134
  "genre_drive_type": { "$ref": "#/$defs/genre_drive_type" },
135
135
  "weight_profile_id": { "type": "string", "minLength": 1 },
136
+ "max_revisions": {
137
+ "type": "integer",
138
+ "minimum": 0,
139
+ "description": "Max gate-driven revision/polish loops before forcing progression."
140
+ },
136
141
  "weight_overrides": {
137
142
  "type": "object",
138
143
  "description": "Optional per-dimension weight overrides (dimension_name -> weight).",