chunk-rows.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. /**
  2. * Lossless storage packing for `assistant/chunk` delta runs. Providers stream
  3. * token-sized deltas, so a log stores hundreds of near-identical event lines
  4. * whose JSON envelopes dwarf their payloads (~56× measured on a real DeepSeek
  5. * session). This module packs each run of consecutive same-block delta chunks
  6. * into ONE storage row — `text-chunks`, `reasoning-chunks`, or
  7. * `tool-call-chunks` — and expands rows back to the exact original events.
  8. *
  9. * Storage rows are a durable-encoding vocabulary, NOT session events: they
  10. * never enter `Session.events`, have no `SessionEventMap` entry, and use bare
  11. * (slash-less) type tags so a reader cannot confuse them with the event
  12. * taxonomy (precedent: the JSONL header line's `session` tag). The encoder
  13. * whitelists exact shapes — anything it does not fully recognize is stored
  14. * verbatim, so unknown fields or future chunk variants lose compression, never
  15. * data. The decoder validates before expanding and fails loud on a malformed
  16. * row-tagged value instead of silently dropping a whole run.
  17. *
  18. * @module @deepseek-ai/dsh-session/chunk-rows
  19. */
  20. import { CallId, assertNever } from '@deepseek-ai/dsh-llm'
  21. import type { StreamChunk } from '@deepseek-ai/dsh-llm'
  22. import type { SessionEvent } from './types.ts'
  23. /** The chunk kinds that may pack; block boundaries, usage, and finish chunks always stay one event per line. */
  24. type DeltaKind = 'text-delta' | 'reasoning-delta' | 'tool-call-delta'
  25. /** A run member: an `assistant/chunk` event whose exact shape the encoder whitelisted. */
  26. type DeltaEvent = SessionEvent<'assistant/chunk'>
  27. /**
  28. * Fields shared by every packed run: placement, block correlation, and member
  29. * timestamps as gaps. Member `k` reconstructs as seq `seq0 + k` and time
  30. * `time0` plus the first `k` gaps; a gap may be negative when the wall clock
  31. * stepped backwards between events.
  32. */
  33. interface RunDataBase {
  34. turn: number
  35. step: number
  36. /** The stream block index every member shares. */
  37. index: number
  38. /** Epoch-ms gaps between consecutive members; length is one less than the member count. */
  39. dt: number[]
  40. }
  41. /** Payload of a `text-chunks`/`reasoning-chunks` row: one entry per member, never joined — token boundaries are data. */
  42. interface TextRunData extends RunDataBase {
  43. texts: string[]
  44. }
  45. /** Payload of a `tool-call-chunks` row: the run-constant call identity plus each member's raw arguments fragment. */
  46. interface ToolCallRunData extends RunDataBase {
  47. id: CallId
  48. /** Present iff every member carried it, with one uniform value (a mixed run never packs). */
  49. name?: string
  50. args: string[]
  51. }
  52. /**
  53. * A packed run of consecutive delta chunk events, discriminated on `type`.
  54. * `seq0`/`time0` anchor the first member; text and reasoning rows share the
  55. * {@link TextRunData} payload, tool-call rows carry {@link ToolCallRunData}.
  56. */
  57. export type ChunkRow =
  58. | { type: 'text-chunks'; seq0: number; time0: number; data: TextRunData }
  59. | { type: 'reasoning-chunks'; seq0: number; time0: number; data: TextRunData }
  60. | { type: 'tool-call-chunks'; seq0: number; time0: number; data: ToolCallRunData }
  61. /** One durable log line's JSON value: a session event verbatim, or a packed chunk row. */
  62. export type StorageRecord = SessionEvent | ChunkRow
  63. /**
  64. * Minimum members before a run packs. Below it a row's envelope rivals the
  65. * event lines it replaces. A format constant, not a tunable: both layouts
  66. * decode identically, so changing it never invalidates stored logs.
  67. */
  68. const MIN_RUN = 3
  69. function isRecord(value: unknown): value is Record<string, unknown> {
  70. return typeof value === 'object' && value !== null
  71. }
  72. /** Exact-key check: `value` has every key in `keys` and nothing else. */
  73. function hasExactKeys(value: object, keys: readonly string[]): boolean {
  74. return Object.keys(value).length === keys.length && keys.every(k => Object.hasOwn(value, k))
  75. }
  76. /**
  77. * Classify an event for packing: its delta kind when the ENTIRE shape
  78. * (envelope, data, chunk — exact keys, primitive types, integer seq/time) is
  79. * whitelisted, else `undefined` (store verbatim). Inputs come from live typed
  80. * appends AND parsed fixture files, so the checks are structural, not
  81. * type-trusted. Integer times keep gap encoding exact: a fractional time would
  82. * reconstruct through float subtraction/addition, which need not round-trip.
  83. */
  84. function classify(event: SessionEvent): DeltaKind | undefined {
  85. if (event.type !== 'assistant/chunk') return undefined
  86. if (!hasExactKeys(event, ['type', 'seq', 'time', 'data'])) return undefined
  87. if (!Number.isSafeInteger(event.seq) || event.seq < 0 || !Number.isSafeInteger(event.time)) return undefined
  88. const data: unknown = event.data
  89. if (!isRecord(data) || !hasExactKeys(data, ['turn', 'step', 'chunk'])) return undefined
  90. if (typeof data.turn !== 'number' || typeof data.step !== 'number') return undefined
  91. const chunk = data.chunk
  92. if (!isRecord(chunk) || typeof chunk.index !== 'number') return undefined
  93. switch (chunk.type) {
  94. case 'text-delta':
  95. case 'reasoning-delta':
  96. return hasExactKeys(chunk, ['type', 'index', 'text']) && typeof chunk.text === 'string'
  97. ? chunk.type
  98. : undefined
  99. case 'tool-call-delta': {
  100. const shapeOk = hasExactKeys(chunk, ['type', 'index', 'id', 'argumentsDelta'])
  101. || (hasExactKeys(chunk, ['type', 'index', 'id', 'name', 'argumentsDelta']) && typeof chunk.name === 'string')
  102. return shapeOk && typeof chunk.id === 'string' && typeof chunk.argumentsDelta === 'string'
  103. ? chunk.type
  104. : undefined
  105. }
  106. // Whitelist fall-through over parsed data: block-start/end, usage, finish,
  107. // and any future chunk variant stay one event per line.
  108. default:
  109. return undefined
  110. }
  111. }
  112. /** The tool-call fields of a whitelisted delta chunk (only after {@link classify} returned `'tool-call-delta'`). */
  113. function toolCallOf(event: DeltaEvent): { id: string; name?: string } {
  114. return event.data.chunk as { id: string; name?: string }
  115. }
  116. /** The block index of a whitelisted delta chunk (not every {@link StreamChunk} variant carries one). */
  117. function indexOf(event: DeltaEvent): number {
  118. return (event.data.chunk as { index: number }).index
  119. }
  120. /** Whether `next` extends a run ending in `prev` (same kind already checked by the caller). */
  121. function continues(prev: DeltaEvent, next: DeltaEvent, kind: DeltaKind): boolean {
  122. if (next.seq !== prev.seq + 1) return false
  123. // Two safe-integer times can sit further apart than a double subtracts
  124. // exactly (2^53-1 and its negation differ by ~2^54); a rounded gap would
  125. // decode to a different timestamp. The check is exact in both directions: a
  126. // true gap within safe range subtracts without rounding and passes, while a
  127. // true gap beyond it rounds to a value that is itself beyond and fails.
  128. if (!Number.isSafeInteger(next.time - prev.time)) return false
  129. if (next.data.turn !== prev.data.turn || next.data.step !== prev.data.step) return false
  130. if (indexOf(next) !== indexOf(prev)) return false
  131. if (kind !== 'tool-call-delta') return true
  132. const a = toolCallOf(prev)
  133. const b = toolCallOf(next)
  134. // `name` must match in presence AND value — a mixed run is not representable.
  135. return a.id === b.id && Object.hasOwn(a, 'name') === Object.hasOwn(b, 'name') && a.name === b.name
  136. }
  137. /** Build the row for a completed run (`run.length >= MIN_RUN`, uniform per {@link continues}). */
  138. function buildRow(kind: DeltaKind, run: readonly DeltaEvent[]): ChunkRow {
  139. const first = run[0] as DeltaEvent
  140. const base = {
  141. turn: first.data.turn,
  142. step: first.data.step,
  143. index: indexOf(first),
  144. dt: run.slice(1).map((event, i) => event.time - (run[i] as DeltaEvent).time),
  145. }
  146. const envelope = { seq0: first.seq, time0: first.time }
  147. if (kind === 'tool-call-delta') {
  148. const call = toolCallOf(first)
  149. return {
  150. type: 'tool-call-chunks',
  151. ...envelope,
  152. data: {
  153. ...base,
  154. id: CallId(call.id),
  155. ...Object.hasOwn(call, 'name') ? { name: call.name as string } : {},
  156. args: run.map(event => (event.data.chunk as { argumentsDelta: string }).argumentsDelta),
  157. },
  158. }
  159. }
  160. const data = { ...base, texts: run.map(event => (event.data.chunk as { text: string }).text) }
  161. return kind === 'text-delta'
  162. ? { type: 'text-chunks', ...envelope, data }
  163. : { type: 'reasoning-chunks', ...envelope, data }
  164. }
  165. /**
  166. * Pack an event batch for storage: each run of at least {@link MIN_RUN}
  167. * consecutive whitelisted same-kind, same-block delta chunk events becomes one
  168. * {@link ChunkRow}; every other event passes through verbatim, in order.
  169. * Pure and stateless — safe over any array, including a batch whose runs were
  170. * split by flush boundaries (the split runs simply pack per batch).
  171. *
  172. * @param events - the batch to encode, in log order.
  173. * @returns the storage records to write, one JSONL line each.
  174. */
  175. export function packChunkRuns(events: readonly SessionEvent[]): StorageRecord[] {
  176. const out: StorageRecord[] = []
  177. let kind: DeltaKind | undefined
  178. let run: DeltaEvent[] = []
  179. const flush = (): void => {
  180. if (kind !== undefined && run.length >= MIN_RUN) out.push(buildRow(kind, run))
  181. else out.push(...run)
  182. kind = undefined
  183. run = []
  184. }
  185. for (const event of events) {
  186. const k = classify(event)
  187. if (k === undefined) {
  188. flush()
  189. out.push(event)
  190. continue
  191. }
  192. const delta = event as DeltaEvent
  193. const last = run[run.length - 1]
  194. if (k === kind && last !== undefined && continues(last, delta, k)) {
  195. run.push(delta)
  196. continue
  197. }
  198. flush()
  199. kind = k
  200. run = [delta]
  201. }
  202. flush()
  203. return out
  204. }
  205. /** Throw the uniform malformed-row diagnostic. */
  206. function malformed(tag: string, why: string): never {
  207. throw new Error(`malformed ${tag} storage row: ${why}`)
  208. }
  209. /** Validate the shared run-data fields and the payload/dt arity; returns the member payload. */
  210. function validateRunData(tag: string, data: Record<string, unknown>, payloadKey: 'texts' | 'args'): string[] {
  211. if (typeof data.turn !== 'number' || typeof data.step !== 'number' || typeof data.index !== 'number') {
  212. malformed(tag, 'turn/step/index must be numbers')
  213. }
  214. const payload = data[payloadKey]
  215. if (!Array.isArray(payload) || payload.length === 0 || payload.some(entry => typeof entry !== 'string')) {
  216. malformed(tag, `${payloadKey} must be a non-empty string array`)
  217. }
  218. const dt = data.dt
  219. if (!Array.isArray(dt) || dt.some(gap => !Number.isSafeInteger(gap))) {
  220. malformed(tag, 'dt must be an array of safe integers')
  221. }
  222. if (dt.length !== payload.length - 1) {
  223. malformed(tag, `dt length ${dt.length} does not match ${payload.length} members`)
  224. }
  225. return payload as string[]
  226. }
  227. /** Validate a row-tagged parsed value's envelope and data, throwing on any malformation. */
  228. function validateRow(value: Record<string, unknown>, tag: ChunkRow['type']): ChunkRow {
  229. if (!hasExactKeys(value, ['type', 'seq0', 'time0', 'data'])) {
  230. malformed(tag, 'envelope must be exactly {type, seq0, time0, data}')
  231. }
  232. if (!Number.isSafeInteger(value.seq0) || (value.seq0 as number) < 0) {
  233. malformed(tag, 'seq0 must be a non-negative safe integer')
  234. }
  235. if (!Number.isSafeInteger(value.time0)) {
  236. malformed(tag, 'time0 must be a safe integer')
  237. }
  238. const data = value.data
  239. if (!isRecord(data)) malformed(tag, 'data must be an object')
  240. let payload: string[]
  241. if (tag === 'tool-call-chunks') {
  242. const withName = hasExactKeys(data, ['turn', 'step', 'index', 'id', 'name', 'dt', 'args'])
  243. if (!withName && !hasExactKeys(data, ['turn', 'step', 'index', 'id', 'dt', 'args'])) {
  244. malformed(tag, 'data must be exactly {turn, step, index, id, name?, dt, args}')
  245. }
  246. if (typeof data.id !== 'string' || (withName && typeof data.name !== 'string')) {
  247. malformed(tag, 'id (and name when present) must be strings')
  248. }
  249. payload = validateRunData(tag, data, 'args')
  250. } else {
  251. if (!hasExactKeys(data, ['turn', 'step', 'index', 'dt', 'texts'])) {
  252. malformed(tag, 'data must be exactly {turn, step, index, dt, texts}')
  253. }
  254. payload = validateRunData(tag, data, 'texts')
  255. }
  256. // Reconstruction bounds. The encoder only packs runs whose member seqs and
  257. // times are all safe integers, so a running value that leaves safe range is
  258. // outside any encoder's image: float arithmetic would round it to a
  259. // different number than exact arithmetic, a silent corruption. Within safe
  260. // range every step is exact, so the first departure is always caught.
  261. if (!Number.isSafeInteger((value.seq0 as number) + payload.length - 1)) {
  262. malformed(tag, 'member seqs must stay safe integers')
  263. }
  264. let time = value.time0 as number
  265. for (const gap of data.dt as number[]) {
  266. time += gap
  267. if (!Number.isSafeInteger(time)) malformed(tag, 'member times must stay safe integers')
  268. }
  269. return value as unknown as ChunkRow
  270. }
  271. /** Expand a validated row back into its exact original events, in order. */
  272. function expandRow(row: ChunkRow): SessionEvent[] {
  273. const members = row.type === 'tool-call-chunks' ? row.data.args : row.data.texts
  274. const events: SessionEvent[] = []
  275. let time = row.time0
  276. for (let k = 0; k < members.length; k++) {
  277. if (k > 0) time += row.data.dt[k - 1] as number
  278. let chunk: StreamChunk
  279. switch (row.type) {
  280. case 'text-chunks':
  281. chunk = { type: 'text-delta', index: row.data.index, text: members[k] as string }
  282. break
  283. case 'reasoning-chunks':
  284. chunk = { type: 'reasoning-delta', index: row.data.index, text: members[k] as string }
  285. break
  286. case 'tool-call-chunks':
  287. chunk = {
  288. type: 'tool-call-delta',
  289. index: row.data.index,
  290. id: row.data.id,
  291. ...Object.hasOwn(row.data, 'name') ? { name: row.data.name as string } : {},
  292. argumentsDelta: members[k] as string,
  293. }
  294. break
  295. /* v8 ignore next 2 -- validateRow only returns the three row tags */
  296. default:
  297. return assertNever(row, 'chunk-rows expandRow')
  298. }
  299. events.push({
  300. type: 'assistant/chunk',
  301. seq: row.seq0 + k,
  302. time,
  303. data: { turn: row.data.turn, step: row.data.step, chunk },
  304. })
  305. }
  306. return events
  307. }
  308. /**
  309. * Decode one parsed JSONL line value into the session event(s) it stores.
  310. * Chunk-row-tagged values validate and expand (a malformed row throws — it is
  311. * corrupt storage, and treating it as an event would silently drop a whole
  312. * run); every other value passes through as a single event, unvalidated.
  313. *
  314. * @param value - one line's `JSON.parse` result.
  315. * @returns the stored events, in log order.
  316. */
  317. export function decodeStorageRecord(value: unknown): SessionEvent[] {
  318. if (!isRecord(value)) return [value as SessionEvent]
  319. const tag = value.type
  320. if (tag !== 'text-chunks' && tag !== 'reasoning-chunks' && tag !== 'tool-call-chunks') {
  321. return [value as SessionEvent]
  322. }
  323. return expandRow(validateRow(value, tag))
  324. }