cpsc-qft / docs /mercury_bundle.rb
SNAPKITTYWEST's picture
October 2026 main drop: mirror from GitHub
eef9104 verified
Raw History Blame Contribute Delete
28.6 kB
# ---- ../lib/mercury/version.rb ----
# *******************************************************************************
# Copyright (c) 2026 Ahmad Ali Parr and others.
#
# This program and the accompanying materials are made available under the
# terms of the CPSC Eclipse Strict Copyleft License, Version 1.0, which is
# available in LICENSES/CPSC-ESCL-1.0.txt, or, at your option, under the
# GNU Affero General Public License, Version 3 only, which is available in
# LICENSES/AGPL-3.0.txt. Both options are strict copyleft. There is no
# classpath exception and no permissive relicensing.
#
# SPDX-License-Identifier: LicenseRef-CPSC-ESCL-1.0 OR AGPL-3.0-only
# *******************************************************************************
module Mercury
VERSION = "0.1.0"
end
# ---- ../lib/mercury/gates.rb ----
# *******************************************************************************
# Copyright (c) 2026 Ahmad Ali Parr and others.
#
# This program and the accompanying materials are made available under the
# terms of the CPSC Eclipse Strict Copyleft License, Version 1.0, which is
# available in LICENSES/CPSC-ESCL-1.0.txt, or, at your option, under the
# GNU Affero General Public License, Version 3 only, which is available in
# LICENSES/AGPL-3.0.txt. Both options are strict copyleft. There is no
# classpath exception and no permissive relicensing.
#
# SPDX-License-Identifier: LicenseRef-CPSC-ESCL-1.0 OR AGPL-3.0-only
# *******************************************************************************
# Gate matrices for Mercury. Little-endian convention: in a k-qubit gate
# applied to qubits [q0, q1, ...], matrix row/column index bit j (value
# 2**j) is the computational-basis state of qubit q_j. For a two-qubit
# gate on [q0, q1] the basis order is 00, 01, 10, 11 reading (bit1, bit0).
module Mercury
module Gates
ONE_QUBIT = %i[i x y z h s t sdg tdg rx ry rz u].freeze
TWO_QUBIT = %i[cnot cz cp swap rzz xx].freeze
THREE_QUBIT = %i[ccx].freeze
PARAM_COUNTS = {
i: 0, x: 0, y: 0, z: 0, h: 0, s: 0, t: 0, sdg: 0, tdg: 0,
rx: 1, ry: 1, rz: 1, u: 3,
cnot: 0, cz: 0, cp: 1, swap: 0, rzz: 1, xx: 0,
ccx: 0
}.freeze
def self.arity(name)
return 1 if ONE_QUBIT.include?(name)
return 2 if TWO_QUBIT.include?(name)
return 3 if THREE_QUBIT.include?(name)
raise ArgumentError, "unknown gate #{name}"
end
def self.param_count(name)
PARAM_COUNTS.fetch(name) { raise ArgumentError, "unknown gate #{name}" }
end
def self.matrix(name, params = [])
need = param_count(name)
raise ArgumentError, "#{name} needs #{need} params, got #{params.length}" unless params.length == need
case name
when :i then [[c(1), c(0)], [c(0), c(1)]]
when :x then [[c(0), c(1)], [c(1), c(0)]]
when :y then [[c(0), c(0, -1)], [c(0, 1), c(0)]]
when :z then [[c(1), c(0)], [c(0), c(-1)]]
when :h
s = Math.sqrt(0.5)
[[c(s), c(s)], [c(s), c(-s)]]
when :s then [[c(1), c(0)], [c(0), c(0, 1)]]
when :t then [[c(1), c(0)], [c(0), phase(Math::PI / 4)]]
when :sdg then [[c(1), c(0)], [c(0), c(0, -1)]]
when :tdg then [[c(1), c(0)], [c(0), phase(-Math::PI / 4)]]
when :rx
th = params[0] / 2.0
co = c(Math.cos(th)); si = c(0, -Math.sin(th))
[[co, si], [si, co]]
when :ry
th = params[0] / 2.0
[[c(Math.cos(th)), c(-Math.sin(th))],
[c(Math.sin(th)), c(Math.cos(th))]]
when :rz
th = params[0] / 2.0
[[phase(-th), c(0)], [c(0), phase(th)]]
when :u
th, ph, la = params
co = c(Math.cos(th / 2.0)); si = c(Math.sin(th / 2.0))
[[co, c(-1) * phase(la) * si],
[phase(ph) * si, phase(ph + la) * co]]
when :cnot
# qubits [target, control]; flips target iff control == 1
[[c(1), c(0), c(0), c(0)],
[c(0), c(1), c(0), c(0)],
[c(0), c(0), c(0), c(1)],
[c(0), c(0), c(1), c(0)]]
when :cz
[[c(1), c(0), c(0), c(0)],
[c(0), c(1), c(0), c(0)],
[c(0), c(0), c(1), c(0)],
[c(0), c(0), c(0), c(-1)]]
when :cp
[[c(1), c(0), c(0), c(0)],
[c(0), c(1), c(0), c(0)],
[c(0), c(0), c(1), c(0)],
[c(0), c(0), c(0), phase(params[0])]]
when :swap
[[c(1), c(0), c(0), c(0)],
[c(0), c(0), c(1), c(0)],
[c(0), c(1), c(0), c(0)],
[c(0), c(0), c(0), c(1)]]
when :rzz
# exp(-i*theta/2 * ZxZ)
a = phase(-params[0] / 2.0); b = phase(params[0] / 2.0)
[[a, c(0), c(0), c(0)],
[c(0), b, c(0), c(0)],
[c(0), c(0), b, c(0)],
[c(0), c(0), c(0), a]]
when :xx
# XxX: parity-even pair used by the CPSC analyzer
[[c(0), c(0), c(0), c(1)],
[c(0), c(0), c(1), c(0)],
[c(0), c(1), c(0), c(0)],
[c(1), c(0), c(0), c(0)]]
when :ccx
# qubits [target, control1, control2]; flips target iff both controls == 1
m = Array.new(8) { |r| Array.new(8) { |col| c(r == col ? 1 : 0) } }
m[6][6] = c(0); m[7][7] = c(0)
m[6][7] = c(1); m[7][6] = c(1)
m
end
end
def self.c(re, im = 0)
Complex(re, im)
end
def self.phase(angle)
Complex(Math.cos(angle), Math.sin(angle))
end
private_class_method :c, :phase
end
end
# ---- ../lib/mercury/circuit.rb ----
# *******************************************************************************
# Copyright (c) 2026 Ahmad Ali Parr and others.
#
# This program and the accompanying materials are made available under the
# terms of the CPSC Eclipse Strict Copyleft License, Version 1.0, which is
# available in LICENSES/CPSC-ESCL-1.0.txt, or, at your option, under the
# GNU Affero General Public License, Version 3 only, which is available in
# LICENSES/AGPL-3.0.txt. Both options are strict copyleft. There is no
# classpath exception and no permissive relicensing.
#
# SPDX-License-Identifier: LicenseRef-CPSC-ESCL-1.0 OR AGPL-3.0-only
# *******************************************************************************
module Mercury
# A quantum circuit: an ordered list of gate operations on n qubits.
#
# c = Mercury::Circuit.new(2) do |c|
# c.h 0
# c.cnot 0, 1
# c.measure_all
# end
#
# Multi-qubit gate qubit order follows the Gates convention: for
# cnot(control, target) the DSL takes (control, target) and stores
# [target, control] so matrix bit 0 is the target. Same for ccx:
# ccx(c1, c2, target) stores [target, c1, c2].
class Circuit
Op = Struct.new(:gate, :qubits, :params, :classical)
attr_reader :n, :ops
DSL_REORDER = { cnot: true, ccx: true }.freeze
def initialize(n)
raise ArgumentError, "n must be >= 1" unless n.is_a?(Integer) && n >= 1
@n = n
@ops = []
yield self if block_given?
end
def add(gate, *qubits, params: [])
gate = gate.to_sym
arity = Gates.arity(gate)
raise ArgumentError, "#{gate} takes #{arity} qubits, got #{qubits.length}" unless qubits.length == arity
raise ArgumentError, "duplicate qubit in #{gate}#{qubits}" unless qubits.uniq.length == qubits.length
qubits.each do |q|
raise ArgumentError, "qubit #{q} out of range for #{@n} qubits" unless q.is_a?(Integer) && q >= 0 && q < @n
end
stored = DSL_REORDER[gate] ? qubits.reverse : qubits
@ops << Op.new(gate, stored, params, nil)
self
end
# Define h(0), x(1), rx(theta, 0), cnot(0, 1), ... from the gate registry.
(Gates::ONE_QUBIT + Gates::TWO_QUBIT + Gates::THREE_QUBIT).each do |gname|
define_method(gname) do |*args|
need = Gates.param_count(gname)
arity = Gates.arity(gname)
raise ArgumentError, "#{gname} needs #{need} params + #{arity} qubits" if args.length < arity
params = args.first(need)
qubits = args.last(arity)
add(gname, *qubits, params: params)
end
end
def measure(qubit, clbit = nil)
raise ArgumentError, "qubit #{qubit} out of range" unless qubit.is_a?(Integer) && qubit >= 0 && qubit < @n
@ops << Op.new(:measure, [qubit], [], clbit || qubit)
self
end
def measure_all
@n.times { |q| measure(q, q) }
self
end
def depth
@ops.length
end
# Minimal Unicode circuit diagram.
def to_s
cols = @ops.map { |op| column(op) }
rows = (0...@n).map do |q|
label = "q#{q}: "
body = cols.map { |col| col[q] }.join("")
label + body
end
rows.join("\n")
end
private
def column(op)
cells = Array.new(@n, "────────")
case op.gate
when :measure
cells[op.qubits[0]] = "───╫M───"
else
qs = op.qubits
tag = op.params.empty? ? op.gate.to_s.upcase : "#{op.gate}#{op.params.map { |p| format('%.2f', p) }.join(',')}".upcase
if qs.length == 1
cells[qs[0]] = "───#{tag[0, 2]}───"
else
# multi-qubit: control dots on qs[1..], box on qs[0], rail between
lo, hi = qs.minmax
(lo..hi).each { |q| cells[q] = "───┼────" }
cells[qs[0]] = "───#{tag[0, 2]}───"
qs[1..].each { |q| cells[q] = "───●────" }
end
end
cells
end
end
end
# ---- ../lib/mercury/simulator.rb ----
# *******************************************************************************
# Copyright (c) 2026 Ahmad Ali Parr and others.
#
# This program and the accompanying materials are made available under the
# terms of the CPSC Eclipse Strict Copyleft License, Version 1.0, which is
# available in LICENSES/CPSC-ESCL-1.0.txt, or, at your option, under the
# GNU Affero General Public License, Version 3 only, which is available in
# LICENSES/AGPL-3.0.txt. Both options are strict copyleft. There is no
# classpath exception and no permissive relicensing.
#
# SPDX-License-Identifier: LicenseRef-CPSC-ESCL-1.0 OR AGPL-3.0-only
# *******************************************************************************
module Mercury
# Full statevector simulator. Little-endian: qubit q is bit q of the
# basis index, matching the Gates matrix convention.
class Statevector
attr_reader :n, :amps
# initial: integer basis index, or MSB-first bitstring like "0011".
def initialize(n, initial: 0)
raise ArgumentError, "n must be >= 1" unless n.is_a?(Integer) && n >= 1
initial = Integer(initial, 2) if initial.is_a?(String)
raise ArgumentError, "initial basis out of range" unless initial.between?(0, (1 << n) - 1)
@n = n
@dim = 1 << n
@amps = Array.new(@dim, Complex(0, 0))
@amps[initial] = Complex(1, 0)
end
def dim
@dim
end
def norm
Math.sqrt(@amps.sum { |a| a.abs**2 })
end
def apply(gate, qubits, params = [])
matrix = Gates.matrix(gate, params)
case qubits.length
when 1 then apply_one(matrix, qubits[0])
when 2 then apply_two(matrix, qubits[0], qubits[1])
when 3 then apply_three(matrix, qubits[0], qubits[1], qubits[2])
else raise ArgumentError, "unsupported arity #{qubits.length}"
end
self
end
def probabilities
@amps.map { |a| a.abs**2 }
end
# Sample `shots` bitstrings (MSB-first strings) from the distribution.
def sample(shots, rng = Random.new)
probs = probabilities
cumulative = []
total = 0.0
probs.each { |p| total += p; cumulative << total }
Array.new(shots) do
r = rng.rand * total
idx = cumulative.bsearch_index { |c| c >= r } || (@dim - 1)
bitstring(idx)
end
end
# Projective Z measurement of one qubit; collapses the state in place.
def measure!(qubit, rng = Random.new)
raise ArgumentError, "qubit out of range" unless qubit.between?(0, @n - 1)
step = 1 << qubit
p0 = 0.0
@amps.each_with_index { |a, s| p0 += a.abs**2 if (s & step).zero? }
outcome = rng.rand < p0 ? 0 : 1
renorm = Math.sqrt(outcome.zero? ? p0 : (1.0 - p0))
raise "measurement of zero-probability branch" if renorm.zero?
@amps.each_with_index do |a, s|
bit = (s & step).zero? ? 0 : 1
@amps[s] = bit == outcome ? a / renorm : Complex(0, 0)
end
outcome
end
def bitstring(index)
# MSB-first for display: qubit n-1 ... qubit 0
(0...@n).map { |q| ((index >> (@n - 1 - q)) & 1).to_s }.join
end
private
def apply_one(m, q)
step = 1 << q
nxt = Array.new(@dim, Complex(0, 0))
(0...@dim).each do |s|
next unless (s & step).zero?
i0 = s
i1 = s | step
a0 = @amps[i0]
a1 = @amps[i1]
nxt[i0] = m[0][0] * a0 + m[0][1] * a1
nxt[i1] = m[1][0] * a0 + m[1][1] * a1
end
@amps = nxt
end
def apply_two(m, q0, q1)
raise ArgumentError, "q0 == q1" if q0 == q1
s0 = 1 << q0
s1 = 1 << q1
nxt = Array.new(@dim, Complex(0, 0))
(0...@dim).each do |s|
next unless (s & s0).zero? && (s & s1).zero?
idx = [s, s | s0, s | s1, s | s0 | s1]
old = [(@amps[idx[0]]), (@amps[idx[1]]), (@amps[idx[2]]), (@amps[idx[3]])]
4.times do |r|
nxt[idx[r]] = m[r][0] * old[0] + m[r][1] * old[1] + m[r][2] * old[2] + m[r][3] * old[3]
end
end
@amps = nxt
end
def apply_three(m, q0, q1, q2)
raise ArgumentError, "repeated qubit" unless [q0, q1, q2].uniq.length == 3
s0 = 1 << q0
s1 = 1 << q1
s2 = 1 << q2
nxt = Array.new(@dim, Complex(0, 0))
(0...@dim).each do |s|
next unless (s & s0).zero? && (s & s1).zero? && (s & s2).zero?
idx = (0...8).map { |b| s | ((b & 1) != 0 ? s0 : 0) | ((b & 2) != 0 ? s1 : 0) | ((b & 4) != 0 ? s2 : 0) }
old = idx.map { |i| @amps[i] }
8.times do |r|
acc = Complex(0, 0)
8.times { |cidx| acc += m[r][cidx] * old[cidx] }
nxt[idx[r]] = acc
end
end
@amps = nxt
end
end
# Runs a Circuit. :measure ops collapse immediately; `shots` samples the
# final distribution.
class Simulator
Result = Struct.new(:counts, :statevector, :probabilities, :measured)
def run(circuit, shots: 0, seed: nil, initial: 0)
rng = seed.nil? ? Random.new : Random.new(seed)
sv = Statevector.new(circuit.n, initial: initial)
measured = {}
circuit.ops.each do |op|
if op.gate == :measure
measured[op.classical] = sv.measure!(op.qubits[0], rng)
else
sv.apply(op.gate, op.qubits, op.params)
end
end
counts = {}
if shots.positive?
sv.sample(shots, rng).each { |b| counts[b] = (counts[b] || 0) + 1 }
end
Result.new(counts, sv.amps.dup, sv.probabilities, measured)
end
end
end
# ---- ../lib/mercury/mini_json.rb ----
# *******************************************************************************
# Copyright (c) 2026 Ahmad Ali Parr and others.
#
# This program and the accompanying materials are made available under the
# terms of the CPSC Eclipse Strict Copyleft License, Version 1.0, which is
# available in LICENSES/CPSC-ESCL-1.0.txt, or, at your option, under the
# GNU Affero General Public License, Version 3 only, which is available in
# LICENSES/AGPL-3.0.txt. Both options are strict copyleft. There is no
# classpath exception and no permissive relicensing.
#
# SPDX-License-Identifier: LicenseRef-CPSC-ESCL-1.0 OR AGPL-3.0-only
# *******************************************************************************
# Minimal JSON parser/generator covering exactly what the Mercury web
# bridge needs (objects, arrays, strings, numbers, booleans, null).
# Pure Ruby, no dependencies — safe inside Ruby WASM.
module Mercury
module MiniJSON
class ParseError < StandardError; end
def self.parse(str)
@src = str
@pos = 0
skip_ws
val = parse_value
skip_ws
raise ParseError, "trailing data at #{@pos}" unless @pos == @src.length
val
end
def self.generate(obj)
case obj
when Hash
"{" + obj.map { |k, v| "#{generate(k.to_s)}:#{generate(v)}" }.join(",") + "}"
when Array
"[" + obj.map { |v| generate(v) }.join(",") + "]"
when String
'"' + obj.gsub(/["\\\b\f\n\r\t]/) { |m| { '"' => '\\"', '\\' => '\\\\', "\b" => '\\b', "\f" => '\\f', "\n" => '\\n', "\r" => '\\r', "\t" => '\\t' }[m] } + '"'
when Integer, Float
raise ParseError, "non-finite number" if obj.is_a?(Float) && !obj.finite?
obj.to_s
when true then "true"
when false then "false"
when nil then "null"
else
raise ParseError, "cannot serialize #{obj.class}"
end
end
def self.skip_ws
@pos += 1 while @pos < @src.length && @src[@pos] =~ /\s/
end
def self.parse_value
case @src[@pos]
when '"' then parse_string
when "{" then parse_object
when "[" then parse_array
when "t" then literal("true", true)
when "f" then literal("false", false)
when "n" then literal("null", nil)
else parse_number
end
end
def self.literal(word, val)
raise ParseError, "bad literal at #{@pos}" unless @src[@pos, word.length] == word
@pos += word.length
val
end
def self.parse_string
@pos += 1 # opening quote
out = +""
while @pos < @src.length
ch = @src[@pos]
case ch
when '"'
@pos += 1
return out
when "\\"
@pos += 1
esc = @src[@pos]
out << case esc
when '"' then '"'
when "\\" then "\\"
when "/" then "/"
when "b" then "\b"
when "f" then "\f"
when "n" then "\n"
when "r" then "\r"
when "t" then "\t"
when "u"
hex = @src[@pos + 1, 4]
@pos += 4
[hex.to_i(16)].pack("U")
else raise ParseError, "bad escape at #{@pos}"
end
@pos += 1
else
out << ch
@pos += 1
end
end
raise ParseError, "unterminated string"
end
def self.parse_object
@pos += 1
obj = {}
skip_ws
if @src[@pos] == "}"
@pos += 1
return obj
end
loop do
skip_ws
raise ParseError, "expected string key at #{@pos}" unless @src[@pos] == '"'
key = parse_string
skip_ws
raise ParseError, "expected : at #{@pos}" unless @src[@pos] == ":"
@pos += 1
skip_ws
obj[key] = parse_value
skip_ws
case @src[@pos]
when "," then @pos += 1
when "}" then @pos += 1; break
else raise ParseError, "expected , or } at #{@pos}"
end
end
obj
end
def self.parse_array
@pos += 1
arr = []
skip_ws
if @src[@pos] == "]"
@pos += 1
return arr
end
loop do
skip_ws
arr << parse_value
skip_ws
case @src[@pos]
when "," then @pos += 1
when "]" then @pos += 1; break
else raise ParseError, "expected , or ] at #{@pos}"
end
end
arr
end
def self.parse_number
m = @src[@pos..].match(/\A-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/)
raise ParseError, "bad number at #{@pos}" unless m
@pos += m[0].length
s = m[0]
(s.include?(".") || s =~ /[eE]/) ? s.to_f : s.to_i
end
end
end
# ---- ../lib/mercury/cpsc.rb ----
# *******************************************************************************
# Copyright (c) 2026 Ahmad Ali Parr and others.
#
# This program and the accompanying materials are made available under the
# terms of the CPSC Eclipse Strict Copyleft License, Version 1.0, which is
# available in LICENSES/CPSC-ESCL-1.0.txt, or, at your option, under the
# GNU Affero General Public License, Version 3 only, which is available in
# LICENSES/AGPL-3.0.txt. Both options are strict copyleft. There is no
# classpath exception and no permissive relicensing.
#
# SPDX-License-Identifier: LicenseRef-CPSC-ESCL-1.0 OR AGPL-3.0-only
# *******************************************************************************
# Ruby mirror of the CPSC Python analyzer (src/cpsc/analyzer.py) plus a
# Trotterized scattering evolution built as a Mercury circuit.
#
# Gate-kind semantics (identical to the Python original):
# - "magnetization": diagonal in the Z basis; preserves sum Z and prod Z.
# - "parity_even": preserves prod Z (Z2 parity) but may change sum Z.
# - "illegal": odd X/Y support; changes Z-parity. Rejected.
module Mercury
module CPSC
# Mercury gate -> analyzer kind. Mirrors analyzer.py's Gate kinds.
KINDS = {
i: "magnetization", z: "magnetization", s: "magnetization",
t: "magnetization", sdg: "magnetization", tdg: "magnetization",
rz: "magnetization", cz: "magnetization", rzz: "magnetization",
cp: "magnetization",
xx: "parity_even",
x: "illegal", y: "illegal", h: "illegal",
rx: "illegal", ry: "illegal", u: "illegal",
cnot: "illegal", swap: "illegal", ccx: "illegal"
}.freeze
GateSpec = Struct.new(:name, :qubits, :kind)
Rejection = Struct.new(:spec, :reason)
class CompilationRejected < StandardError; end
class SymmetryAnalyzer
def initialize(n, conserve_magnetization: false)
raise ArgumentError, "n must be positive" unless n.is_a?(Integer) && n >= 1
@n = n
@conserve_magnetization = conserve_magnetization
end
def check(specs)
specs.flat_map do |spec|
reason = reject_reason(spec)
reason ? [Rejection.new(spec, reason)] : []
end
end
def admit(specs)
rejected = check(specs)
unless rejected.empty?
detail = rejected.map { |r| "#{r.spec.name}: #{r.reason}" }.join("; ")
raise CompilationRejected, detail
end
specs.dup
end
private
def reject_reason(spec)
spec.qubits.each do |q|
return "qubit #{q} outside lattice of size #{@n}" unless q.is_a?(Integer) && q.between?(0, @n - 1)
end
case spec.kind
when "illegal"
"odd X/Y support changes Z-parity; forbidden sector"
when "parity_even", "magnetization"
nil
else
"unknown gate kind #{spec.kind}"
end
end
end
# Classify a Mercury::Circuit::Op into a GateSpec for the analyzer.
def self.classify(op)
kind = KINDS.fetch(op.gate) { raise ArgumentError, "cannot classify #{op.gate}" }
label = op.params.empty? ? op.gate.to_s.upcase : "#{op.gate}(#{op.params.map { |p| format('%.3f', p) }.join(',')})"
GateSpec.new("#{label}[#{op.qubits.join(',')}]", op.qubits, kind)
end
# Run the analyzer over a whole circuit. Returns
# { admitted: bool, rejections: [{gate, reason}] }.
def self.analyze(circuit, conserve_magnetization: false)
analyzer = SymmetryAnalyzer.new(circuit.n, conserve_magnetization: conserve_magnetization)
specs = circuit.ops.reject { |op| op.gate == :measure }.map { |op| classify(op) }
rejections = analyzer.check(specs)
{
admitted: rejections.empty?,
rejections: rejections.map { |r| { gate: r.spec.name, reason: r.reason } }
}
end
# First-order Trotter circuit for H = -J sum Z_i Z_{i+1} + lam sum X_i
# (periodic), matching src/cpsc/hamiltonian.py. Per step dt:
# prod_bonds exp(+i J dt Z_i Z_{i+1}) then prod_i exp(-i lam dt X_i).
def self.trotter_circuit(n, j:, lam:, time:, steps:)
raise ArgumentError, "steps must be >= 1" unless steps >= 1
dt = time.to_f / steps
Circuit.new(n) do |c|
steps.times do
(0...n).each { |i| c.rzz(-2.0 * j * dt, i, (i + 1) % n) }
(0...n).each { |i| c.rx(2.0 * lam * dt, i) } unless lam.zero?
end
end
end
def self.parity_of(basis, n)
basis.to_s(2).count("1") & 1
end
def self.magnetization_of(basis, n)
mag = 0
n.times { |i| mag += ((basis >> i) & 1).zero? ? 1 : -1 }
mag
end
# Probability mass outside the input Z2-parity sector.
def self.leakage(amps, n, parity)
wrong = 0.0
amps.each_with_index do |a, b|
wrong += a.abs**2 if parity_of(b, n) != parity
end
wrong
end
# <psi|H|psi> for the transverse-field Ising H, computed directly.
def self.energy_expectation(amps, n, j:, lam:)
dim = 1 << n
e = 0.0
dim.times do |s|
p = amps[s].abs**2
next if p.zero?
zz = 0.0
n.times do |i|
zi = ((s >> i) & 1).zero? ? 1.0 : -1.0
zj = ((s >> ((i + 1) % n)) & 1).zero? ? 1.0 : -1.0
zz += zi * zj
end
e += p * (-j * zz)
end
unless lam.zero?
x = 0.0
n.times do |i|
bit = 1 << i
dim.times { |s| x += (amps[s].conj * amps[s ^ bit]).real }
end
e += lam * x
end
e
end
end
end
# ---- app.rb ----
# *******************************************************************************
# Copyright (c) 2026 Ahmad Ali Parr and others.
#
# This program and the accompanying materials are made available under the
# terms of the CPSC Eclipse Strict Copyleft License, Version 1.0, which is
# available in LICENSES/CPSC-ESCL-1.0.txt, or, at your option, under the
# GNU Affero General Public License, Version 3 only, which is available in
# LICENSES/AGPL-3.0.txt. Both options are strict copyleft. There is no
# classpath exception and no permissive relicensing.
#
# SPDX-License-Identifier: LicenseRef-CPSC-ESCL-1.0 OR AGPL-3.0-only
# *******************************************************************************
# Mercury web bridge. Concatenated after lib/mercury/*.rb by build.sh into
# mercury_bundle.rb, evaluated once inside Ruby WASM at boot. JavaScript
# drives it via vm.eval("handle_request('<json>')").
#
# Request: {"n":2,"initial":"00","ops":[["h",[0],[]],["cnot",[0,1],[]]],
# "shots":1024,"seed":42,"cpsc":true,"magnetization":false,
# "j":1.0,"lam":0.0}
# Response: {"diagram":"...","probs":[[bits,p],...],"counts":{...},
# "statevector":[[bits,re,im],...],"norm":1.0,
# "cpsc":{"admitted":true,"rejections":[],"leakage":0.0,"energy":0.0}}
def bitstring_of(index, n)
(0...n).map { |q| ((index >> (n - 1 - q)) & 1).to_s }.join
end
def handle_request(json_str)
req = Mercury::MiniJSON.parse(json_str)
n = req["n"].to_i
raise "n must be 1..12" unless n.between?(1, 12)
c = Mercury::Circuit.new(n)
(req["ops"] || []).each do |op|
name = op[0].to_s.to_sym
qubits = op[1].map(&:to_i)
params = (op[2] || []).map(&:to_f)
c.add(name, *qubits, params: params)
end
initial = req.fetch("initial", "0" * n).to_s
raise "initial must be #{n} bits" unless initial.match?(/\A[01]{#{n}}\z/)
init_idx = Integer(initial, 2)
shots = req.fetch("shots", 0).to_i
seed = req["seed"]
res = Mercury::Simulator.new.run(c, shots: shots, seed: seed, initial: init_idx)
probs = []
res.probabilities.each_with_index do |p, i|
probs << [bitstring_of(i, n), p] if p > 1e-9
end
probs.sort_by! { |_, p| -p }
sv = []
res.statevector.each_with_index do |a, i|
sv << [bitstring_of(i, n), a.real, a.imag] if a.abs > 1e-9
end
sv.sort_by! { |_, re, im| -(re * re + im * im) }
out = {
"diagram" => c.to_s,
"probs" => probs.first(24),
"counts" => res.counts,
"statevector" => sv.first(24),
"norm" => Math.sqrt(res.probabilities.sum)
}
if req["cpsc"]
verdict = Mercury::CPSC.analyze(c, conserve_magnetization: !!req["magnetization"])
out["cpsc"] = {
"admitted" => verdict[:admitted],
"rejections" => verdict[:rejections],
"leakage" => Mercury::CPSC.leakage(res.statevector, n, Mercury::CPSC.parity_of(init_idx, n)),
"energy" => Mercury::CPSC.energy_expectation(
res.statevector, n,
j: req.fetch("j", 1.0).to_f, lam: req.fetch("lam", 0.0).to_f
)
}
end
Mercury::MiniJSON.generate(out)
rescue => e
Mercury::MiniJSON.generate({ "error" => "#{e.class}: #{e.message}" })
end