Skip to content

Measurement-dependent classical errors (division by zero, array index) compile to unchecked instructions with ret i64 0 — simulator raises, compiled output continues #3478

Description

@AJ-comp

Summary

qsc catches integer division by zero and out-of-range array indices at compile time when the operands are static. When the operand depends on a measurement result, the Adaptive-family profiles emit the unchecked instruction — no diagnostic, no runtime check, and no path to a non-zero exit code. The same program raises under simulation and silently continues when compiled. This is not a conformance claim (the spec contemplates such errors as UB); it is that one source means two different things depending on targetProfile, and that the emitted IR has no instruction that could report the failure at all.

Repro A — division by zero (Adaptive_RI, Adaptive_RIF, Adaptive)

namespace T {
  @EntryPoint()
  operation Main() : Int {
    use q = Qubit();
    X(q);                            // deterministic: M(q) is always One
    let d = M(q) == One ? 0 | 1;     // divisor is always 0
    return 100 / d;
  }
}

Unrestricted simulator: raises Error: division by zero, 20/20 shots. All three Adaptive-family profiles compile clean, with zero comparison (icmp) instructions in the module and an unchecked sdiv whose divisor is always 0, ending in ret i64 0. On Adaptive_RI / Adaptive_RIF (QIR v1):

block_3:
  %var_8 = phi i64 [0, %block_1], [1, %block_2]
  %var_6 = sdiv i64 100, %var_8      ; %var_8 is 0 on every shot
  call void @__quantum__rt__int_record_output(i64 %var_6, i8* ...)
  ret i64 0

Plain Adaptive (QIR v2) lowers the divisor via alloca/load rather than a phi, but is otherwise the same — unchecked sdiv i64 100, <load>, zero icmp, ret i64 0. A static divisor (100 / 0) is caught on all three: Qsc.PartialEval.EvaluationFailed: division by zero. X(q) is used so the divisor is deterministically 0 and no sampling argument is needed; H(q) makes it probabilistic with the same result.

Repro B — array index (Adaptive only)

The docs promise this one unconditionally (item access): "An exception is thrown at runtime if the index … is outside the bounds of the array." The simulator does; Adaptive does not.

namespace T {
  @EntryPoint()
  operation Main() : Int {
    let table = [100, 200, 300];
    use q = Qubit[2];
    H(q[0]); H(q[1]);
    let a = M(q[0]) == One ? 1 | 0;
    let b = M(q[1]) == One ? 2 | 0;
    return table[a + b];             // index in {0,1,2,3}; 3 is out of bounds
  }
}
@array0 = internal constant [3 x i64] [i64 100, i64 200, i64 300]
%var_12 = getelementptr i64, ptr @array0, i64 %var_11
%var_20 = load i64, ptr %var_12

Adaptive_RI/Adaptive_RIF reject this with Qsc.CapabilitiesCk.UseOfDynamicIndex. Adaptive permits dynamic indices by design (module flags !"arrays" i1 true, !"backwards_branching" i2 3) — that is intended; the gap is that permitting them comes with no check. A static out-of-range index is caught on all three. (SSA names above are illustrative and depend on the exact source.)

Why these are the same bug

Both follow the same shape in qsc_partial_eval: reject only the statically-provable case, emit unchecked otherwise.

lib.rs:3213 (comment at 3212) — literal only:

// Validate that the RHS is not a zero.
if let Operand::Literal(Literal::Integer(0)) = rhs_operand {
    let error = EvalError::DivZero(bin_op_expr_span).into();
    return Err(error);
}

lib.rs:5094 — only when the array is empty:

if array.is_empty() {
    // Even though we don't know what the index value is, we know any index into an empty array is out of range,
    // so just return an error with index 0 here.

lib.rs:507 — the exit code is a hardcoded literal:

// Encode finalize as `Return(Some(Integer(0)))` so the QIR v2 renderer emits
// the entry-point convention as `ret i64 0` through the value-return path.

And qsc_rir's Instruction enum (rir.rs:377) has no Fail/Abort/Trap variant — so a compiled program has no instruction that could report a runtime failure, whatever happens. The division and array cases are two symptoms of that one structural gap.

Expected

Any one of these, cheapest first:

  1. A compile-time diagnostic when a measurement-dependent value reaches a fallible classical operation — the RCA machinery already does exactly this for UseOfDynamicIndex.
  2. An emitted runtime check producing a non-zero exit code. The spec provides the mechanism and assigns it to the compiler: "Injecting suitable logic to produce a non-zero exit code … is generally up to the compiler, and the backend is not required to detect runtime failures"; "The program IR must use exit codes within the range 1 to 63 to indicate a failure."
  3. At minimum, publish the limitation. The v1.30.0 notes point to the wiki QIR page for the Adaptive profile's "latest capabilities, limitations, and known issues", but that page has only a Runtime Capabilities Check error reference — no limitations section. Repro A is not specific to the in-progress Adaptive profile: it reproduces on Adaptive_RI/Adaptive_RIF too, and the literal-only Div check dates to Partial evaluation support for binary operations #1499 (2024-05-13). Unchanged on main as of ebe6090.

Not claiming a conformance violation

The spec contemplates real-time classical errors as UB — "An Adaptive Profile program that undergoes a real-time classical error (for example unchecked division by zero) has undefined behavior." The point is narrower: the same Q# source silently means two different things depending on targetProfile, the compiled path drops a check the simulator performs, and the IR cannot express the failure — and none of this is documented.

Environment

qdk 1.30.0 / qsharp 1.30.0 (unchanged on main as of ebe6090), Python 3.12, Windows

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions