Skip to content

feat(stim-parser): fuse consecutive compatible instructions like Stim (#291) - #294

Draft
lkdvos wants to merge 3 commits into
QuEraComputing:mainfrom
lkdvos:issue-291
Draft

lkdvos wants to merge 3 commits into
QuEraComputing:mainfrom
lkdvos:issue-291

Conversation

@lkdvos

@lkdvos lkdvos commented Nov 13, 2026

Copy link
Copy Markdown

READ FIRST: Note from myself: opening this as draft PR for now, since there are quite a few design choices that are made based on how important/desirable several features are. Before having in-depth reviews on code, should probably briefly discuss code design choices so we don't waste time on this.


Closes #291.

Stim's parser fuses consecutive lines with the same instruction, tag and arguments (H 0 / H 1 → H 0 1). parse and parse_extended now do the same.

Design

Fusion is a new final pipeline stage, after validate and lower:

Source → Parsed → Validated → Fused<Program>                  (parse)
                       └────→ Lowered → Fused<ExtendedProgram> (parse_extended)

Why fusion runs last. Fusing earlier would mean later checks see merged lines instead of source lines, which causes two problems:

  • It can hide errors. I_ERROR[correlated_loss](0.1) 0 / I_ERROR[correlated_loss](0.1) 1 has an odd target count on each line, so both are invalid. Fused first, they would pass as a valid 0 1.
  • It reports errors on the wrong line. In T 0 / T rec[-1], the bad record target is on line 2. If the lines were fused first, the error would land on line 1.

Running fusion after every check means each check sees one source line, and fusion itself can't fail. The order is enforced by the types: lower() only exists on the unfused Validated state.

Why a separate stage. validate and lower keep doing one thing each. finish() without .fuse() still gives one instruction per line, for tooling that needs a 1:1 mapping to the source.

Which instructions fuse. The rules follow Stim's CircuitInstruction::can_fuse:

  • Two instructions fuse when they have the same name, tag and arguments. Arguments are compared as numbers, so 0.1 and 0.10 match.
  • Annotations, TICK and REPEAT never fuse.
  • CORRELATED_ERROR / ELSE_CORRELATED_ERROR never fuse either: each line is a single joint error event, so merging two lines would change the noise channel.

For everything else, a multi-target line means the same as its lines one after another, so fusion doesn't change behaviour.

Lines dropped for errors. With a sink that keeps going after errors (Collect), an invalid line is dropped but still separates its neighbours: H 0 / TIKC / H 1 stays two instructions, since TIKC may have been meant as a barrier. Fusion runs as a separate pass, after the dropped line is gone, so validate and lower record the span of every node they skip. With FailFast, which parse / parse_extended use, that list is always empty.

Differences from Stim

  • Spelling aliases aren't fused (CX/CNOT, M/MZ). The AST keeps each spelling as its own variant so the printer can reproduce the source. Fusing them would mean choosing which spelling survives, which is left for a follow-up.
  • parse_extended fuses by operation. S[T] 0 / T 1 becomes one T: the extended AST describes operations, and both lines are the same operation. Plain parse keeps them apart, like Stim.

ppvm-stim

ppvm_stim::validate runs on the fused program and reports an instruction's first line, which is what Stim does too. That's accurate for checks on the name and arguments, since those are the same on every line of a run. It isn't accurate for checks on individual operands. So record-control and MPP errors now quote the offending operand and name the line range, e.g. pair 'rec[-5] 1' in the instruction fused from lines 2-3. This goes in the message text rather than in new fields to avoid a breaking change to ExecError. Stim reports these errors at sample time without any line number.

Testing

New tests/fusion.rs, with expected outputs taken from Stim 1.15. The AST proptest checks that print and parse are inverses, so it now re-parses through the unfused pipeline (the printer writes one instruction per line). cargo test --workspace and the Python suite pass.

Follow-ups

  • Exact ppvm-stim error lines for text input: validate before fusing.
  • Display for Target / PauliFactor.
  • Alias fusion.
  • Reject rec[...] on non-controlled gates at parse time, as Stim does.

Assisted by Claude Code

lkdvos and others added 3 commits November 13, 2026 16:18
Add `Pipeline::fuse`, a final stage that merges consecutive compatible
instructions the way Stim's `Circuit` parser does (`H 0` / `H 1` becomes
`H 0 1`):

    Source -> Parsed -> Validated -> Fused<Program>
                            \-> Lowered -> Fused<ExtendedProgram>

Fusion runs after every stage that can reject input, and `lower` is only
available on the unfused `Validated` state, so validation and lowering
always check one source line at a time. `finish()` without `fuse()`
still returns one instruction per line.

Instructions fuse when they have the same name, tag and arguments.
Spelling aliases (`CX`/`CNOT`) are not treated as the same. Annotations,
`REPEAT` blocks and `CORRELATED_ERROR`/`ELSE_CORRELATED_ERROR` never
fuse. The extended AST fuses by operation, so `S[T] 0` / `T 1` becomes a
single `T`.

`validate` and `lower` now record the spans of the nodes they skip, and
fusion never merges across one, so with a non-aborting sink an invalid
line still separates the instructions around it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e_extended

`parse` and `parse_extended` now end with the fusion stage, so the
parsed program has the same instruction structure as Stim's: `H 0` /
`H 1` parses as one `H 0 1`. Expected outputs in `tests/fusion.rs` are
taken from Stim 1.15.

The AST print/parse property test re-parses through the unfused
pipeline, since the printer emits one instruction per line.

Closes QuEraComputing#291

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… errors

A fused instruction can span several source lines, and `ExecError`
reports the first. Record-control and MPP-product errors now quote the
offending operand (`pair 'rec[-5] 1'`, `X1*X1`) and, for a fused
instruction, name its line range:

    invalid measurement-record control for 'CX' at line 2: rec[-5] looks
    back before the start of the measurement record (1 measurement(s)
    recorded so far; pair 'rec[-5] 1' in the instruction fused from
    lines 2-3)

`ExecError` itself is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

stim parser should merge gates across lines

1 participant