fast-completions 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5741f8a7684abbb66479904f8b49d14afebfd783cf3069e2e2c91717a0765238
4
- data.tar.gz: 1a20474b48f9278d815b1561101a34cc229ac37c1cfcaeb83ffe9f1ba5bbcf9a
3
+ metadata.gz: 4ad51b1c13ffc9236e40151891d9c935967c8ca06b982d43e9871e628900e888
4
+ data.tar.gz: b814642c835590b0399ff287b0ea2631bf2382766f242ba95a9e8857f9d8a8ae
5
5
  SHA512:
6
- metadata.gz: bae0596f622cdee7289c271939aca77dc8e10d539aeed94b8c18388ad445957db4b19beb45a6359da7c971d3173d24e42ca917720499cfd081b398fd4e70f3ec
7
- data.tar.gz: 30d7cc0ac12964163ca196e3e6a87e2e9e150de3db334ad342d55d923b55f0c522073108def6618c727a78593c5fb5d67f90d2e6d3267e794753575d9ab36bfe
6
+ metadata.gz: 2bfbd4a381f09305f49fdeb578cbde26cf0599de031efa9af638d88dce48b96c38abbabd52e97071633404cdfa7c52237d05b718389465b25595a333a559b549
7
+ data.tar.gz: d510754ec3b2da6fb8564d51d1dd6925f7382423fc9312a4618af126c7174679a4641b62f6e537a2c78f4c6e10d68a299ff455c100093a4f9a1857b4ee9cace8
data/CHANGELOG.md CHANGED
@@ -3,6 +3,74 @@
3
3
  Notable changes to the fast-completions gem. The format is prose-first,
4
4
  one section per released version, newest first.
5
5
 
6
+ ## 0.5.0 (2026-10-17)
7
+
8
+ - Tool-args streaming is now an incremental string-state machine
9
+ (`Fast::Completions::ArgsStreamer`, a new stdlib-only class): instead
10
+ of eagerly re-parsing the whole arguments scratch on every delta
11
+ (`JSON.parse` → `repair_json` → re-parse, O(n²) across a stream), each
12
+ tool-call block owns a streamer whose parser state carries ACROSS
13
+ deltas and only the NEW fragment's bytes are walked per delta — O(n)
14
+ total, no per-delta copy of any accumulated buffer, and no exception
15
+ on the streaming path (the machine is total on any fragment sequence;
16
+ malformed input freezes the live view rather than crashing the step).
17
+ This is a deliberate departure from pi, which re-parses per delta
18
+ (openai-completions.ts :652-658).
19
+ - The live view of a tool-call block's `arguments` Hash is now a
20
+ first-class feature (operator ruling, plan ENTRY 1 — “watching a
21
+ file write is extremely nice”): the same Hash object is populated in
22
+ place for the whole stream, nested containers materialize at their
23
+ opening token, and an in-flight string value GROWS live — a consumer
24
+ holding the reference sees it grow — instead of the whole pair
25
+ vanishing until the closing quote. Escape sequences split by a delta boundary are held (the
26
+ 0.4.0/0.4.1 crash class becomes a structural non-event); invalid
27
+ escape chars and invalidated `\u` runs contribute their literal text
28
+ (`repair_json` parity, absorbed rather than called); surrogate halves
29
+ never enter the live value, pairs combine; scalars materialize as raw
30
+ text and are replaced by Integer/Float/true/false/null when the token
31
+ terminates (malformed scalars keep their raw text). After the root
32
+ object closes — or the stream becomes unparseable — the live view
33
+ freezes. End states are unchanged and byte-identical to 0.4.1:
34
+ finalize remains the sole authority, parsing the full scratch once
35
+ (`parse_streaming_json`: the JSON.parse fast path when the stream
36
+ completed, the repair walk and `{}` as the last resort otherwise).
37
+ Mid-stream, the two protocol_spec pins that encoded the old
38
+ truncation artifact as `eq({})` are amended to the growing view —
39
+ the ONLY spec changes besides the machine's new unit spec.
40
+ - Bench harness: `compare_refs.rb` now measures a fifth ref — 0.5.0,
41
+ the working tree at HEAD (the lane tip before the integrator's
42
+ landing commit) — beside the four releases, pairs 0.4.1→0.5.0, and
43
+ gains the machine's own pair-scoped floor: tool_args at 60 µs/delta
44
+ (0.4.1 measured 877.4 µs/delta on the frozen 24 KB / 320-fragment
45
+ workload; the machine lands in single-digit µs). BASELINE.md is
46
+ regenerated with the 0.5.0 column. The four bench scripts stay
47
+ byte-frozen — the workload the floors are calibrated to.
48
+
49
+ ## 0.4.1 (2026-10-16)
50
+
51
+ - Fix a mid-stream crash in the tool-call live-parse (regression against
52
+ pi, found live on zai/glm-5.3): when a delta boundary split an escape
53
+ sequence, the arguments scratch buffer ended mid-string with a lone
54
+ trailing backslash; `repair_json` doubled it (repaired != partial) and
55
+ the re-parse of the still-unterminated string raised `JSON::ParserError`
56
+ — uncaught, because Ruby rescue clauses do not cascade (an exception
57
+ raised in one rescue body is not handled by the next rescue clause of
58
+ the same method). The repaired re-parse now gets its own rescue (the
59
+ same lesson `Client#parse_repaired` already had), returning `{}` as the
60
+ last resort — pi's `parseStreamingJson` wraps the repair path in a
61
+ try/catch and always returns a valid object; the port dropped that
62
+ outer catch.
63
+ - Bench harness (perf-baseline): `gems/completions/bench/` ships four
64
+ deterministic, network-free, stdlib-only benchmark scripts over the hot
65
+ paths (text-heavy SSE+protocol stream; tool-args-heavy stream through the
66
+ O(n²) per-delta live re-parse; Codec normalize → to_wire round-trip;
67
+ strict-schema rewrite) and compare_refs.rb, which measures v0.2.0 →
68
+ v0.3.0 → v0.4.0 → 0.4.1 through temporary read-only worktrees and writes
69
+ bench/BASELINE.md — per-path × ref medians, consecutive-release deltas,
70
+ and per-pair verdicts against frozen per-op floors. No behavior changes;
71
+ the capture is single-machine, and future runs compare against the
72
+ floors, never the cells.
73
+
6
74
  ## 0.4.0 (2026-10-14)
7
75
 
8
76
  - Strict-sampling tool schemas (pi constrained-sampling, JSON-schema half):
@@ -0,0 +1,404 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fast
4
+ module Completions
5
+ # Incremental tool-args string-state machine (I02, 0.5.0). A DELIBERATE
6
+ # departure from pi: pi re-parses the whole arguments scratch on every
7
+ # delta (openai-completions.ts :652-658) — O(n²) across a stream and
8
+ # the pair-in-flight vanishes until its closing quote. This machine
9
+ # instead carries per-block parser state ACROSS deltas and walks only
10
+ # the NEW fragment's bytes each time (O(n) total, no per-delta copy of
11
+ # any accumulated buffer, no exception on the streaming path), growing
12
+ # the live view (`block[:arguments]`, the root_target below) IN PLACE.
13
+ # The authoritative whole-buffer parse still happens exactly once, at
14
+ # finalize (Protocol.parse_streaming_json — the unchanged pi parity).
15
+ #
16
+ # THE LIVE-VIEW CONTRACT (frozen, Specification L1-L9). L8 totality:
17
+ # this source throws no exceptions and installs none — the machine
18
+ # is total on any fragment sequence: malformed input freezes the
19
+ # live view and never crashes the stream. The frozen interface:
20
+ # ArgsStreamer.new(
21
+ # root_target) — the block's arguments Hash, populated in place (L1);
22
+ # #consume(String) -> self — append to the scratch, walk the new bytes;
23
+ # #scratch -> String — the raw bytes accumulated, for the finalize
24
+ # parse. Everything below the interface is this file's own design.
25
+ class ArgsStreamer
26
+ attr_reader :scratch
27
+
28
+ # L3 scalar classification (frozen): a scalar that terminates is
29
+ # REPLACED by Integer/Float/true/false/nil when its raw text is a
30
+ # WELL-FORMED scalar, and keeps its raw-text String when malformed
31
+ # — the frozen clause's own examples are `01` and `1.`, so JSON's
32
+ # grammar (leading zeros, dangling fractions) gates the
33
+ # classification; the Integer/Float shapes below are the frozen
34
+ # forms minus the leading-zero overlap the malformed clause rules
35
+ # out. Integer for -?(0|[1-9]\d*), Float for that mantissa plus a
36
+ # fraction and/or exponent.
37
+ INTEGER_TEXT = /\A-?(?:0|[1-9]\d*)\z/
38
+ FLOAT_TEXT = /\A-?(?:(?:0|[1-9]\d*)\.\d+(?:[eE][+-]?\d+)?|(?:0|[1-9]\d*)(?:\.\d+)?[eE][+-]?\d+)\z/
39
+
40
+ # Fast path: a run of string-content bytes needing no per-byte
41
+ # handling — not a quote, not a backslash, not a raw control char,
42
+ # not a UTF-8 lead/continuation byte. Binary (/n) so it matches the
43
+ # byte view of the fragment.
44
+ PLAIN_RUN = /[^"\\\x00-\x1f\x80-\xff]+/n
45
+
46
+ # Byte constants (structure is always ASCII; content may be any
47
+ # byte — multibyte sequences are assembled, never mistaken for it).
48
+ QUOTE = 0x22
49
+ BACKSLASH = 0x5C
50
+ COLON = 0x3A
51
+ COMMA = 0x2C
52
+ OPEN_BRACE = 0x7B
53
+ CLOSE_BRACE = 0x7D
54
+ OPEN_BRACKET = 0x5B
55
+ CLOSE_BRACKET = 0x5D
56
+
57
+ def initialize(root_target)
58
+ @root = root_target
59
+ @scratch = +""
60
+ # Machine state (:value/:after_value refer to @stack.last; the
61
+ # frame layout is [container, :hash|:array]):
62
+ # :scan_root — before the root token (whitespace skipped)
63
+ # :frozen — root closed or structurally unparseable (L7);
64
+ # later bytes never mutate the live view again
65
+ # :key_first — object after `{`: first key or `}`
66
+ # :key — object after a comma: a key is required
67
+ # :colon — between a completed key and its value
68
+ # :value — a value token may start (or `]` closing an
69
+ # EMPTY array, per @may_close)
70
+ # :after_value— expecting `,` or the container's closer
71
+ # :in_key / :in_string — accumulating a key / a string value
72
+ # :in_scalar — accumulating raw scalar text
73
+ # :escape / :u_escape — mid-escape (held across deltas, L5/L6)
74
+ @state = :scan_root
75
+ @stack = []
76
+ @key = nil # completed key text for the pending pair
77
+ @buf = nil # the String being grown (a live value or a key)
78
+ @str_back = nil # :in_key or :in_string — where an escape returns
79
+ @cell = nil # [container, key_or_index] — the in-flight scalar slot
80
+ @scalar = nil # raw scalar text (replaced on termination, L3)
81
+ @digits = nil # collected hex digits of a \u run
82
+ @pending_high = nil # held high surrogate (L6)
83
+ @utf8_bytes = nil # partial multibyte sequence being assembled
84
+ @utf8_len = 0
85
+ @may_close = false # :value may see `]` (freshly opened array)
86
+ end
87
+
88
+ # Append the fragment to the scratch and walk ONLY its bytes. An
89
+ # empty fragment is a no-op (L8); a frozen machine still accumulates
90
+ # the scratch — finalize, not this walk, is the authority (L9).
91
+ def consume(fragment)
92
+ return self unless fragment.is_a?(String)
93
+
94
+ # Scratch discipline (L8 totality — no exception ever leaves this
95
+ # file): the scratch stays UTF-8 so the finalize parse gets a
96
+ # JSON-parseable string. A same-encoding or ASCII-only fragment
97
+ # appends with no copy; anything else (a binary fragment cut
98
+ # mid-multibyte, odd encodings) is normalized byte-for-byte to
99
+ # UTF-8 first — invalid bytes stay invalid and finalize's repair
100
+ # path owns them. `bytes` is the walk view: binary for the
101
+ # byte-level regex, the fragment itself when it is ASCII-only
102
+ # (the regex accepts an ASCII-only string in any encoding).
103
+ bytes = fragment.ascii_only? ? fragment : fragment.b
104
+ if fragment.encoding == Encoding::UTF_8 || fragment.ascii_only?
105
+ @scratch << fragment
106
+ else
107
+ @scratch << bytes.dup.force_encoding(Encoding::UTF_8)
108
+ end
109
+ return self if @state == :frozen
110
+
111
+ frag = bytes
112
+ i = 0
113
+ length = frag.bytesize
114
+ while i < length
115
+ byte = frag.getbyte(i)
116
+ if ((@state == :in_string || @state == :in_key) && @utf8_bytes.nil?) &&
117
+ byte != QUOTE && byte != BACKSLASH && byte >= 0x20 && byte < 0x80
118
+ # Fast path: a plain run — one in-place append, one regex scan
119
+ # of the NEW bytes only. A held high surrogate drops here: its
120
+ # pair never lands (L6).
121
+ @pending_high = nil
122
+ run = PLAIN_RUN.match(frag, i)[0]
123
+ @buf << run
124
+ i += run.bytesize
125
+ next
126
+ end
127
+ i += 1
128
+ case @state
129
+ when :in_string, :in_key
130
+ consume_string_byte(byte) || (i -= 1) # false = reprocess the byte
131
+ when :escape
132
+ consume_escape_byte(byte) || (i -= 1)
133
+ when :u_escape
134
+ consume_unicode_byte(byte) || (i -= 1)
135
+ when :scan_root
136
+ case byte
137
+ when 0x09, 0x0A, 0x0D, 0x20 then nil # whitespace skipped
138
+ when OPEN_BRACE
139
+ @stack.push([@root, :hash]) # the live Hash populates (L7)
140
+ @state = :key_first
141
+ else # array, scalar, garbage before `{` — empty and frozen
142
+ @state = :frozen
143
+ end
144
+ when :key_first, :key
145
+ case byte
146
+ when 0x09, 0x0A, 0x0D, 0x20 then nil
147
+ when QUOTE
148
+ @buf = +""
149
+ @pending_high = nil
150
+ @utf8_bytes = nil
151
+ @state = :in_key
152
+ when CLOSE_BRACE
153
+ if @state == :key_first
154
+ close_container # `{}` — the empty object closes
155
+ else # after a comma a key is required: unparseable
156
+ @state = :frozen
157
+ end
158
+ else
159
+ @state = :frozen
160
+ end
161
+ when :colon
162
+ case byte
163
+ when 0x09, 0x0A, 0x0D, 0x20 then nil
164
+ when COLON
165
+ @may_close = false
166
+ @state = :value
167
+ else
168
+ @state = :frozen
169
+ end
170
+ when :value
171
+ case byte
172
+ when 0x09, 0x0A, 0x0D, 0x20 then nil
173
+ when QUOTE then open_string_value
174
+ when OPEN_BRACE then open_container({}, :hash)
175
+ when OPEN_BRACKET then open_container([], :array)
176
+ when CLOSE_BRACKET
177
+ if @may_close # `[]`; any other `]` here is unparseable
178
+ close_container
179
+ else
180
+ @state = :frozen
181
+ end
182
+ when CLOSE_BRACE, COMMA then @state = :frozen # no value can start with these
183
+ else
184
+ open_scalar(byte)
185
+ end
186
+ when :after_value
187
+ case byte
188
+ when 0x09, 0x0A, 0x0D, 0x20 then nil
189
+ when COMMA
190
+ if @stack.last[1] == :hash
191
+ @state = :key
192
+ else
193
+ @may_close = false
194
+ @state = :value
195
+ end
196
+ when CLOSE_BRACE
197
+ @stack.last[1] == :hash ? close_container : @state = :frozen
198
+ when CLOSE_BRACKET
199
+ @stack.last[1] == :array ? close_container : @state = :frozen
200
+ else
201
+ @state = :frozen
202
+ end
203
+ when :in_scalar
204
+ case byte
205
+ when COMMA, CLOSE_BRACE, CLOSE_BRACKET, 0x09, 0x0A, 0x0D, 0x20
206
+ # Token terminated (delimiter, closer, or whitespace): the
207
+ # raw-text String is REPLACED in its slot by the classified
208
+ # value (L3); the terminator itself is reprocessed below.
209
+ @cell[0][@cell[1]] = scalar_value(@scalar)
210
+ @state = :after_value
211
+ i -= 1
212
+ else
213
+ @scalar << byte
214
+ end
215
+ end
216
+ end
217
+ self
218
+ end
219
+
220
+ private
221
+
222
+ # One byte inside a key or a string value. Returns false when the
223
+ # byte must be reprocessed (a broken multibyte sequence is dropped
224
+ # and the offending byte starts over).
225
+ def consume_string_byte(byte)
226
+ if @utf8_bytes
227
+ if byte >= 0x80 && byte <= 0xBF
228
+ @utf8_bytes << byte
229
+ if @utf8_bytes.size == @utf8_len
230
+ sequence = @utf8_bytes.pack("C*").force_encoding(Encoding::UTF_8)
231
+ @buf << sequence if sequence.valid_encoding?
232
+ @utf8_bytes = nil
233
+ end
234
+ else # the sequence never completes: dropped, byte reprocessed
235
+ @utf8_bytes = nil
236
+ return false
237
+ end
238
+ elsif byte >= 0x80
239
+ @pending_high = nil # a held high's pair never lands (L6)
240
+ if byte >= 0xC2 && byte <= 0xF4 # a valid lead byte; anything else is dropped
241
+ @utf8_len = byte < 0xE0 ? 2 : (byte < 0xF0 ? 3 : 4)
242
+ @utf8_bytes = [byte]
243
+ end
244
+ elsif byte == QUOTE # the string closes
245
+ @pending_high = nil # a held high's pair never lands (L6)
246
+ if @state == :in_key
247
+ @key = @buf
248
+ @state = :colon
249
+ else
250
+ @state = :after_value
251
+ end
252
+ elsif byte == BACKSLASH
253
+ @str_back = @state
254
+ @state = :escape
255
+ else # plain ASCII content, raw control characters included (L5)
256
+ @pending_high = nil
257
+ @buf << byte
258
+ end
259
+ true
260
+ end
261
+
262
+ # The byte after a backslash. Returns false when the byte must be
263
+ # reprocessed (an invalid escape char contributes the literal
264
+ # backslash, then the char itself is handled as content, L5).
265
+ def consume_escape_byte(byte)
266
+ case byte
267
+ when 0x75 # u — the hex run starts; a held high may still pair (L6)
268
+ @digits = +""
269
+ @state = :u_escape
270
+ when QUOTE then settle_escape("\"")
271
+ when BACKSLASH then settle_escape("\\")
272
+ when 0x2F then settle_escape("/") # \/ — repair_json parity
273
+ when 0x62 then settle_escape("\b")
274
+ when 0x66 then settle_escape("\f")
275
+ when 0x6E then settle_escape("\n")
276
+ when 0x72 then settle_escape("\r")
277
+ when 0x74 then settle_escape("\t")
278
+ else
279
+ @pending_high = nil
280
+ @buf << "\\" # invalid escape char: literal `\`, char reprocessed
281
+ @state = @str_back
282
+ return false
283
+ end
284
+ true
285
+ end
286
+
287
+ def settle_escape(text)
288
+ @pending_high = nil # a held high's pair never lands (L6)
289
+ @buf << text
290
+ @state = @str_back
291
+ end
292
+
293
+ # One byte of a \u run. Returns false when the byte must be
294
+ # reprocessed (an invalidated run contributes literal `\u` + the
295
+ # digits collected, then the offending byte is handled as content,
296
+ # L5 — a short run invalidated by the string's closing quote is the
297
+ # "short at close" case).
298
+ def consume_unicode_byte(byte)
299
+ if hex_byte?(byte)
300
+ @digits << byte
301
+ if @digits.size == 4
302
+ codepoint = @digits.to_i(16)
303
+ if codepoint >= 0xD800 && codepoint <= 0xDBFF
304
+ @pending_high = codepoint # held for a possible pair (L6)
305
+ elsif codepoint >= 0xDC00 && codepoint <= 0xDFFF
306
+ if @pending_high # the pair combines into one codepoint (L6)
307
+ pair = 0x10000 + ((@pending_high & 0x3FF) << 10) + (codepoint & 0x3FF)
308
+ @buf << pair
309
+ @pending_high = nil
310
+ end # a lone low half never enters the live value (L6)
311
+ else
312
+ @pending_high = nil
313
+ @buf << codepoint
314
+ end
315
+ @state = @str_back
316
+ end
317
+ else
318
+ @pending_high = nil
319
+ @buf << "\\u"
320
+ @buf << @digits
321
+ @state = @str_back
322
+ return false
323
+ end
324
+ true
325
+ end
326
+
327
+ def hex_byte?(byte)
328
+ (byte >= 0x30 && byte <= 0x39) || (byte >= 0x41 && byte <= 0x46) ||
329
+ (byte >= 0x61 && byte <= 0x66)
330
+ end
331
+
332
+ # A string value materializes at its OPENING quote — the slot exists
333
+ # with "" before any content byte (L3) — and grows by in-place
334
+ # append for the rest of the stream (L1).
335
+ def open_string_value
336
+ value = +""
337
+ attach(value)
338
+ @buf = value
339
+ @pending_high = nil
340
+ @utf8_bytes = nil
341
+ @state = :in_string
342
+ end
343
+
344
+ # An object/array value materializes at its opening token and is
345
+ # inserted into its parent immediately (L3), then mutated in place
346
+ # (L1). A fresh array may still be closed empty while @may_close.
347
+ def open_container(container, kind)
348
+ attach(container)
349
+ @stack.push([container, kind])
350
+ if kind == :hash
351
+ @state = :key_first
352
+ else
353
+ @may_close = true
354
+ @state = :value
355
+ end
356
+ end
357
+
358
+ # A scalar materializes as its raw-text String at its FIRST byte
359
+ # (L3), inserted into its parent; the slot is replaced when the
360
+ # token terminates (see :in_scalar above).
361
+ def open_scalar(byte)
362
+ @scalar = +""
363
+ @scalar << byte
364
+ attach(@scalar)
365
+ @state = :in_scalar
366
+ end
367
+
368
+ # Insert a fresh value into the innermost container. Duplicate keys
369
+ # collapse to one slot, last value wins (L2); array elements append.
370
+ # The slot pair also serves scalar replacement — `container[key]=`
371
+ # and `container[index]=` are the same expression for both kinds.
372
+ def attach(value)
373
+ frame = @stack.last
374
+ if frame[1] == :hash
375
+ frame[0][@key] = value
376
+ @cell = [frame[0], @key]
377
+ else
378
+ @cell = [frame[0], frame[0].size]
379
+ frame[0] << value
380
+ end
381
+ end
382
+
383
+ # Close the innermost container. The root object closing (or the
384
+ # last frame popping empty) freezes the live view (L7).
385
+ def close_container
386
+ @stack.pop
387
+ @state = @stack.empty? ? :frozen : :after_value
388
+ end
389
+
390
+ def scalar_value(text)
391
+ case text
392
+ when "true" then true
393
+ when "false" then false
394
+ when "null" then nil
395
+ else
396
+ if text.match?(INTEGER_TEXT) then text.to_i
397
+ elsif text.match?(FLOAT_TEXT) then text.to_f
398
+ else text # malformed (`01`, `1.` at close): keeps its raw text
399
+ end
400
+ end
401
+ end
402
+ end
403
+ end
404
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "args_streamer"
4
+
3
5
  module Fast
4
6
  module Completions
5
7
  # THE BYTE CONTRACT (Specification §9): Ruby Hash insertion order +
@@ -210,14 +212,24 @@ module Fast
210
212
  end
211
213
 
212
214
  # OQ-4: control-character repair (pi utils/json-parse.ts repairJson
213
- # :20-77), {} as the last resort (pi's partial-json fallback).
215
+ # :20-77), {} as the last resort (pi's partial-json fallback). The
216
+ # repaired re-parse gets its OWN method — a rescue body's exception
217
+ # is not caught by a later rescue clause of the same method (the
218
+ # 0.4.0 leak: a delta boundary splitting an escape sequence left the
219
+ # scratch buffer mid-string with a trailing backslash; repair "fixed"
220
+ # it, the re-parse raised, and the step died). pi's outer try/catch
221
+ # absorbs this; so do we.
214
222
  def parse_streaming_json(partial)
215
223
  return {} if partial.nil? || partial.strip.empty?
216
224
 
217
225
  JSON.parse(partial)
218
226
  rescue JSON::ParserError
219
227
  repaired = repair_json(partial)
220
- repaired == partial ? {} : JSON.parse(repaired)
228
+ repaired == partial ? {} : parse_repaired_json(repaired)
229
+ end
230
+
231
+ def parse_repaired_json(repaired)
232
+ JSON.parse(repaired)
221
233
  rescue JSON::ParserError
222
234
  {}
223
235
  end
@@ -301,7 +313,7 @@ module Fast
301
313
  @thinking_block = nil
302
314
  @tool_by_index = {}
303
315
  @tool_by_id = {}
304
- @args_scratch = {}.compare_by_identity
316
+ @args_streamers = {}.compare_by_identity # per-block ArgsStreamer (I02); each owns its scratch
305
317
  @streamed_details = nil # :334-341
306
318
  @has_finish_reason = false
307
319
  @stop_reason = nil
@@ -430,7 +442,7 @@ module Fast
430
442
  # its block — by index, by id, or by creation — while CARRYING an
431
443
  # index the block has never bound, binds it here. FIRST index
432
444
  # wins; one index per block, ever. Boundness is tracked
433
- # accumulator-side (identity-keyed, like @args_scratch): the
445
+ # accumulator-side (identity-keyed, like @args_streamers): the
434
446
  # blocks ARE message content — no scratch keys on them.
435
447
  @tool_index_bound ||= {}.compare_by_identity
436
448
  if index && !@tool_index_bound.key?(block)
@@ -440,8 +452,15 @@ module Fast
440
452
 
441
453
  raw = entry.dig("function", "arguments")
442
454
  raw = "" unless raw.is_a?(String)
443
- @args_scratch[block] = (@args_scratch[block] || "") + raw
444
- block[:arguments] = Protocol.parse_streaming_json(@args_scratch[block]) # best-effort live parse (OQ-4)
455
+ # I02 (0.5.0): the incremental args machine. The old seam — a
456
+ # full-buffer copy plus an eager whole-scratch re-parse per delta
457
+ # — is gone. This is a DELIBERATE departure from pi, which
458
+ # re-parses per delta (openai-completions.ts :652-658): the
459
+ # streamer carries parser state across deltas, walks ONLY the
460
+ # new fragment's bytes, and grows the live view in place (the
461
+ # block's arguments Hash); the one authoritative whole-buffer
462
+ # parse still happens at finalize (finalize_blocks below).
463
+ (@args_streamers[block] ||= ArgsStreamer.new(block[:arguments])).consume(raw)
445
464
  events << Event.new(type: :tool_call_delta, content_index: content_index(block),
446
465
  delta: raw, partial: @partial)
447
466
  events
@@ -459,7 +478,13 @@ module Fast
459
478
  block[:signature] = JSON.generate(@streamed_details) if @streamed_details
460
479
  events << Event.new(type: :thinking_end, content_index: index, content: block[:thinking], partial: @partial)
461
480
  when :tool_call
462
- raw = @args_scratch.delete(block) || ""
481
+ streamer = @args_streamers.delete(block)
482
+ raw = streamer ? streamer.scratch : "" # no-fragment blocks finalize an empty scratch
483
+ # Finalize is the SOLE authority (L9): one whole-buffer parse
484
+ # of the raw bytes — the JSON.parse fast path when the stream
485
+ # completed, the repair walk + {} last resort otherwise
486
+ # (pi :453 / utils/json-parse.ts :20-90). The live Hash the
487
+ # machine grew is replaced here, exactly once.
463
488
  block[:arguments] = Protocol.parse_streaming_json(raw)
464
489
  events << Event.new(type: :tool_call_end, content_index: index,
465
490
  tool_call: {id: block[:id], name: block[:name], arguments: block[:arguments]},
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Fast
4
4
  module Completions
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fast-completions
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Jacobs
@@ -34,6 +34,7 @@ files:
34
34
  - CHANGELOG.md
35
35
  - README.md
36
36
  - lib/fast/completions.rb
37
+ - lib/fast/completions/args_streamer.rb
37
38
  - lib/fast/completions/cancellation.rb
38
39
  - lib/fast/completions/chat.rb
39
40
  - lib/fast/completions/client.rb