compression.spec.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  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. ignorable: bound.ignorable,
  45. }
  46. }
  47. describe('SQLite compression', () => {
  48. it('pins the schema-20 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-20 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. ignorable: 0,
  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, ignorable: 0 }))
  187. .toThrow(/packed discriminator requires a chunk tag/)
  188. })
  189. it.each(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])(
  190. 'preserves an ignorable 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. ignorable: true,
  198. } as unknown as SessionEvent
  199. const physical = row(logical)
  200. expect(physical.ignorable).toBe(1)
  201. expect(decodeRow(physical)).toEqual([logical])
  202. },
  203. )
  204. it('compresses small repetitive data with the shared dictionary', () => {
  205. const event = {
  206. type: 'tool/result',
  207. seq: 1,
  208. time: 2,
  209. data: { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'hello world '.repeat(40) }] } },
  210. sourceEventSeqs: [0],
  211. surfaceOp: 'append',
  212. } as unknown as SessionEvent
  213. const bound = bindRecord(event)
  214. expect(bound.data).toBeInstanceOf(Uint8Array)
  215. expect(decodeRow(row(event))).toEqual([event])
  216. })
  217. it('compresses large data and run-encodes consecutive provenance arrays', () => {
  218. const sources = Array.from({ length: 2_000 }, (_, index) => index + 10)
  219. const event = {
  220. type: 'assistant/message',
  221. seq: sources.at(-1)! + 1,
  222. time: 1,
  223. data: { text: 'x'.repeat(8_192) },
  224. sourceEventSeqs: sources,
  225. surfaceOp: 'append',
  226. } as unknown as SessionEvent
  227. const bound = bindRecord(event)
  228. expect(bound.data).toBeInstanceOf(Uint8Array)
  229. expect(bound.sourceEventSeqs).toBeInstanceOf(Uint8Array)
  230. expect(bound.sourceEventSeqs?.[0]).toBe(1)
  231. expect(bound.sourceEventSeqs?.byteLength).toBeLessThan(Buffer.byteLength(JSON.stringify(sources)))
  232. expect(decodeRow(row(event))).toEqual([event])
  233. const small = bindRecord({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } })
  234. expect(typeof small.data).toBe('string')
  235. })
  236. it('round-trips empty, descending, and maximum-safe provenance deltas', () => {
  237. for (const sources of [
  238. [],
  239. [1, 3, 4, 5, 10],
  240. [Number.MAX_SAFE_INTEGER - 1, 0, Number.MAX_SAFE_INTEGER - 2],
  241. ]) {
  242. const event = {
  243. type: 'assistant/message',
  244. seq: Number.MAX_SAFE_INTEGER,
  245. time: 1,
  246. data: {},
  247. sourceEventSeqs: sources,
  248. surfaceOp: 'append',
  249. } as unknown as SessionEvent
  250. expect(decodeRow(row(event))).toEqual([event])
  251. }
  252. })
  253. it('does not impose a persistence-only provenance length limit', () => {
  254. const sources = Array.from({ length: 1_000_001 }, (_, index) => index)
  255. const event = {
  256. type: 'assistant/message',
  257. seq: sources.length,
  258. time: 1,
  259. data: {},
  260. sourceEventSeqs: sources,
  261. surfaceOp: 'append',
  262. } as unknown as SessionEvent
  263. expect(bindRecord(event).sourceEventSeqs?.[0]).toBe(1)
  264. })
  265. it.each([-1, 0.5])('rejects invalid provenance sequence %s before encoding', (sourceSeq) => {
  266. const event = {
  267. type: 'assistant/message',
  268. seq: 1,
  269. time: 1,
  270. data: {},
  271. sourceEventSeqs: [sourceSeq],
  272. surfaceOp: 'append',
  273. } as unknown as SessionEvent
  274. expect(() => bindRecord(event)).toThrow(/non-negative safe integers/)
  275. })
  276. it('rejects malformed compressed and delta-encoded values', () => {
  277. const scalar = row({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } })
  278. expect(() => decodeRow({ ...scalar, data: Buffer.from('not zstd') })).toThrow()
  279. expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x00]) }))
  280. .toThrow(/truncated tagged payload/)
  281. expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x01]) }))
  282. .toThrow(/truncated tagged payload/)
  283. expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x02, 0x00]) }))
  284. .toThrow(/unknown encoding tag/)
  285. expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x00, 0x80]) }))
  286. .toThrow(/truncated varint/)
  287. expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x00, 0x80, 0x00]) }))
  288. .toThrow(/non-canonical varint/)
  289. // tag 0, first value 0, then a negative delta (zigzag 0x01) from 0
  290. expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x00, 0x00, 0x01]) }))
  291. .toThrow(/decoded seq is out of range/)
  292. // tag 0, first value MAX_SAFE_INTEGER, then a positive delta overflowing it
  293. expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([
  294. 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x02,
  295. ]) })).toThrow(/decoded seq is out of range/)
  296. expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([
  297. 0x00, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x10,
  298. ]) })).toThrow(/varint is out of range/)
  299. expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.concat([
  300. Buffer.from([0x00]), Buffer.alloc(9, 0x80),
  301. ]) }))
  302. .toThrow(/varint is out of range/)
  303. expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x01, 0x00, 0x01]) }))
  304. .toThrow(/run exceeds its event sequence/)
  305. expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x01, 0x00, 0x00]) }))
  306. .toThrow(/run count must be positive/)
  307. expect(() => decodeRow({ ...scalar, seq: 2, source_event_seqs: Buffer.from([0x01, 0x00, 0x01, 0x00, 0x01]) }))
  308. .toThrow(/runs must ascend/)
  309. })
  310. it('rejects an oversized packed data column before JSON decoding', () => {
  311. const oversized: EventRow = {
  312. seq: 0,
  313. type: 'text-chunks',
  314. time: 1,
  315. data: ' '.repeat(MAX_PACKED_DATA_BYTES + 1),
  316. source_event_seqs: null,
  317. surface_op: null,
  318. ignorable: 0,
  319. }
  320. expect(() => decodeRow(oversized)).toThrow(/data exceeds/)
  321. })
  322. it('bounds packed data while decompressing', () => {
  323. const serialized = JSON.stringify({
  324. turn: 1,
  325. step: 1,
  326. index: 0,
  327. dt: [0, 0],
  328. texts: ['x'.repeat(MAX_PACKED_DATA_BYTES), 'b', 'c'],
  329. })
  330. const oversized: EventRow = {
  331. seq: 0,
  332. type: 'text-chunks',
  333. time: 1,
  334. data: zstdCompressSync(serialized),
  335. source_event_seqs: null,
  336. surface_op: null,
  337. ignorable: 0,
  338. }
  339. expect(() => decodeRow(oversized)).toThrow(/Buffer larger than/)
  340. })
  341. it('distinguishes removable and committed physical corruption', () => {
  342. const start = row({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } })
  343. const skipped = row({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } })
  344. expect(scanRows([start, skipped])).toEqual({ preserved: [
  345. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  346. ], tornFrom: 2 })
  347. const end = row({
  348. type: 'turn/end',
  349. seq: 3,
  350. time: 3,
  351. data: { turn: 1, reason: { kind: 'completed' } },
  352. })
  353. expect(() => scanRows([start, skipped, end])).toThrow(/invalid committed physical row at seq 2/)
  354. const malformed = {
  355. ...row(packChunkRuns([chunk(0), chunk(1), chunk(2)])[0]!),
  356. data: '{not json',
  357. }
  358. const committedEnd = row({
  359. type: 'turn/end',
  360. seq: 1,
  361. time: 4,
  362. data: { turn: 1, reason: { kind: 'completed' } },
  363. })
  364. expect(() => scanRows([malformed, committedEnd]))
  365. .toThrow(/invalid committed physical row at seq 0/)
  366. })
  367. it('treats a malformed packed tail as one removable physical row', () => {
  368. const malformed: EventRow = {
  369. seq: 0,
  370. type: 'text-chunks',
  371. time: 1,
  372. data: JSON.stringify({ turn: 1, step: 1, index: 0, dt: [], texts: ['a', 'b'] }),
  373. source_event_seqs: null,
  374. surface_op: null,
  375. ignorable: 0,
  376. }
  377. expect(scanRows([malformed])).toEqual({ preserved: [], tornFrom: 0 })
  378. })
  379. })