compression.spec.ts 15 KB

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