refactor(parsers): align the unified parser surface with peer traits, and let vendors supply their own - #178
Conversation
|
📊 Conformance matrix rendered — view in CI summary |
|
@coderabbitai review @devin review Context for both: this is the peer-trait + vendor-registry half of a two-way split of #174. It is deliberately behaviour-neutral — main's unified golden feed and this branch's are the same sha256 ( Worth the most scrutiny:
|
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '=== Changed files ==='
git diff --name-status origin/main...HEAD || git diff --name-status main...HEAD
echo
echo '=== Unified module outline ==='
ast-grep outline parsers/v2/src/unified/mod.rs --items all --view expanded
echo
echo '=== Unified module diff ==='
git diff --unified=80 origin/main...HEAD -- parsers/v2/src/unified/mod.rs \
|| git diff --unified=80 main...HEAD -- parsers/v2/src/unified/mod.rs
echo
echo '=== Documentation and compiled example diffs ==='
git diff --unified=60 origin/main...HEAD -- parsers/v2/CUSTOM_PARSERS.md parsers/v2/tests/vendor_parser_example.rs \
|| git diff --unified=60 main...HEAD -- parsers/v2/CUSTOM_PARSERS.md parsers/v2/tests/vendor_parser_example.rs
echo
echo '=== Registry API definitions, calls, and tests ==='
rg -n -C 5 -P '\b(register_unified_parser|unregister_unified_parser|builtin_unified_families|UnifiedParserEvent|parse_into|tool_call_id|preserve_special_tokens)\b' \
parsers/v2 --glob '*.rs' --glob '*.md'Length of output: 127129 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '=== Public exports ==='
cat -n parsers/v2/src/lib.rs | sed -n '1,55p'
echo
echo '=== Registry integration tests ==='
cat -n parsers/v2/tests/vendor_registry.rs | sed -n '1,245p'
echo
echo '=== Documentation example and compiled example ==='
cat -n parsers/v2/CUSTOM_PARSERS.md | sed -n '1,80p'
cat -n parsers/v2/tests/vendor_parser_example.rs | sed -n '1,150p'
echo
echo '=== Checks that link CUSTOM_PARSERS.md to vendor_parser_example.rs ==='
rg -n -C 4 -P 'CUSTOM_PARSERS|vendor_parser_example|include_str!|extract.*markdown|markdown.*extract|mdbook' \
. --glob '!target/**' --glob '!**/.git/**'
echo
echo '=== All unified parser construction call sites ==='
rg -n -C 4 -P '\bcreate_unified_parser_for_family\s*\(' \
. --glob '*.rs' --glob '!target/**'Length of output: 44929
P1 — Keep the published parser example identical to the compiled example. For example,
Use the same implementation in both locations. Prefer a single source of truth or a test that extracts and compiles the Markdown block. I found no correctness issue in the registry under its documented startup-only contract. The alias canonicalization and process-isolated registry tests cover the requested behavior. You are interacting with an AI system. |
8c785de to
a086bd3
Compare
|
@coderabbitai full review The head you analysed ( What changed in the rebase, since it is material:
Worth the most scrutiny on this head:
This PR is deliberately behaviour-neutral: main's unified golden feed and this branch's are the same sha256, so parser output is not the review surface — API shape and the registry are. |
|
✅ Action performedFull review finished. |
WalkthroughThe unified parser now uses ChangesUnified parser API
Conformance tooltip and layout behavior
Estimated code review effort: 4 (Complex) | ~60 minutes Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
parsers/v2/src/unified/mod.rs (2)
583-586: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueReuse
canonical_unified_familyinstead of a second generated match.The vendor branch at Line 560 already resolves the canonical name with
canonical_unified_family(family).unwrap_or(family). This second match repeats the alias arms and needs anunreachable!. Using the helper removes the duplication and the unreachable arm.♻️ Proposed refactor
- let canonical = match family { - $($family $(| $alias)* => $family,)+ - _ => unreachable!("matched above"), - }; + let canonical = canonical_unified_family(family).unwrap_or(family);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@parsers/v2/src/unified/mod.rs` around lines 583 - 586, Replace the generated canonical match in the family handling flow with the existing canonical_unified_family(family).unwrap_or(family) helper used by the vendor branch. Remove the duplicated alias arms and unreachable! fallback while preserving the resolved canonical family behavior.
555-573: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse one return type for all parser constructors
qwen3_unifiedreturnsBox<dyn UnifiedParser>, whileUnifiedParserFactoryreturnsResult<Box<dyn UnifiedParser>>. This prevents fallible built-in constructors from usingunified_registry!directly. Make built-in constructors returnResult<Box<dyn UnifiedParser>>, or document this intentional distinction.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@parsers/v2/src/unified/mod.rs` around lines 555 - 573, The built-in constructor path in create_unified_parser_for_family must use the same Result<Box<dyn UnifiedParser>> return type as UnifiedParserFactory. Update qwen3_unified and any other constructors registered through unified_registry! to return the fallible result type, propagating construction errors through the existing match and preserving the vendor factory path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@conformance/utils/src/assets/conformance.js`:
- Around line 694-703: Scope touch/pen provenance to the originating click so
stale pointer state cannot affect later keyboard activation. In
conformance/utils/src/assets/conformance.js at lines 694-703, track and clear
the state on click cancellation or completion; at lines 824-830, consume the
click-scoped state before preventDefault() and pin(). In
conformance/utils/tests/test_browser_smoke.py at lines 553-644, add a
touch-then-keyboard regression test verifying keyboard activation does not enter
pin mode.
In `@parsers/v2/CUSTOM_PARSERS.md`:
- Around line 24-35: The documented parser must emit each input byte only once:
update the parse_into/finish example in parsers/v2/CUSTOM_PARSERS.md lines 24-35
to emit committed content immediately while retaining only uncommitted buffered
content for finish, and ensure finish flushes that remainder once. In
parsers/v2/tests/vendor_parser_example.rs lines 23-45, extract and compile the
Markdown code block or generate the block from the tested implementation so the
documentation and executable example cannot diverge.
In `@parsers/v2/src/unified/mod.rs`:
- Around line 144-152: Implement the documented reset contract by overriding
reset in WrappedBlockScanner/ScannerUnified: return the current buffer via
std::mem::take, clear the scanner state flags such as in_block and in_reasoning,
and reset next_index to its initial value so subsequent parsing starts as a
fresh stream.
---
Nitpick comments:
In `@parsers/v2/src/unified/mod.rs`:
- Around line 583-586: Replace the generated canonical match in the family
handling flow with the existing
canonical_unified_family(family).unwrap_or(family) helper used by the vendor
branch. Remove the duplicated alias arms and unreachable! fallback while
preserving the resolved canonical family behavior.
- Around line 555-573: The built-in constructor path in
create_unified_parser_for_family must use the same Result<Box<dyn
UnifiedParser>> return type as UnifiedParserFactory. Update qwen3_unified and
any other constructors registered through unified_registry! to return the
fallible result type, propagating construction errors through the existing match
and preserving the vendor factory path.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Enterprise
Run ID: 7251359e-5339-47f9-bcd1-13e40c3faa88
📒 Files selected for processing (14)
conformance/tests/capture_cross_version.rsconformance/tests/unified_render.rsconformance/utils/piece1_coupling_gate.shconformance/utils/src/assets/conformance.cssconformance/utils/src/assets/conformance.jsconformance/utils/src/assets/conformance_view.jsconformance/utils/tests/test_browser_smoke.pyconformance/utils/tests/test_stream_on_batch.pyparsers/v2/CUSTOM_PARSERS.mdparsers/v2/src/lib.rsparsers/v2/src/tool_calling/scan.rsparsers/v2/src/unified/mod.rsparsers/v2/tests/vendor_parser_example.rsparsers/v2/tests/vendor_registry.rs
KrishnanPrash
left a comment
There was a problem hiding this comment.
AI Generated Comment
Six comments, all in parsers/v2. Four are the same shape: the contract this PR publishes and the code implementing it disagree, in ways assemble currently hides, which is why the suite is green. Since handing this surface to people outside the crate is the whole point, those seem worth settling before the API is out. The last two are small.
Verified against head 2fd0eba locally: cargo test --workspace --all-targets --locked gives 1372 passed, cargo clippy --workspace --all-targets gives 0 warnings, and the conformance/fixtures tree plus both LFS pointers are byte-identical to main. The evidence in the description holds.
2fd0eba to
12a16a2
Compare
12a16a2 to
ddaa264
Compare
… and let vendors supply their own Split out of DIS-2544 / PR #174, which bundled several unrelated concerns into ~7k lines across 56 files and stalled in review. This carries everything that does NOT depend on qwen3 request modes. No corpus change: the unified golden feed is byte-identical to main, enforced by conformance/utils/piece1_coupling_gate.sh. PEER-TRAIT ALIGNMENT UnifiedDelta::Text { text } -> UnifiedParserEvent::Text(String) peer name, peer variant ORDER, peer payload shapes. push (required) -> parse_into (required), push derived from it finish -> Result<Vec<..>> -> finish -> Result<UnifiedParserOutput> + initialize(&[u32]), reset, preserve_special_tokens, tool_call_id + UnifiedParserOutput with the peer accumulation helpers `UnifiedParserOutput` is documented as a CUMULATIVE buffer whose appends coalesce, so a caller cannot index a per-advance window and the built-in agrees with the helpers vendors are told to use. The stay-committed-on-error guarantee is stated on `parse_into`, whose buffer the caller owns; `push` returns `Result<Vec<_>>` and has nowhere to carry partial output. `ScannerUnified` overrides `reset` to drain the scanner and restart the tool index, and a parser that buffers is required to do the same. VENDOR REGISTRY register_unified_parser("acme_v1", factory) add a family register_unified_parser("qwen3", factory) SHADOW a family we ship unregister_unified_parser("qwen3") ours is reachable again Keyed on the CANONICAL family name, so registering `qwen3` also shadows its `qwen3_coder` alias — keying on the caller's spelling meant one family silently ran two different parsers depending on routing. Shadowing is non-destructive. `builtin_unified_families()` excludes vendor entries, because the conformance suite iterates it and a vendor family has no cases here. `CUSTOM_PARSERS.md` documents the trait and the contract a parser must honour. Its worked example is compiled as tests/vendor_parser_example.rs, and a test asserts the Markdown block and the compiled file are the same implementation — the previous "the build fails here first" claim was false, and the two had already drifted. GUI AND CONFORMANCE TOOLING Hover popups no longer depend on a media query. Listener REGISTRATION sat behind `matchMedia('(hover: hover)')`, and Chrome reports that false on machines that deliver real hover — the browser was sending hover events with nothing listening, and the same gate silently removed keyboard access. Listeners are now unconditional; only tap-to-pin consults `PointerEvent.pointerType`, consumed per click so a touch cannot arm a later keyboard activation. Evidence: cargo test --workspace --all-targets --locked 1373 passed, 33 suites, 0 failed python3 -m pytest conformance/utils/tests/ 128 passed cargo clippy --workspace --all-targets 0 warnings piece-1 coupling gate PASS golden.tar.gz / inputs.tar.gz 43db2838... / 70fda09d... unchanged Signed-off-by: Keiven Chang <keivenchang@users.noreply.github.com> SEMVER: bumped dynamo-parsers-v2 0.1.27 -> 0.2.0. This removes the public `UnifiedDelta` export and changes the `UnifiedParser` trait surface, so a downstream crate importing `UnifiedDelta` or implementing the old trait stops compiling. Under Cargo's 0.x rules the MINOR position is the breaking one, so 0.2.0 is the honest version. Deprecated compatibility wrappers were considered and rejected: the enum's variants changed shape (`Text { text }` -> `Text(String)`), so a wrapper could not preserve pattern matching, and the crate has no external consumers today — every in-repo dependant is updated in this change.
ddaa264 to
cbdf2bf
Compare
indrajit96
left a comment
There was a problem hiding this comment.
Some higher level commetns
1. The parser factory receives too little information
Today the factory looks like:
fn create_parser(tools: &[Tool]) -> Box
It receives only the available tools.
That works for Qwen because its parser mostly needs the tool schemas. Future parsers may also need:
- Which tokenizer/model variant is being used?
- Did the prompt already open the reasoning channel?
- Is the model producing native markup or guided JSON?
- Must special tokens be preserved?
- Does this deployment have vendor-specific configuration?
For example, Kimi K3 might start in reasoning mode:
Prompt already opened:
<|open|>think<|sep|>
Generated output starts directly with:
I need to call the weather tool...
The parser must know that those first generated bytes are reasoning. tools alone cannot tell it that.
A richer factory could receive:
ParserCreateContext {
tools,
model_config,
tokenizer_info,
starting_channel: Reasoning,
output_mode: NativeMarkup,
}
2. The scanner structure may be too specific to Qwen-like formats
The current scanner appears to expect a mostly flat structure:
BLOCK
INVOKE
INVOKE
BLOCK_END
Kimi K3 may have nested channels or sections. A simplified example is:
reasoning
└── tool section
└── invoke
A flat scanner may incorrectly close the outer block when it encounters the inner section, or emit events in the wrong order.
Before adding more model families, could we clarify whether ScannerUnified is intended to support nested formats? If it is, the scanner likely needs an explicit stack or model-specific state-machine extension point rather than only
configurable delimiters.
3. Kimi K3 (OR any NEW Model) needs a real incremental parser, not only a registry entry
Adding Kimi K3 support ideally will involve more than?
register_unified_parser("kimi_k3", create_kimi_parser);
The parser must handle arbitrary streaming boundaries. For example, these chunks must behave exactly like one complete string:
Chunk 1: "<|open|>to"
Chunk 2: "ol<|sep|>{\"name\":"
Chunk 3: "\"weather\"}..."
We should avoid implementing this logic separately in the v2 tool parser and unified parser. Otherwise, fixes for partial tokens, nesting, or malformed output must be made twice.
I suggest:
One incremental Kimi K3 state machine
├── tool-only adapter
└── unified-parser adapter
Both adapters would then share identical buffering, token recognition, validation, and error handling.
Is that the design for new models?
…faces disagreeing The trait promised that on `Err` the committed events stay and the uncommitted buffer is intact for `reset`. Neither held for the only shipped family: both invoke sites drained the buffer BEFORE the fallible emitter, and the drain loop collected into a local vector that `?` dropped on the way out. An emitter failing on the second call lost the leading text, the call that had already succeeded, and both invoke bodies; `reset` returned only the trailing markup. The peer keeps all of it, which is what the contract was copied from. The drain loop now writes through an `EventSink` the CALLER owns, so a committed event cannot be retracted, and each invoke is consumed only after its emitter succeeds. `ScannerUnified::parse_into` writes straight into the caller's output, which also removes the second allocation per advance that Indrajit flagged. `preserve_special_tokens` moves onto the grammar and both surfaces read it. The tool-only adapter answered `true` while the unified adapter over the SAME scanner inherited the trait default `false`, so a decoder honouring the flag could be told two different things about identical markup. Aggregation is the peer's OR rule over the tool and reasoning components. The shipped `true` is retained; whether `true` is itself right needs a tokenizer inventory and is a separate change. `UnifiedParserOutput::append` now coalesces across the seam like the peer helper, so joining two buffers and accumulating straight through agree; `parse_complete` joins through it rather than a raw `Vec::append`. Also: the worked vendor example obeys the `reset` rule the guide calls MANDATORY; the doc-sync test derives its own method list, so a method present in only one copy fails instead of passing unnoticed (that is how `reset` drifted); the constructor no longer promises a unified debug wrapper that does not exist; and the registry docs no longer call a fully lock-guarded table "not linearizable" with "undefined" results. Golden feed regenerated from scratch: byte-identical (169880fb), so none of this changes parsed output. Signed-off-by: Keiven Chang <keivenchang@users.noreply.github.com>
…merge rule `FromIterator` still used a raw `collect()`, so `collect()`ing two adjacent `Text` events produced a different event stream than pushing the same bytes — the same defect `append` had, one constructor over. dynamo-review-agent caught it on the previous head; fixing `append` without auditing its siblings is what left it. Routes `from_iter` through the push helpers, and makes `ScannerUnified::finish` write straight into the output like `parse_into` (dropping the last intermediate vector on that path). Adds construction-parity tests pinning `collect()` and `append` to the same result as pushing, so the next constructor cannot drift alone. Golden feed regenerated from scratch: byte-identical (169880fb). Signed-off-by: Keiven Chang <keivenchang@users.noreply.github.com>
Pointer provenance recorded the kind of pointer in one document-wide variable, and only a tooltip host's own click consumed it. So a tap that landed anywhere else — a heading, the legend, any non-host — left `touch` armed indefinitely. A later activation on a host that carries no pointerdown of its own (keyboard Enter, a synthetic click) then read that stale `touch`, pinned, and `preventDefault()`ed the parser-source link the user asked for. Provenance now records WHERE the pointer went down as well, and pinning requires the gesture to have started inside the same host. This is the sibling of the consume-on-use fix already in this PR: that one stopped provenance outliving its click, this one stops it belonging to a different element. The first regression I wrote for this was a false positive — it drove a mouse pointerdown on the host, which overwrote the stale value, so it passed with the fix reverted. The committed test uses a pointerdown-less activation, which is the interaction that actually reads stale provenance, and it fails on the pre-fix asset. Signed-off-by: Keiven Chang <keivenchang@users.noreply.github.com>
…their oracle A proactive sweep for siblings of the classes found earlier on this PR. Two classes came back clean (state-consumed-before-a-rejecting-operation, and JS state scoped to the wrong element); these are the rest. The vendor guide asserted contracts the shipped parsers do not honour: - "Argument bytes are verbatim ... do not reorder keys or re-serialize" is the exact opposite of what the XML families do — they schema-type, re-serialize, and then restore source key order. Replaced with the real invariant: keep meaning and order, and require verbatim BYTES only where the model already emits API-shaped JSON. - Split-invariance promised identical EVENTS; the shipped check compares assembled output, and raw event boundaries legitimately differ between chunkings. - The error-recovery row promised committed output on `Err` without naming the spelling. `push` cannot honour it — it owns the buffer it would have to return. - Reasoning promotion is the shipped Qwen policy, not a rule every grammar must follow. - The corpus section told a vendor to "point it at your parser", then explained two paragraphs later that vendor families deliberately do not enrol. There is no such flag; enrolling means adding cases and harness wiring, and that is now what it says. `UnifiedParserOutput` documented a type invariant it cannot enforce: `events` is public to match the peer type, so a direct struct literal or `events.extend` yields a value with adjacent same-kind events. Privatising it would diverge from the peer shape this surface exists to match, so the docs now state it as a helper-mediated convention and say plainly that a value which did not come through the helpers may break the rule. Two tests renamed to what their oracles actually prove: the doc-sync test compares the trait method set and bodies, not the whole example (a doc-only struct field rename would still pass, and that limit is now stated); and the stale-provenance regression drives a pointerdown-less activation, which is the vulnerable case — a genuine mouse click brings its own pointerdown and overwrites the stale value. Workspace: 1386 tests pass, clippy clean, coupling gate PASS, feed unchanged. Signed-off-by: Keiven Chang <keivenchang@users.noreply.github.com>
Renaming two tests to match their oracles left the prose describing them untouched. `CUSTOM_PARSERS.md` still named `doc_example_matches_compiled_example`, which no longer exists, and the browser regression's docstring still described a "genuine MOUSE click" after the test was corrected to drive a pointerdown-less activation. Renaming without grepping for references is the same fix-the-named-line habit this PR keeps demonstrating; both are now checked to zero remaining occurrences. Also states the doc-sync guard's limit where a vendor reads it: it does not compile the Markdown block and compares only the `impl UnifiedParser` methods, so a doc-only change to an import or struct field slips past. The PR body claimed the example is compiled "so the instructions cannot rot unnoticed"; narrowed to what the guard does. Workspace 1386 pass, 17 browser tests, coupling gate PASS, feed unchanged. Signed-off-by: Keiven Chang <keivenchang@users.noreply.github.com>
|
Yes — that is the design boundary. #178 only aligns the trait and adds startup registration for parsers that already exist; it intentionally does not claim the tools-only factory can construct tokenizer/model-dependent parsers or that |
|
@indrajit96 — all three are fixed, here's where. Heads up that your three threads show as resolved under my account; I didn't resolve them and I'd have left them for you, so please reopen any you're not happy with. 1. You were right, and it was worse than the single-invoke case. With Two changes: the drain loop now writes through a sink the caller owns, so a committed event can't be retracted; and both invoke sites call the emitter before Regressions for first/later x wrapped/bare, driven through the public I checked vLLM while fixing this — their tool trait documents exactly this contract and 2. Contradictory Agreed, two surfaces over one scanner can't answer differently. The value now lives on the grammar spec and both adapters read it, aggregated as the OR over tool + reasoning components — same rule as vLLM's Partly deferred, deliberately: I kept the shipped 3. Two vectors per Real. I went the other way on your second suggestion on purpose: overriding Current head is |
indrajit96
left a comment
There was a problem hiding this comment.
Thanks a lot for this massive lift off!!
@keivenchang
Approved with a couple of nits I see nothing blocking
Signed-off-by: Keiven Chang <keivenchang@users.noreply.github.com>
qwen3 gains the two request modes the reasoning-then-tool split cannot honour: a prompt-prefilled channel (`UnifiedParserStartingState`) and guided-JSON tool output (`UnifiedToolOutputMode`). Other model families are unchanged. Built on the peer-trait surface from #178: the parser writes committed events straight into the caller's `UnifiedParserOutput`, consumes an invoke only after its emitter succeeds, and returns unconsumed bytes from `reset`. Corpus grows 99 -> 240 cases. Released peer captures are append-only: every pre-existing member of `inputs`, `golden`, `dynamo_v2-0.1.23`, `vllm_python-0.25.1` and `vllm_rust-0.25.1` is byte-identical, with the 138 new cases added alongside and the live parser recorded under a new `dynamo_v2-0.2.0` shard rather than rewriting an older label. Split invariance is enforced by generation, not by example: `generated_control_marker_pairs_are_split_invariant` builds inputs from the marker alphabet, takes all ordered pairs, and asserts one-push output equals every char-boundary split — which is what caught the three cases where a competing marker, a payload boundary before a header, and post-payload whitespace each produced different output depending on chunking. Signed-off-by: Keiven Chang <keivenchang@users.noreply.github.com>
Overview:
This PR aligns the unified parser's ordered-event and incremental-parse surface with the peer traits, and lets a third party supply their own unified parser — including replacing one this crate ships — without forking.
Two peer capabilities are deliberately NOT in this PR, so "aligned" should not be read as "drop-in adoptable": the peer's
createtakes a tokenizer as well as the tools, while the vendor factory here takes only tools, so a peer parser that resolves its markers from a tokenizer cannot be constructed through this registry yet; andstructural_tag_model, which constrains generation for strict tool calling, has no type or consumer in this crate. Both need a design, and neither is closed by the follow-up request-mode PR — that one selects between native markup and guided JSON in already-generated bytes, which is a different question. It also fixes hover popups in the conformance report, which had stopped opening entirely.Split out of DIS-2544 / #174, which bundled several unrelated concerns into ~7k lines across 56 files and stalled in review. This half does not depend on qwen3 request modes, so it lands on its own; the request modes follow stacked on top in #174.
No corpus change. The unified golden feed is byte-identical to
main.Where this sits
flowchart TB M["main · 99 golden cases"] A["#178 · THE SURFACE · UnifiedParser trait + output type + vendor registry"] B["#174 · REQUEST MODES · StartingState + ToolOutputMode · corpus 99 → 237"] C["#166 · SECOND FAMILY · gemma4 on the surface, graded by #174's corpus"] M --> A --> B --> C classDef here fill:#fff4ce,stroke:#b06000,stroke-width:3px,color:#000 classDef other fill:#f6f8fa,stroke:#8c959f,color:#000 classDef base fill:#eef1f4,stroke:#8c959f,color:#000 class M base class A here class B,C otherThe full graph — every trait, type and registry entry each layer adds, and which layer consumes it — is in DIS-2644, kept there because it is too wide to read inline on GitHub.
Review aid: the lifecycle this PR pins down
stateDiagram-v2 direction LR [*] --> Fresh Fresh --> Streaming : parse_into Streaming --> Streaming : parse_into Streaming --> Errored : Err Errored --> Fresh : reset() Streaming --> Ended : finish() Ended --> [*]What each edge guarantees, and where to check it in the diff:
parse_intoErrin the same advance cannot retract themScannerUnified::parse_intowrites straight throughEventSinkErrnext_indexnot advancedbuffer.drain(..)reset()in_block,in_reasoning,resume_reasoning,suppress_normal_text,next_indexreset_streaminscan.rsfinish()ScannerUnified::finishpush()is the one spelling that CANNOT honour the first row — it owns its buffer and returnsResult<Vec<_>>, which has nowhere to carry partial output. A parser that may commit and then fail must be driven throughparse_into.Review aid: what goes in, what comes out
Every line below is marked with where it comes from. Verified against the vLLM Rust parser crate at
rust/src/parser/src/unified/mod.rs(v0.26.0).Provenance of every trait method:
parse_into·initialize·reset·preserve_special_tokens·tool_call_idfinishpush(chunk) -> Result<Vec<UnifiedParserEvent>>parse_complete(output) -> Result<Vec<UnifiedEvent>>parse_into/finishmakes stream/batch parity structuralregister_unified_parser/unregister/ aliasesAccumulation helpers
push_text·push_reasoning·push_call·appendare FROM vLLM by name and semantics, including thatappendcoalesces across the seam. One difference: vLLM'sappend(other: Self)consumes; ours takes&mut Self.In vLLM, deliberately NOT here:
create(tools, tokenizer)— their construction takes a tokenizer, ours takes only&[Tool], so a peer parser that resolves markers from a tokenizer cannot be built through this registry yet.structural_tag_model()— constrains generation for strict tool calling; no type or consumer in this crate.append_tool_output()— bridges their separate tool-only output type.Why a flat
Vecand not{reasoning_text, content, tool_calls}. A bundle of parallel fields physically cannot say whether text came before or after a call — that is why the split path today hoists every thought to the front and merges them. Ordering is not a field you can add; it has to be the shape. This is the part vLLM already got right, and the reason aligning to it was worth doing.Two distinctions that are easy to misread in the diff:
ToolCallDelta.argumentsis aStringfragment that concatenates across deltas;UnifiedEvent::ToolCall.argumentsis a parsedserde_json::Value.assemble()is the single fold between them — and both the fold and the assembled type are ours, not vLLM's.UnifiedParserEventuses tuple variants (vLLM's shape);UnifiedEventuses struct variants with a serde tag, because it serializes to the golden-corpus schema.Resultisanyhow::Resultthroughout.Review aid: what an emitter failure costs, after this PR
The ordering below is the fix. Before it, the buffer was drained before the fallible call and the drain loop collected into a local vector that
?dropped — so this same input lostprefix, theokcall that had already succeeded, and both invoke bodies, leavingreset()with only</tool_call>suffix.sequenceDiagram autonumber participant C as Caller (owns output) participant S as Scanner drain loop participant E as Emitter (fallible) C->>S: parse_into("prefix<tool_call>ok…boom…</tool_call>suffix") S-->>C: push_text("prefix") — COMMITTED S->>E: parse_invoke(ok) E-->>S: Ok(ToolCall) S-->>C: push_call(ok) — COMMITTED S->>S: drain the ok bytes (only after success) S->>E: parse_invoke(boom) E-->>S: Err Note over S: boom bytes were NOT drained S-->>C: Err C->>S: reset() S-->>C: "<function=boom></function></tool_call>suffix"Example (before → after):
Hovering a cell in the conformance report opened nothing on a normal desktop:
Details:
UnifiedParserEventin peer variant order and payload shapes,parse_intoas the required method,finish -> UnifiedParserOutput,initialize(&[u32]), plusreset/preserve_special_tokens/tool_call_idand the peer accumulation helpers.parse_completeand the assembledUnifiedEventview stay additive, so a peer-shaped caller never sees them.register_unified_parser/unregister_unified_parser, keyed on the canonical family name so registeringqwen3also shadows itsqwen3_coderalias. Shadowing is non-destructive; unregistering restores the built-in exactly.builtin_unified_families()excludes vendor entries, because the conformance suite iterates it and a vendor family has no cases here.CUSTOM_PARSERS.mddocuments the contract, and its worked example exists as a compiled integration test (tests/vendor_parser_example.rs) using only the public API, with a guard asserting the guide'simpl UnifiedParsermethods match that file's. The guard is scoped to those method bodies — it does not compile the Markdown block, so a doc-only change to imports or struct fields would still slip past it.Verification:
conformance/utils/piece1_coupling_gate.shis the falsifiable proof that no request-mode work leaked in: it rejects every request-mode symbol from the diff, provesconformance/fixturesand the manifest are byte-identical tomain, pins both LFS pointers, and confirms the generator still emits main's 33 scenarios / 99 cases. It was negative-controlled against the combined branch, where it fails for eight independent reasons.Where should the reviewer start?
parsers/v2/src/unified/mod.rs(the trait and registry), thenparsers/v2/CUSTOM_PARSERS.md./coderabbit profile chill
Summary by CodeRabbit
New Features
Bug Fixes
Documentation
Tests