compression.spec.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. import { describe, expect, it } from 'vitest'
  2. import { createHash } from 'node:crypto'
  3. import { readFileSync } from 'node:fs'
  4. import { zstdCompressSync } from 'node:zlib'
  5. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  6. import { ToolCallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
  7. import {
  8. decodeStorageRecord,
  9. MAX_PACKED_DATA_BYTES,
  10. MAX_PACKED_ROW_MEMBERS,
  11. packChunkRuns,
  12. type StorageRecord,
  13. } from '../src/codec.ts'
  14. import {
  15. bindRecord,
  16. decodeRow,
  17. scanRows,
  18. } from '../src/compression.ts'
  19. import type { EventRow } from '../src/schema.ts'
  20. function chunk(seq: number, text = `token-${seq}`): SessionEvent {
  21. return {
  22. type: 'assistant/chunk',
  23. seq,
  24. time: 1_000 + seq,
  25. data: {
  26. turn: 1,
  27. step: 1,
  28. chunk: { type: 'text-delta', index: 0, text },
  29. },
  30. }
  31. }
  32. function event(seq: number, time: number, value: StreamChunk, turn = 1, step = 1): SessionEvent {
  33. return { type: 'assistant/chunk', seq, time, data: { turn, step, chunk: value } }
  34. }
  35. function row(record: StorageRecord): EventRow {
  36. const bound = bindRecord(record)
  37. return {
  38. seq: bound.seq,
  39. type: bound.type,
  40. time: bound.time,
  41. data: bound.data,
  42. source_event_seqs: bound.sourceEventSeqs,
  43. surface_op: bound.surfaceOp,
  44. is_packed: bound.isPacked,
  45. }
  46. }
  47. describe('SQLite compression', () => {
  48. it('pins the schema-19 dictionary bytes', () => {
  49. const dictionary = readFileSync(new URL('../resources/zstd-dictionary.bin', import.meta.url))
  50. expect(createHash('sha256').update(dictionary).digest('hex'))
  51. .toBe('dad18fa0247a8fdd886a62d8552eabd36cbd50c25af172873080d2f0ae770d17')
  52. })
  53. it('stores a 100-member run in one row and restores every logical event', () => {
  54. const events = Array.from({ length: 100 }, (_, index) => chunk(index))
  55. const records = packChunkRuns(events)
  56. expect(records).toHaveLength(1)
  57. expect(records[0]?.type).toBe('text-chunks')
  58. expect(scanRows(records.map(row)).preserved).toEqual(events)
  59. })
  60. it('partitions long and large runs within schema-owned row limits', () => {
  61. const long = Array.from({ length: MAX_PACKED_ROW_MEMBERS + 3 }, (_, index) => chunk(index))
  62. const longRecords = packChunkRuns(long)
  63. expect(longRecords).toHaveLength(2)
  64. expect(scanRows(longRecords.map(row)).preserved).toEqual(long)
  65. const large = Array.from({ length: 4 }, (_, index) => chunk(index, 'x'.repeat(300_000)))
  66. const largeRecords = packChunkRuns(large)
  67. expect(largeRecords).toHaveLength(2)
  68. for (const record of largeRecords) {
  69. if (record.type.endsWith('-chunks')) {
  70. expect(Buffer.byteLength(JSON.stringify(record.data))).toBeLessThanOrEqual(MAX_PACKED_DATA_BYTES)
  71. }
  72. }
  73. expect(scanRows(largeRecords.map(row)).preserved).toEqual(large)
  74. const individuallyLarge = Array.from({ length: 3 }, (_, index) => chunk(index, 'x'.repeat(400_000)))
  75. expect(packChunkRuns(individuallyLarge)).toEqual(individuallyLarge)
  76. const byteBound = Array.from({ length: 10 }, (_, index) => chunk(index, 'x'.repeat(150_000)))
  77. const byteBoundRecords = packChunkRuns(byteBound)
  78. expect(byteBoundRecords.length).toBeGreaterThan(1)
  79. expect(scanRows(byteBoundRecords.map(row)).preserved).toEqual(byteBound)
  80. })
  81. it('packs every owned kind and preserves optional tool-call names', () => {
  82. const events = [
  83. ...[0, 1, 2].map(seq => event(seq, seq, { type: 'reasoning-delta', index: 1, text: `${seq}` })),
  84. ...[3, 4, 5].map(seq => event(seq, seq, {
  85. type: 'tool-call-delta', index: 2, id: ToolCallId('named'), name: 'write', argumentsDelta: `${seq}`,
  86. })),
  87. ...[6, 7, 8].map(seq => event(seq, seq, {
  88. type: 'tool-call-delta', index: 3, id: ToolCallId('unnamed'), argumentsDelta: `${seq}`,
  89. })),
  90. ]
  91. const records = packChunkRuns(events)
  92. expect(records.map(record => record.type)).toEqual([
  93. 'reasoning-chunks', 'tool-call-chunks', 'tool-call-chunks',
  94. ])
  95. expect(records.flatMap(decodeStorageRecord)).toEqual(events)
  96. })
  97. it('keeps every off-format delta scalar and splits incompatible runs', () => {
  98. const malformed = (seq: number, data: unknown): SessionEvent => ({
  99. type: 'assistant/chunk', seq, time: 10 + seq, data,
  100. } as SessionEvent)
  101. const values: SessionEvent[] = [
  102. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  103. { ...chunk(1), extra: true } as unknown as SessionEvent,
  104. { ...chunk(-1), seq: -1 },
  105. { ...chunk(3), time: 1.5 },
  106. malformed(4, 'data'),
  107. malformed(5, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' }, extra: 1 }),
  108. malformed(6, { turn: '1', step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } }),
  109. malformed(7, { turn: 1, step: 1, chunk: 'chunk' }),
  110. malformed(8, { turn: 1, step: 1, chunk: { type: 'text-delta', index: '0', text: 'x' } }),
  111. malformed(9, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 1 } }),
  112. malformed(10, { turn: 1, step: 1, chunk: { type: 'tool-call-delta', index: 0, id: 1, argumentsDelta: 'x' } }),
  113. malformed(11, { turn: 1, step: 1, chunk: { type: 'tool-call-delta', index: 0, id: 'id', name: 1, argumentsDelta: 'x' } }),
  114. malformed(12, { turn: 1, step: 1, chunk: { type: 'usage', index: 0, totalTokens: 1 } }),
  115. ]
  116. expect(packChunkRuns(values)).toEqual(values)
  117. const gap = [chunk(0), chunk(1), chunk(3)]
  118. const step = [chunk(0), chunk(1), event(2, 2, { type: 'text-delta', index: 0, text: 'x' }, 1, 2)]
  119. const block = [chunk(0), chunk(1), event(2, 2, { type: 'text-delta', index: 1, text: 'x' })]
  120. const unsafeTime = [
  121. event(0, Number.MIN_SAFE_INTEGER, { type: 'text-delta', index: 0, text: 'a' }),
  122. event(1, Number.MAX_SAFE_INTEGER, { type: 'text-delta', index: 0, text: 'b' }),
  123. event(2, Number.MAX_SAFE_INTEGER, { type: 'text-delta', index: 0, text: 'c' }),
  124. ]
  125. const toolName = [0, 1, 2].map(seq => event(seq, seq, {
  126. type: 'tool-call-delta', index: 0, id: ToolCallId('id'),
  127. ...seq === 2 ? {} : { name: 'write' }, argumentsDelta: 'x',
  128. }))
  129. for (const events of [gap, step, block, unsafeTime, toolName]) {
  130. expect(packChunkRuns(events)).toEqual(events)
  131. }
  132. })
  133. it.each([
  134. ['extra envelope field', { type: 'text-chunks', seq0: 0, time0: 1, data: {}, extra: true }],
  135. ['negative sequence', { type: 'text-chunks', seq0: -1, time0: 1, data: {} }],
  136. ['fractional time', { type: 'text-chunks', seq0: 0, time0: 1.5, data: {} }],
  137. ['primitive data', { type: 'text-chunks', seq0: 0, time0: 1, data: 'bad' }],
  138. ['text fields', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], args: [] } }],
  139. ['non-numeric placement', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: '1', step: 1, index: 0, dt: [0, 0], texts: ['a', 'b', 'c'] } }],
  140. ['non-array members', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: 'abc' } }],
  141. ['too few members', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0], texts: ['a', 'b'] } }],
  142. ['too many members', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: Array(1_024).fill(0), texts: Array(1_025).fill('a') } }],
  143. ['non-string member', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: ['a', 1, 'c'] } }],
  144. ['invalid gaps', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0.5], texts: ['a', 'b', 'c'] } }],
  145. ['non-array gaps', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: '00', texts: ['a', 'b', 'c'] } }],
  146. ['gap arity', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0], texts: ['a', 'b', 'c'] } }],
  147. ['oversized data', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: ['x'.repeat(400_000), 'x'.repeat(400_000), 'x'.repeat(400_000)] } }],
  148. ['sequence overflow', { type: 'text-chunks', seq0: Number.MAX_SAFE_INTEGER, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: ['a', 'b', 'c'] } }],
  149. ['time overflow', { type: 'text-chunks', seq0: 0, time0: Number.MAX_SAFE_INTEGER, data: { turn: 1, step: 1, index: 0, dt: [1, 0], texts: ['a', 'b', 'c'] } }],
  150. ['tool fields', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], args: ['a', 'b', 'c'] } }],
  151. ['tool id', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, id: 1, dt: [0, 0], args: ['a', 'b', 'c'] } }],
  152. ['tool name', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, id: 'id', name: 1, dt: [0, 0], args: ['a', 'b', 'c'] } }],
  153. ])('rejects malformed packed data: %s', (_label, record) => {
  154. expect(() => decodeStorageRecord(record)).toThrow(/malformed .* storage row/)
  155. })
  156. it('decodes the schema-19 row vocabulary without another package codec', () => {
  157. const fixture: EventRow = {
  158. seq: 7,
  159. type: 'text-chunks',
  160. time: 90,
  161. data: JSON.stringify({ turn: 2, step: 3, index: 1, dt: [2, -1], texts: ['a', 'b', 'c'] }),
  162. source_event_seqs: null,
  163. surface_op: null,
  164. is_packed: 1,
  165. }
  166. expect(decodeRow(fixture)).toEqual([
  167. { ...chunk(7, 'a'), time: 90, data: { turn: 2, step: 3, chunk: { type: 'text-delta', index: 1, text: 'a' } } },
  168. { ...chunk(8, 'b'), time: 92, data: { turn: 2, step: 3, chunk: { type: 'text-delta', index: 1, text: 'b' } } },
  169. { ...chunk(9, 'c'), time: 91, data: { turn: 2, step: 3, chunk: { type: 'text-delta', index: 1, text: 'c' } } },
  170. ])
  171. expect(decodeStorageRecord('scalar')).toEqual(['scalar'])
  172. expect(decodeStorageRecord(chunk(0))).toEqual([chunk(0)])
  173. })
  174. it('rejects surface columns on packed rows', () => {
  175. const packed = row(packChunkRuns([chunk(0), chunk(1), chunk(2)])[0]!)
  176. const invalid: EventRow[] = [
  177. { ...packed, source_event_seqs: Buffer.alloc(0) },
  178. { ...packed, surface_op: '"append"' },
  179. ]
  180. for (const candidate of invalid) {
  181. expect(() => decodeRow(candidate)).toThrow(/surface fields must be null/)
  182. }
  183. })
  184. it('rejects the packed discriminator on a scalar event type', () => {
  185. const scalar = row({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } })
  186. expect(() => decodeRow({ ...scalar, is_packed: 1 }))
  187. .toThrow(/packed discriminator requires a chunk tag/)
  188. })
  189. it.each(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])(
  190. 'preserves a logical event named %s as a scalar row',
  191. (type) => {
  192. const logical = {
  193. type,
  194. seq: 0,
  195. time: 1,
  196. data: { future: true },
  197. } as unknown as SessionEvent
  198. const physical = row(logical)
  199. expect(physical.is_packed).toBe(0)
  200. expect(decodeRow(physical)).toEqual([logical])
  201. },
  202. )
  203. it('compresses small repetitive data with the shared dictionary', () => {
  204. const event = {
  205. type: 'tool/result',
  206. seq: 1,
  207. time: 2,
  208. data: { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'hello world '.repeat(40) }] } },
  209. sourceEventSeqs: [0],
  210. surfaceOp: 'append',
  211. } as unknown as SessionEvent
  212. const bound = bindRecord(event)
  213. expect(bound.data).toBeInstanceOf(Uint8Array)
  214. expect(decodeRow(row(event))).toEqual([event])
  215. })
  216. it('compresses large data and run-encodes consecutive provenance arrays', () => {
  217. const sources = Array.from({ length: 2_000 }, (_, index) => index + 10)
  218. const event = {
  219. type: 'assistant/message',
  220. seq: sources.at(-1)! + 1,
  221. time: 1,
  222. data: { text: 'x'.repeat(8_192) },
  223. sourceEventSeqs: sources,
  224. surfaceOp: 'append',
  225. } as unknown as SessionEvent
  226. const bound = bindRecord(event)
  227. expect(bound.data).toBeInstanceOf(Uint8Array)
  228. expect(bound.sourceEventSeqs).toBeInstanceOf(Uint8Array)
  229. expect(bound.sourceEventSeqs?.[0]).toBe(1)
  230. expect(bound.sourceEventSeqs?.byteLength).toBeLessThan(Buffer.byteLength(JSON.stringify(sources)))
  231. expect(decodeRow(row(event))).toEqual([event])
  232. const small = bindRecord({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } })
  233. expect(typeof small.data).toBe('string')
  234. })
  235. it('round-trips empty, descending, and maximum-safe provenance deltas', () => {
  236. for (const sources of [
  237. [],
  238. [1, 3, 4, 5, 10],
  239. [Number.MAX_SAFE_INTEGER - 1, 0, Number.MAX_SAFE_INTEGER - 2],
  240. ]) {
  241. const event = {
  242. type: 'assistant/message',
  243. seq: Number.MAX_SAFE_INTEGER,
  244. time: 1,
  245. data: {},
  246. sourceEventSeqs: sources,
  247. surfaceOp: 'append',
  248. } as unknown as SessionEvent
  249. expect(decodeRow(row(event))).toEqual([event])
  250. }
  251. })
  252. it('does not impose a persistence-only provenance length limit', () => {
  253. const sources = Array.from({ length: 1_000_001 }, (_, index) => index)
  254. const event = {
  255. type: 'assistant/message',
  256. seq: sources.length,
  257. time: 1,
  258. data: {},
  259. sourceEventSeqs: sources,
  260. surfaceOp: 'append',
  261. } as unknown as SessionEvent
  262. expect(bindRecord(event).sourceEventSeqs?.[0]).toBe(1)
  263. })
  264. it.each([-1, 0.5])('rejects invalid provenance sequence %s before encoding', (sourceSeq) => {
  265. const event = {
  266. type: 'assistant/message',
  267. seq: 1,
  268. time: 1,
  269. data: {},
  270. sourceEventSeqs: [sourceSeq],
  271. surfaceOp: 'append',
  272. } as unknown as SessionEvent
  273. expect(() => bindRecord(event)).toThrow(/non-negative safe integers/)
  274. })
  275. it('rejects malformed compressed and delta-encoded values', () => {
  276. const scalar = row({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } })
  277. expect(() => decodeRow({ ...scalar, data: Buffer.from('not zstd') })).toThrow()
  278. expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x00]) }))
  279. .toThrow(/truncated tagged payload/)
  280. expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x01]) }))
  281. .toThrow(/truncated tagged payload/)
  282. expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x02, 0x00]) }))
  283. .toThrow(/unknown encoding tag/)
  284. expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x00, 0x80]) }))
  285. .toThrow(/truncated varint/)
  286. expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x00, 0x80, 0x00]) }))
  287. .toThrow(/non-canonical varint/)
  288. // tag 0, first value 0, then a negative delta (zigzag 0x01) from 0
  289. expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x00, 0x00, 0x01]) }))
  290. .toThrow(/decoded seq is out of range/)
  291. // tag 0, first value MAX_SAFE_INTEGER, then a positive delta overflowing it
  292. expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([
  293. 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x02,
  294. ]) })).toThrow(/decoded seq is out of range/)
  295. expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([
  296. 0x00, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x10,
  297. ]) })).toThrow(/varint is out of range/)
  298. expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.concat([
  299. Buffer.from([0x00]), Buffer.alloc(9, 0x80),
  300. ]) }))
  301. .toThrow(/varint is out of range/)
  302. expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x01, 0x00, 0x01]) }))
  303. .toThrow(/run exceeds its event sequence/)
  304. expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x01, 0x00, 0x00]) }))
  305. .toThrow(/run count must be positive/)
  306. expect(() => decodeRow({ ...scalar, seq: 2, source_event_seqs: Buffer.from([0x01, 0x00, 0x01, 0x00, 0x01]) }))
  307. .toThrow(/runs must ascend/)
  308. })
  309. it('rejects an oversized packed data column before JSON decoding', () => {
  310. const oversized: EventRow = {
  311. seq: 0,
  312. type: 'text-chunks',
  313. time: 1,
  314. data: ' '.repeat(MAX_PACKED_DATA_BYTES + 1),
  315. source_event_seqs: null,
  316. surface_op: null,
  317. is_packed: 1,
  318. }
  319. expect(() => decodeRow(oversized)).toThrow(/data exceeds/)
  320. })
  321. it('bounds packed data while decompressing', () => {
  322. const serialized = JSON.stringify({
  323. turn: 1,
  324. step: 1,
  325. index: 0,
  326. dt: [0, 0],
  327. texts: ['x'.repeat(MAX_PACKED_DATA_BYTES), 'b', 'c'],
  328. })
  329. const oversized: EventRow = {
  330. seq: 0,
  331. type: 'text-chunks',
  332. time: 1,
  333. data: zstdCompressSync(serialized),
  334. source_event_seqs: null,
  335. surface_op: null,
  336. is_packed: 1,
  337. }
  338. expect(() => decodeRow(oversized)).toThrow(/Buffer larger than/)
  339. })
  340. it('distinguishes removable and committed physical corruption', () => {
  341. const start = row({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } })
  342. const skipped = row({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } })
  343. expect(scanRows([start, skipped])).toEqual({ preserved: [
  344. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  345. ], tornFrom: 2 })
  346. const end = row({
  347. type: 'turn/end',
  348. seq: 3,
  349. time: 3,
  350. data: { turn: 1, reason: { kind: 'completed' } },
  351. })
  352. expect(() => scanRows([start, skipped, end])).toThrow(/invalid committed physical row at seq 2/)
  353. const malformed = {
  354. ...row(packChunkRuns([chunk(0), chunk(1), chunk(2)])[0]!),
  355. data: '{not json',
  356. }
  357. const committedEnd = row({
  358. type: 'turn/end',
  359. seq: 1,
  360. time: 4,
  361. data: { turn: 1, reason: { kind: 'completed' } },
  362. })
  363. expect(() => scanRows([malformed, committedEnd]))
  364. .toThrow(/invalid committed physical row at seq 0/)
  365. })
  366. it('treats a malformed packed tail as one removable physical row', () => {
  367. const malformed: EventRow = {
  368. seq: 0,
  369. type: 'text-chunks',
  370. time: 1,
  371. data: JSON.stringify({ turn: 1, step: 1, index: 0, dt: [], texts: ['a', 'b'] }),
  372. source_event_seqs: null,
  373. surface_op: null,
  374. is_packed: 1,
  375. }
  376. expect(scanRows([malformed])).toEqual({ preserved: [], tornFrom: 0 })
  377. })
  378. })