scale-benchmark.spec.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. import { afterAll, describe, expect, it } from 'vitest'
  2. import * as fs from 'node:fs'
  3. import * as os from 'node:os'
  4. import * as path from 'node:path'
  5. import {
  6. assembleMaterials, deriveChapterFacts, listChapters, loadMaterialPackage,
  7. paths, queryLedger, scanChapter, serializeDocument, syncFinalizedIndex,
  8. searchFinalized, writeAuthorMemory,
  9. } from '../src/index'
  10. import { makeFixtureBook } from './helpers/fixture-book'
  11. import type { EmbeddingProvider } from '../src/retrieval/types'
  12. /** 确定性嵌入桩(无网络):文本码位伪向量,供容量基准测量索引构建路径,不冒充真实嵌入质量。 */
  13. const stubVec = (text: string): readonly number[] => {
  14. const v = [0, 0, 0, 0, 0, 0, 0, 0]
  15. for (let i = 0; i < text.length; i++) v[i % 8] += text.charCodeAt(i) % 997
  16. return v.map((x) => x / 1000)
  17. }
  18. const stubProvider: EmbeddingProvider = {
  19. metadata: { provider: 'stub', model: 'stub-8d', dimensions: 8, revision: 'v1', batchSize: 64 },
  20. embed: (inputs) => Promise.resolve(inputs.map((input) => stubVec(input.text))),
  21. embedBatch: (inputs) => Promise.resolve(inputs.map((input) => stubVec(input.text))),
  22. }
  23. /**
  24. * 10B 合成容量基准(任务 09-06-longform-scale-validation)。
  25. * 固定种子、不请求模型;200/1000 章、约 200 万字书仓。
  26. * 默认跳过以免拖慢常规测试;显式运行:SCALE_BENCH=1 pnpm exec vitest run packages/core/tests/scale-benchmark.spec.ts
  27. */
  28. const RUN = process.env['SCALE_BENCH'] === '1'
  29. const KEEP = process.env['KEEP_10B'] === '1'
  30. const REPS = 5
  31. const roots: string[] = []
  32. afterAll(() => {
  33. if (KEEP) return
  34. for (const root of roots) {
  35. try { fs.rmSync(root, { recursive: true, force: true, maxRetries: 6, retryDelay: 300 }) } catch { /* %TEMP% 残留无害 */ }
  36. }
  37. })
  38. interface BenchResult { op: string; book: string; median: number; p95: number; detail?: string }
  39. const results: BenchResult[] = []
  40. function timeOp(op: string, book: string, fn: () => unknown, detail?: string): void {
  41. const times: number[] = []
  42. for (let i = 0; i < REPS; i++) {
  43. const t0 = performance.now()
  44. fn()
  45. times.push(performance.now() - t0)
  46. }
  47. times.sort((a, b) => a - b)
  48. results.push({ op, book, median: times[Math.floor(times.length / 2)]!, p95: times[Math.min(times.length - 1, Math.ceil(times.length * 0.95) - 1)]!, detail })
  49. }
  50. function makeBook(label: string, chapters: number, opts?: { 记忆条数?: number; 卷摘要?: number }): { root: string; buildMs: number } {
  51. const t0 = performance.now()
  52. const root = fs.mkdtempSync(path.join(os.tmpdir(), `webnovel-10b-${label}-`))
  53. roots.push(root)
  54. makeFixtureBook(root, chapters, { 每卷章数: 50 })
  55. // 夹具补记:末章须见于本卷卷纲脚手架(真实书里卷纲随章滚动补记)
  56. const lateVol = Math.ceil(chapters / 50)
  57. fs.appendFileSync(path.join(root, paths.卷纲(lateVol)), `\n- 卷末收束:章${chapters} 〔已确认〕\n`, 'utf-8')
  58. const memoryCount = opts?.记忆条数 ?? 0
  59. for (let i = 1; i <= memoryCount; i++) {
  60. const r = writeAuthorMemory(root, {
  61. 名称: `决策样本${String(i).padStart(3, '0')}`, 类: '决策',
  62. 描述: `第${i}条作者决策的一句话描述`,
  63. 标签: ['容量夹具'],
  64. 来源: '对谈共创', 正文: `这是第${i}条作者决策的正文,记录当时的判断依据与适用边界。`.repeat(3),
  65. })
  66. if (!r.ok) throw new Error(`记忆写入失败:${r.reason}`)
  67. }
  68. for (let v = 1; v <= (opts?.卷摘要 ?? 0); v++) {
  69. const rel = paths.卷摘要(v)
  70. fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true })
  71. fs.writeFileSync(path.join(root, rel), `# 卷摘要\n\n卷${v}的阶段摘要:主线推进与伏笔状态。\n`.repeat(20), 'utf-8')
  72. }
  73. // 加厚定稿章至约 2500 字/章(1000 章 ≈ 250 万字总定稿),保持确定性内容
  74. const finalizedDir = path.join(root, '定稿')
  75. const fatten = (dir: string): void => {
  76. for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
  77. const abs = path.join(dir, e.name)
  78. if (e.isDirectory()) fatten(abs)
  79. else if (e.name.endsWith('.md') && !e.name.endsWith('.gitkeep')) {
  80. fs.appendFileSync(abs, `\n${'剧情依细纲推进,线索逐章埋设与回收。'.repeat(120)}\n`, 'utf-8')
  81. }
  82. }
  83. }
  84. fatten(finalizedDir)
  85. return { root, buildMs: performance.now() - t0 }
  86. }
  87. function charCount(root: string): number {
  88. let total = 0
  89. const walk = (dir: string): void => {
  90. for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
  91. const abs = path.join(dir, e.name)
  92. if (e.isDirectory()) walk(abs)
  93. else if (e.name.endsWith('.md')) total += fs.readFileSync(abs, 'utf-8').length
  94. }
  95. }
  96. walk(path.join(root, '定稿'))
  97. return total
  98. }
  99. async function benchBook(label: string, root: string, chapters: number): Promise<void> {
  100. const late = { 卷: Math.ceil(chapters / 50), 章: chapters, 章名: `章${chapters}` }
  101. const early = { 卷: 1, 章: 1, 章名: '章1' }
  102. timeOp('listChapters', label, () => {
  103. const keys = listChapters(root)
  104. expect(keys.filter((k) => k.章 > 0).length).toBeGreaterThanOrEqual(chapters)
  105. })
  106. timeOp('scanChapter+derive(末章)', label, () => {
  107. const sc = scanChapter(root, late)
  108. expect(sc.key.章).toBe(late.章)
  109. expect(deriveChapterFacts(sc).事实项.length).toBeGreaterThan(0)
  110. })
  111. timeOp('assembleMaterials(末章)', label, () => {
  112. const r = assembleMaterials(root, late)
  113. expect(r.ok, r.gaps.join(';')).toBe(true)
  114. }, '十段+窗口切片')
  115. timeOp('loadMaterialPackage(末章)', label, () => {
  116. const pkg = loadMaterialPackage(root, late)
  117. expect(pkg.问题, pkg.问题.join(';')).toEqual([])
  118. expect(Object.keys(pkg.段).length).toBeGreaterThan(0)
  119. })
  120. timeOp('queryLedger(全部线索)', label, () => {
  121. const r = queryLedger(root, { 名称: '线索' })
  122. expect(r.ok).toBe(true)
  123. })
  124. timeOp('queryLedger(章上限=50)', label, () => {
  125. const r = queryLedger(root, { 名称: '线索', 章上限: 50 })
  126. expect(r.ok).toBe(true)
  127. })
  128. const syncTimes: number[] = []
  129. let syncDetail = ''
  130. for (let i = 0; i < REPS; i++) {
  131. const t0 = performance.now()
  132. const syncResult = await syncFinalizedIndex(root, { getProvider: () => stubProvider })
  133. if (!syncResult.ok) throw new Error(`索引同步失败:${syncResult.failure.message}`)
  134. syncTimes.push(performance.now() - t0)
  135. syncDetail = `索引${syncResult.state.chapters}章/场景生成${syncResult.state.generated}`
  136. }
  137. syncTimes.sort((a, b) => a - b)
  138. results.push({ op: 'syncFinalizedIndex(全量重建)', book: label, median: syncTimes[Math.floor(syncTimes.length / 2)]!, p95: syncTimes[syncTimes.length - 1]!, detail: syncDetail })
  139. const searchTimes: number[] = []
  140. for (let i = 0; i < REPS; i++) {
  141. const t0 = performance.now()
  142. const r = await searchFinalized(root, { query: '城门', mode: 'keyword' })
  143. expect(r.ok).toBe(true)
  144. searchTimes.push(performance.now() - t0)
  145. }
  146. searchTimes.sort((a, b) => a - b)
  147. results.push({ op: 'searchFinalized(关键词)', book: label, median: searchTimes[Math.floor(searchTimes.length / 2)]!, p95: searchTimes[searchTimes.length - 1]!, detail: syncDetail })
  148. // 历史章界不变量:首章与末章材料均不得串入未来章事实
  149. expect(assembleMaterials(root, early).ok).toBe(true)
  150. const latePkg = loadMaterialPackage(root, late)
  151. expect(latePkg.问题).toEqual([])
  152. const lateText = JSON.stringify(latePkg.段)
  153. expect(/第9\d{3}章|事实@第9\d{3}章/.test(lateText)).toBe(false)
  154. results.push({ op: 'invariant:历史章界', book: label, median: 0, p95: 0, detail: '通过' })
  155. }
  156. describe.skipIf(!RUN)('10B 合成容量基准(200/1000 章)', () => {
  157. it('200 章书', async () => {
  158. const small = makeBook('200', 200, { 记忆条数: 30, 卷摘要: 4 })
  159. await benchBook('200章', small.root, 200)
  160. writeReport({ 小书: { chapters: 200, 定稿字数: charCount(small.root), buildMs: Math.round(small.buildMs), 记忆: 30, 卷摘要: 4 } }, '200')
  161. }, 300_000)
  162. it('1000 章书', async () => {
  163. const big = makeBook('1000', 1000, { 记忆条数: 200, 卷摘要: 20 })
  164. await benchBook('1000章', big.root, 1000)
  165. writeReport({ 大书: { chapters: 1000, 定稿字数: charCount(big.root), buildMs: Math.round(big.buildMs), 记忆: 200, 卷摘要: 20 } }, '1000')
  166. }, 600_000)
  167. })
  168. function writeReport(fixture: Record<string, unknown>, tag: string): void {
  169. const report = {
  170. date: '2026-09-19',
  171. node: process.version,
  172. platform: process.platform,
  173. reps: REPS,
  174. fixture,
  175. results: [...results],
  176. roots: KEEP ? roots : undefined,
  177. }
  178. fs.mkdirSync('.tmp/scale-benchmark', { recursive: true })
  179. fs.writeFileSync(`.tmp/scale-benchmark/10b-benchmark-results-${tag}-2026-09-19.json`, JSON.stringify(report, null, 2) + '\n')
  180. for (const r of results) console.log(`${r.book} ${r.op}: median ${r.median.toFixed(1)}ms p95 ${r.p95.toFixed(1)}ms ${r.detail ?? ''}`)
  181. }