A new crate, wai/wai-quantum-py, built by maturin 1.15.0 into one abi3 wheel per platform (pyo3 0.29.3, Python 3.9 on), module wai_quantum. Its extension module wraps the shared job engine and nothing else: run, plan, lint, draw (text and the engine SVG), layout, critique, improve, shortcuts, equivalent, verify, seal, canonical, digest, methods, accuracy_classes and history, values crossing one to one with JSON (NaN, integers past 64 bits and non-string keys refused). A job is the service form as a plain dict with helpers (counts, probabilities, expectations, amplitudes, method, accuracy, receipt); Key reads and writes the command line key files and finds the same user key; bloch gives each qubit reduced state from the engine expectation values. A refusal raises EngineError with the engine message, its line, and a code slot for the engine diagnostic codes. Service is a client in the standard library alone for the job service (submit, status, result, cancel, run), naming itself wai-quantum-python/<version>, its receipts checked here offline. Widgets (the widgets extra): Circuit (the engine drawing, gates clickable, authorship marks from a history), Result, Receipt (the engine verdict) and Bloch (drag to turn), each a plain ES module, each with static HTML (and SVG) fallbacks that render without the widget library. Five doc notebooks run in CI and are held to their stored outputs. ci/check.sh is the whole check; 62 pytest tests hold the package to the command line (same results, plans, lints, critiques, drawings, methods, equivalence verdicts; receipts verified both ways; the same user key; the digest recomputed from Python canonical bytes), to the service run locally, and run the front ends under node on the payloads; Python 3.9 smoke with the wheel alone. Workflow wai-quantum-py.yml runs it on linux and macos. Co-Authored-By: Claude Opus 5.5 <[email protected]>
229 lines
9.9 KiB
JavaScript
229 lines
9.9 KiB
JavaScript
// The widgets' front ends (python/wai_quantum/static/*.js), run under node on the
|
|
// data the Python package builds (written by test_widgets.py), against a small
|
|
// document of their own: elements, SVG elements, attributes, classes, events and
|
|
// the selectors the front ends use. The engine's SVG, which the Circuit widget
|
|
// places as markup, is read by a small XML reader, so its gates can be clicked.
|
|
//
|
|
// node tests/front_ends.mjs payloads.json
|
|
import { readFileSync } from 'node:fs';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
import assert from 'node:assert/strict';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const staticDir = join(here, '..', 'python', 'wai_quantum', 'static');
|
|
const payloads = JSON.parse(readFileSync(process.argv[2], 'utf8'));
|
|
|
|
class Text {
|
|
constructor(s) { this.text = String(s); this.parent = null; }
|
|
get textContent() { return this.text; }
|
|
}
|
|
|
|
class ClassList {
|
|
constructor(e) { this.e = e; }
|
|
get set() { return new Set((this.e.attrs.class || '').split(/\s+/).filter(Boolean)); }
|
|
contains(c) { return this.set.has(c); }
|
|
add(c) { const s = this.set; s.add(c); this.e.attrs.class = [...s].join(' '); }
|
|
remove(c) { const s = this.set; s.delete(c); this.e.attrs.class = [...s].join(' '); }
|
|
toggle(c, on) { const want = on === undefined ? !this.contains(c) : !!on; if (want) this.add(c); else this.remove(c); return want; }
|
|
}
|
|
|
|
class Element {
|
|
constructor(tag, ns = null) {
|
|
this.tagName = tag; this.ns = ns; this.attrs = {}; this.children = []; this.parent = null;
|
|
this.listeners = {}; this.classList = new ClassList(this); this.style = {};
|
|
}
|
|
get className() { return this.attrs.class || ''; }
|
|
set className(v) { this.attrs.class = v; }
|
|
get dataset() {
|
|
const d = {};
|
|
for (const [k, v] of Object.entries(this.attrs)) if (k.startsWith('data-')) d[k.slice(5).replace(/-(\w)/g, (_, c) => c.toUpperCase())] = v;
|
|
return d;
|
|
}
|
|
setAttribute(k, v) { this.attrs[k] = String(v); }
|
|
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
|
|
append(...kids) {
|
|
for (const k of kids) {
|
|
const n = typeof k === 'string' ? new Text(k) : k;
|
|
if (n.parent) n.parent.children = n.parent.children.filter((c) => c !== n);
|
|
n.parent = this;
|
|
this.children.push(n);
|
|
}
|
|
}
|
|
replaceChildren(...kids) { for (const c of this.children) c.parent = null; this.children = []; this.append(...kids); }
|
|
replaceWith(n) {
|
|
const p = this.parent; if (!p) return;
|
|
const i = p.children.indexOf(this);
|
|
n.parent = p; p.children[i] = n; this.parent = null;
|
|
}
|
|
get textContent() { return this.children.map((c) => c.textContent).join(''); }
|
|
set textContent(s) { this.replaceChildren(String(s)); }
|
|
set innerHTML(s) { this.replaceChildren(...parseXml(s)); }
|
|
get checked() { return !!this._checked; }
|
|
set checked(v) { this._checked = !!v; }
|
|
get value() { return this.attrs.value ?? ''; }
|
|
set value(v) { this.attrs.value = String(v); }
|
|
addEventListener(ev, f) { (this.listeners[ev] ||= []).push(f); }
|
|
dispatch(ev, detail = {}) { for (const f of this.listeners[ev] || []) f({ target: this, pointerId: 1, ...detail }); }
|
|
setPointerCapture() {}
|
|
*walk() { for (const c of this.children) if (c instanceof Element) { yield c; yield* c.walk(); } }
|
|
querySelectorAll(sel) { return [...this.walk()].filter((e) => matches(e, sel)); }
|
|
querySelector(sel) { return this.querySelectorAll(sel)[0] || null; }
|
|
}
|
|
|
|
// Selectors: `tag`, `.a.b`, `[attr]`, and a child step `x > y`; enough for the front ends.
|
|
function matchesOne(e, s) {
|
|
const m = /^([a-z]*)((?:\.[\w-]+)*)((?:\[[\w-]+\])*)$/i.exec(s.trim());
|
|
if (!m) throw new Error(`selector not handled here: ${s}`);
|
|
const [, tag, classes, attrs] = m;
|
|
if (tag && e.tagName !== tag) return false;
|
|
for (const c of classes.split('.').filter(Boolean)) if (!e.classList.contains(c)) return false;
|
|
for (const a of attrs.match(/[\w-]+/g) || []) if (!(a in e.attrs)) return false;
|
|
return true;
|
|
}
|
|
function matches(e, sel) {
|
|
return sel.split(',').some((part) => {
|
|
const steps = part.split('>').map((x) => x.trim());
|
|
if (!matchesOne(e, steps.at(-1))) return false;
|
|
return steps.length === 1 || (e.parent instanceof Element && matchesOne(e.parent, steps[0]));
|
|
});
|
|
}
|
|
|
|
// The engine's SVG is XML it writes itself: elements, attributes in double quotes,
|
|
// text with the five entities.
|
|
function parseXml(s) {
|
|
const root = new Element('#root');
|
|
const stack = [root];
|
|
const unesc = (t) => t.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/&/g, '&');
|
|
const re = /<(\/?)([\w:-]+)((?:\s+[\w:-]+="[^"]*")*)\s*(\/?)>|([^<]+)/g;
|
|
let m;
|
|
while ((m = re.exec(s))) {
|
|
const [, close, tag, attrs, self, text] = m;
|
|
if (text !== undefined) { stack.at(-1).append(unesc(text)); continue; }
|
|
if (close) { const top = stack.pop(); assert.equal(top.tagName, tag, `unbalanced </${tag}>`); continue; }
|
|
const e = new Element(tag, 'svg');
|
|
for (const a of attrs.matchAll(/([\w:-]+)="([^"]*)"/g)) e.attrs[a[1]] = unesc(a[2]);
|
|
stack.at(-1).append(e);
|
|
if (!self) stack.push(e);
|
|
}
|
|
assert.equal(stack.length, 1, 'unclosed elements in the SVG');
|
|
return root.children.slice();
|
|
}
|
|
|
|
globalThis.document = {
|
|
createElement: (t) => new Element(t),
|
|
createElementNS: (ns, t) => new Element(t, ns),
|
|
};
|
|
|
|
function model(data, traits = {}) {
|
|
const state = { data, ...traits };
|
|
const on = {};
|
|
let saves = 0;
|
|
return {
|
|
get: (k) => state[k],
|
|
set: (k, v) => { state[k] = v; for (const f of on[`change:${k}`] || []) f(); },
|
|
save_changes: () => { saves += 1; },
|
|
on: (ev, f) => (on[ev] ||= []).push(f),
|
|
off: (ev, f) => { on[ev] = (on[ev] || []).filter((g) => g !== f); },
|
|
get saves() { return saves; },
|
|
};
|
|
}
|
|
|
|
async function widget(name) {
|
|
return (await import(pathToFileURL(join(staticDir, `${name}.js`)).href)).default;
|
|
}
|
|
|
|
const checks = [];
|
|
function check(what, f) { checks.push([what, f]); }
|
|
|
|
check('circuit', async () => {
|
|
const w = await widget('circuit');
|
|
const m = model(payloads.circuit, { selected: null });
|
|
const host = new Element('div');
|
|
const cleanup = w.render({ model: m, el: host });
|
|
const svg = host.querySelector('svg');
|
|
assert.ok(svg, 'the engine drawing is placed');
|
|
const gates = host.querySelectorAll('.qc-item[data-op]');
|
|
assert.equal(gates.length, 2);
|
|
assert.equal(host.querySelectorAll('.qc-item').length, 4);
|
|
gates[1].dispatch('click');
|
|
assert.equal(m.get('selected'), 1);
|
|
assert.match(host.textContent, /operation 1: ● on q0, ⊕ on q1/);
|
|
assert.ok(host.querySelector('.qc-item.wq-selected[data-op]'));
|
|
host.querySelectorAll('.qc-item[data-op]')[1].dispatch('click');
|
|
assert.equal(m.get('selected'), null, 'a second click clears it');
|
|
assert.equal(host.querySelectorAll('input').length, 0, 'no authorship toggle without marks');
|
|
cleanup();
|
|
// With marks: the toggle hides and shows them.
|
|
const mh = model(payloads.circuit_hands, { selected: null });
|
|
const h2 = new Element('div');
|
|
w.render({ model: mh, el: h2 });
|
|
const box = h2.querySelector('input');
|
|
assert.ok(box && box.checked);
|
|
assert.ok(h2.querySelector('.qc-item.qc-by-assistant'));
|
|
box.checked = false; box.dispatch('change');
|
|
assert.ok(h2.querySelector('svg').classList.contains('wq-unmarked'));
|
|
});
|
|
|
|
check('result', async () => {
|
|
const w = await widget('result');
|
|
const d = payloads.result;
|
|
const m = model(d, { frequencies: false, top: 16 });
|
|
const host = new Element('div');
|
|
w.render({ model: m, el: host });
|
|
const text = host.textContent;
|
|
assert.match(text, /stabilizer/); assert.match(text, /exact/); assert.match(text, /verified/);
|
|
const bars = host.querySelectorAll('rect');
|
|
assert.equal(bars.length, Object.keys(d.counts).length);
|
|
const values = () => host.querySelector('svg').children.filter((c) => c.tagName === 'text' && c.attrs.class === 'wq-small').map((c) => c.textContent);
|
|
assert.deepEqual(values(), Object.values(d.counts).map(String));
|
|
m.set('frequencies', true);
|
|
assert.deepEqual(values(), Object.values(d.counts).map((v) => (v / d.shots).toFixed(4)));
|
|
m.set('top', 1);
|
|
assert.equal(host.querySelectorAll('rect').length, 1);
|
|
assert.match(host.textContent, /more shots fell on outcomes not drawn/);
|
|
});
|
|
|
|
check('receipt', async () => {
|
|
const w = await widget('receipt');
|
|
const host = new Element('div');
|
|
w.render({ model: model(payloads.receipt), el: host });
|
|
assert.match(host.textContent, /verified/);
|
|
assert.ok(host.textContent.includes(payloads.receipt.signer));
|
|
assert.ok(host.textContent.includes(payloads.receipt.digest));
|
|
assert.match(host.textContent, /published demo key/);
|
|
const bad = new Element('div');
|
|
w.render({ model: model(payloads.receipt_forged), el: bad });
|
|
assert.match(bad.textContent, /INVALID/);
|
|
assert.doesNotMatch(bad.textContent, /verified/);
|
|
});
|
|
|
|
check('bloch', async () => {
|
|
const w = await widget('bloch');
|
|
const d = payloads.bloch;
|
|
const m = model(d, { view: [-35, 20] });
|
|
const host = new Element('div');
|
|
w.render({ model: m, el: host });
|
|
assert.equal(host.querySelectorAll('svg').length, d.qubits.length);
|
|
assert.match(host.textContent, /q0/); assert.match(host.textContent, /length 1/);
|
|
// q0 is |+⟩: from the view straight on, its tip is a radius to the right of the centre.
|
|
m.set('view', [0, 0]);
|
|
const tip = host.querySelectorAll('svg')[0].children.filter((c) => c.tagName === 'circle')[1];
|
|
const r = 150 * 0.36;
|
|
assert.ok(Math.abs(Number(tip.attrs.cx) - (75 + r)) < 0.02 && Math.abs(Number(tip.attrs.cy) - 79) < 0.02, JSON.stringify(tip.attrs));
|
|
// A drag turns the view and keeps it in the model.
|
|
const grid = host.querySelector('.wq-grid');
|
|
grid.dispatch('pointerdown', { clientX: 0, clientY: 0 });
|
|
grid.dispatch('pointermove', { clientX: 50, clientY: 25 });
|
|
grid.dispatch('pointerup', {});
|
|
assert.deepEqual(m.get('view'), [30, 10]);
|
|
assert.ok(m.saves >= 1);
|
|
});
|
|
|
|
let n = 0;
|
|
for (const [what, f] of checks) {
|
|
await f();
|
|
n += 1;
|
|
console.log(`ok ${what}`);
|
|
}
|
|
console.log(`front ends: ${n} widgets, every check passed`);
|