Skip to content

tool_parse: unclosed namespaced tool-call openers no longer rescan the reply - #913

Open
jishnuvenugopal wants to merge 2 commits into
ashhart:mainfrom
jishnuvenugopal:fix/tool-parse-namespaced-closers
Open

jishnuvenugopal wants to merge 2 commits into
ashhart:mainfrom
jishnuvenugopal:fix/tool-parse-namespaced-closers

Conversation

@jishnuvenugopal

Copy link
Copy Markdown
Contributor

Thank you, @ashhart, for spotting this scan in your review of #882, and for the care you put into every review and
into TensorFold itself. This is the follow-up for the scan you named there.

What this changes

envelopes (zig/src/server/tool_parse.zig) paired each namespaced opener <NAME:tool_call> with its closer by
searching from the opener, so an opener with no closer searched to the end of the reply, once per opener. Closers
(new, zig/src/server/tool_closers.zig) reads every </NAME:tool_call> once, by ASCII-lowered name, the first time a
namespaced opener appears, and gives each opener the first closer at or after it: the one findIgnoreCasePos found.
Openers come in order, so each name's closers are passed once, also when every opener has a different name; the other
scans and the envelope sort are unchanged.

Verified outcome

  • Why: a reply with many namespaced tool-call openers that never close slowed down with the square of its length.
    <ns:tool_call> repeated to 128 KiB took 64 ms to parse at the end of the reply, against 1.2 ms for 16 KiB.
  • What was done: the closing markers are all found in one read of the reply, instead of one search to the end of
    the reply per opener.
  • Result: that reply now parses in 0.4 ms, and replies read exactly as before: 50,000 varied replies give
    byte-identical results, and a test checks the new lookup against the old search on 5,000 random texts.

Receipt

  • Environment: main at c7137bb, Zig 0.17.0, Apple M4, macOS 27.0.1. Host code: no model or GPU.

  • Exactness: n/a. No engine change; the reply's tokens and text are the same.

  • Decode speed before and after: n/a. bench_openai.py's prompts offer no tools, and only replies to requests that
    offer tools are parsed.

  • Prompt speed before and after: n/a, for the same reason.

  • Speed on <ns:tool_call> repeated, in ReleaseSafe as the server ships, fastest of three:

    Reader c7137bb, 16 KiB c7137bb, 128 KiB This branch, 16 KiB This branch, 128 KiB
    parse 1.15 ms 63.9 ms 0.05 ms 0.39 ms
    finishedProse 1.17 ms 64.0 ms 0.06 ms 0.48 ms
  • Same output: a local fuzz harness (not part of this change), with namespaced openers and closers added to its
    inputs, ran 50,000 edited replies through parse, closeCall, hideToolCalls and finishedProse on c7137bb and
    on this branch. A digest of everything they returned, call ids left out, is equal (2411aa090ffa1aff).

  • Tests run:

    • New, namespaced openers without closers read in linear time (tool_parse_test.zig): parse on 1,000 openers and
      on 8,000, once with one name and once with a new name per opener. 8x the openers may take at most 24x the time
      (or under 20 ms), the bound fuzz-cortheon's linear-time test uses. It fails on c7137bb and passes on this branch.
    • New, a closer found by name is the one a search for it finds: on 5,000 random texts of closers in mixed case,
      near-misses and non-ASCII names, Closers.next equals findIgnoreCasePos for every name at every start. It
      fails with the cursor off by one and with the names not lowered.
    • zig build test test-golden -Dcpu=apple_m1, uncached: 164 of 164 steps, 1983 of 1991 tests pass, 8 skip,
      0 fail (c7137bb: 1981 of 1989, 8 skip); the two more are the new tests. zig build fuzz-cortheon passes.
      zig fmt --check build.zig zig is clean. Lean: 527 problems on the base and 527 on this branch, 0 new
      (lean_new.py).
    • With tool_parse: a Gemma call's strings go back in one pass #881, server: tool-call readers search for an absent opener once #882 and tool_parse: a GLM call's keys without values read in linear time #883: the four merge cleanly and tool_parse.zig is 599 lines (hence the new file). Together,
      every reader in the local harness passes its linear-time check on every shape, the harness digest is unchanged,
      and the checks above pass.
  • Replies without a namespaced opener never read the closers, so parse's other paths do no new work.

  • Not run, and why: no CUDA host here. The parser is server code that both backends use, and its tests need no GPU.

Checklist

  • Every token still goes through the lane rounds. No serial path, and nothing that needs drafts off. (No engine
    change.)
  • Drafted output equals "draft": false, a resumed prompt equals a fresh one, and concurrent equals solo. (No
    engine change; not re-run.)
  • No precision traded for speed. If the bits change, the description says which sums change and why. (No
    arithmetic changes.)
  • Prompt processing is no slower than the last release. (n/a, as above.)
  • The description says which platforms I ran, Metal M1 to M5 and CUDA, and which I could not.
  • New tests fail before the change, pass after it, and skip cleanly without their dependency. (The timing test
    fails before. The equivalence test calls Closers, which this change adds.)
  • Comments and docstrings are one line. No measurements or history in the source.
  • No personal data, machine names, internal hosts or local paths. No AI attribution lines.

@cwschroeder cwschroeder left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed a8786a7b against main 46a08c18; no blocking issue found. The productive caller advances from monotonically, the ASCII-lowered lookup retains the previous name matching, and the new envelope end equals the old closer length. The index removes repeated suffix searches in this path; the other scans and envelope sort retain their existing costs.

I added an independent full-output comparison on M5 Max, macOS 26.6.2, Zig 0.17.0, ReleaseSafe. All 100,000 deterministic cases match, including nested/overlapping namespaces, distinct non-ASCII bytes, damaged tags, other tool formats, empty tools and call limits. There are 59,087 cases with calls. Content, normalized calls, closeCall, both hideToolCalls modes and finishedProse are compared byte for byte; only generated call IDs are excluded.

The reproducible harness and runner pin both commits. A negative control shortening the candidate's envelope end by one byte fails with a content mismatch at case 10, covering residue handling beyond the closer-position oracle. The unmodified comparison passes with output digest f72c1dab522f51e891056de0c94565fb491b6388a0dfd7e699b677f4666d8101.

All four current-head CI checks pass. This additional receipt checks host parser correctness; I made no new timing or device measurement.

@ashhart ashhart mentioned this pull request Oct 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants