Skip to content

fix: avoid integer overflow in ParameterExpression arithmetic - #16980

Open
Eduardo Alcantara (edubraqd) wants to merge 2 commits into
Qiskit:mainfrom
edubraqd:fix/parameter-int-overflow
Open

Eduardo Alcantara (edubraqd) wants to merge 2 commits into
Qiskit:mainfrom
edubraqd:fix/parameter-int-overflow

Conversation

@edubraqd

Copy link
Copy Markdown

Summary

Integer arithmetic on ParameterExpression overflows the internal i64, which panics in debug builds and wraps silently to a wrong value in release builds. It reproduces from the public API:

from qiskit import QuantumCircuit
from qiskit.circuit import Parameter

p = Parameter("p")
qc = QuantumCircuit(1)
qc.global_phase = p * 2
qc.assign_parameters({p: 2**62})   # panics (debug) / silently wraps to a bogus phase (release)

Same path is hit by (p * 2).assign(p, 2**62).numeric(), float(p + 2**62 ...), -p at i64::MIN, and p ** 3 with a large base.

Cause

In crates/circuit/src/parameter/symbol_expr.rs, the Int/Int branches of Add, Sub, Mul, Neg and Value::pow use raw l + r, l - r, l * r, -v and e.pow(r as u32). The as u32 also truncates a large exponent before overflowing.

Fix

Use checked arithmetic and fall back to Value::Real(f64) when the exact integer result does not fit in an i64. This mirrors the coercion the same operators already apply when mixing Int and Real, so in-range integer math is unchanged and the boundary degrades to a finite float instead of panicking/wrapping. Values that stay in range keep their exact Int result.

One file changed plus a release note. Added a Rust unit test (test_value_int_arithmetic_saturates_to_real) covering each operator at the i64 boundary and confirming in-range results are untouched.

AI/LLM disclosure

  • No part of this submission is LLM generated.
  • Some written text was generated by: Claude (Claude Code), edited and reviewed by me.
  • Some submitted code was generated by: Claude (Claude Code); I reviewed the diff, ran the tests, and can explain the change.

Integer-valued `Value`s used raw `+`, `-`, `*`, unary `-` and `pow`, which
panic on overflow in debug builds and wrap silently in release builds. This
was reachable from public API, e.g. binding a parameter to a large integer:

    qc.global_phase = p * 2
    qc.assign_parameters({p: 2**62})

The Int/Int paths now use checked arithmetic and fall back to a floating-point
result when the exact integer value does not fit in an i64, matching the
existing Int/Real coercion in the same operators.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@qiskit-bot qiskit-bot added the Community PR PRs from contributors that are not 'members' of the Qiskit repo label Sep 12, 2026
@qiskit-bot

Copy link
Copy Markdown
Collaborator

Thank you for opening a new pull request.

Before your PR can be merged it will first need to pass continuous integration tests and be reviewed. Sometimes the review process can be slow, so please be patient.

While you're waiting, please feel free to review other open PRs. While only a subset of people are authorized to approve pull requests for merging, everyone is encouraged to review open pull requests. Doing reviews helps reduce the burden on the core team and helps make the project's code better for everyone.

One or more of the following people are relevant to this code:

  • @Qiskit/terra-core

@1ucian0 Luciano Bello (1ucian0) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks Eduardo! This fix makes sense to me.

@1ucian0 Luciano Bello (1ucian0) moved this to Waiting for maintainer response in Contributor Monitoring Oct 14, 2026

@Cryoris Julien Gacon (Cryoris) 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.

Thanks for submitting a fix. What about div when we divide by something close to 0 such that the result is outside i64?

Also it would be nice to have a Python test, at least as regression test for the example in your readme.

Comment on lines +5 to +19
could overflow the internal 64-bit integer representation. Binding a
parameter to a large integer and then adding, subtracting, multiplying,
negating or exponentiating it (for example via :meth:`.QuantumCircuit.assign_parameters`,
the ``global_phase`` of a circuit, or :meth:`.ParameterExpression.numeric`)
would panic in debug builds and silently wrap around to an incorrect value
in release builds. Such operations now fall back to a floating-point result
when the exact integer result does not fit in a 64-bit integer, for example::

from qiskit import QuantumCircuit
from qiskit.circuit import Parameter

p = Parameter("p")
qc = QuantumCircuit(1)
qc.global_phase = p * 2
qc.assign_parameters({p: 2**62}) # previously panicked or wrapped

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.

This is quite wordy, I think we can reduce this

Suggested change
could overflow the internal 64-bit integer representation. Binding a
parameter to a large integer and then adding, subtracting, multiplying,
negating or exponentiating it (for example via :meth:`.QuantumCircuit.assign_parameters`,
the ``global_phase`` of a circuit, or :meth:`.ParameterExpression.numeric`)
would panic in debug builds and silently wrap around to an incorrect value
in release builds. Such operations now fall back to a floating-point result
when the exact integer result does not fit in a 64-bit integer, for example::
from qiskit import QuantumCircuit
from qiskit.circuit import Parameter
p = Parameter("p")
qc = QuantumCircuit(1)
qc.global_phase = p * 2
qc.assign_parameters({p: 2**62}) # previously panicked or wrapped
could overflow the internal 64-bit integer representation, like::
from qiskit import QuantumCircuit
from qiskit.circuit import Parameter
p = Parameter("p")
qc = QuantumCircuit(1)
qc.global_phase = p * 2
qc.assign_parameters({p: 2**62}) # previously panicked or wrapped
Such operations now fall back to a floating-point result when the exact integer result does
not fit in a 64-bit integer.

@jakelishman

Jake Lishman (jakelishman) commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

It's not obvious that this actually is the correct solution for a symbolic engine. This makes it not error, but silently lifts the logic from integer to floating point, which affects the semantics of all subsequent operations. The alternative is of course to add a BigInt variety to the tree, which might have significant knock-on implications because of the complexity of the current code.

Lifting to float may be appropriate because Qiskit it's not a general-purpose symbolic engine, but it is something that needs to have a positive decision and probably a bit of discussion, which might have been better in a bug report / feature request.

@Cryoris

Copy link
Copy Markdown
Contributor

Lifting to float may be appropriate because Qiskit it's not a general-purpose symbolic engine, but it is something that needs to have a positive decision and probably a bit of discussion, which might have been better in a bug report / feature request.

Yeah that also made me a bit uneasy when reading it, yet we already do have some lifting to float present, e.g. for divisions or trigonometric functions (the slightly strange thing there is that we sometimes try to cast back to int when it happens to hit an integer value).

The other point is backward compat, those overflows would've worked with the Python version of parameter expressions, right? (Maybe points for a more detailed discussion)

@jakelishman

Jake Lishman (jakelishman) commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Most general-purpose symbolic engines will support arbitrary-width integers, whether in Python or not. We're less general purpose, so we don't have to do that.

I'm not saying I know the right answer, or necessarily want/need to be part of the decision here, I'm just saying that there is a concrete decision that should be made, considering the options.

This mirrors the coercion the same operators already apply when mixing Int and Real, so in-range integer math is unchanged and the boundary degrades to a finite float instead of panicking/wrapping.

This is a completely different situation. Mixing Real and Int necessarily promotes to Real and is explicitly done by interaction. Overflowing an arbitrary internal width boundary in library code not something the user made a positive choice to do; if they wanted to cast to Real in general, they'd have the option to do the cast.

@1ucian0

Copy link
Copy Markdown
Member

Let's block this PR on the discussion resolution in #17217

@1ucian0 Luciano Bello (1ucian0) added the on hold Can not fix yet label Oct 14, 2026
`Value::Int / Value::Int` computed the quotient in `f64` and cast back with
`as i64`, which rounds above 2**53 and saturates `i64::MIN / -1`. Divide in
integers with `checked_rem`/`checked_div` instead, falling back to a float
on overflow like the other operators.

`Int ** Real` narrowed integral results to `Int` with a saturating cast, so
`2 ** 70.0` evaluated to `i64::MAX`. Only narrow when the value fits.

Add a Python regression test covering the boundary of every operator.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@edubraqd

Copy link
Copy Markdown
Author

Thanks Julien Gacon (@Cryoris). On div:

  • Dividing by a Real never produces an Int, so it can't overflow i64: the result is l as f64 / r. A divisor within SYMEXPR_EPSILON of zero hits the existing rhs == 0.0 check and becomes inf, which binding rejects with ZeroDivisionError: attempted to bind infinite value to parameter. Above that threshold you just get a large float (p / q with p=3, q=1e-14 gives 3e14).
  • With an Int divisor, |r| >= 1, so the only out-of-range quotient is i64::MIN / -1. From Python, p / -1 is rewritten as a negation, which this PR already covers, so I could only reach it at the Value level. There t as i64 saturates to i64::MAX instead of panicking.

Checking that path turned up two more cases of the same "compute in f64, cast back with as i64" pattern, both reachable from Python and not touched by this PR so far:

p, q = Parameter("p"), Parameter("q")
(p ** q).assign(p, 2).assign(q, 70.0).numeric()          # 9223372036854775807, saturated
(p / q).assign(p, 3 * 2**60 + 3).assign(q, 3).numeric()   # 2**60, should be 2**60 + 1

The latest commit:

  • Int / Int now divides in integers with checked_rem/checked_div. It returns an exact Int when divisible, a float otherwise, and i64::MIN / -1 falls back to a float like the other operators.
  • Int ** Real only narrows to Int when the result fits in i64.
  • Adds the Python regression test test_integer_overflow_falls_back_to_float. It covers the global_phase example from the description, each operator at the i64 boundary, and the two cases above, and it fails on the previous commit. The Rust unit test is extended to match.

On Jake Lishman (@jakelishman)'s point: agreed, falling back to a float is a choice that should be made on purpose. I kept every operator on the same rule, so whatever #17217 decides (float, BigInt, or raising) is a mechanical change across them. I'll rework it once that's settled.

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

Community PR PRs from contributors that are not 'members' of the Qiskit repo on hold Can not fix yet

Projects

Status: Waiting for maintainer response

Development

Successfully merging this pull request may close these issues.

PanicException: overflow in ParameterExpression arithmetic

5 participants