فهرست منبع

docs: add length governance design

Ma 7 ماه پیش
والد
کامیت
5a27a045b5
1فایلهای تغییر یافته به همراه247 افزوده شده و 0 حذف شده
  1. 247 0
      docs/plans/2026-03-22-length-governance-design.md

+ 247 - 0
docs/plans/2026-03-22-length-governance-design.md

@@ -0,0 +1,247 @@
+# Length Governance Redesign
+
+## Goal
+
+Reduce chapter-length drift in InkOS without resorting to hard truncation, so user-specified targets like `--words 2200` converge toward a reasonable range instead of expanding unpredictably across writer and reviser passes.
+
+## Problem Statement
+
+Issue `#58` is not a single prompt bug. It is a system-level drift problem caused by multiple layers:
+
+1. Writer prompts currently frame length as a lower bound (`不少于 X 字`) instead of a target range.
+2. Writer runtime budgeting uses a loose token ceiling, not an explicit output-governance mechanism.
+3. Length accounting currently uses raw string length, which is not a stable user-facing counting model.
+4. Reviser and spot-fix passes can expand or compress the chapter again, but they do not share a common length guardrail with the writer.
+
+Observed behavior on merged `master` confirmed the drift:
+
+- `draft --words 500` produced `1186`
+- a later revise pass expanded the same chapter to `1805`
+
+This makes length control a combined model-capability and system-governance problem, not something that can be solved by lowering `maxTokens` or chopping the tail off the chapter.
+
+## External Constraint
+
+The 2026 ecosystem direction is consistent:
+
+- model APIs expose upper bounds, not exact output length controls
+- prompt wording alone is not enough for precise length following
+- stable length control requires runtime feedback or structured planning
+
+For InkOS, that means the right fix is an inference-time governor inside the pipeline, not hard truncation.
+
+## Scope
+
+In scope:
+
+- introduce a conservative `LengthSpec`
+- unify counting semantics for Chinese and English
+- enforce the same length policy across writer and reviser
+- add a single-pass length normalization step when output leaves the allowed band
+- record length drift in logs/results
+
+Out of scope:
+
+- exact-length generation
+- paragraph/scene-level budget planning
+- provider-specific fine-tuning
+- hard-cut truncation of finished chapters
+
+## Chosen Approach
+
+Use a conservative runtime governor shared by the full chapter pipeline.
+
+Three approaches were considered:
+
+1. Prompt-only range wording
+2. Conservative runtime governor
+3. Planner-driven scene budgets
+
+Approach `2` is the chosen design because it materially reduces drift without requiring a large planner/writer rewrite. Approach `3` remains the long-term path if tighter control is needed later.
+
+## Design Principles
+
+1. User target length remains authoritative.
+2. The system should aim for a band, not an exact number.
+3. Quality and continuity take priority over exact length.
+4. Normalization is corrective, not recursive.
+5. Counting must match user expectations as closely as possible.
+
+## LengthSpec
+
+Introduce a lightweight runtime object shared by pipeline stages:
+
+```ts
+interface LengthSpec {
+  target: number;
+  softMin: number;
+  softMax: number;
+  hardMin: number;
+  hardMax: number;
+  countingMode: "zh_chars" | "en_words";
+  normalizeMode: "expand" | "compress" | "none";
+}
+```
+
+### Source of Truth
+
+`target` must come from user-visible inputs in this order:
+
+1. `--words`
+2. `book.chapterWordCount`
+3. project default
+
+The system must not invent a target independently of user or book configuration.
+
+### Conservative Defaults
+
+Example for `target = 2200`:
+
+- `softMin = 1900`
+- `softMax = 2500`
+- `hardMin = 1600`
+- `hardMax = 2800`
+
+The exact ratios can be implementation details, but the behavior should remain conservative: pull drift inward without over-correcting.
+
+## Counting Model
+
+Length accounting must stop using raw `.length` as the user-facing truth.
+
+Use:
+
+- `zh_chars` for Chinese writing
+- `en_words` for English writing
+
+Definitions:
+
+- `zh_chars`: count CJK characters as the primary unit and ignore Markdown headings/metadata outside chapter body
+- `en_words`: count word tokens in prose, not raw characters
+
+This gives InkOS a stable public contract for what “2200 字” or “2200 words” actually means.
+
+## Runtime Flow
+
+### 1. Pipeline entry
+
+`PipelineRunner.writeDraft()` and `PipelineRunner.writeNextChapter()` build a `LengthSpec` before invoking writer or reviser.
+
+### 2. Writer pass
+
+Writer receives `LengthSpec` and shifts the prompt contract from:
+
+- `正文不少于 X 字`
+
+to something equivalent to:
+
+- target `X`
+- acceptable range `[softMin, softMax]`
+- keep the chapter complete while staying near the target band
+
+### 3. Post-writer normalization
+
+If the draft lands outside the soft range, the pipeline runs one corrective `normalize-length` pass:
+
+- if too long: compress without deleting required plot beats
+- if too short: expand without adding new branches or consuming future outline nodes
+
+### 4. Audit / revise
+
+Reviser must consume the same `LengthSpec`. Fixes must preserve the target band unless the requested correction genuinely forces a change.
+
+### 5. Post-reviser normalization
+
+If revise/spot-fix pushes the chapter back outside the soft range, run one final normalization pass.
+
+### 6. Final persistence
+
+Persist the chapter even if it still misses the hard range, but emit a visible warning instead of silently pretending the target was met.
+
+## Single Normalize Rule
+
+`normalize-length` is itself an LLM pass, so it must not recurse.
+
+Rule:
+
+- at most one normalization after writer
+- at most one normalization after reviser
+- no loops
+- if a normalization pass still fails to re-enter the desired band, save the chapter and attach a warning
+
+This prevents runaway cost and “rewrite forever to chase a number” behavior.
+
+## Normalizer Responsibilities
+
+The normalizer is not another reviser. It has a narrower contract:
+
+- preserve chapter intent
+- preserve hard facts
+- preserve current plot beats
+- preserve final hook / scene completeness
+- change length only through local compression or elaboration
+
+It must not:
+
+- add a new subplot
+- consume a future reveal
+- retcon facts
+- silently delete a required resolution beat
+
+## Logging and Telemetry
+
+Each chapter should expose a compact length trace:
+
+- `target`
+- `softMin`
+- `softMax`
+- `hardMin`
+- `hardMax`
+- `countingMode`
+- `writerCount`
+- `postWriterNormalizeCount`
+- `postReviseCount`
+- `finalCount`
+- `normalizeApplied`
+- `lengthWarning`
+
+This should be visible enough for debugging and issue triage, even if the full trace stays internal.
+
+## Failure Strategy
+
+Conservative failure policy:
+
+1. outside soft range -> try one normalize pass
+2. still outside soft range -> continue
+3. outside hard range at final save -> persist plus warning
+
+InkOS should not hard-truncate the prose and should not re-run the whole chapter indefinitely.
+
+## Testing Strategy
+
+Minimum coverage:
+
+1. Writer over target triggers normalization
+2. Reviser drift triggers post-revise normalization
+3. Normalizer runs at most once per boundary
+4. Chinese and English counting modes differ correctly
+5. Final hard-range miss records a warning instead of causing destructive fallback
+
+## Rollout
+
+This should ship as default pipeline behavior, not as a separate user feature flag.
+
+Reason:
+
+- issue `#58` affects baseline usability
+- the conservative governor is additive and low risk
+- the system already accepts that length is approximate, so tightening approximation is a compatibility improvement
+
+## Future Work
+
+If the conservative governor is still too loose, the next step is not more prompt tweaking. The next step is scene-level planning:
+
+- planner-generated scene budgets
+- scene-aware writer generation
+- explicit remaining-budget tracking inside the write loop
+
+That is intentionally deferred out of this MVP.