|
|
@@ -1,31 +1,29 @@
|
|
|
import { describe, it, expect } from "vitest"
|
|
|
import {
|
|
|
+ charsPerTokenForLanguage,
|
|
|
computeContextBudget,
|
|
|
computeNovelContextTokenBudget,
|
|
|
computeWritingContextPackTokenBudget,
|
|
|
- contextScaleForLanguage,
|
|
|
resolveContextPackTokenBudget,
|
|
|
planChapterRequestBudget,
|
|
|
planLlmRequestBudget,
|
|
|
planOutlineRequestBudget,
|
|
|
LlmContextBudgetError,
|
|
|
- WRITING_OUTPUT_RESERVE_MULTIPLIER,
|
|
|
} from "./context-budget"
|
|
|
|
|
|
-// The base-math tests pin langScale=1 so they stay deterministic
|
|
|
+// The base-math tests pin charsPerToken explicitly so they stay deterministic
|
|
|
// regardless of the active UI language (the app defaults to zh).
|
|
|
describe("computeContextBudget", () => {
|
|
|
- it("falls back to the 200K-char default for falsy input", () => {
|
|
|
+ it("falls back to the 200K-token default for falsy input", () => {
|
|
|
expect(computeContextBudget(undefined, 1).maxCtx).toBe(204_800)
|
|
|
expect(computeContextBudget(0, 1).maxCtx).toBe(204_800)
|
|
|
expect(computeContextBudget(Number.NaN, 1).maxCtx).toBe(204_800)
|
|
|
})
|
|
|
|
|
|
- it("allocates fractional sub-budgets from the window", () => {
|
|
|
- const b = computeContextBudget(200_000, 1)
|
|
|
- expect(b.responseReserve).toBe(30_000)
|
|
|
- expect(b.indexBudget).toBe(10_000)
|
|
|
- expect(b.pageBudget).toBe(100_000)
|
|
|
+ it("converts the token window into a character capacity", () => {
|
|
|
+ const b = computeContextBudget(200_000, 4)
|
|
|
+ expect(b.maxCtx).toBe(800_000)
|
|
|
+ expect(b.responseReserve).toBe(120_000)
|
|
|
})
|
|
|
})
|
|
|
|
|
|
@@ -39,63 +37,107 @@ describe("shared LLM request budget", () => {
|
|
|
minimumContextTokens: 4_000,
|
|
|
})
|
|
|
expect(plan).toMatchObject({
|
|
|
- windowTokens: 51_200,
|
|
|
+ windowTokens: 184_320,
|
|
|
outputTokens: 16_384,
|
|
|
- contextTokenBudget: 26_624,
|
|
|
+ contextTokenBudget: 40_000,
|
|
|
scaffoldReserveTokens: 8_192,
|
|
|
- inputTokenBudget: 34_816,
|
|
|
+ inputTokenBudget: 167_936,
|
|
|
})
|
|
|
expect(
|
|
|
plan.outputTokens + plan.contextTokenBudget + plan.scaffoldReserveTokens,
|
|
|
).toBeLessThanOrEqual(plan.windowTokens)
|
|
|
})
|
|
|
|
|
|
+ it("treats maxContextSize as tokens, not characters", () => {
|
|
|
+ // The window is a token count already; planning must not divide it down.
|
|
|
+ // Before this was fixed a 200K window planned against 51200 tokens, so a
|
|
|
+ // Chinese session could only use a quarter of the model's real capacity.
|
|
|
+ const plan = planLlmRequestBudget({
|
|
|
+ maxContextSize: 204_800,
|
|
|
+ desiredOutputTokens: 8_192,
|
|
|
+ scaffoldReserveTokens: 0,
|
|
|
+ })
|
|
|
+ expect(plan.windowTokens).toBeGreaterThan(180_000)
|
|
|
+ expect(plan.inputTokenBudget).toBeGreaterThan(170_000)
|
|
|
+ })
|
|
|
+
|
|
|
it("reduces output but never below 512 before rejecting an impossible window", () => {
|
|
|
const reduced = planLlmRequestBudget({
|
|
|
- maxContextSize: 4_096,
|
|
|
+ maxContextSize: 1_024,
|
|
|
desiredOutputTokens: 8_192,
|
|
|
scaffoldReserveTokens: 256,
|
|
|
minimumContextTokens: 400,
|
|
|
})
|
|
|
expect(reduced.outputTokens).toBe(512)
|
|
|
- expect(reduced.contextTokenBudget).toBe(256)
|
|
|
+ expect(reduced.contextTokenBudget).toBe(153)
|
|
|
expect(() => planLlmRequestBudget({
|
|
|
- maxContextSize: 2_000,
|
|
|
+ maxContextSize: 512,
|
|
|
desiredOutputTokens: 8_192,
|
|
|
scaffoldReserveTokens: 64,
|
|
|
})).toThrow(LlmContextBudgetError)
|
|
|
})
|
|
|
+
|
|
|
+ it("converges output down to the declared output cap", () => {
|
|
|
+ const plan = planLlmRequestBudget({
|
|
|
+ maxContextSize: 1_000_000,
|
|
|
+ desiredOutputTokens: 150_000,
|
|
|
+ scaffoldReserveTokens: 8_192,
|
|
|
+ maxOutputTokensCap: 65_536,
|
|
|
+ })
|
|
|
+ expect(plan.outputTokens).toBe(65_536)
|
|
|
+ })
|
|
|
+
|
|
|
+ it("raises output to the thinking floor without passing the cap", () => {
|
|
|
+ const raised = planLlmRequestBudget({
|
|
|
+ maxContextSize: 204_800,
|
|
|
+ desiredOutputTokens: 4_000,
|
|
|
+ scaffoldReserveTokens: 8_192,
|
|
|
+ thinkingFloorTokens: 16_384,
|
|
|
+ })
|
|
|
+ expect(raised.outputTokens).toBe(16_384)
|
|
|
+
|
|
|
+ const capped = planLlmRequestBudget({
|
|
|
+ maxContextSize: 204_800,
|
|
|
+ desiredOutputTokens: 4_000,
|
|
|
+ scaffoldReserveTokens: 8_192,
|
|
|
+ thinkingFloorTokens: 16_384,
|
|
|
+ maxOutputTokensCap: 8_192,
|
|
|
+ })
|
|
|
+ expect(capped.outputTokens).toBe(8_192)
|
|
|
+ })
|
|
|
})
|
|
|
|
|
|
describe("outline request budget", () => {
|
|
|
- it.each([
|
|
|
- [204_800, 8_192, 16_384],
|
|
|
- [262_143, 8_192, 16_384],
|
|
|
- [262_144, 8_192, 24_576],
|
|
|
- [524_287, 8_192, 24_576],
|
|
|
- [524_288, 8_192, 32_768],
|
|
|
- [1_000_000, 8_192, 32_768],
|
|
|
- ])("uses stage tiers at window %i", (maxContextSize, analysis, generation) => {
|
|
|
+ it("scales the output with the window instead of stepping through tiers", () => {
|
|
|
expect(planOutlineRequestBudget({
|
|
|
- maxContextSize,
|
|
|
+ maxContextSize: 204_800,
|
|
|
stage: "analysis",
|
|
|
- langScale: 1,
|
|
|
- }).outputTokens).toBe(analysis)
|
|
|
+ }).outputTokens).toBe(8_192)
|
|
|
+ expect(planOutlineRequestBudget({
|
|
|
+ maxContextSize: 204_800,
|
|
|
+ stage: "generation",
|
|
|
+ }).outputTokens).toBe(30_720)
|
|
|
+ expect(planOutlineRequestBudget({
|
|
|
+ maxContextSize: 1_000_000,
|
|
|
+ stage: "generation",
|
|
|
+ }).outputTokens).toBe(150_000)
|
|
|
+ })
|
|
|
+
|
|
|
+ it("bounds the generation output by the declared output cap", () => {
|
|
|
expect(planOutlineRequestBudget({
|
|
|
- maxContextSize,
|
|
|
+ maxContextSize: 1_000_000,
|
|
|
stage: "generation",
|
|
|
- langScale: 1,
|
|
|
- }).outputTokens).toBe(generation)
|
|
|
+ maxOutputTokens: 65_536,
|
|
|
+ }).outputTokens).toBe(65_536)
|
|
|
})
|
|
|
|
|
|
it("silently raises a legacy 128K window to 204800", () => {
|
|
|
const plan = planOutlineRequestBudget({
|
|
|
maxContextSize: 128_000,
|
|
|
stage: "generation",
|
|
|
- langScale: 1,
|
|
|
})
|
|
|
- expect(plan.windowTokens).toBe(51_200)
|
|
|
- expect(plan.outputTokens).toBe(16_384)
|
|
|
+ expect(plan.windowTokens).toBe(184_320)
|
|
|
+ expect(plan.outputTokens).toBe(30_720)
|
|
|
})
|
|
|
})
|
|
|
|
|
|
@@ -109,7 +151,6 @@ describe("chapter request budget", () => {
|
|
|
maxContextSize: 204_800,
|
|
|
chapterTargetChars,
|
|
|
stage: "generation",
|
|
|
- langScale: 1,
|
|
|
})
|
|
|
expect(plan.outputTokens).toBe(outputTokens)
|
|
|
expect(
|
|
|
@@ -117,85 +158,95 @@ describe("chapter request budget", () => {
|
|
|
).toBeLessThanOrEqual(plan.windowTokens)
|
|
|
})
|
|
|
|
|
|
+ it("keeps chapter output tied to target length, not to the window", () => {
|
|
|
+ // A 3000-character chapter needs the same output on a 1M model as on a 200K
|
|
|
+ // one, so the generous window must not inflate the request.
|
|
|
+ expect(planChapterRequestBudget({
|
|
|
+ maxContextSize: 1_000_000,
|
|
|
+ chapterTargetChars: 3_000,
|
|
|
+ stage: "generation",
|
|
|
+ }).outputTokens).toBe(8_000)
|
|
|
+ })
|
|
|
+
|
|
|
+ it("bounds the generation output by the declared output cap", () => {
|
|
|
+ expect(planChapterRequestBudget({
|
|
|
+ maxContextSize: 204_800,
|
|
|
+ chapterTargetChars: 6_000,
|
|
|
+ stage: "generation",
|
|
|
+ maxOutputTokens: 4_096,
|
|
|
+ }).outputTokens).toBe(4_096)
|
|
|
+ })
|
|
|
+
|
|
|
it("uses 4096 tokens for task analysis", () => {
|
|
|
expect(planChapterRequestBudget({
|
|
|
maxContextSize: 204_800,
|
|
|
chapterTargetChars: 3_000,
|
|
|
stage: "analysis",
|
|
|
- langScale: 1,
|
|
|
}).outputTokens).toBe(4_096)
|
|
|
})
|
|
|
})
|
|
|
|
|
|
-describe("contextScaleForLanguage", () => {
|
|
|
- it("keeps scale 1 for English and other non-CJK languages", () => {
|
|
|
- expect(contextScaleForLanguage("en")).toBe(1)
|
|
|
- expect(contextScaleForLanguage("en-US")).toBe(1)
|
|
|
- expect(contextScaleForLanguage("fr")).toBe(1)
|
|
|
+describe("charsPerTokenForLanguage", () => {
|
|
|
+ it("uses the 4:1 ratio for English and other non-CJK languages", () => {
|
|
|
+ expect(charsPerTokenForLanguage("en")).toBe(4)
|
|
|
+ expect(charsPerTokenForLanguage("en-US")).toBe(4)
|
|
|
+ expect(charsPerTokenForLanguage("fr")).toBe(4)
|
|
|
})
|
|
|
|
|
|
it("falls back to the active UI language when none is given", () => {
|
|
|
- // Test env initialises i18n to zh, so the implicit lookup is CJK-scaled.
|
|
|
- expect(contextScaleForLanguage()).toBeCloseTo(0.425, 5)
|
|
|
+ // Test env initialises i18n to zh, so the implicit lookup is CJK.
|
|
|
+ expect(charsPerTokenForLanguage()).toBe(1)
|
|
|
})
|
|
|
|
|
|
- it("shrinks the window for CJK languages", () => {
|
|
|
- expect(contextScaleForLanguage("zh")).toBeCloseTo(0.425, 5)
|
|
|
- expect(contextScaleForLanguage("zh-CN")).toBeCloseTo(0.425, 5)
|
|
|
- expect(contextScaleForLanguage("ja")).toBeCloseTo(0.425, 5)
|
|
|
- expect(contextScaleForLanguage("ko")).toBeCloseTo(0.425, 5)
|
|
|
+ it("counts one character per token for CJK languages", () => {
|
|
|
+ // Must match the token estimator, which also counts 1 CJK char = 1 token.
|
|
|
+ expect(charsPerTokenForLanguage("zh")).toBe(1)
|
|
|
+ expect(charsPerTokenForLanguage("zh-CN")).toBe(1)
|
|
|
+ expect(charsPerTokenForLanguage("ja")).toBe(1)
|
|
|
+ expect(charsPerTokenForLanguage("ko")).toBe(1)
|
|
|
})
|
|
|
})
|
|
|
|
|
|
describe("computeContextBudget language scaling", () => {
|
|
|
- it("scales the effective window down for CJK UIs", () => {
|
|
|
- const zh = contextScaleForLanguage("zh")
|
|
|
- expect(computeContextBudget(200_000, zh).maxCtx).toBe(85_000)
|
|
|
- expect(computeContextBudget(204_800, zh).maxCtx).toBe(87_040)
|
|
|
+ it("yields fewer characters for CJK because each token holds less", () => {
|
|
|
+ const zh = charsPerTokenForLanguage("zh")
|
|
|
+ expect(computeContextBudget(200_000, zh).maxCtx).toBe(200_000)
|
|
|
+ expect(computeContextBudget(204_800, zh).maxCtx).toBe(204_800)
|
|
|
})
|
|
|
|
|
|
- it("leaves English windows untouched", () => {
|
|
|
- expect(computeContextBudget(200_000, contextScaleForLanguage("en")).maxCtx).toBe(200_000)
|
|
|
+ it("yields four characters per token for English", () => {
|
|
|
+ expect(computeContextBudget(200_000, charsPerTokenForLanguage("en")).maxCtx).toBe(800_000)
|
|
|
})
|
|
|
})
|
|
|
|
|
|
describe("computeNovelContextTokenBudget", () => {
|
|
|
- it("preserves the legacy 32K-token deep-chapter budget on the default window", () => {
|
|
|
- // Default window (204800 chars) → cap 33280 tokens, so 32000 is kept intact.
|
|
|
- expect(computeNovelContextTokenBudget(204_800, 32_000, 1)).toBe(32_000)
|
|
|
- expect(computeNovelContextTokenBudget(undefined, 32_000, 1)).toBe(32_000)
|
|
|
+ it("keeps a requested budget that fits under the window share", () => {
|
|
|
+ expect(computeNovelContextTokenBudget(204_800, 32_000)).toBe(32_000)
|
|
|
+ expect(computeNovelContextTokenBudget(undefined, 32_000)).toBe(32_000)
|
|
|
})
|
|
|
|
|
|
it("caps an unset (0 / unlimited) budget at the window-derived ceiling", () => {
|
|
|
- expect(computeNovelContextTokenBudget(204_800, 0, 1)).toBe(33_280)
|
|
|
- expect(computeNovelContextTokenBudget(204_800, undefined, 1)).toBe(33_280)
|
|
|
+ expect(computeNovelContextTokenBudget(204_800, 0)).toBe(133_120)
|
|
|
+ expect(computeNovelContextTokenBudget(204_800, undefined)).toBe(133_120)
|
|
|
})
|
|
|
|
|
|
it("clamps an over-large user budget down to the ceiling", () => {
|
|
|
- expect(computeNovelContextTokenBudget(204_800, 100_000, 1)).toBe(33_280)
|
|
|
+ expect(computeNovelContextTokenBudget(204_800, 200_000)).toBe(133_120)
|
|
|
})
|
|
|
|
|
|
it("shrinks the budget proportionally for small windows", () => {
|
|
|
- // 32000 chars → floor(32000 * 0.65 / 4) = 5200 tokens.
|
|
|
- expect(computeNovelContextTokenBudget(32_000, 32_000, 1)).toBe(5_200)
|
|
|
+ expect(computeNovelContextTokenBudget(32_000, 32_000)).toBe(20_800)
|
|
|
})
|
|
|
|
|
|
it("never drops below the token floor", () => {
|
|
|
- expect(computeNovelContextTokenBudget(1_000, 0, 1)).toBe(4_000)
|
|
|
- })
|
|
|
-
|
|
|
- it("tightens the ceiling for CJK UIs so the same request is capped down", () => {
|
|
|
- // zh: maxCtx 204800*0.425=87040 → cap floor(87040*0.65/4)=14144 tokens.
|
|
|
- const zh = contextScaleForLanguage("zh")
|
|
|
- expect(computeNovelContextTokenBudget(204_800, 32_000, zh)).toBe(14_144)
|
|
|
- expect(computeNovelContextTokenBudget(204_800, 0, zh)).toBe(14_144)
|
|
|
+ expect(computeNovelContextTokenBudget(1_000, 0)).toBe(4_000)
|
|
|
})
|
|
|
})
|
|
|
|
|
|
describe("resolveContextPackTokenBudget", () => {
|
|
|
it("always returns a positive finite budget for auto mode", () => {
|
|
|
- const budget = resolveContextPackTokenBudget({ maxContextSize: 204_800, contextTokenBudget: 0, langScale: 1 })
|
|
|
- expect(budget).toBe(33_280)
|
|
|
+ const budget = resolveContextPackTokenBudget({ maxContextSize: 204_800, contextTokenBudget: 0 })
|
|
|
+ expect(budget).toBe(133_120)
|
|
|
expect(Number.isFinite(budget)).toBe(true)
|
|
|
})
|
|
|
|
|
|
@@ -203,79 +254,69 @@ describe("resolveContextPackTokenBudget", () => {
|
|
|
expect(resolveContextPackTokenBudget({
|
|
|
maxContextSize: 204_800,
|
|
|
contextTokenBudget: 10_000,
|
|
|
- langScale: 1,
|
|
|
})).toBe(10_000)
|
|
|
})
|
|
|
})
|
|
|
|
|
|
describe("computeWritingContextPackTokenBudget", () => {
|
|
|
- it("reserves at least maxOutputTokens (as chars) before allocating the pack", () => {
|
|
|
- const maxContextSize = 204_800
|
|
|
- const chapterTargetChars = 3_000
|
|
|
- const langScale = 1
|
|
|
- const { maxCtx } = computeContextBudget(maxContextSize, langScale)
|
|
|
+ it("leaves room for the chapter output and scaffolding inside the window", () => {
|
|
|
+ const plan = planChapterRequestBudget({
|
|
|
+ maxContextSize: 204_800,
|
|
|
+ contextTokenBudget: 0,
|
|
|
+ chapterTargetChars: 3_000,
|
|
|
+ stage: "generation",
|
|
|
+ })
|
|
|
const budget = computeWritingContextPackTokenBudget({
|
|
|
- maxContextSize,
|
|
|
+ maxContextSize: 204_800,
|
|
|
contextTokenBudget: 0,
|
|
|
- chapterTargetChars,
|
|
|
- langScale,
|
|
|
+ chapterTargetChars: 3_000,
|
|
|
})
|
|
|
- const maxOutputTokens = 8_000
|
|
|
- const targetReserveTokens = Math.ceil((chapterTargetChars * WRITING_OUTPUT_RESERVE_MULTIPLIER) / 1.7)
|
|
|
- const outputReserveChars = Math.max(targetReserveTokens, maxOutputTokens) * 4
|
|
|
- const scaffold = Math.max(8_000, Math.floor(maxCtx * 0.08))
|
|
|
- expect(budget * 4 + outputReserveChars + scaffold).toBeLessThanOrEqual(maxCtx)
|
|
|
- expect(outputReserveChars).toBeGreaterThanOrEqual(maxOutputTokens * 4)
|
|
|
+ expect(budget).toBe(plan.contextTokenBudget)
|
|
|
+ expect(budget + plan.outputTokens + plan.scaffoldReserveTokens)
|
|
|
+ .toBeLessThanOrEqual(plan.windowTokens)
|
|
|
})
|
|
|
|
|
|
it("shrinks when chapter target grows", () => {
|
|
|
const smallTarget = computeWritingContextPackTokenBudget({
|
|
|
maxContextSize: 204_800,
|
|
|
chapterTargetChars: 3_000,
|
|
|
- langScale: 1,
|
|
|
})
|
|
|
const largeTarget = computeWritingContextPackTokenBudget({
|
|
|
maxContextSize: 204_800,
|
|
|
chapterTargetChars: 6_000,
|
|
|
- langScale: 1,
|
|
|
})
|
|
|
expect(largeTarget).toBeLessThanOrEqual(smallTarget)
|
|
|
})
|
|
|
|
|
|
it("grows with a larger window within the general cap", () => {
|
|
|
const smallWindow = computeWritingContextPackTokenBudget({
|
|
|
- maxContextSize: 64_000,
|
|
|
+ maxContextSize: 204_800,
|
|
|
chapterTargetChars: 3_000,
|
|
|
- langScale: 1,
|
|
|
})
|
|
|
const largeWindow = computeWritingContextPackTokenBudget({
|
|
|
- maxContextSize: 204_800,
|
|
|
+ maxContextSize: 1_000_000,
|
|
|
chapterTargetChars: 3_000,
|
|
|
- langScale: 1,
|
|
|
})
|
|
|
- expect(largeWindow).toBeGreaterThanOrEqual(smallWindow)
|
|
|
+ expect(largeWindow).toBeGreaterThan(smallWindow)
|
|
|
})
|
|
|
|
|
|
it("never exceeds the general window cap", () => {
|
|
|
const budget = computeWritingContextPackTokenBudget({
|
|
|
maxContextSize: 204_800,
|
|
|
chapterTargetChars: 3_000,
|
|
|
- langScale: 1,
|
|
|
})
|
|
|
- expect(budget).toBeLessThanOrEqual(computeNovelContextTokenBudget(204_800, 0, 1))
|
|
|
+ expect(budget).toBeLessThanOrEqual(computeNovelContextTokenBudget(204_800, 0))
|
|
|
})
|
|
|
|
|
|
it("clamps an explicit user budget to the writing-derived auto budget", () => {
|
|
|
const auto = computeWritingContextPackTokenBudget({
|
|
|
maxContextSize: 204_800,
|
|
|
chapterTargetChars: 3_000,
|
|
|
- langScale: 1,
|
|
|
})
|
|
|
expect(computeWritingContextPackTokenBudget({
|
|
|
maxContextSize: 204_800,
|
|
|
- contextTokenBudget: 100_000,
|
|
|
+ contextTokenBudget: 300_000,
|
|
|
chapterTargetChars: 3_000,
|
|
|
- langScale: 1,
|
|
|
})).toBe(auto)
|
|
|
})
|
|
|
})
|