Skip to content

[Type check] An and/or whose right operand returns counts as divergent, so a non-Unit function can end without a value #3929

Closed

Description

@amcasey

Kind: The type checker accepts an ill-typed program; the simulator and QIR then return made-up values.
Affects: the simulator, Adaptive_RI, Adaptive_RIF and Adaptive

F is declared to return Int, but its body is false and (return 1);. The right operand never runs, so F reaches
the end of its body without a value, which should be a type error. Instead the program type-checks: the simulator
returns () from the Int function, and QIR returns 0.

Q#

function F() : Int {
    false and (return 1);
}
operation Main() : Int { F() }

Outputs

1 shot each. The simulator runs the Q# program directly; each profile compiles it to QIR, which is run with
qir-runner.

Run Output Correct?
Simulator () no
Base rejected (Qdk.Qsc.CapabilitiesCk.UseOfIntOutput) n/a
Adaptive_RI 0 no
Adaptive_RIF 0 no
Adaptive 0 no
Correct output a type error: F's body doesn't produce an Int

Correct output

The program should be rejected, as it is when the body is just false;.

Other shapes

true or (return 1); and mutable b = false; set b and= (return 1); behave the same way. With fail "x" in place of
return 1 (false and (fail "x");), the simulator returns () and compiling for Adaptive_RI, Adaptive_RIF or
Adaptive panics at the non-Unit block-tail invariant
(invariants.rs:672). That panic is #3819's, and the
invariant is right: the type checker shouldn't have accepted the program.

Probable cause

infer_binop (qsc_frontend/src/typeck/rules.rs:847)
computes let diverges = lhs.diverges || rhs.diverges; and applies it to every binary operator
(rules.rs:925), including the short-circuiting and
and or, whose right operand may not run. and= and or= reuse infer_binop
(rules.rs:257-260). For and/or, only the left
operand's divergence should count.

Reproduce

"""A non-Unit function can end without a value: `and`/`or` with a `return` on the right counts as divergent

Type-checks each Q# program below (it should be rejected), then runs it in the Q#
simulator and compiles it to QIR for each target profile, running that QIR with qir-runner. Exits 0 while the bug reproduces.

Requires: pip install qdk qirrunner
"""

import os
import re
import sys
import tempfile

import qirrunner
from qdk import Context, TargetProfile

# name -> (Q# source, correct outcome, runs that show the bug). Each program should be
# rejected by the type checker.
CASES = {
    'false and (return 1);': ("""
function F() : Int {
    false and (return 1);
}
operation Main() : Int { F() }
""", 'rejected', ['Simulator', 'Adaptive_RI', 'Adaptive_RIF', 'Adaptive']),
    'true or (return 1);': ("""
function F() : Int {
    true or (return 1);
}
operation Main() : Int { F() }
""", 'rejected', ['Simulator', 'Adaptive_RI', 'Adaptive_RIF', 'Adaptive']),
    'set b and= (return 1);': ("""
function F() : Int {
    mutable b = false;
    set b and= (return 1);
}
operation Main() : Int { F() }
""", 'rejected', ['Simulator', 'Adaptive_RI', 'Adaptive_RIF', 'Adaptive']),
}

ENTRY = "Main()"
SHOTS = 1
PROFILES = ['Base', 'Adaptive_RI', 'Adaptive_RIF', 'Adaptive']


def fmt(value):
    """Formats a simulator value the same way as qir-runner output below."""
    if value is None:
        return "()"
    if type(value).__name__ == "Result":
        return "1" if str(value).endswith("One") else "0"
    if isinstance(value, bool):
        return str(value).lower()
    if isinstance(value, float):
        return repr(value)
    if isinstance(value, (tuple, list)):
        inner = ", ".join(fmt(v) for v in value)
        return f"({inner})" if isinstance(value, tuple) else f"[{inner}]"
    return str(value)


def parse_shots(text):
    """Turns qir-runner's output records into one formatted value per shot."""
    shots, records = [], None
    for line in text.splitlines():
        fields = line.split("\t")
        if fields[0] == "START":
            records = []
        elif fields[0] == "OUTPUT" and records is not None:
            records.append((fields[1], fields[2]))
        elif fields[0] == "END" and records is not None:
            shots.append(to_text(iter(records)) if records else "()")
            if fields[1] != "0":
                shots[-1] = f"exit code {fields[1]}"
            records = None
    return shots


def to_text(records):
    kind, value = next(records)
    if kind in ("TUPLE", "ARRAY"):
        inner = ", ".join(to_text(records) for _ in range(int(value)))
        return f"({inner})" if kind == "TUPLE" else f"[{inner}]"
    if kind == "DOUBLE":
        return repr(float(value))
    if kind == "BOOL":
        return value.lower()
    return value


def run_qir(qir):
    with tempfile.NamedTemporaryFile("w", suffix=".ll", delete=False) as file:
        file.write(qir)
    handler = qirrunner.OutputHandler()
    try:
        qirrunner.run(file.name, shots=SHOTS, rng_seed=1, output_fn=handler.handle)
    except Exception as error:  # qir-runner rejects the module
        message = str(error).strip().splitlines()[0]
        return {"invalid QIR: " + re.sub(r"^\S+\.ll:\d+:\d+: (error: )?", "", message)}
    finally:
        os.unlink(file.name)
    return set(parse_shots(handler.get_output()))


def compile_qir(source, profile):
    """Returns (qir, None) on success or (None, description) when compilation fails."""
    ctx = Context(target_profile=getattr(TargetProfile, profile))
    try:
        ctx.eval(source)
        return str(ctx.compile(ENTRY)), None
    except (KeyboardInterrupt, SystemExit):
        raise
    except BaseException as error:  # pyo3 surfaces Rust panics as PanicException (a BaseException)
        kind = "COMPILER PANIC" if type(error).__name__ == "PanicException" else "rejected"
        return None, f"{kind}: {str(error).strip().splitlines()[0]}"


def outputs(source):
    rows = {}
    ctx = Context(target_profile=TargetProfile.Unrestricted)
    try:
        ctx.eval(source)
    except Exception as error:  # the type checker rejects the program, as it should
        rows["Simulator"] = {"rejected: " + str(error).strip().splitlines()[0]}
    else:
        rows["Simulator"] = {fmt(v) for v in ctx.run(ENTRY, SHOTS, seed=1)}
    for profile in PROFILES:
        qir, failure = compile_qir(source, profile)
        rows[profile] = {failure} if failure else run_qir(qir)
    return rows


def show(name, rows, expected):
    print(f"--- {name} (expected {expected})")
    width = max(map(len, rows)) + 2
    for row, values in rows.items():
        print(f"  {row:<{width}}{' | '.join(sorted(values))}")


def main():
    reproduced = []
    for name, (source, expected, affected) in CASES.items():
        rows = outputs(source)
        show(name, rows, expected)
        wrong = [run for run in affected if not all(v.startswith("rejected") for v in rows[run])]
        if wrong:
            reproduced.append(f"{name} ({', '.join(wrong)})")
    if reproduced:
        print(f"REPRODUCED: {'; '.join(reproduced)}")
        return 0
    print("NOT REPRODUCED")
    return 1


if __name__ == "__main__":
    sys.exit(main())

Activity

  1. added a commit that references this issue on Oct 9, 2026
    2ed4d12
  2. added 5 commits that reference this issue on Oct 9, 2026
    c649b4f
    5006e59
    a32d51b
    68a3458
    9bdbd3e
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions